feat: SLA live timer, notification bell, lt-toggle MD, right drawer, kanban drag-drop
- TicketView: SLA banner now shows live HH:MM:SS elapsed + countdown via JS setInterval (previously showed static hours from PHP) - TicketView: Markdown toggles in comment form replaced with lt-toggle switches - layout_header: In-app notification bell (🔔) with dropdown panel for all users - layout_footer: Notification JS — polls /api/notifications.php every 60s, badge count, mark-all-read, panel open/close with Escape/outside-click - api/notifications.php (new): Returns assign/comment/status-change events from audit_log for current user's tickets and watched tickets; mark-read via user_preferences - DashboardView: Ticket preview right drawer — Ctrl+click title or ⊙ peek button opens lt-drawer-right with ticket summary extracted from table row DOM - DashboardView: lt.sortable wired on all 4 kanban columns (group='kanban') Cross-column drag = status change via POST /api/update_ticket.php with optimistic UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Notifications API
|
||||||
|
*
|
||||||
|
* GET → returns recent notifications for the current user (last 7 days, max 30)
|
||||||
|
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
|
||||||
|
*
|
||||||
|
* Notifications are derived from audit_log:
|
||||||
|
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
|
||||||
|
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
|
||||||
|
* - Status changes on watched (via ticket_watchers)
|
||||||
|
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
|
||||||
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
|
||||||
|
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
if (($data['action'] ?? '') === 'mark_read') {
|
||||||
|
$prefsModel->setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s'));
|
||||||
|
apiRespond(['success' => true]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Unknown action']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: fetch notifications ──────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last_seen timestamp (when user last marked all read)
|
||||||
|
$prefs = $prefsModel->getUserPreferences($userId);
|
||||||
|
$lastSeen = $prefs['notif_last_seen'] ?? null;
|
||||||
|
|
||||||
|
// Username for @mention detection
|
||||||
|
$myUsername = $currentUser['username'] ?? '';
|
||||||
|
|
||||||
|
// Query 1: Tickets assigned to me (events from other users)
|
||||||
|
$assignSql = "SELECT
|
||||||
|
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = 'assign'
|
||||||
|
AND al.entity_type = 'ticket'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
AND al.details LIKE ?
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 15";
|
||||||
|
|
||||||
|
$assignLike = '%"assigned_to":' . $userId . '%';
|
||||||
|
$stmt = $conn->prepare($assignSql);
|
||||||
|
$stmt->bind_param('is', $userId, $assignLike);
|
||||||
|
$stmt->execute();
|
||||||
|
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Query 2: Comments on tickets I own or watch (events from other users)
|
||||||
|
$commentSql = "SELECT DISTINCT
|
||||||
|
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
INNER JOIN tickets t ON t.ticket_id = CAST(al.entity_id AS UNSIGNED)
|
||||||
|
LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ?
|
||||||
|
WHERE al.action_type = 'comment'
|
||||||
|
AND al.entity_type = 'ticket'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
AND (t.assigned_to = ? OR t.created_by = ? OR tw.user_id IS NOT NULL)
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 15";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($commentSql);
|
||||||
|
$stmt->bind_param('iiii', $userId, $userId, $userId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$commentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Query 3: Status changes on watched tickets (from other users)
|
||||||
|
$statusSql = "SELECT DISTINCT
|
||||||
|
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
||||||
|
WHERE al.action_type = 'update'
|
||||||
|
AND al.entity_type = 'ticket'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
AND al.details LIKE '%\"field\":\"status\"%'
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 10";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($statusSql);
|
||||||
|
$stmt->bind_param('ii', $userId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Merge, deduplicate by log_id, sort by created_at desc
|
||||||
|
$all = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
||||||
|
$id = (int)$row['log_id'];
|
||||||
|
if (isset($seen[$id])) continue;
|
||||||
|
$seen[$id] = true;
|
||||||
|
$all[] = $row;
|
||||||
|
}
|
||||||
|
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||||
|
$all = array_slice($all, 0, 30);
|
||||||
|
|
||||||
|
// Format for response
|
||||||
|
$notifications = [];
|
||||||
|
foreach ($all as $row) {
|
||||||
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
|
$ticketId = (int)$row['entity_id'];
|
||||||
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
|
|
||||||
|
// Build human-readable title
|
||||||
|
$title = match($row['action_type']) {
|
||||||
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
|
'update' => (function() use ($row, $details, $ticketId) {
|
||||||
|
$from = $details['old_value'] ?? '?';
|
||||||
|
$to = $details['new_value'] ?? '?';
|
||||||
|
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
||||||
|
})(),
|
||||||
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
|
};
|
||||||
|
|
||||||
|
$ticketTitle = $details['title'] ?? null;
|
||||||
|
if ($ticketTitle) {
|
||||||
|
$title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifications[] = [
|
||||||
|
'log_id' => (int)$row['log_id'],
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $title,
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'is_read' => $isRead,
|
||||||
|
'action' => $row['action_type'],
|
||||||
|
'url' => "/ticket/{$ticketId}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
|
||||||
|
|
||||||
|
apiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'notifications' => $notifications,
|
||||||
|
'unread_count' => $unreadCount,
|
||||||
|
'last_seen' => $lastSeen,
|
||||||
|
]);
|
||||||
+66
-1
@@ -1151,7 +1151,13 @@ function populateKanbanCards() {
|
|||||||
card.className = 'lt-kanban-card lt-kanban-card--p' + pNum;
|
card.className = 'lt-kanban-card lt-kanban-card--p' + pNum;
|
||||||
card.setAttribute('role', 'button');
|
card.setAttribute('role', 'button');
|
||||||
card.setAttribute('tabindex', '0');
|
card.setAttribute('tabindex', '0');
|
||||||
card.onclick = () => window.location.href = '/ticket/' + encodeURIComponent(ticketId);
|
card.dataset.ticketId = ticketId;
|
||||||
|
card.dataset.status = status;
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
// Don't navigate if drag just ended (drag adds/removes is-dragging briefly)
|
||||||
|
if (card.dataset.dragged) { delete card.dataset.dragged; return; }
|
||||||
|
window.location.href = '/ticket/' + encodeURIComponent(ticketId);
|
||||||
|
});
|
||||||
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
|
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
|
||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
'<div class="lt-kanban-card-header">' +
|
'<div class="lt-kanban-card-header">' +
|
||||||
@@ -1172,6 +1178,65 @@ function populateKanbanCards() {
|
|||||||
const s = el.dataset.status;
|
const s = el.dataset.status;
|
||||||
el.textContent = '(' + (counts[s] || 0) + ')';
|
el.textContent = '(' + (counts[s] || 0) + ')';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Kanban drag-and-drop via lt.sortable ──────────────────────
|
||||||
|
if (window.lt && lt.sortable) {
|
||||||
|
const colStatusMap = {
|
||||||
|
'kanban-col-open': 'Open',
|
||||||
|
'kanban-col-pending': 'Pending',
|
||||||
|
'kanban-col-inprogress': 'In Progress',
|
||||||
|
'kanban-col-closed': 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleKanbanSort(newItems, movedCard) {
|
||||||
|
if (!movedCard) return;
|
||||||
|
const newColEl = movedCard.parentNode;
|
||||||
|
const newColId = newColEl ? newColEl.id : null;
|
||||||
|
const newStatus = colStatusMap[newColId];
|
||||||
|
const oldStatus = movedCard.dataset.status;
|
||||||
|
const ticketId = movedCard.dataset.ticketId;
|
||||||
|
|
||||||
|
if (!newStatus || !ticketId || newStatus === oldStatus) return;
|
||||||
|
|
||||||
|
movedCard.dataset.status = newStatus;
|
||||||
|
movedCard.dataset.dragged = '1';
|
||||||
|
|
||||||
|
// Optimistically update column counts
|
||||||
|
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
|
||||||
|
const inc = document.querySelector(`.column-count[data-status="${newStatus}"]`);
|
||||||
|
if (dec) dec.textContent = '(' + Math.max(0, (parseInt(dec.textContent.replace(/\D/g,''),10)||1) - 1) + ')';
|
||||||
|
if (inc) inc.textContent = '(' + ((parseInt(inc.textContent.replace(/\D/g,''),10)||0) + 1) + ')';
|
||||||
|
|
||||||
|
// POST status update
|
||||||
|
fetch('/api/update_ticket.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
||||||
|
body: JSON.stringify({ ticket_id: parseInt(ticketId, 10), status: newStatus })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
|
||||||
|
movedCard.dataset.status = newStatus;
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Status update failed: ' + (data.error || 'Unknown error'));
|
||||||
|
// Revert: put card back in original column
|
||||||
|
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
|
||||||
|
if (origCol) origCol.appendChild(movedCard);
|
||||||
|
movedCard.dataset.status = oldStatus;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
lt.toast.error('Network error — status not saved');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(columns).forEach(status => {
|
||||||
|
const col = columns[status];
|
||||||
|
if (col) lt.sortable.init(col, { group: 'kanban', onSort: handleKanbanSort });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore view mode on page load — click the kanban tab button to trigger lt.tabs
|
// Restore view mode on page load — click the kanban tab button to trigger lt.tabs
|
||||||
|
|||||||
@@ -1029,4 +1029,125 @@ var advForm = document.getElementById('advancedSearchForm');
|
|||||||
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
|
TICKET PREVIEW RIGHT DRAWER
|
||||||
|
Opens when a ticket title link is ctrl/cmd+clicked or via
|
||||||
|
the preview icon — shows summary without full navigation.
|
||||||
|
═══════════════════════════════════════════════════════════ -->
|
||||||
|
<aside class="lt-drawer-right" id="ticketPreviewDrawer" aria-hidden="true"
|
||||||
|
aria-label="Ticket preview" data-overlay="ticketPreviewDrawerOverlay">
|
||||||
|
<div class="lt-drawer-right-header">
|
||||||
|
<span class="lt-drawer-right-title" id="drawerTicketId">Ticket Preview</span>
|
||||||
|
<button type="button" class="lt-drawer-right-close" data-drawer-close aria-label="Close preview">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-drawer-right-body" id="drawerBody">
|
||||||
|
<!-- Content injected by JS -->
|
||||||
|
</div>
|
||||||
|
<div class="lt-drawer-right-footer">
|
||||||
|
<a id="drawerOpenLink" href="#" class="lt-btn lt-btn-primary lt-btn-sm">Open Full Ticket</a>
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-drawer-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="lt-drawer-right-overlay" id="ticketPreviewDrawerOverlay"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Ticket Preview Drawer ──────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
var drawer = document.getElementById('ticketPreviewDrawer');
|
||||||
|
var body = document.getElementById('drawerBody');
|
||||||
|
var idLabel = document.getElementById('drawerTicketId');
|
||||||
|
var openLink = document.getElementById('drawerOpenLink');
|
||||||
|
if (!drawer || !body) return;
|
||||||
|
|
||||||
|
var pLabels = { '1':'P1 — Critical', '2':'P2 — High', '3':'P3 — Medium', '4':'P4 — Low', '5':'P5 — Minimal' };
|
||||||
|
var dotClass = { 'Open':'lt-dot-up', 'In Progress':'lt-dot-warn', 'Pending':'lt-dot--orange', 'Closed':'lt-dot-idle' };
|
||||||
|
|
||||||
|
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
function fmtAge(dateStr) {
|
||||||
|
var d = new Date(dateStr);
|
||||||
|
if (isNaN(d)) return dateStr;
|
||||||
|
var diff = Math.floor((Date.now() - d) / 1000);
|
||||||
|
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
|
||||||
|
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
|
||||||
|
return Math.floor(diff/86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDrawerFromRow(link) {
|
||||||
|
var href = link.getAttribute('href') || '';
|
||||||
|
var m = href.match(/\/ticket\/(\d+)/);
|
||||||
|
if (!m) return;
|
||||||
|
var ticketId = m[1];
|
||||||
|
if (openLink) openLink.href = href;
|
||||||
|
|
||||||
|
// Extract data from the table row (already rendered in DOM — no extra fetch needed)
|
||||||
|
var row = link.closest('tr');
|
||||||
|
var cells = row ? row.querySelectorAll('td') : [];
|
||||||
|
var hasCheckbox = row && row.querySelector('input[type="checkbox"]') !== null;
|
||||||
|
var o = hasCheckbox ? 1 : 0; // column offset for checkbox col
|
||||||
|
|
||||||
|
var priority = cells[1 + o] ? cells[1 + o].textContent.trim() : '';
|
||||||
|
var title = cells[2 + o] ? cells[2 + o].querySelector('.ticket-link')?.textContent.trim() || '' : '';
|
||||||
|
var category = cells[3 + o] ? cells[3 + o].textContent.trim() : '';
|
||||||
|
var typeVal = cells[4 + o] ? cells[4 + o].textContent.trim() : '';
|
||||||
|
var status = cells[5 + o] ? cells[5 + o].textContent.trim().replace(/^\s*●\s*/, '') : '';
|
||||||
|
var createdBy = cells[6 + o] ? cells[6 + o].textContent.trim() : '';
|
||||||
|
var assignedTo= cells[7 + o] ? cells[7 + o].textContent.trim() : '';
|
||||||
|
var age = cells[8 + o] ? cells[8 + o].textContent.trim() : '';
|
||||||
|
|
||||||
|
if (idLabel) idLabel.textContent = '[ #' + esc(ticketId) + ' ]';
|
||||||
|
var dc = dotClass[status] || 'lt-dot-idle';
|
||||||
|
var pNum = priority.replace(/[^1-5]/g, '') || '?';
|
||||||
|
var pLabel = pLabels[pNum] || ('P' + pNum);
|
||||||
|
|
||||||
|
body.innerHTML =
|
||||||
|
'<div class="lt-frame" style="margin-bottom:0.75rem;padding:0.6rem 0.75rem">' +
|
||||||
|
'<div style="font-weight:700;margin-bottom:0.5rem;font-size:0.9rem;line-height:1.3">' + esc(title) + '</div>' +
|
||||||
|
'<div class="lt-kv-grid" style="margin-bottom:0">' +
|
||||||
|
'<div class="lt-kv-row"><span class="lt-kv-label">Status</span><span class="lt-kv-value"><span class="lt-dot ' + dc + '" style="display:inline-block;vertical-align:middle;margin-right:0.35rem"></span>' + esc(status) + '</span></div>' +
|
||||||
|
'<div class="lt-kv-row"><span class="lt-kv-label">Priority</span><span class="lt-kv-value">' + esc(pLabel) + '</span></div>' +
|
||||||
|
'<div class="lt-kv-row"><span class="lt-kv-label">Category</span><span class="lt-kv-value">' + esc(category||'—') + '</span></div>' +
|
||||||
|
'<div class="lt-kv-row"><span class="lt-kv-label">Type</span><span class="lt-kv-value">' + esc(typeVal||'—') + '</span></div>' +
|
||||||
|
'<div class="lt-kv-row"><span class="lt-kv-label">Assigned</span><span class="lt-kv-value">' + esc(assignedTo||'Unassigned') + '</span></div>' +
|
||||||
|
(createdBy ? '<div class="lt-kv-row"><span class="lt-kv-label">Created by</span><span class="lt-kv-value">' + esc(createdBy) + '</span></div>' : '') +
|
||||||
|
(age ? '<div class="lt-kv-row"><span class="lt-kv-label">Age</span><span class="lt-kv-value">' + esc(age) + '</span></div>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="lt-text-muted lt-text-xs" style="text-align:center">Click "Open Full Ticket" for description, comments & attachments.</p>';
|
||||||
|
|
||||||
|
lt.rightDrawer.open('ticketPreviewDrawer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept clicks on .ticket-link with Ctrl/Cmd held → open drawer
|
||||||
|
// Normal left-click still navigates to full ticket page
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var link = e.target.closest('.ticket-link');
|
||||||
|
if (!link) return;
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
openDrawerFromRow(link);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a small [⊙] peek icon after each title link for easy drawer access
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('.ticket-link').forEach(function(link) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.title = 'Quick preview (Ctrl+click)';
|
||||||
|
btn.setAttribute('aria-label', 'Quick preview');
|
||||||
|
btn.innerHTML = '⧉';
|
||||||
|
btn.style.cssText = 'font-size:0.7rem;margin-left:0.3rem;opacity:0;border:none;background:none;cursor:pointer;color:var(--accent-cyan);vertical-align:middle;padding:0 0.1rem;line-height:1;transition:opacity 0.15s';
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
openDrawerFromRow(link);
|
||||||
|
});
|
||||||
|
link.addEventListener('mouseenter', function() { btn.style.opacity = '0.5'; });
|
||||||
|
link.addEventListener('mouseleave', function() { btn.style.opacity = '0'; });
|
||||||
|
link.parentNode.insertBefore(btn, link.nextSibling);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php include __DIR__ . '/layout_footer.php'; ?>
|
<?php include __DIR__ . '/layout_footer.php'; ?>
|
||||||
|
|||||||
+76
-14
@@ -192,19 +192,23 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
||||||
role="alert" aria-live="polite"
|
role="alert" aria-live="polite"
|
||||||
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
||||||
|
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
||||||
|
data-sla-hours="<?= $slaTargetHours ?>"
|
||||||
style="margin-bottom:0.75rem">
|
style="margin-bottom:0.75rem">
|
||||||
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
|
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
|
||||||
<div class="lt-alert-body">
|
<div class="lt-alert-body">
|
||||||
<div class="lt-alert-title"><?= $alertLabel ?></div>
|
<div class="lt-alert-title"><?= $alertLabel ?></div>
|
||||||
<div class="lt-alert-msg">
|
<div class="lt-alert-msg">
|
||||||
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
||||||
Elapsed: <strong><?= $elapsedHours ?>h</strong>
|
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
||||||
<?php if ($slaBreached): ?>
|
<?php if (!$slaBreached): ?>
|
||||||
— <span class="lt-text-danger">SLA BREACHED</span>
|
— Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
|
||||||
|
<?php else: ?>
|
||||||
|
— <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" style="margin-top:0.35rem"
|
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
|
||||||
aria-label="SLA progress <?= $slaPct ?>%">
|
aria-label="SLA progress <?= $slaPct ?>%">
|
||||||
<div class="lt-progress-bar" style="width:<?= $slaPct ?>%"></div>
|
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,8 +221,64 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
</div>
|
</div>
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
(function(){
|
(function(){
|
||||||
var id='priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
|
var banner = document.getElementById('priorityAlertBanner');
|
||||||
try{ if(sessionStorage.getItem('lt_dismissed_'+id)) document.getElementById('priorityAlertBanner').classList.add('dismissed'); }catch(e){}
|
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
|
||||||
|
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {}
|
||||||
|
|
||||||
|
// Live SLA timers — start after base.js initialises lt
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (!banner || banner.classList.contains('dismissed')) return;
|
||||||
|
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
||||||
|
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||||
|
var deadline = new Date(createdAt + slaMs);
|
||||||
|
var elapsedEl = document.getElementById('slaElapsedTimer');
|
||||||
|
var countdownEl = document.getElementById('slaCountdownTimer');
|
||||||
|
var overrunEl = document.getElementById('slaOverrunTimer');
|
||||||
|
var progressBar = document.getElementById('slaProgressBar');
|
||||||
|
var progressWrap = document.getElementById('slaProgress');
|
||||||
|
|
||||||
|
function fmtHMS(ms) {
|
||||||
|
var s = Math.floor(Math.abs(ms) / 1000);
|
||||||
|
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
||||||
|
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
var now = Date.now();
|
||||||
|
var elapsed = now - createdAt;
|
||||||
|
var remaining = deadline - now;
|
||||||
|
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||||
|
|
||||||
|
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||||
|
if (progressBar) progressBar.style.width = pct + '%';
|
||||||
|
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
// SLA not yet breached
|
||||||
|
if (countdownEl) {
|
||||||
|
countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||||
|
countdownEl.className = pct >= 75 ? 'lt-text-danger' : 'lt-text-cyan';
|
||||||
|
}
|
||||||
|
if (progressWrap && pct >= 75) {
|
||||||
|
progressWrap.className = progressWrap.className.replace('lt-progress--green','lt-progress--red');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Breached
|
||||||
|
if (countdownEl && !overrunEl) {
|
||||||
|
countdownEl.innerHTML = 'SLA BREACHED (+' + fmtHMS(-remaining) + ')';
|
||||||
|
countdownEl.className = 'lt-text-danger';
|
||||||
|
} else if (overrunEl) {
|
||||||
|
overrunEl.textContent = fmtHMS(-remaining);
|
||||||
|
}
|
||||||
|
if (progressWrap && !progressWrap.classList.contains('lt-progress--red')) {
|
||||||
|
progressWrap.className = progressWrap.className.replace('lt-progress--green','').replace('lt-progress--red','') + ' lt-progress--red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
@@ -445,14 +505,16 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
aria-label="Add a comment"></textarea>
|
aria-label="Add a comment"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-controls lt-flex lt-flex-gap-sm lt-flex-align-center">
|
<div class="comment-controls lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<div class="markdown-toggles lt-flex lt-flex-gap-sm">
|
<div class="markdown-toggles lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<label class="lt-filter-option">
|
<label class="lt-toggle lt-toggle--sm" title="Enable Markdown formatting">
|
||||||
<input type="checkbox" class="lt-checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
|
<input type="checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
|
||||||
Markdown
|
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
|
||||||
|
<span class="lt-toggle-label lt-text-xs">MD</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="lt-filter-option">
|
<label class="lt-toggle lt-toggle--sm" title="Preview rendered Markdown">
|
||||||
<input type="checkbox" class="lt-checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
|
<input type="checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
|
||||||
Preview
|
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
|
||||||
|
<span class="lt-toggle-label lt-text-xs">Preview</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
|
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
|
||||||
|
|||||||
@@ -172,6 +172,84 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Notification Bell ─────────────────────────────────────────────
|
||||||
|
<?php if (!empty($GLOBALS['currentUser'])): ?>
|
||||||
|
(function() {
|
||||||
|
var bell = document.getElementById('lt-notif-bell');
|
||||||
|
var panel = document.getElementById('lt-notif-panel');
|
||||||
|
var list = document.getElementById('lt-notif-list');
|
||||||
|
var clearBtn = document.getElementById('lt-notif-clear-btn');
|
||||||
|
var wrapEl = document.getElementById('lt-notif-wrap');
|
||||||
|
if (!bell || !panel) return;
|
||||||
|
|
||||||
|
var _open = false;
|
||||||
|
|
||||||
|
function fmtTime(dateStr) {
|
||||||
|
var d = new Date(dateStr);
|
||||||
|
var diff = Math.floor((Date.now() - d) / 1000);
|
||||||
|
if (diff < 60) return diff + 's ago';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
if (diff < 86400)return Math.floor(diff / 3600) + 'h ago';
|
||||||
|
return Math.floor(diff / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
function renderNotifications(data) {
|
||||||
|
lt.notif.set(bell, data.unread_count || 0);
|
||||||
|
if (!data.notifications || !data.notifications.length) {
|
||||||
|
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">No recent notifications</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = data.notifications.map(function(n) {
|
||||||
|
return '<div class="lt-notif-item' + (n.is_read ? '' : ' lt-notif-item--unread') +
|
||||||
|
'" tabindex="0" role="link" data-url="' + esc(n.url) + '">' +
|
||||||
|
'<div class="lt-notif-dot' + (n.is_read ? ' lt-notif-dot--read' : '') + '"></div>' +
|
||||||
|
'<div class="lt-notif-item-body">' +
|
||||||
|
'<div class="lt-notif-item-title">' + esc(n.title) + '</div>' +
|
||||||
|
'<div class="lt-notif-item-time">' + fmtTime(n.created_at) + '</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('.lt-notif-item').forEach(function(item) {
|
||||||
|
function go() { if (item.dataset.url) window.location.href = item.dataset.url; }
|
||||||
|
item.addEventListener('click', go);
|
||||||
|
item.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifications() {
|
||||||
|
fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(renderNotifications)
|
||||||
|
.catch(function() {
|
||||||
|
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() { _open = true; panel.removeAttribute('aria-hidden'); bell.setAttribute('aria-expanded','true'); loadNotifications(); }
|
||||||
|
function closePanel() { _open = false; panel.setAttribute('aria-hidden','true'); bell.setAttribute('aria-expanded','false'); }
|
||||||
|
|
||||||
|
bell.addEventListener('click', function(e) { e.stopPropagation(); _open ? closePanel() : openPanel(); });
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function() {
|
||||||
|
fetch('/api/notifications.php', {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
||||||
|
body: JSON.stringify({ action: 'mark_read' })
|
||||||
|
}).then(loadNotifications);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
||||||
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
||||||
|
|
||||||
|
// Initial badge count + poll every 60s
|
||||||
|
loadNotifications();
|
||||||
|
setInterval(loadNotifications, 60000);
|
||||||
|
})();
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
// Footer hint bar actions (keyboard help + settings — work on all pages)
|
// Footer hint bar actions (keyboard help + settings — work on all pages)
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
var btn = e.target.closest('[data-action]');
|
var btn = e.target.closest('[data-action]');
|
||||||
|
|||||||
@@ -174,6 +174,32 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
<?php if ($_lt_isAdmin): ?>
|
<?php if ($_lt_isAdmin): ?>
|
||||||
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<!-- Notification Bell -->
|
||||||
|
<?php if (!empty($_lt_user)): ?>
|
||||||
|
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
|
||||||
|
<button type="button"
|
||||||
|
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-wrap"
|
||||||
|
id="lt-notif-bell"
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="lt-notif-panel"
|
||||||
|
title="Notifications">
|
||||||
|
🔔
|
||||||
|
</button>
|
||||||
|
<div class="lt-notif-panel" id="lt-notif-panel" aria-hidden="true" role="dialog" aria-label="Notifications">
|
||||||
|
<div class="lt-notif-panel-header">
|
||||||
|
<span>Notifications</span>
|
||||||
|
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-notif-panel-list" id="lt-notif-list">
|
||||||
|
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-notif-panel-footer">
|
||||||
|
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm" style="width:100%;text-align:center">View activity log</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||||
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user