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:
2026-04-04 17:21:21 -04:00
parent 9916daa904
commit 3c29c6ee6f
6 changed files with 531 additions and 15 deletions
+164
View File
@@ -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,
]);