Make SLA priority-alert banner update live on priority change (#64)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
This commit is contained in:
+198
-13
@@ -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 =
|
||||
`<span class="lt-sla-icon" aria-hidden="true">${meta.icon}</span>` +
|
||||
'<div class="lt-sla-info">' +
|
||||
`<div class="lt-sla-title">${lt.escHtml(meta.label)} — SLA: <span id="slaElapsedTimer"></span> elapsed of ${slaTargetHours}h limit` +
|
||||
(slaBreached ? ' <span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>' : '') +
|
||||
'</div>' +
|
||||
`<div class="lt-sla-bar" aria-label="SLA progress ${slaPct}%" id="slaProgress">` +
|
||||
`<div class="lt-sla-fill" id="slaProgressBar" style="width:${slaPct}%"></div>` +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(slaBreached
|
||||
? `<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer">${Math.round((elapsedSeconds - slaTargetHours * 3600) / 360) / 10}h</span> over</div>`
|
||||
: '<div class="lt-sla-meta" id="slaCountdownTimer"></div>') +
|
||||
'<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>';
|
||||
|
||||
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 = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||
} else {
|
||||
lt.toast.error('Error loading more attachments');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||
if (!append) {
|
||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||
} 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 = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="attachments-grid">';
|
||||
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) {
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
if (grid) {
|
||||
grid.insertAdjacentHTML('beforeend', html);
|
||||
} else {
|
||||
container.innerHTML = '<div class="attachments-grid">' + html + '</div>';
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
+10
-86
@@ -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';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
||||
<?php
|
||||
$slaTargetHours = match ($priorityNum) {
|
||||
1 => 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');
|
||||
?>
|
||||
<!-- SLA banner — P1/P2 only, dismissible per session -->
|
||||
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
|
||||
data-sla-id="<?= $slaId ?>"
|
||||
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
||||
data-sla-hours="<?= $slaTargetHours ?>"
|
||||
style="margin-bottom:0.75rem">
|
||||
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
|
||||
<div class="lt-sla-info">
|
||||
<div class="lt-sla-title">
|
||||
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
|
||||
<?php if ($slaBreached) : ?>
|
||||
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
|
||||
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$slaBreached) : ?>
|
||||
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
|
||||
<?php else : ?>
|
||||
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
|
||||
<?php endif ?>
|
||||
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
<?php // SLA banner (P1/P2, non-Closed tickets) is rendered and kept live by
|
||||
// renderSlaBanner() in ticket.js, so it can also rebuild/tear itself
|
||||
// down when priority changes client-side without a page reload. ?>
|
||||
<div id="priorityAlertBannerAnchor"></div>
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||
(function(){
|
||||
var banner = document.getElementById('priorityAlertBanner');
|
||||
var id = banner.dataset.slaId;
|
||||
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
|
||||
|
||||
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
||||
banner.hidden = true;
|
||||
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (banner.hidden) 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 fillBar = 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(':');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof renderSlaBanner === 'function') {
|
||||
renderSlaBanner(window.ticketData.priority);
|
||||
}
|
||||
|
||||
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 (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();
|
||||
setInterval(tick, 1000);
|
||||
});
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
<?php endif ?>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
TICKET DETAIL FRAME
|
||||
|
||||
Reference in New Issue
Block a user