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'; ?>
+76 -14
View File
@@ -192,19 +192,23 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
role="alert" aria-live="polite"
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">
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
<div class="lt-alert-body">
<div class="lt-alert-title"><?= $alertLabel ?></div>
<div class="lt-alert-msg">
SLA target: <strong><?= $slaTargetHours ?>h</strong> &mdash;
Elapsed: <strong><?= $elapsedHours ?>h</strong>
<?php if ($slaBreached): ?>
&mdash; <span class="lt-text-danger">SLA BREACHED</span>
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
<?php if (!$slaBreached): ?>
&mdash; Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
<?php else: ?>
&mdash; <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
<?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 ?>%">
<div class="lt-progress-bar" style="width:<?= $slaPct ?>%"></div>
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div>
</div>
@@ -217,8 +221,64 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){
var id='priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
try{ if(sessionStorage.getItem('lt_dismissed_'+id)) document.getElementById('priorityAlertBanner').classList.add('dismissed'); }catch(e){}
var banner = document.getElementById('priorityAlertBanner');
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>
<?php endif ?>
@@ -445,14 +505,16 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
aria-label="Add a comment"></textarea>
</div>
<div class="comment-controls lt-flex lt-flex-gap-sm lt-flex-align-center">
<div class="markdown-toggles lt-flex lt-flex-gap-sm">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
Markdown
<div class="markdown-toggles lt-flex lt-flex-gap-sm lt-flex-align-center">
<label class="lt-toggle lt-toggle--sm" title="Enable Markdown formatting">
<input type="checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">MD</span>
</label>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
Preview
<label class="lt-toggle lt-toggle--sm" title="Preview rendered Markdown">
<input type="checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">Preview</span>
</label>
</div>
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
+78
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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)
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
+26
View File
@@ -174,6 +174,32 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<?php if ($_lt_isAdmin): ?>
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
<?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">
&#x1F514;
</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&hellip;</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; ?>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Switch to light mode" title="Switch to light mode">&#x2600;</button>