From 3c29c6ee6f90c5d4c1e4553d52916c6c937b43a4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 4 Apr 2026 17:21:21 -0400 Subject: [PATCH] feat: SLA live timer, notification bell, lt-toggle MD, right drawer, kanban drag-drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/notifications.php | 164 ++++++++++++++++++++++++++++++++++++++++ assets/js/dashboard.js | 67 +++++++++++++++- views/DashboardView.php | 121 +++++++++++++++++++++++++++++ views/TicketView.php | 90 ++++++++++++++++++---- views/layout_footer.php | 78 +++++++++++++++++++ views/layout_header.php | 26 +++++++ 6 files changed, 531 insertions(+), 15 deletions(-) create mode 100644 api/notifications.php diff --git a/api/notifications.php b/api/notifications.php new file mode 100644 index 0000000..7c88ef9 --- /dev/null +++ b/api/notifications.php @@ -0,0 +1,164 @@ +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, +]); diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 68af2aa..cab45e6 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -1151,7 +1151,13 @@ function populateKanbanCards() { card.className = 'lt-kanban-card lt-kanban-card--p' + pNum; card.setAttribute('role', 'button'); 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.innerHTML = '
' + @@ -1172,6 +1178,65 @@ function populateKanbanCards() { const s = el.dataset.status; 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 diff --git a/views/DashboardView.php b/views/DashboardView.php index feded98..3140587 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -1029,4 +1029,125 @@ var advForm = document.getElementById('advancedSearchForm'); if (advForm) advForm.addEventListener('submit', performAdvancedSearch); + + +
+ + + diff --git a/views/TicketView.php b/views/TicketView.php index 4090fbc..30b3e01 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -192,19 +192,23 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr @@ -445,14 +505,16 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr aria-label="Add a comment">
-
-