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
+66 -1
View File
@@ -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 =
'<div class="lt-kanban-card-header">' +
@@ -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