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
+121
View File
@@ -1029,4 +1029,125 @@ var advForm = document.getElementById('advancedSearchForm');
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
</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">&#x2715;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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 &amp; 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 = '&#x29C9;';
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'; ?>