Fix ticket age, bulk assign, add column visibility toggle
- TicketView: ticket age was measuring from last update not creation; fixed to always use created_at - dashboard.js: bulk assign used non-existent onSelect callback (no selection was ever stored); fixed to onChange with selected[0], added max:1 to enforce single-select - base.js: lt.combobox Enter key only fired when focusedIdx >= 0; now falls back to first filtered result when no arrow key used - DashboardView + dashboard.js + dashboard.css: add COLS ▾ button on table header that opens a checkbox panel to show/hide optional columns (Ticket ID, Category, Type, Created By, Assigned To, Created, Updated); state persisted in localStorage, Reset button restores all; core columns (Priority, Title, Status, Actions) always visible; data-col attributes added to all th/td for CSS targeting Notifications bell: was functional all along — was broken by the notifications.php 500 error (now fixed). Avg resolution: correct, tickets genuinely take ~158 days average on this dataset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,43 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Column toggle panel ─────────────────────────────────────── */
|
||||||
|
.col-toggle-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.col-toggle-panel[aria-hidden="false"] { display: block; }
|
||||||
|
.col-toggle-title {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.4rem 0.65rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.col-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.col-toggle-row:hover { background: var(--bg-hover); }
|
||||||
|
.col-toggle-footer {
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* Unit suffix on resolution time stat (smaller, muted) */
|
/* Unit suffix on resolution time stat (smaller, muted) */
|
||||||
.lt-stat-unit {
|
.lt-stat-unit {
|
||||||
font-size: 0.65em;
|
font-size: 0.65em;
|
||||||
|
|||||||
+1
-1
@@ -1963,7 +1963,7 @@
|
|||||||
inputEl.addEventListener('keydown', e => {
|
inputEl.addEventListener('keydown', e => {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
|
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
|
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
|
||||||
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
|
if (e.key === 'Enter') { e.preventDefault(); const idx = focusedIdx >= 0 ? focusedIdx : 0; if (filtered[idx]) _toggle(filtered[idx].value); }
|
||||||
if (e.key === 'Escape') { _setOpen(false); }
|
if (e.key === 'Escape') { _setOpen(false); }
|
||||||
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
|
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
|
||||||
});
|
});
|
||||||
|
|||||||
+77
-1
@@ -603,7 +603,8 @@ function showBulkAssignModal() {
|
|||||||
label: u.display_name || u.username
|
label: u.display_name || u.username
|
||||||
}));
|
}));
|
||||||
lt.combobox.init(input, items, {
|
lt.combobox.init(input, items, {
|
||||||
onSelect: function(item) { _bulkAssignUserId = item.value; }
|
max: 1,
|
||||||
|
onChange: function(selected) { _bulkAssignUserId = selected[0] || null; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1543,3 +1544,78 @@ setInterval(initRelativeTimes, 60000);
|
|||||||
// Export for use in other scripts
|
// Export for use in other scripts
|
||||||
window.showLoadingOverlay = showLoadingOverlay;
|
window.showLoadingOverlay = showLoadingOverlay;
|
||||||
window.hideLoadingOverlay = hideLoadingOverlay;
|
window.hideLoadingOverlay = hideLoadingOverlay;
|
||||||
|
|
||||||
|
// ── Column visibility toggle ──────────────────────────────────────
|
||||||
|
(function initColToggle() {
|
||||||
|
const LS_KEY = 'lt_col_visibility';
|
||||||
|
|
||||||
|
function getHidden() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch(_) { return []; }
|
||||||
|
}
|
||||||
|
function saveHidden(cols) {
|
||||||
|
try { localStorage.setItem(LS_KEY, JSON.stringify(cols)); } catch(_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVisibility(hidden) {
|
||||||
|
const table = document.getElementById('tickets-table');
|
||||||
|
if (!table) return;
|
||||||
|
// All toggleable columns
|
||||||
|
const all = ['ticket_id','category','type','created_by','assigned_to','created_at','updated_at'];
|
||||||
|
all.forEach(col => {
|
||||||
|
const vis = !hidden.includes(col);
|
||||||
|
table.querySelectorAll('[data-col="' + col + '"]').forEach(el => {
|
||||||
|
el.style.display = vis ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Update checkboxes
|
||||||
|
document.querySelectorAll('.col-toggle-cb').forEach(cb => {
|
||||||
|
cb.checked = !hidden.includes(cb.dataset.col);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const btn = document.getElementById('colToggleBtn');
|
||||||
|
const panel = document.getElementById('colTogglePanel');
|
||||||
|
const reset = document.getElementById('colToggleReset');
|
||||||
|
if (!btn || !panel) return;
|
||||||
|
|
||||||
|
// Apply saved state on load
|
||||||
|
applyVisibility(getHidden());
|
||||||
|
|
||||||
|
// Toggle panel open/close
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = panel.getAttribute('aria-hidden') === 'false';
|
||||||
|
panel.setAttribute('aria-hidden', open ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||||||
|
btn.textContent = (open ? 'COLS \u25BE' : 'COLS \u25B4');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!btn.contains(e.target) && !panel.contains(e.target)) {
|
||||||
|
panel.setAttribute('aria-hidden', 'true');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.textContent = 'COLS \u25BE';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox change
|
||||||
|
panel.addEventListener('change', function(e) {
|
||||||
|
if (!e.target.classList.contains('col-toggle-cb')) return;
|
||||||
|
const hidden = Array.from(document.querySelectorAll('.col-toggle-cb'))
|
||||||
|
.filter(cb => !cb.checked)
|
||||||
|
.map(cb => cb.dataset.col);
|
||||||
|
saveHidden(hidden);
|
||||||
|
applyVisibility(hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
if (reset) {
|
||||||
|
reset.addEventListener('click', function() {
|
||||||
|
saveHidden([]);
|
||||||
|
applyVisibility([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
+46
-13
@@ -568,7 +568,40 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<!-- Ticket table frame -->
|
<!-- Ticket table frame -->
|
||||||
<div class="lt-frame">
|
<div class="lt-frame">
|
||||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="lt-section-header">Ticket Queue</div>
|
<div class="lt-section-header" style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem">
|
||||||
|
<span>Ticket Queue</span>
|
||||||
|
<div style="position:relative;display:inline-block">
|
||||||
|
<button type="button" id="colToggleBtn"
|
||||||
|
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||||
|
aria-haspopup="true" aria-expanded="false"
|
||||||
|
aria-controls="colTogglePanel"
|
||||||
|
title="Show/hide columns"
|
||||||
|
style="font-size:0.65rem;letter-spacing:0.05em">COLS ▾</button>
|
||||||
|
<div id="colTogglePanel" class="col-toggle-panel" aria-hidden="true" role="dialog" aria-label="Column visibility">
|
||||||
|
<div class="col-toggle-title">Visible Columns</div>
|
||||||
|
<?php
|
||||||
|
$toggleableCols = [
|
||||||
|
'ticket_id' => 'Ticket ID',
|
||||||
|
'category' => 'Category',
|
||||||
|
'type' => 'Type',
|
||||||
|
'created_by' => 'Created By',
|
||||||
|
'assigned_to' => 'Assigned To',
|
||||||
|
'created_at' => 'Created',
|
||||||
|
'updated_at' => 'Updated',
|
||||||
|
];
|
||||||
|
foreach ($toggleableCols as $colKey => $colName): ?>
|
||||||
|
<label class="col-toggle-row">
|
||||||
|
<input type="checkbox" class="lt-checkbox col-toggle-cb"
|
||||||
|
data-col="<?= $colKey ?>" checked>
|
||||||
|
<span><?= $colName ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<div class="col-toggle-footer">
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full" id="colToggleReset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="lt-table-wrap">
|
<div class="lt-table-wrap">
|
||||||
<table class="lt-table lt-table-responsive" id="tickets-table" aria-label="Ticket queue">
|
<table class="lt-table lt-table-responsive" id="tickets-table" aria-label="Ticket queue">
|
||||||
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
|
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
|
||||||
@@ -596,7 +629,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
];
|
];
|
||||||
foreach ($columns as $col => $label):
|
foreach ($columns as $col => $label):
|
||||||
if ($col === '_actions'): ?>
|
if ($col === '_actions'): ?>
|
||||||
<th scope="col" class="col-actions">Actions</th>
|
<th scope="col" class="col-actions" data-col="_actions">Actions</th>
|
||||||
<?php else:
|
<?php else:
|
||||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||||
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
||||||
@@ -604,7 +637,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
|
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
|
||||||
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<th scope="col" class="<?= $sortClass ?>"
|
<th scope="col" class="<?= $sortClass ?>" data-col="<?= $col ?>"
|
||||||
data-action="navigate" data-url="<?= $sortUrl ?>"
|
data-action="navigate" data-url="<?= $sortUrl ?>"
|
||||||
<?= $ariaSort ?>
|
<?= $ariaSort ?>
|
||||||
style="cursor:pointer"><?= $label ?></th>
|
style="cursor:pointer"><?= $label ?></th>
|
||||||
@@ -646,20 +679,20 @@ include __DIR__ . '/layout_header.php';
|
|||||||
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
|
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
|
||||||
</td>
|
</td>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<td data-label="Ticket ID">
|
<td data-label="Ticket ID" data-col="ticket_id">
|
||||||
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
|
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
|
||||||
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
|
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Priority">
|
<td data-label="Priority" data-col="priority">
|
||||||
<?php $badgeClass = match($pNum) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
|
<?php $badgeClass = match($pNum) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
|
||||||
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
|
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Title" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
<td data-label="Title" data-col="title" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||||
<span data-tooltip="<?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?>" data-tooltip-pos="top"><?= htmlspecialchars($row['title']) ?></span>
|
<span data-tooltip="<?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?>" data-tooltip-pos="top"><?= htmlspecialchars($row['title']) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
|
<td data-label="Category" data-col="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="Type" data-col="type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
|
||||||
<td data-label="Status">
|
<td data-label="Status" data-col="status">
|
||||||
<?php $rowDotClass = match($row['status']) {
|
<?php $rowDotClass = match($row['status']) {
|
||||||
'Open' => 'lt-dot-up',
|
'Open' => 'lt-dot-up',
|
||||||
'In Progress' => 'lt-dot-warn',
|
'In Progress' => 'lt-dot-warn',
|
||||||
@@ -670,8 +703,8 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<span class="lt-dot <?= $rowDotClass ?>" aria-hidden="true" style="vertical-align:middle;margin-right:0.3rem"></span>
|
<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>
|
<span class="lt-status lt-status-<?= $rowStatusSlug ?>"><?= htmlspecialchars($row['status']) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Created By" class="lt-text-xs"><?= $creator ?></td>
|
<td data-label="Created By" data-col="created_by" class="lt-text-xs"><?= $creator ?></td>
|
||||||
<td data-label="Assigned To" class="lt-text-xs">
|
<td data-label="Assigned To" data-col="assigned_to" class="lt-text-xs">
|
||||||
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
|
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
|
||||||
<?php if ($assigneeDisplay): ?>
|
<?php if ($assigneeDisplay): ?>
|
||||||
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
|
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
|
||||||
@@ -679,10 +712,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<span class="lt-text-muted">Unassigned</span>
|
<span class="lt-text-muted">Unassigned</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Created" class="lt-text-xs lt-text-muted ts-cell"
|
<td data-label="Created" data-col="created_at" class="lt-text-xs lt-text-muted ts-cell"
|
||||||
data-ts="<?= htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
data-ts="<?= htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||||
title="<?= date('Y-m-d H:i T', strtotime($row['created_at'])) ?>"><?= $createdFmt ?></td>
|
title="<?= date('Y-m-d H:i T', strtotime($row['created_at'])) ?>"><?= $createdFmt ?></td>
|
||||||
<td data-label="Updated" class="lt-text-xs lt-text-muted ts-cell"
|
<td data-label="Updated" data-col="updated_at" class="lt-text-xs lt-text-muted ts-cell"
|
||||||
data-ts="<?= htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
|
data-ts="<?= htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||||
title="<?= date('Y-m-d H:i T', strtotime($row['updated_at'])) ?>"><?= $updatedFmt ?></td>
|
title="<?= date('Y-m-d H:i T', strtotime($row['updated_at'])) ?>"><?= $updatedFmt ?></td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
|
|||||||
@@ -64,9 +64,8 @@ function formatAction(array $event): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate ticket age
|
// Calculate ticket age from creation (not last update)
|
||||||
$lastUpdate = !empty($ticket['updated_at']) ? strtotime($ticket['updated_at']) : strtotime($ticket['created_at']);
|
$ageSeconds = time() - strtotime($ticket['created_at']);
|
||||||
$ageSeconds = time() - $lastUpdate;
|
|
||||||
$ageDays = floor($ageSeconds / 86400);
|
$ageDays = floor($ageSeconds / 86400);
|
||||||
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
||||||
$ageClass = 'lt-text-muted';
|
$ageClass = 'lt-text-muted';
|
||||||
|
|||||||
Reference in New Issue
Block a user