Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3664719148 | ||
|
|
d7940b1e31 | ||
|
|
fba251b85d | ||
|
|
fd777aa690 | ||
|
|
a9a39adcf8 | ||
|
|
23d94bfae7 | ||
|
|
e39b4f81ea | ||
|
|
3d5adbbfda | ||
|
|
b0765eb7f4 | ||
|
|
d84d8fae58 | ||
|
|
b6d3cc4e70 |
@@ -102,12 +102,50 @@ try {
|
|||||||
// Sanitize filename for Content-Disposition
|
// Sanitize filename for Content-Disposition
|
||||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||||
|
|
||||||
|
$fileSize = filesize($filePath);
|
||||||
|
|
||||||
|
// Parse a single-range "Range: bytes=start-end" request header (RFC 7233).
|
||||||
|
// Multi-range requests aren't supported; they fall through to a full 200 response.
|
||||||
|
$rangeStart = 0;
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = false;
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) {
|
||||||
|
if ($m[1] === '' && $m[2] === '') {
|
||||||
|
// Malformed ("bytes=-") — ignore and serve the full file.
|
||||||
|
} elseif ($m[1] === '') {
|
||||||
|
// Suffix range: last N bytes
|
||||||
|
$suffixLength = (int)$m[2];
|
||||||
|
$rangeStart = max(0, $fileSize - $suffixLength);
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = true;
|
||||||
|
} else {
|
||||||
|
$rangeStart = (int)$m[1];
|
||||||
|
$rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1);
|
||||||
|
$isRangeRequest = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) {
|
||||||
|
http_response_code(416);
|
||||||
|
header('Content-Range: bytes */' . $fileSize);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rangeLength = $rangeEnd - $rangeStart + 1;
|
||||||
|
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
header('Content-Type: ' . $attachment['mime_type']);
|
header('Content-Type: ' . $attachment['mime_type']);
|
||||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||||
header('Content-Length: ' . $attachment['file_size']);
|
|
||||||
header('Cache-Control: private, max-age=3600');
|
header('Cache-Control: private, max-age=3600');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
if ($isRangeRequest) {
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
|
||||||
|
}
|
||||||
|
header('Content-Length: ' . $rangeLength);
|
||||||
|
|
||||||
// Prevent PHP from timing out on large files
|
// Prevent PHP from timing out on large files
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
|
||||||
@@ -125,9 +163,18 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!feof($handle)) {
|
fseek($handle, $rangeStart);
|
||||||
echo fread($handle, 8192);
|
$remaining = $rangeLength;
|
||||||
|
$chunkSize = 8192;
|
||||||
|
while ($remaining > 0 && !feof($handle)) {
|
||||||
|
$read = ($remaining < $chunkSize) ? $remaining : $chunkSize;
|
||||||
|
$data = fread($handle, $read);
|
||||||
|
if ($data === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
echo $data;
|
||||||
flush();
|
flush();
|
||||||
|
$remaining -= strlen($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
|
||||||
WHERE al.action_type = 'update'
|
WHERE al.action_type = 'update'
|
||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
ResponseHelper::error('Invalid ticket ID format');
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
|
||||||
|
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 40;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
@@ -123,7 +126,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$attachmentModel = new AttachmentModel($conn);
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
$total = $attachmentModel->getAttachmentCount($ticketId);
|
||||||
|
$attachments = $attachmentModel->getAttachments($ticketId, $limit, $offset);
|
||||||
|
|
||||||
// Add formatted file size and icon to each attachment
|
// Add formatted file size and icon to each attachment
|
||||||
foreach ($attachments as &$att) {
|
foreach ($attachments as &$att) {
|
||||||
@@ -131,7 +135,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseHelper::success(['attachments' => $attachments]);
|
ResponseHelper::success([
|
||||||
|
'attachments' => $attachments,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
'has_more' => ($offset + $limit) < $total,
|
||||||
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ResponseHelper::serverError('Failed to load attachments');
|
ResponseHelper::serverError('Failed to load attachments');
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-15
@@ -12,40 +12,43 @@ require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
$ticketId = isset($_GET['ticket_id'])
|
$ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
|
||||||
? (int)$_GET['ticket_id']
|
|
||||||
: (int)($data['ticket_id'] ?? 0);
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$ticketId = (int)($data['ticket_id'] ?? 0);
|
$ticketIdRaw = $data['ticket_id'] ?? '';
|
||||||
$action = $data['action'] ?? '';
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the canonical ticket_id string from the fetched ticket row, not the
|
||||||
|
// raw request value, so ticket_watchers always stores exactly what's in
|
||||||
|
// tickets.ticket_id.
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
if ($action === 'watch') {
|
if ($action === 'watch') {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
} else {
|
} else {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
}
|
}
|
||||||
@@ -54,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$countStmt = $conn->prepare(
|
$countStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
);
|
);
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
@@ -73,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ticketId <= 0) {
|
if ($ticketIdRaw === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||||
exit;
|
exit;
|
||||||
@@ -83,17 +86,19 @@ if ($ticketId <= 0) {
|
|||||||
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
||||||
// already checks this).
|
// already checks this).
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
$watchingStmt = $conn->prepare(
|
$watchingStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
$watchingStmt->bind_param("si", $ticketId, $userId);
|
||||||
$watchingStmt->execute();
|
$watchingStmt->execute();
|
||||||
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$watchingStmt->close();
|
$watchingStmt->close();
|
||||||
@@ -107,7 +112,7 @@ $watchersStmt = $conn->prepare(
|
|||||||
ORDER BY tw.created_at ASC
|
ORDER BY tw.created_at ASC
|
||||||
LIMIT 6"
|
LIMIT 6"
|
||||||
);
|
);
|
||||||
$watchersStmt->bind_param("i", $ticketId);
|
$watchersStmt->bind_param("s", $ticketId);
|
||||||
$watchersStmt->execute();
|
$watchersStmt->execute();
|
||||||
$watchersResult = $watchersStmt->get_result();
|
$watchersResult = $watchersStmt->get_result();
|
||||||
$watchers = [];
|
$watchers = [];
|
||||||
@@ -118,7 +123,7 @@ $watchersStmt->close();
|
|||||||
|
|
||||||
// True watcher count (the list above is capped at 6 for the avatar group)
|
// True watcher count (the list above is capped at 6 for the avatar group)
|
||||||
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
|
|||||||
@@ -361,14 +361,9 @@ function initSidebarFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (clearFiltersBtn) {
|
if (clearFiltersBtn) {
|
||||||
clearFiltersBtn.addEventListener('click', () => {
|
// Delegate to clearAllFilters() so both controls always clear the same
|
||||||
const params = new URLSearchParams(window.location.search);
|
// complete set of filter params instead of two independently-maintained lists.
|
||||||
['status','category','type',
|
clearFiltersBtn.addEventListener('click', clearAllFilters);
|
||||||
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
|
|
||||||
].forEach(k => params.delete(k));
|
|
||||||
params.set('page', '1');
|
|
||||||
window.location.search = params.toString();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+202
-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)
|
* Handle metadata field changes (priority, category, type)
|
||||||
*/
|
*/
|
||||||
@@ -475,10 +609,12 @@ function handleMetadataChanges() {
|
|||||||
// Update window.ticketData
|
// Update window.ticketData
|
||||||
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
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') {
|
if (fieldName === 'priority') {
|
||||||
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
||||||
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
||||||
|
renderSlaBanner(window.ticketData.priority);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1027,35 +1163,62 @@ function resetUploadUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAttachments() {
|
const ATTACHMENT_PAGE_SIZE = 40;
|
||||||
const ticketId = window.ticketData.id;
|
let attachmentOffset = 0;
|
||||||
const container = document.getElementById('attachmentsList');
|
let attachmentTotal = 0;
|
||||||
|
|
||||||
|
function loadAttachments() {
|
||||||
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
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 => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
renderAttachments(data.attachments || []);
|
attachmentTotal = data.total;
|
||||||
} else {
|
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>';
|
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Error loading more attachments');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.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');
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
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>';
|
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<div class="attachments-grid">';
|
let grid = append ? container.querySelector('.attachments-grid') : null;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
attachments.forEach(att => {
|
attachments.forEach(att => {
|
||||||
const uploaderName = att.display_name || att.username || 'Unknown';
|
const uploaderName = att.display_name || att.username || 'Unknown';
|
||||||
@@ -1095,8 +1258,34 @@ function renderAttachments(attachments) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
html += '</div>';
|
if (grid) {
|
||||||
container.innerHTML = html;
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
grid.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
} 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
|
// Initialize lightbox on image thumbnails
|
||||||
if (window.lt && lt.lightbox) {
|
if (window.lt && lt.lightbox) {
|
||||||
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ header('Content-Type: application/json');
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Load environment variables with error check
|
||||||
$envFile = __DIR__ . '/.env';
|
$envFile = __DIR__ . '/.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($excludeUserId !== null) {
|
if ($excludeUserId !== null) {
|
||||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
$stmt->bind_param("si", $ticketId, $excludeUserId);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|||||||
@@ -243,11 +243,12 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
|
|||||||
|
|
||||||
-- ============ ticket_watchers ============
|
-- ============ ticket_watchers ============
|
||||||
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
||||||
`ticket_id` int(11) NOT NULL,
|
`ticket_id` varchar(9) NOT NULL,
|
||||||
`user_id` int(11) NOT NULL,
|
`user_id` int(11) NOT NULL,
|
||||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||||
PRIMARY KEY (`ticket_id`,`user_id`),
|
PRIMARY KEY (`ticket_id`,`user_id`),
|
||||||
KEY `idx_watcher_user` (`user_id`)
|
KEY `idx_watcher_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ tickets ============
|
-- ============ tickets ============
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
|
||||||
|
--
|
||||||
|
-- ticket_watchers.ticket_id was int(11), while every other satellite table
|
||||||
|
-- (ticket_comments, ticket_attachments, ticket_dependencies,
|
||||||
|
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
|
||||||
|
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
|
||||||
|
-- other satellite table, so orphaned watcher rows could never be caught by
|
||||||
|
-- referential integrity. Ticket IDs are always 9-digit numeric strings
|
||||||
|
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
|
||||||
|
-- conversion below is lossless for real data.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
-- Remove any watcher rows that no longer point at a real ticket (possible
|
||||||
|
-- today precisely because there was no FK to prevent it) before adding the
|
||||||
|
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
|
||||||
|
DELETE tw FROM `ticket_watchers` tw
|
||||||
|
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
|
||||||
|
WHERE t.`ticket_id` IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
|
||||||
+68
-11
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks per-statement progress within a migration file. MySQL DDL statements
|
||||||
|
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
|
||||||
|
// rollback() around a whole file can't actually undo DDL already executed
|
||||||
|
// earlier in that same file. This table lets a re-run after a partial failure
|
||||||
|
// resume from the statement after the last one that succeeded, instead of
|
||||||
|
// re-executing already-applied DDL and wedging on "already exists" errors.
|
||||||
|
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
|
||||||
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
last_statement_index INT NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
)";
|
||||||
|
|
||||||
|
if (!$conn->query($createProgressTable)) {
|
||||||
|
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Get list of completed migrations
|
// Get list of completed migrations
|
||||||
$completed = [];
|
$completed = [];
|
||||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||||
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute migration - handle multiple statements
|
// Execute migration statement-by-statement, tracking progress as we go.
|
||||||
$conn->begin_transaction();
|
// No begin_transaction()/rollback() here: DDL statements auto-commit in
|
||||||
|
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
|
||||||
|
// file would only create the illusion of atomicity while giving no real
|
||||||
|
// protection. Instead, each statement commits immediately (autocommit),
|
||||||
|
// and its index is durably recorded so a later re-run can resume exactly
|
||||||
|
// where a previous run left off rather than re-executing already-applied
|
||||||
|
// DDL.
|
||||||
try {
|
try {
|
||||||
// Split by semicolon but respect statements properly
|
// Split by semicolon but respect statements properly
|
||||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||||
$statements = array_filter(
|
$statements = array_values(array_filter(
|
||||||
array_map('trim', explode(';', $sql)),
|
array_map('trim', explode(';', $sql)),
|
||||||
function($stmt) {
|
function($stmt) {
|
||||||
// Remove comments and check if there's actual SQL
|
// Remove comments and check if there's actual SQL
|
||||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||||
return !empty(trim($cleaned));
|
return !empty(trim($cleaned));
|
||||||
}
|
}
|
||||||
);
|
));
|
||||||
|
|
||||||
|
$resumeFrom = 0;
|
||||||
|
$progressStmt = $conn->prepare(
|
||||||
|
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
|
||||||
|
);
|
||||||
|
$progressStmt->bind_param('s', $filename);
|
||||||
|
$progressStmt->execute();
|
||||||
|
$progressRow = $progressStmt->get_result()->fetch_assoc();
|
||||||
|
$progressStmt->close();
|
||||||
|
if ($progressRow) {
|
||||||
|
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
|
||||||
|
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
|
||||||
|
. " after a previous partial failure... ";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($statements as $index => $statement) {
|
||||||
|
if ($index < $resumeFrom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($statements as $statement) {
|
|
||||||
if (!$conn->query($statement)) {
|
if (!$conn->query($statement)) {
|
||||||
// Some "errors" are acceptable (like "index already exists")
|
// Some "errors" are acceptable (like "index already exists")
|
||||||
$error = $conn->error;
|
$error = $conn->error;
|
||||||
if (strpos($error, 'Duplicate key name') !== false ||
|
if (strpos($error, 'Duplicate key name') !== false ||
|
||||||
strpos($error, 'already exists') !== false) {
|
strpos($error, 'already exists') !== false) {
|
||||||
// Index already exists, that's fine
|
// Index already exists, that's fine
|
||||||
continue;
|
} else {
|
||||||
|
throw new Exception($error);
|
||||||
}
|
}
|
||||||
throw new Exception($error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record progress after every statement so a later run can
|
||||||
|
// resume from here even if a subsequent statement fails.
|
||||||
|
$upsert = $conn->prepare(
|
||||||
|
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
|
||||||
|
);
|
||||||
|
$upsert->bind_param('si', $filename, $index);
|
||||||
|
$upsert->execute();
|
||||||
|
$upsert->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the migration
|
// Record the migration as fully complete and clear its progress marker
|
||||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||||
$stmt->bind_param('s', $filename);
|
$stmt->bind_param('s', $filename);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
throw new Exception("Could not record migration: " . $conn->error);
|
throw new Exception("Could not record migration: " . $conn->error);
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->commit();
|
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
|
||||||
|
$clearProgress->bind_param('s', $filename);
|
||||||
|
$clearProgress->execute();
|
||||||
|
$clearProgress->close();
|
||||||
|
|
||||||
echo "OK\n";
|
echo "OK\n";
|
||||||
$success++;
|
$success++;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$conn->rollback();
|
// Nothing to roll back: every statement up to the failure already
|
||||||
|
// committed (DDL implicitly, everything else via autocommit). The
|
||||||
|
// progress marker recorded above reflects exactly how far this file
|
||||||
|
// got, so the next run will resume right after the last success.
|
||||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class AttachmentModel
|
|||||||
/**
|
/**
|
||||||
* Get all attachments for a ticket
|
* Get all attachments for a ticket
|
||||||
*/
|
*/
|
||||||
public function getAttachments($ticketId)
|
public function getAttachments($ticketId, int $limit = 0, int $offset = 0)
|
||||||
{
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
@@ -24,8 +24,16 @@ class AttachmentModel
|
|||||||
WHERE a.ticket_id = ?
|
WHERE a.ticket_id = ?
|
||||||
ORDER BY a.uploaded_at DESC";
|
ORDER BY a.uploaded_at DESC";
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$sql .= " LIMIT ? OFFSET ?";
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId);
|
if ($limit > 0) {
|
||||||
|
$stmt->bind_param("sii", $ticketId, $limit, $offset);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param("s", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
|||||||
@@ -726,7 +726,10 @@ class TicketModel
|
|||||||
$groupConditions = [];
|
$groupConditions = [];
|
||||||
foreach ($userGroups as $group) {
|
foreach ($userGroups as $group) {
|
||||||
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
||||||
$params[] = $group;
|
// Strip spaces from the bound value too, matching the REPLACE()
|
||||||
|
// applied to the column, so a group name like "IT Support" is
|
||||||
|
// normalized the same way on both sides of the comparison.
|
||||||
|
$params[] = str_replace(' ', '', $group);
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
}
|
}
|
||||||
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
||||||
|
|||||||
+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_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
||||||
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
||||||
$json_updated_at = json_encode($ticket['updated_at'], 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_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
||||||
$json_comment_page = json_encode((int)$commentPageSize, 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);
|
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
|
||||||
@@ -127,6 +128,7 @@ window.ticketData = {
|
|||||||
category: {$json_category},
|
category: {$json_category},
|
||||||
type: {$json_type},
|
type: {$json_type},
|
||||||
updated_at: {$json_updated_at},
|
updated_at: {$json_updated_at},
|
||||||
|
created_at_ts: {$json_created_at_ts},
|
||||||
totalComments: {$json_total_comments},
|
totalComments: {$json_total_comments},
|
||||||
commentOffset: {$json_comment_page},
|
commentOffset: {$json_comment_page},
|
||||||
commentPageSize:{$json_comment_page},
|
commentPageSize:{$json_comment_page},
|
||||||
@@ -209,95 +211,17 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
<?php // SLA banner (P1/P2, non-Closed tickets) is rendered and kept live by
|
||||||
<?php
|
// renderSlaBanner() in ticket.js, so it can also rebuild/tear itself
|
||||||
$slaTargetHours = match ($priorityNum) {
|
// down when priority changes client-side without a page reload. ?>
|
||||||
1 => 8, 2 => 24, default => 72
|
<div id="priorityAlertBannerAnchor"></div>
|
||||||
};
|
|
||||||
$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>
|
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
(function(){
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var banner = document.getElementById('priorityAlertBanner');
|
if (typeof renderSlaBanner === 'function') {
|
||||||
var id = banner.dataset.slaId;
|
renderSlaBanner(window.ticketData.priority);
|
||||||
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(':');
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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>
|
</script>
|
||||||
<?php endif ?>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
TICKET DETAIL FRAME
|
TICKET DETAIL FRAME
|
||||||
|
|||||||
Reference in New Issue
Block a user