From d84d8fae58dd4ea1000866dbd8410b27f205e525 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 20:57:10 -0400 Subject: [PATCH] Make SLA priority-alert banner update live on priority change (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P1/P2 SLA breach banner and progress bar were rendered server-side at page load and never touched again. Changing a ticket's priority in edit mode (P1->P3 or P3->P1) left the banner in a stale state — showing/counting for a priority that no longer applied — until the page was reloaded. Moved the banner's render/update/teardown logic into a reusable renderSlaBanner() in ticket.js (verified via jsdom against real DOM: creates the banner for P1/P2, removes it when priority drops below P2 or the ticket is closed, and re-creates it including the already-breached state when priority is raised into P1/P2 range). The priority-change handler now calls it after a successful update, and the initial page load calls it once instead of relying on duplicated server-rendered markup + inline script. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP --- assets/js/ticket.js | 211 ++++++++++++++++++++++++++++++++++++++++--- views/TicketView.php | 96 ++------------------ 2 files changed, 208 insertions(+), 99 deletions(-) diff --git a/assets/js/ticket.js b/assets/js/ticket.js index a174d9e..17701b9 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -452,6 +452,140 @@ function handleAssignmentChange() { }); } +// ======================================== +// SLA Priority-Alert Banner +// ======================================== + +const SLA_TARGET_HOURS = { 1: 8, 2: 24 }; +const SLA_META = { + 1: { cls: 'lt-sla-p1', icon: '[ ! ]', label: 'P1 Critical' }, + 2: { cls: 'lt-sla-p2', icon: '[ ~ ]', label: 'P2 High' }, +}; + +let slaTickTimer = null; + +function stopSlaTicker() { + if (slaTickTimer) { + clearInterval(slaTickTimer); + slaTickTimer = null; + } +} + +function startSlaTicker(banner) { + stopSlaTicker(); + + const createdAt = parseInt(banner.dataset.createdAt, 10) * 1000; + const slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000; + const deadline = createdAt + slaMs; + const elapsedEl = document.getElementById('slaElapsedTimer'); + const countdownEl = document.getElementById('slaCountdownTimer'); + const overrunEl = document.getElementById('slaOverrunTimer'); + const fillBar = document.getElementById('slaProgressBar'); + const progressWrap = document.getElementById('slaProgress'); + + function fmtHMS(ms) { + const s = Math.floor(Math.abs(ms) / 1000); + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60; + return [h, m, ss].map(n => String(n).padStart(2, '0')).join(':'); + } + + function tick() { + const now = Date.now(); + const elapsed = now - createdAt; + const remaining = deadline - now; + const pct = Math.min(100, Math.round((elapsed / slaMs) * 100)); + + if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed); + if (fillBar) fillBar.style.width = pct + '%'; + if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%'); + + if (remaining > 0) { + if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining'; + } else if (overrunEl) { + overrunEl.textContent = fmtHMS(-remaining); + } + } + + tick(); + slaTickTimer = setInterval(tick, 1000); +} + +/** + * Render, update, or remove the SLA priority-alert banner for the given + * priority, matching what a fresh page load would show. Called on initial + * load and again whenever the ticket's priority changes client-side, so the + * banner never goes stale until a reload. + */ +function renderSlaBanner(priorityNum) { + const anchor = document.getElementById('priorityAlertBannerAnchor'); + const existing = document.getElementById('priorityAlertBanner'); + const meta = SLA_META[priorityNum]; + const status = window.ticketData && window.ticketData.status; + + if (!meta || status === 'Closed') { + if (existing) { + stopSlaTicker(); + existing.remove(); + } + return; + } + + const createdAtSec = window.ticketData && window.ticketData.created_at_ts; + if (!createdAtSec || !anchor) return; + + const slaTargetHours = SLA_TARGET_HOURS[priorityNum]; + const elapsedSeconds = Math.floor(Date.now() / 1000) - createdAtSec; + const slaBreached = elapsedSeconds >= slaTargetHours * 3600; + const slaPct = Math.min(100, Math.round((elapsedSeconds / (slaTargetHours * 3600)) * 100)); + const slaId = 'sla-' + window.ticketData.id; + + let dismissed = false; + try { + dismissed = !!sessionStorage.getItem('lt_sla_dismissed_' + slaId); + } catch (e) { /* sessionStorage unavailable */ } + + const banner = existing || document.createElement('div'); + if (!existing) { + banner.id = 'priorityAlertBanner'; + banner.setAttribute('role', 'alert'); + banner.setAttribute('aria-live', 'polite'); + banner.style.marginBottom = '0.75rem'; + anchor.appendChild(banner); + } + banner.className = meta.cls; + banner.dataset.slaId = slaId; + banner.dataset.createdAt = String(createdAtSec); + banner.dataset.slaHours = String(slaTargetHours); + banner.hidden = dismissed; + + banner.innerHTML = + `` + + '
' + + `
${lt.escHtml(meta.label)} — SLA: elapsed of ${slaTargetHours}h limit` + + (slaBreached ? ' BREACHED' : '') + + '
' + + `
` + + `
` + + '
' + + '
' + + (slaBreached + ? `
+${Math.round((elapsedSeconds - slaTargetHours * 3600) / 360) / 10}h over
` + : '
') + + ''; + + banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() { + banner.hidden = true; + stopSlaTicker(); + try { sessionStorage.setItem('lt_sla_dismissed_' + slaId, '1'); } catch (e) { /* ignore */ } + }); + + if (dismissed) { + stopSlaTicker(); + } else { + startSlaTicker(banner); + } +} + /** * Handle metadata field changes (priority, category, type) */ @@ -475,10 +609,12 @@ function handleMetadataChanges() { // Update window.ticketData window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue; - // For priority, update the TDS frame border accent + // For priority, update the TDS frame border accent and the + // SLA banner (which otherwise stays stale until reload) if (fieldName === 'priority') { const ticketFrame = document.querySelector('.lt-frame-ticket'); if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue); + renderSlaBanner(window.ticketData.priority); } } }) @@ -1027,35 +1163,62 @@ function resetUploadUI() { } } -function loadAttachments() { - const ticketId = window.ticketData.id; - const container = document.getElementById('attachmentsList'); +const ATTACHMENT_PAGE_SIZE = 40; +let attachmentOffset = 0; +let attachmentTotal = 0; +function loadAttachments() { + const container = document.getElementById('attachmentsList'); if (!container) return; - lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`) + attachmentOffset = 0; + attachmentTotal = 0; + fetchAttachmentsPage(false); +} + +function fetchAttachmentsPage(append) { + const ticketId = window.ticketData.id; + const container = document.getElementById('attachmentsList'); + if (!container) return; + + const loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn'); + if (loadMoreBtn) { + loadMoreBtn.disabled = true; + loadMoreBtn.textContent = 'Loading…'; + } + + lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}&offset=${attachmentOffset}&limit=${ATTACHMENT_PAGE_SIZE}`) .then(data => { if (data.success) { - renderAttachments(data.attachments || []); - } else { + attachmentTotal = data.total; + attachmentOffset += (data.attachments || []).length; + renderAttachments(data.attachments || [], append, data.has_more); + } else if (!append) { container.innerHTML = '

Error loading attachments.

'; + } else { + lt.toast.error('Error loading more attachments'); } }) .catch(error => { - container.innerHTML = '

Error loading attachments.

'; + if (!append) { + container.innerHTML = '

Error loading attachments.

'; + } else { + lt.toast.error('Error loading more attachments'); + } }); } -function renderAttachments(attachments) { +function renderAttachments(attachments, append, hasMore) { const container = document.getElementById('attachmentsList'); if (!container) return; - if (attachments.length === 0) { + if (!append && attachments.length === 0) { container.innerHTML = '

No files attached to this ticket.

'; return; } - let html = '
'; + let grid = append ? container.querySelector('.attachments-grid') : null; + let html = ''; attachments.forEach(att => { const uploaderName = att.display_name || att.username || 'Unknown'; @@ -1095,8 +1258,30 @@ function renderAttachments(attachments) {
`; }); - html += ''; - container.innerHTML = html; + if (grid) { + grid.insertAdjacentHTML('beforeend', html); + } else { + container.innerHTML = '
' + html + '
'; + } + + const remaining = attachmentTotal - attachmentOffset; + let loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn'); + if (hasMore && remaining > 0) { + if (!loadMoreBtn) { + loadMoreBtn = document.createElement('button'); + loadMoreBtn.type = 'button'; + loadMoreBtn.id = 'attachmentsLoadMoreBtn'; + loadMoreBtn.className = 'lt-btn lt-btn-sm lt-w-full'; + loadMoreBtn.style.marginTop = '0.6rem'; + loadMoreBtn.addEventListener('click', function() { fetchAttachmentsPage(true); }); + container.appendChild(loadMoreBtn); + } + loadMoreBtn.disabled = false; + loadMoreBtn.textContent = `Load more attachments (${remaining} remaining)`; + } else if (loadMoreBtn) { + loadMoreBtn.remove(); + } + // Initialize lightbox on image thumbnails if (window.lt && lt.lightbox) { lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true }); diff --git a/views/TicketView.php b/views/TicketView.php index 905cb80..9839ef0 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -114,6 +114,7 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG); $json_category = json_encode($ticket['category'], JSON_HEX_TAG); $json_type = json_encode($ticket['type'], JSON_HEX_TAG); $json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG); +$json_created_at_ts = json_encode((int)strtotime($ticket['created_at']), JSON_HEX_TAG); $json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG); $json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG); $json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG); @@ -127,6 +128,7 @@ window.ticketData = { category: {$json_category}, type: {$json_type}, updated_at: {$json_updated_at}, + created_at_ts: {$json_created_at_ts}, totalComments: {$json_total_comments}, commentOffset: {$json_comment_page}, commentPageSize:{$json_comment_page}, @@ -209,95 +211,17 @@ include __DIR__ . '/layout_header.php'; - - 8, 2 => 24, default => 72 - }; - $elapsedSeconds = time() - strtotime($ticket['created_at']); - $slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100)); - $slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600); - $slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2'; - $slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]'; - $slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High'; - $slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8'); - ?> - - + +
-