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:
2026-04-04 11:54:26 -04:00
parent 85afec64ac
commit c0dfbdbc26
4 changed files with 170 additions and 48 deletions
+20
View File
@@ -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
View File
@@ -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>
+118 -28
View File
@@ -22,26 +22,46 @@ $pageScripts = [
// Helper functions
function getEventIcon(string $actionType): string {
return match($actionType) {
'create' => '[ + ]',
'update' => '[ ~ ]',
'comment' => '[ > ]',
'view' => '[ . ]',
'assign' => '[ @ ]',
'status_change' => '[ ! ]',
default => '[ * ]',
'create' => '[+]',
'update' => '[~]',
'comment' => '[>]',
'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> &mdash;
Elapsed: <strong><?= $elapsedHours ?>h</strong>
<?php if ($slaBreached): ?>
&mdash; <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)">&#x2715;</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
View File
@@ -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