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
+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>