feat: status dots, priority banners, lt-tags, command palette, activity timeline improvements
- Fix DashboardView asset version (was hardcoded 20260327, now uses config ASSET_VERSION) - Add lt-dot status indicators on dashboard table rows and ticket view toolbar - Add lt-tag display for Category/Type in ticket read mode (swaps to select in edit mode) - Add P1/P2 SLA alert banner with elapsed time, progress bar, per-session dismiss - Wire command palette (Ctrl+K): global nav + admin links via lt.cmdPalette.init() - Fix cmdPalette.init() call format (flat array, not nested group objects) - Improve activity timeline: richer formatAction(), better color coding by event type, inline status transitions shown in meta row, icon column added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,10 @@ function toggleEditMode() {
|
||||
metadataFields.forEach(field => {
|
||||
field.classList.remove('lt-display-field');
|
||||
});
|
||||
|
||||
// Show edit-mode selects for category/type, hide their read-mode tags
|
||||
document.querySelectorAll('.read-mode-tag').forEach(el => { el.style.display = 'none'; });
|
||||
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = ''; });
|
||||
} else {
|
||||
saveTicket();
|
||||
editButton.textContent = 'Edit Ticket';
|
||||
@@ -159,6 +163,22 @@ function toggleEditMode() {
|
||||
metadataFields.forEach(field => {
|
||||
field.classList.add('lt-display-field');
|
||||
});
|
||||
|
||||
// Hide edit-mode selects, show and update read-mode tags
|
||||
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = 'none'; });
|
||||
var catSel = document.getElementById('categorySelect');
|
||||
var typSel = document.getElementById('typeSelect');
|
||||
var catTag = document.getElementById('categoryTag');
|
||||
var typTag = document.getElementById('typeTag');
|
||||
if (catTag) {
|
||||
if (catSel) catTag.textContent = catSel.options[catSel.selectedIndex].text;
|
||||
catTag.style.display = '';
|
||||
}
|
||||
if (typTag) {
|
||||
if (typSel) typTag.textContent = typSel.options[typSel.selectedIndex].text;
|
||||
typTag.style.display = '';
|
||||
}
|
||||
document.querySelectorAll('.read-mode-tag:not(#categoryTag):not(#typeTag)').forEach(el => { el.style.display = ''; });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -14,13 +14,14 @@ require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
$pageTitle = 'Dashboard';
|
||||
$activeNav = 'dashboard';
|
||||
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
|
||||
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||
$pageScripts = [
|
||||
'/assets/js/markdown.js?v=20260327',
|
||||
'/assets/js/dashboard.js?v=20260327',
|
||||
'/assets/js/advanced-search.js?v=20260327',
|
||||
'/assets/js/keyboard-shortcuts.js?v=20260327',
|
||||
'/assets/js/settings.js?v=20260327',
|
||||
"/assets/js/markdown.js?v={$_v}",
|
||||
"/assets/js/dashboard.js?v={$_v}",
|
||||
"/assets/js/advanced-search.js?v={$_v}",
|
||||
"/assets/js/keyboard-shortcuts.js?v={$_v}",
|
||||
"/assets/js/settings.js?v={$_v}",
|
||||
];
|
||||
|
||||
// ── Pagination helpers ────────────────────────────────────────────────────────
|
||||
@@ -431,6 +432,14 @@ include __DIR__ . '/layout_header.php';
|
||||
<td data-label="Category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
|
||||
<td data-label="Type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
|
||||
<td data-label="Status">
|
||||
<?php $rowDotClass = match($row['status']) {
|
||||
'Open' => 'lt-dot-up',
|
||||
'In Progress' => 'lt-dot-warn',
|
||||
'Pending' => 'lt-dot--orange',
|
||||
'Closed' => 'lt-dot-idle',
|
||||
default => 'lt-dot-idle',
|
||||
}; ?>
|
||||
<span class="lt-dot <?= $rowDotClass ?>" aria-hidden="true" style="vertical-align:middle;margin-right:0.3rem"></span>
|
||||
<span class="lt-status lt-status-<?= $rowStatusSlug ?>"><?= htmlspecialchars($row['status']) ?></span>
|
||||
</td>
|
||||
<td data-label="Created By" class="lt-text-xs"><?= $creator ?></td>
|
||||
|
||||
+111
-21
@@ -28,20 +28,40 @@ function getEventIcon(string $actionType): string {
|
||||
'view' => '[.]',
|
||||
'assign' => '[@]',
|
||||
'status_change' => '[!]',
|
||||
'attachment' => '[^]',
|
||||
'delete' => '[x]',
|
||||
default => '[*]',
|
||||
};
|
||||
}
|
||||
|
||||
function formatAction(array $event): string {
|
||||
return match($event['action_type']) {
|
||||
'create' => 'created this ticket',
|
||||
'update' => 'updated this ticket',
|
||||
'comment' => 'added a comment',
|
||||
'view' => 'viewed this ticket',
|
||||
'assign' => 'assigned this ticket',
|
||||
'status_change' => 'changed the status',
|
||||
default => $event['action_type'],
|
||||
};
|
||||
$det = $event['details'] ?? [];
|
||||
switch ($event['action_type']) {
|
||||
case 'create': return 'created this ticket';
|
||||
case 'comment': return 'posted a comment';
|
||||
case 'view': return 'viewed this ticket';
|
||||
case 'attachment': return 'uploaded a file';
|
||||
case 'delete': return 'deleted a comment';
|
||||
case 'assign':
|
||||
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
||||
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
||||
return 'assigned to ' . htmlspecialchars($to);
|
||||
}
|
||||
return 'assigned this ticket';
|
||||
case 'status_change':
|
||||
if (is_array($det) && isset($det['status']['from'], $det['status']['to'])) {
|
||||
return htmlspecialchars($det['status']['from']) . ' → ' . htmlspecialchars($det['status']['to']);
|
||||
}
|
||||
return 'changed the status';
|
||||
case 'update':
|
||||
if (is_array($det)) {
|
||||
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
|
||||
if ($fields) return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
|
||||
}
|
||||
return 'updated this ticket';
|
||||
default:
|
||||
return $event['action_type'];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ticket age
|
||||
@@ -113,6 +133,17 @@ include __DIR__ . '/layout_header.php';
|
||||
<span class="lt-text-muted lt-text-xs">Ticket #<?= htmlspecialchars($ticket['ticket_id']) ?></span>
|
||||
</div>
|
||||
<div class="lt-btn-group">
|
||||
<!-- Status dot indicator -->
|
||||
<?php
|
||||
$dotClass = match($ticket['status']) {
|
||||
'Open' => 'lt-dot-up',
|
||||
'In Progress' => 'lt-dot-warn',
|
||||
'Pending' => 'lt-dot--orange',
|
||||
'Closed' => 'lt-dot-idle',
|
||||
default => 'lt-dot-idle',
|
||||
};
|
||||
?>
|
||||
<span class="lt-dot <?= $dotClass ?>" aria-hidden="true" title="<?= htmlspecialchars($ticket['status']) ?>"></span>
|
||||
<!-- Status select — always visible, instant workflow change -->
|
||||
<select id="statusSelect"
|
||||
class="lt-select lt-select-sm lt-status-select lt-status-<?= $statusSlug ?>"
|
||||
@@ -144,6 +175,53 @@ 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']);
|
||||
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
||||
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
||||
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
||||
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
||||
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
||||
?>
|
||||
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
|
||||
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
||||
role="alert" aria-live="polite"
|
||||
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
||||
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> —
|
||||
Elapsed: <strong><?= $elapsedHours ?>h</strong>
|
||||
<?php if ($slaBreached): ?>
|
||||
— <span class="lt-text-danger">SLA BREACHED</span>
|
||||
<?php endif ?>
|
||||
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" style="margin-top:0.35rem"
|
||||
aria-label="SLA progress <?= $slaPct ?>%">
|
||||
<div class="lt-progress-bar" style="width:<?= $slaPct ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="lt-alert-close" aria-label="Dismiss"
|
||||
onclick="(function(btn){
|
||||
var id=btn.closest('[data-alert-id]').dataset.alertId;
|
||||
try{sessionStorage.setItem('lt_dismissed_'+id,'1');}catch(e){}
|
||||
btn.closest('.lt-alert').classList.add('dismissed');
|
||||
})(this)">✕</button>
|
||||
</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){}
|
||||
})();
|
||||
</script>
|
||||
<?php endif ?>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
TICKET DETAIL FRAME
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
@@ -177,7 +255,12 @@ include __DIR__ . '/layout_header.php';
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Category</span>
|
||||
<span class="lt-kv-value">
|
||||
<select id="categorySelect" class="lt-select lt-select-sm editable-metadata lt-display-field" aria-label="Category">
|
||||
<?php $catColor = match($ticket['category']) { 'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>'' }; ?>
|
||||
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
|
||||
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
|
||||
<!-- Edit mode select — shown only when editing -->
|
||||
<select id="categorySelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" aria-label="Category">
|
||||
<?php foreach (['Hardware','Software','Network','Security','General'] as $c): ?>
|
||||
<option value="<?= $c ?>" <?= $ticket['category'] === $c ? 'selected' : '' ?>><?= $c ?></option>
|
||||
<?php endforeach ?>
|
||||
@@ -187,7 +270,12 @@ include __DIR__ . '/layout_header.php';
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Type</span>
|
||||
<span class="lt-kv-value">
|
||||
<select id="typeSelect" class="lt-select lt-select-sm editable-metadata lt-display-field" aria-label="Type">
|
||||
<?php $typeColor = match($ticket['type']) { 'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>'' }; ?>
|
||||
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
|
||||
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
|
||||
<!-- Edit mode select — shown only when editing -->
|
||||
<select id="typeSelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" aria-label="Type">
|
||||
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
|
||||
<option value="<?= $t ?>" <?= $ticket['type'] === $t ? 'selected' : '' ?>><?= $t ?></option>
|
||||
<?php endforeach ?>
|
||||
@@ -592,30 +680,33 @@ include __DIR__ . '/layout_header.php';
|
||||
$evtFmt = date('M d, Y H:i', strtotime($event['created_at']));
|
||||
$tClass = match($event['action_type']) {
|
||||
'create' => 'lt-timeline-item--green',
|
||||
'status_change' => 'lt-timeline-item--orange',
|
||||
'comment' => '',
|
||||
'status_change' => 'lt-timeline-item--cyan',
|
||||
'comment' => 'lt-timeline-item--green',
|
||||
'assign' => 'lt-timeline-item--orange',
|
||||
'attachment' => 'lt-timeline-item--orange',
|
||||
'update' => '',
|
||||
'delete' => 'lt-timeline-item--red',
|
||||
default => 'lt-timeline-item--dim',
|
||||
};
|
||||
?>
|
||||
<div class="lt-timeline-item <?= $tClass ?>">
|
||||
<div class="lt-timeline-meta">
|
||||
<span class="lt-timeline-icon lt-text-xs" aria-hidden="true"><?= $icon ?></span>
|
||||
<span class="lt-timeline-actor"><?= $actor ?></span>
|
||||
<span class="lt-timeline-action"><?= htmlspecialchars($action) ?></span>
|
||||
<span class="lt-timeline-time ts-cell"
|
||||
<span class="lt-timeline-action"><?= $action /* already escaped in formatAction for dynamic parts */ ?></span>
|
||||
<span class="lt-timeline-time ts-cell lt-text-muted lt-text-xs"
|
||||
data-ts="<?= htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||
title="<?= $evtFmt ?>"><?= $evtFmt ?></span>
|
||||
</div>
|
||||
<?php if (!empty($event['details'])): ?>
|
||||
<?php if (!empty($event['details']) && !in_array($event['action_type'], ['status_change', 'assign', 'comment', 'view'], true)): ?>
|
||||
<div class="lt-timeline-body lt-text-xs lt-text-muted">
|
||||
<?php
|
||||
$det = $event['details'];
|
||||
if (is_array($det)) {
|
||||
$parts = [];
|
||||
foreach ($det as $k => $v) {
|
||||
// Delta format: { field: { from: '...', to: '...' } }
|
||||
if (is_array($v) && isset($v['from'], $v['to'])) {
|
||||
$label = ucfirst(str_replace('_', ' ', $k));
|
||||
// Truncate long values (e.g. description)
|
||||
$from = mb_strlen((string)$v['from']) > 60
|
||||
? mb_substr((string)$v['from'], 0, 60) . '…'
|
||||
: (string)$v['from'];
|
||||
@@ -626,12 +717,11 @@ include __DIR__ . '/layout_header.php';
|
||||
. '<span class="lt-text-muted">' . htmlspecialchars($from) . '</span>'
|
||||
. ' <span class="lt-text-amber">→</span> '
|
||||
. '<span class="lt-text-cyan">' . htmlspecialchars($to) . '</span>';
|
||||
} elseif ($k !== 'old_value' && $k !== 'new_value') {
|
||||
// Legacy flat format fallback
|
||||
} elseif (!in_array($k, ['old_value', 'new_value'], true)) {
|
||||
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
|
||||
}
|
||||
}
|
||||
echo implode('<br>', $parts);
|
||||
if ($parts) echo implode('<br>', $parts);
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
+17
-14
@@ -138,21 +138,24 @@
|
||||
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
||||
|
||||
// Command palette — global navigation commands available on all pages
|
||||
lt.cmdPalette.init([
|
||||
{
|
||||
group: 'Navigation',
|
||||
items: [
|
||||
{ icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
|
||||
{ icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Help',
|
||||
items: [
|
||||
{ icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
||||
]
|
||||
},
|
||||
var _cpCmds = [
|
||||
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
|
||||
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
|
||||
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
||||
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
||||
];
|
||||
<?php if (!empty($GLOBALS['currentUser']['is_admin'])): ?>
|
||||
_cpCmds = _cpCmds.concat([
|
||||
{ id: 'admin-templates', group: 'Admin', icon: 'T', label: 'Templates', action: function() { window.location.href = '/admin/templates'; } },
|
||||
{ id: 'admin-workflow', group: 'Admin', icon: 'W', label: 'Workflow', action: function() { window.location.href = '/admin/workflow'; } },
|
||||
{ id: 'admin-recurring', group: 'Admin', icon: 'R', label: 'Recurring Tickets',action: function() { window.location.href = '/admin/recurring-tickets'; } },
|
||||
{ id: 'admin-fields', group: 'Admin', icon: 'F', label: 'Custom Fields', action: function() { window.location.href = '/admin/custom-fields'; } },
|
||||
{ id: 'admin-activity', group: 'Admin', icon: 'A', label: 'User Activity', action: function() { window.location.href = '/admin/user-activity'; } },
|
||||
{ id: 'admin-audit', group: 'Admin', icon: 'L', label: 'Audit Log', action: function() { window.location.href = '/admin/audit-log'; } },
|
||||
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
|
||||
]);
|
||||
<?php endif ?>
|
||||
lt.cmdPalette.init(_cpCmds);
|
||||
}
|
||||
|
||||
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
|
||||
|
||||
Reference in New Issue
Block a user