Files
tinker_tickets/views/DashboardView.php
T
jared ce0ea66994
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 21s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 3s
Security / PHP Security (semgrep) (push) Successful in 2m0s
Charts: click a segment to filter the dashboard (#22)
All three charts (priority donut, status donut, category bar) now navigate to
the same URL filters the stat cards already use, with a pointer cursor on
hover, a title hint, and "click to filter" in the tooltip.

The status each click applies is explicit rather than left to the default. With
no `status` param the controller falls back to the viewer's
default_status_filters preference, which can be anything, so the list would not
necessarily match what the chart counted. StatsModel builds by_priority and
by_category with `status != 'Closed'` while by_status spans every status, so
only the priority and category charts pin the open set; the status chart filters
on the clicked status alone (which is how clicking "Closed" works at all).

Verified two ways:
- 17/17 in headless chromium, driving the real chart script from this view with
  the Chart constructor stubbed, asserting the exact query each click produces
  and that a click hitting no segment navigates nowhere.
- Against the live database, every segment's count equals the number of tickets
  its filter returns — 12/12 across all three charts — so the list you land on
  matches the number you clicked.
2026-08-07 23:15:51 -04:00

1472 lines
74 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* DashboardView.php — Main ticket dashboard, redesigned for TDS v1.2.
*
* Receives from controller:
* $tickets, $totalTickets, $totalPages, $page
* $stats — open_tickets, critical, unassigned, created_today, closed_today, avg_resolution_hours
* $categories, $types — arrays for sidebar filters
*/
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Dashboard';
$activeNav = 'dashboard';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = [
"https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css",
"/assets/css/dashboard.css?v={$_v}",
];
$pageScripts = [
"/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}",
"https://cdn.jsdelivr.net/npm/flatpickr",
"https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js",
];
// ── Pagination helpers ────────────────────────────────────────────────────────
$currentSort = $_GET['sort'] ?? 'ticket_id';
$currentDir = (($_GET['dir'] ?? 'desc') === 'asc') ? 'asc' : 'desc';
// ── Active filter detection ───────────────────────────────────────────────────
$activeFilters = [];
if (!empty($_GET['status'])) {
foreach (explode(',', $_GET['status']) as $s) {
$activeFilters[] = ['type' => 'status', 'value' => trim($s), 'label' => 'Status: ' . trim($s)];
}
}
if (!empty($_GET['priority'])) {
$pArr = is_array($_GET['priority']) ? $_GET['priority'] : explode(',', $_GET['priority']);
foreach ($pArr as $p) {
$activeFilters[] = ['type' => 'priority', 'value' => trim($p), 'label' => 'Priority: P' . trim($p)];
}
}
if (!empty($_GET['category'])) {
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . $_GET['category']];
}
if (!empty($_GET['type'])) {
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . $_GET['type']];
}
if (!empty($_GET['assigned_to'])) {
$label = match ($_GET['assigned_to']) {
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . $_GET['assigned_to']
};
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
}
if (!empty($_GET['created_from']) || !empty($_GET['created_to'])) {
$from = $_GET['created_from'] ?? '';
$to = $_GET['created_to'] ?? '';
$label = $from === $to && $from ? 'Created: ' . $from : 'Created: ' . ($from ?: '…') . ' ' . ($to ?: '…');
$activeFilters[] = ['type' => 'created_from', 'value' => $from, 'label' => $label];
}
if (!empty($_GET['updated_from']) || !empty($_GET['updated_to'])) {
$from = $_GET['updated_from'] ?? '';
$to = $_GET['updated_to'] ?? '';
$label = $from === $to && $from ? 'Updated: ' . $from : 'Updated: ' . ($from ?: '…') . ' ' . ($to ?: '…');
$activeFilters[] = ['type' => 'updated_from', 'value' => $from, 'label' => $label];
}
if (!empty($_GET['closed_from']) || !empty($_GET['closed_to'])) {
$from = $_GET['closed_from'] ?? '';
$to = $_GET['closed_to'] ?? '';
$label = $from === $to && $from ? 'Closed: ' . $from : 'Closed: ' . ($from ?: '…') . ' ' . ($to ?: '…');
$activeFilters[] = ['type' => 'closed_from', 'value' => $from, 'label' => $label];
}
$_lt_statuses = $GLOBALS['config']['TICKET_STATUSES'];
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
$currentCategories = isset($_GET['category']) ? explode(',', $_GET['category']) : [];
$currentTypes = isset($_GET['type']) ? explode(',', $_GET['type']) : [];
$isAdmin = $GLOBALS['currentUser']['is_admin'] ?? false;
$colCount = $isAdmin ? 12 : 11;
include __DIR__ . '/layout_header.php';
?>
<!-- ═══════════════════════════════════════════════════════════
PAGE HEADER
═══════════════════════════════════════════════════════════ -->
<div class="lt-page-header">
<h1 class="lt-page-title">[ TICKETS ]</h1>
<div class="lt-btn-group">
<a href="<?= htmlspecialchars($GLOBALS['config']['BASE_URL']) ?>/ticket/create"
class="lt-btn lt-btn-primary">+ NEW TICKET</a>
<button type="button" class="lt-btn lt-btn-sm" data-action="manual-refresh"
title="Refresh now (auto-refreshes every 5 min)">REFRESH</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="open-settings">CFG</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
STATS GRID
═══════════════════════════════════════════════════════════ -->
<?php if (isset($stats)) : ?>
<div class="lt-stats-grid" id="statsGrid">
<?php
// Trend indicators — derived from existing stats without extra DB query
// Logic: if more closed today than created → improving (green), if more created → warn, else idle
$trendOpen = ($stats['closed_today'] > $stats['created_today']) ? 'lt-dot-up' :
($stats['created_today'] > $stats['closed_today'] ? 'lt-dot-warn' : 'lt-dot-idle');
$trendCrit = ($stats['critical'] > 0) ? 'lt-dot-warn' : 'lt-dot-up';
$trendUnassi = ($stats['unassigned'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
$trendToday = ($stats['created_today'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
$trendClosed = ($stats['closed_today'] > 0) ? 'lt-dot-up' : 'lt-dot-idle';
?>
<div class="lt-stat-card stat-open" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
title="Click to filter by active tickets" aria-label="Open tickets">
<div class="lt-stat-icon">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value">
<?= (int)$stats['open_tickets'] ?>
<span class="lt-dot <?= $trendOpen ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Open Tickets</div>
</div>
</div>
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
data-filter-key="priority" data-filter-val="1"
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value">
<?= (int)$stats['critical'] ?>
<span class="lt-dot <?= $trendCrit ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Critical (P1)</div>
</div>
</div>
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
data-filter-key="assigned_to" data-filter-val="unassigned"
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value">
<?= (int)$stats['unassigned'] ?>
<span class="lt-dot <?= $trendUnassi ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Unassigned</div>
</div>
</div>
<div class="lt-stat-card stat-today" role="button" tabindex="0"
title="Tickets created today" aria-label="Tickets created today">
<div class="lt-stat-icon lt-text-cyan">[ + ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value">
<?= (int)$stats['created_today'] ?>
<span class="lt-dot <?= $trendToday ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Created Today</div>
</div>
</div>
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Closed"
title="Click to filter closed tickets" aria-label="Closed tickets today">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value">
<?= (int)$stats['closed_today'] ?>
<span class="lt-dot <?= $trendClosed ?>" style="margin-left:0.4rem;vertical-align:middle" aria-hidden="true"></span>
</div>
<div class="lt-stat-label">Closed Today</div>
</div>
</div>
<?php
$avgHours = $stats['avg_resolution_hours'] ?? 0;
if ($avgHours <= 0) {
$avgDisplay = '—';
$avgUnit = '';
} elseif ($avgHours < 1) {
$avgDisplay = (string)max(1, (int)round($avgHours * 60));
$avgUnit = 'min';
} elseif ($avgHours < 48) {
$avgDisplay = (string)(int)round($avgHours);
$avgUnit = 'hr';
} elseif ($avgHours < 336) { // <14 days
$avgDisplay = number_format($avgHours / 24, 1);
$avgUnit = 'days';
} else {
$avgDisplay = number_format($avgHours / 168, 1);
$avgUnit = 'wks';
}
$avgTitle = $avgHours > 0 ? number_format($avgHours, 1) . ' hours' : 'No data';
?>
<div class="lt-stat-card stat-time" title="Average resolution time: <?= htmlspecialchars($avgTitle) ?>" aria-label="Avg resolution time">
<div class="lt-stat-icon lt-text-muted">&#x23F1;</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= htmlspecialchars($avgDisplay) ?><span class="lt-stat-unit"><?= $avgUnit ?></span></div>
<div class="lt-stat-label">Avg Resolution</div>
</div>
</div>
</div>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
CHARTS ROW (Chart.js — loaded from CDN on this page only)
═══════════════════════════════════════════════════════════ -->
<div class="lt-grid-3" style="margin-bottom:0.75rem" id="chartsRow">
<div class="lt-frame" id="chartPriorityWrap">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Priority Distribution</div>
<div class="lt-section-body" style="padding:0.5rem">
<div style="position:relative;width:100%;height:170px">
<canvas id="chartPriority" aria-label="Priority distribution donut chart" role="img"></canvas>
</div>
</div>
</div>
<div class="lt-frame" id="chartStatusWrap">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Status Breakdown</div>
<div class="lt-section-body" style="padding:0.5rem">
<div style="position:relative;width:100%;height:170px">
<canvas id="chartStatus" aria-label="Status breakdown donut chart" role="img"></canvas>
</div>
</div>
</div>
<div class="lt-frame" id="chartCategoryWrap">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Category Breakdown</div>
<div class="lt-section-body" style="padding:0.5rem">
<div style="position:relative;width:100%;height:170px">
<canvas id="chartCategory" aria-label="Category breakdown bar chart" role="img"></canvas>
</div>
</div>
</div>
</div>
<script nonce="<?= $nonce ?>">
// ── Dashboard charts (Chart.js, loaded from CDN) ─────────────────
(function() {
function waitForChart(cb, tries) {
tries = tries || 0;
if (window.Chart) { cb(); }
else if (tries < 30) { setTimeout(function() { waitForChart(cb, tries+1); }, 200); }
}
var COLORS = {
green: '#00ff41', cyan: '#00d4ff', amber: '#ffb000',
red: '#ff4d4d', purple: '#b48eff', orange: '#ff8c00',
muted: 'rgba(0,255,65,0.25)'
};
var priorityData = <?= json_encode(array_values(array_map(
fn($k, $v) => ['label' => $k, 'count' => $v],
array_keys($stats['by_priority'] ?? []),
array_values($stats['by_priority'] ?? [])
))) ?>;
var statusData = <?= json_encode(array_values(array_map(
fn($k, $v) => ['label' => $k, 'count' => $v],
array_keys($stats['by_status'] ?? []),
array_values($stats['by_status'] ?? [])
))) ?>;
var categoryData = <?= json_encode(array_values(array_map(
fn($k, $v) => ['label' => $k, 'count' => $v],
array_keys($stats['by_category'] ?? []),
array_values($stats['by_category'] ?? [])
))) ?>;
// ── Click-to-filter ────────────────────────────────────────────────────────
// Charts navigate to the same URL filters the stat cards use.
//
// The status the click filters on has to be explicit rather than left to the
// default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans
// every status — so only the priority and category charts pin the open set.
function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
}
function gotoFilter(params) {
var qs = new URLSearchParams();
Object.keys(params).forEach(function(k) {
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
});
window.location.href = '/?' + qs.toString();
}
// Each chart maps a clicked label to a filter. Returns null when the label
// can't be mapped, so the click is simply ignored.
var CHART_FILTERS = {
chartPriority: function(label) {
var m = /^P(\d+)$/.exec(label);
return m ? { priority: m[1], status: openStatuses() } : null;
},
chartStatus: function(label) {
return label ? { status: label } : null;
},
chartCategory: function(label) {
return label ? { category: label, status: openStatuses() } : null;
}
};
function filterOnClick(canvasId) {
return function(evt, elements, chart) {
if (!elements || !elements.length) return;
var label = chart.data.labels[elements[0].index];
var mapper = CHART_FILTERS[canvasId];
var params = mapper && mapper(label);
if (params) gotoFilter(params);
};
}
// Pointer cursor over clickable segments so the affordance is visible.
function filterOnHover(evt, elements) {
if (evt && evt.native && evt.native.target) {
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
}
}
function makeDonut(canvasId, data, colorMap) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return;
ctx.title = 'Click a segment to filter the ticket list';
return new Chart(ctx, {
type: 'doughnut',
data: {
labels: data.map(function(d) { return d.label; }),
datasets: [{
data: data.map(function(d) { return d.count; }),
backgroundColor: data.map(function(d, i) {
return colorMap[d.label] || Object.values(COLORS)[i % 6];
}),
borderWidth: 0,
hoverOffset: 4
}]
},
options: {
responsive: true, maintainAspectRatio: false,
onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
},
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
},
cutout: '68%'
}
});
}
function makeBar(canvasId, data) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return;
ctx.title = 'Click a bar to filter the ticket list';
return new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(function(d) { return d.label; }),
datasets: [{
data: data.map(function(d) { return d.count; }),
backgroundColor: 'rgba(0,212,255,0.25)',
borderColor: '#00d4ff',
borderWidth: 1
}]
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
},
scales: {
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
}
}
});
}
waitForChart(function() {
var pColors = { 'P1': COLORS.red, 'P2': COLORS.amber, 'P3': COLORS.cyan, 'P4': COLORS.green, 'P5': COLORS.muted };
var sColors = { 'Open': COLORS.green, 'Pending': COLORS.amber, 'In Progress': COLORS.cyan, 'Closed': COLORS.muted };
makeDonut('chartPriority', priorityData, pColors);
makeDonut('chartStatus', statusData, sColors);
makeBar('chartCategory', categoryData.slice(0, 8));
});
})();
</script>
<?php if (!empty($stats['by_assignee'])) : ?>
<!-- ═══════════════════════════════════════════════════════════
TEAM WORKLOAD PANEL
═══════════════════════════════════════════════════════════ -->
<details class="lt-frame workload-panel" style="margin-bottom:0.75rem" id="workloadPanel">
<summary class="lt-section-header" style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:0.5rem">
<span>&#x25B8;</span> Team Workload
<span class="lt-text-xs lt-text-muted" style="font-weight:normal">&mdash; open tickets by assignee</span>
</summary>
<div class="lt-section-body">
<?php
$byAssignee = $stats['by_assignee'];
$maxLoad = max(array_column($byAssignee, 'open_count') ?: [1]);
?>
<div class="workload-grid">
<?php foreach ($byAssignee as $a) :
$count = (int)$a['open_count'];
$name = $a['display_name'] ?? $a['username'] ?? 'Unknown';
$pct = $maxLoad > 0 ? round(($count / $maxLoad) * 100) : 0;
$barClass = $pct >= 80 ? 'lt-progress--red' : ($pct >= 50 ? 'lt-progress--cyan' : 'lt-progress--green');
$words = array_filter(explode(' ', $name));
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$avatarColor = $avatarColors[abs(crc32($name)) % count($avatarColors)];
$userId = (int)($a['user_id'] ?? 0);
?>
<div class="workload-item">
<div class="lt-avatar lt-avatar--sm <?= $avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($name) ?>">
<?php if ($userId > 0) : ?>
<img src="/api/user_avatar.php?user_id=<?= $userId ?>" alt="" class="lt-avatar-img">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div>
<div class="workload-info">
<div class="workload-name lt-text-xs"><?= htmlspecialchars($name) ?></div>
<div class="lt-progress lt-progress--sm <?= $barClass ?>" style="margin:0.2rem 0"
aria-label="<?= $count ?> open tickets" title="<?= $count ?> open">
<div class="lt-progress-bar" style="width:<?= max(4, $pct) ?>%"></div>
</div>
</div>
<span class="lt-text-xs lt-text-muted workload-count"><?= $count ?></span>
</div>
<?php endforeach ?>
</div>
</div>
</details>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
VIEW TABS (Table / Kanban) — dual-purpose: lt.tabs + dashboard.js set-view-mode
═══════════════════════════════════════════════════════════ -->
<div class="lt-tab-bar" role="tablist" aria-label="Ticket view mode">
<button type="button" id="tableViewBtn"
class="lt-tab active"
role="tab" aria-selected="true" aria-controls="tab-table"
data-tab="tab-table"
data-action="set-view-mode" data-mode="table">
&#x25A4; Table View
</button>
<button type="button" id="cardViewBtn"
class="lt-tab"
role="tab" aria-selected="false" aria-controls="tab-kanban"
data-tab="tab-kanban"
data-action="set-view-mode" data-mode="card">
&#x2295; Kanban
</button>
</div><!-- /.lt-tab-bar -->
<!-- ═══════════════════════════════════════════════════════════
LAYOUT WRAPPER: sidebar + main content
═══════════════════════════════════════════════════════════ -->
<div class="lt-layout" id="dashboardLayout">
<!-- ─── SIDEBAR: Filters ──────────────────────────────────── -->
<aside class="lt-sidebar" id="lt-sidebar" role="complementary" aria-label="Filter options">
<div class="lt-sidebar-header">
<span>Filters</span>
</div>
<div class="lt-sidebar-body" id="dashboardSidebar">
<!-- Status Filter -->
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Status</legend>
<?php foreach ($GLOBALS['config']['TICKET_STATUSES'] as $s) : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox sidebar-filter"
name="status" value="<?= htmlspecialchars($s) ?>"
<?= in_array($s, $currentStatus) ? 'checked' : '' ?>>
<?= htmlspecialchars($s) ?>
</label>
<?php endforeach ?>
</fieldset>
<!-- Category Filter -->
<?php if (!empty($categories)) : ?>
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Category</legend>
<?php foreach ($categories as $cat) : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox sidebar-filter"
name="category" value="<?= htmlspecialchars($cat) ?>"
<?= in_array($cat, $currentCategories) ? 'checked' : '' ?>>
<?= htmlspecialchars($cat) ?>
</label>
<?php endforeach ?>
</fieldset>
<?php endif ?>
<!-- Type Filter -->
<?php if (!empty($types)) : ?>
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Type</legend>
<?php foreach ($types as $type) : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox sidebar-filter"
name="type" value="<?= htmlspecialchars($type) ?>"
<?= in_array($type, $currentTypes) ? 'checked' : '' ?>>
<?= htmlspecialchars($type) ?>
</label>
<?php endforeach ?>
</fieldset>
<?php endif ?>
<!-- Date Filters -->
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Created</legend>
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
<input type="date" id="filter-created-from" name="created_from" class="lt-input lt-input-sm"
placeholder="From" value="<?= htmlspecialchars($_GET['created_from'] ?? '') ?>">
<input type="date" id="filter-created-to" name="created_to" class="lt-input lt-input-sm"
placeholder="To" value="<?= htmlspecialchars($_GET['created_to'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Updated</legend>
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
<input type="date" id="filter-updated-from" name="updated_from" class="lt-input lt-input-sm"
placeholder="From" value="<?= htmlspecialchars($_GET['updated_from'] ?? '') ?>">
<input type="date" id="filter-updated-to" name="updated_to" class="lt-input lt-input-sm"
placeholder="To" value="<?= htmlspecialchars($_GET['updated_to'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Closed</legend>
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
<input type="date" id="filter-closed-from" name="closed_from" class="lt-input lt-input-sm"
placeholder="From" value="<?= htmlspecialchars($_GET['closed_from'] ?? '') ?>">
<input type="date" id="filter-closed-to" name="closed_to" class="lt-input lt-input-sm"
placeholder="To" value="<?= htmlspecialchars($_GET['closed_to'] ?? '') ?>">
</div>
</fieldset>
<div class="lt-btn-group lt-flex-col">
<button type="button" id="apply-filters-btn" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
<button type="button" id="clear-filters-btn" class="lt-btn lt-btn-ghost lt-btn-sm">CLEAR ALL</button>
</div>
</div><!-- /.lt-sidebar-body -->
</aside>
<!-- ─── MAIN CONTENT ─────────────────────────────────────── -->
<div class="lt-content">
<!-- Toolbar: search + export + count -->
<div class="lt-toolbar">
<div class="lt-toolbar-left">
<button type="button" id="lt-sidebar-toggle-btn" class="lt-btn lt-btn-ghost lt-btn-sm"
aria-label="Toggle filter sidebar" title="Toggle filters">&#x22EE;&#x22EE; Filters</button>
<form method="GET" action="" class="lt-search-form" role="search">
<?php foreach (['status','category','type','sort','dir'] as $p) : ?>
<?php if (isset($_GET[$p])) : ?>
<input type="hidden" name="<?= $p ?>" value="<?= htmlspecialchars($_GET[$p]) ?>">
<?php endif ?>
<?php endforeach ?>
<div class="lt-search">
<input type="text" name="search" class="lt-input lt-search-input"
id="searchInput"
placeholder="> Search tickets..."
value="<?= htmlspecialchars($_GET['search'] ?? '') ?>"
aria-label="Search tickets">
</div>
<button type="submit" class="lt-btn lt-btn-sm">SEARCH</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="open-advanced-search">
FILTER
</button>
<?php if (!empty($_GET['search'])) : ?>
<a href="?" class="lt-btn lt-btn-sm lt-btn-ghost" aria-label="Clear search">&#x2715;</a>
<?php endif ?>
</form>
</div>
<div class="lt-toolbar-right">
<span class="lt-text-muted lt-text-xs">
<?= $totalTickets ?> ticket<?= $totalTickets !== 1 ? 's' : '' ?>
</span>
<!-- Export dropdown (admin + selection) -->
<?php if ($isAdmin) : ?>
<div class="lt-dropdown-wrap" id="exportDropdown" style="display:none">
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost lt-dropdown-trigger"
id="exportDropdownTrigger"
data-action="toggle-export-menu"
aria-expanded="false" aria-haspopup="true">
EXPORT (<span id="exportCount">0</span>) &#x25BE;
</button>
<div class="lt-dropdown-panel lt-dropdown-panel--right" id="exportDropdownContent" aria-hidden="true">
<button type="button" class="lt-dropdown-item" data-action="export-tickets" data-format="csv">
&#x2193; CSV
</button>
<button type="button" class="lt-dropdown-item" data-action="export-tickets" data-format="json">
&#x2193; JSON
</button>
</div>
</div>
<?php endif ?>
</div>
</div><!-- /.lt-toolbar -->
<!-- Saved filter quick-switch pills -->
<div id="savedFilterPills" class="saved-filter-pills lt-flex lt-flex-wrap lt-flex-gap-sm" style="display:none;padding:0.35rem 0 0.1rem" aria-label="Saved filters"></div>
<!-- Active filters bar -->
<?php if (!empty($activeFilters)) : ?>
<div class="active-filters-bar lt-flex lt-flex-wrap lt-flex-gap-sm" role="group" aria-label="Active filters">
<span class="lt-text-xs lt-text-muted">Active:</span>
<?php foreach ($activeFilters as $f) : ?>
<span class="lt-badge filter-badge"
data-filter-type="<?= htmlspecialchars($f['type']) ?>"
data-filter-value="<?= htmlspecialchars($f['value']) ?>">
<?= htmlspecialchars($f['label']) ?>
<button type="button" class="filter-remove"
data-action="remove-filter"
data-filter-type="<?= htmlspecialchars($f['type']) ?>"
data-filter-value="<?= htmlspecialchars($f['value']) ?>"
aria-label="Remove <?= htmlspecialchars($f['label']) ?> filter">&#x2715;</button>
</span>
<?php endforeach ?>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm"
data-action="clear-all-filters">CLEAR ALL</button>
</div>
<?php endif ?>
<!-- Search results info -->
<?php if (!empty($_GET['search'])) : ?>
<div class="lt-msg lt-msg-info">
Showing results for: <strong><?= htmlspecialchars($_GET['search']) ?></strong>
&mdash; <?= $totalTickets ?> ticket<?= $totalTickets !== 1 ? 's' : '' ?> found
</div>
<?php endif ?>
<!-- ══════════════════════════════════════════════════════
TAB PANEL: TABLE VIEW
══════════════════════════════════════════════════════ -->
<div id="tab-table" class="lt-tab-panel active" role="tabpanel" aria-labelledby="tableViewBtn">
<!-- Bulk actions (admin only, shown when tickets selected) -->
<?php if ($isAdmin) : ?>
<div class="bulk-actions-inline" style="display:none" aria-live="polite">
<span id="selected-count" class="lt-text-amber lt-text-sm">0</span>
<span class="lt-text-xs lt-text-muted"> tickets selected</span>
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="bulk-status">STATUS</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="bulk-assign">ASSIGN</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="bulk-priority">PRIORITY</button>
<button type="button" class="lt-btn lt-btn-sm" data-action="clear-selection">CLEAR</button>
</div>
</div>
<?php endif ?>
<!-- Ticket table frame -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<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 &#x25BE;</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">
<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>
<thead>
<tr>
<?php if ($isAdmin) : ?>
<th scope="col" class="col-checkbox">
<input type="checkbox" class="lt-checkbox" id="selectAllCheckbox"
data-action="toggle-select-all" aria-label="Select all tickets">
</th>
<?php endif ?>
<?php
$columns = [
'ticket_id' => 'Ticket ID',
'priority' => 'Priority',
'title' => 'Title',
'category' => 'Category',
'type' => 'Type',
'status' => 'Status',
'created_by' => 'Created By',
'assigned_to' => 'Assigned To',
'created_at' => 'Created',
'updated_at' => 'Updated',
'_actions' => 'Actions',
];
foreach ($columns as $col => $label) :
if ($col === '_actions') : ?>
<th scope="col" class="col-actions" data-col="_actions">Actions</th>
<?php else :
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
?>
<th scope="col" class="<?= $sortClass ?>" data-col="<?= $col ?>"
data-action="navigate" data-url="<?= $sortUrl ?>"
<?= $ariaSort ?>
style="cursor:pointer"><?= $label ?></th>
<?php endif ?>
<?php endforeach ?>
</tr>
</thead>
<tbody>
<?php if (empty($tickets)) : ?>
<tr>
<td colspan="<?= $colCount ?>" class="lt-empty">
<div class="lt-empty-state">
<div class="lt-empty-state-icon">&#x1F4ED;</div>
<div class="lt-empty-state-title">No Tickets Found</div>
<div class="lt-empty-state-body">No tickets match your current filters.</div>
<?php if (!empty($activeFilters) || !empty($_GET['search'])) : ?>
<a href="?" class="lt-btn lt-btn-sm lt-btn-ghost">Clear Filters</a>
<?php endif ?>
</div>
</td>
</tr>
<?php else : ?>
<?php foreach ($tickets as $row) :
$creator = htmlspecialchars($row['creator_display_name'] ?? $row['creator_username'] ?? 'System');
$assignedTo = htmlspecialchars($row['assigned_display_name'] ?? $row['assigned_username'] ?? 'Unassigned');
$pNum = (int)$row['priority'];
$rowStatusSlug = strtolower(str_replace(' ', '-', $row['status']));
$critClass = ($pNum === 1) ? ' lt-row-critical' : '';
$warnClass = ($pNum === 2) ? ' lt-row-warning' : '';
$createdFmt = date('Y-m-d H:i', strtotime($row['created_at']));
$updatedFmt = date('Y-m-d H:i', strtotime($row['updated_at']));
?>
<tr class="lt-row-p<?= $pNum ?><?= $critClass ?><?= $warnClass ?>">
<?php if ($isAdmin) : ?>
<td data-label="Select" data-action="toggle-row-checkbox" class="checkbox-cell">
<input type="checkbox" class="lt-checkbox ticket-checkbox"
value="<?= htmlspecialchars($row['ticket_id']) ?>"
data-action="update-selection"
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
</td>
<?php endif ?>
<td data-label="Ticket ID" data-col="ticket_id">
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
</td>
<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'
}; ?>
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
</td>
<td data-label="Title" data-col="title" class="col-title">
<span data-tooltip="<?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?>" data-tooltip-pos="top"><?= htmlspecialchars($row['title']) ?></span>
</td>
<td data-label="Category" data-col="category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
<td data-label="Type" data-col="type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
<td data-label="Status" data-col="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" data-col="created_by" class="lt-text-xs"><?= $creator ?></td>
<td data-label="Assigned To" data-col="assigned_to" class="lt-text-xs">
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
<?php if ($assigneeDisplay) : ?>
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
<?php else : ?>
<span class="lt-text-muted">Unassigned</span>
<?php endif ?>
</td>
<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') ?>"
title="<?= date('Y-m-d H:i T', strtotime($row['created_at'])) ?>"><?= $createdFmt ?></td>
<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') ?>"
title="<?= date('Y-m-d H:i T', strtotime($row['updated_at'])) ?>"><?= $updatedFmt ?></td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="view-ticket"
data-ticket-id="<?= htmlspecialchars($row['ticket_id']) ?>"
aria-label="View ticket <?= htmlspecialchars($row['ticket_id']) ?>">View</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
data-action="quick-status"
data-ticket-id="<?= htmlspecialchars($row['ticket_id']) ?>"
data-status="<?= htmlspecialchars($row['status'], ENT_QUOTES) ?>"
aria-label="Change status">&#x7E;</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
data-action="quick-assign"
data-ticket-id="<?= htmlspecialchars($row['ticket_id']) ?>"
aria-label="Assign ticket">@</button>
</div>
</td>
</tr>
<?php endforeach ?>
<?php endif ?>
</tbody>
</table>
</div><!-- /.lt-table-wrap -->
</div><!-- /.lt-frame -->
<!-- Pagination -->
<?php if ($totalPages > 1) : ?>
<div class="lt-pagination" role="navigation" aria-label="Ticket pagination">
<?php
$currentParams = $_GET;
if ($page > 1) {
$currentParams['page'] = $page - 1;
$prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $prevUrl . '" aria-label="Previous page">&#xAB;</button>';
}
$range = range(max(1, $page - 2), min($totalPages, $page + 2));
if (!in_array(1, $range)) {
$currentParams['page'] = 1;
$url1 = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $url1 . '">1</button>';
if ($range[0] > 2) {
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">&hellip;</span>';
}
}
foreach ($range as $i) {
$currentParams['page'] = $i;
$iUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
$activeClass = ($i === $page) ? ' lt-btn-primary' : '';
echo '<button class="lt-btn lt-btn-sm' . $activeClass . '" data-action="navigate" data-url="' . $iUrl . '" ' . ($i === $page ? 'aria-current="page"' : '') . '>' . $i . '</button>';
}
if (!in_array($totalPages, $range)) {
if ($range[count($range) - 1] < $totalPages - 1) {
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">&hellip;</span>';
}
$currentParams['page'] = $totalPages;
$urlLast = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $urlLast . '">' . $totalPages . '</button>';
}
if ($page < $totalPages) {
$currentParams['page'] = $page + 1;
$nextUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $nextUrl . '" aria-label="Next page">&#xBB;</button>';
}
?>
</div>
<?php endif ?>
</div><!-- /#tab-table -->
<!-- ══════════════════════════════════════════════════════
TAB PANEL: KANBAN VIEW
══════════════════════════════════════════════════════ -->
<div id="tab-kanban" class="lt-tab-panel" role="tabpanel" aria-labelledby="cardViewBtn">
<div class="lt-grid-4">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">
Open <span class="lt-text-xs lt-text-muted column-count" data-status="Open"></span>
</div>
<div class="lt-section-body kanban-cards" id="kanban-col-open" style="min-height:80px"></div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">
Pending <span class="lt-text-xs lt-text-muted column-count" data-status="Pending"></span>
</div>
<div class="lt-section-body kanban-cards" id="kanban-col-pending" style="min-height:80px"></div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">
In Progress <span class="lt-text-xs lt-text-muted column-count" data-status="In Progress"></span>
</div>
<div class="lt-section-body kanban-cards" id="kanban-col-inprogress" style="min-height:80px"></div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">
Closed <span class="lt-text-xs lt-text-muted column-count" data-status="Closed"></span>
</div>
<div class="lt-section-body kanban-cards" id="kanban-col-closed" style="min-height:80px"></div>
</div>
</div><!-- /.lt-grid-4 -->
</div><!-- /#tab-kanban -->
</div><!-- /.lt-content -->
</div><!-- /.lt-layout -->
<!-- ═══════════════════════════════════════════════════════════
SETTINGS MODAL
═══════════════════════════════════════════════════════════ -->
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="settingsModalTitle">[ CFG ] SYSTEM PREFERENCES</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close settings">&#x2715;</button>
</div>
<div class="lt-modal-body">
<div class="settings-section">
<h4 class="lt-subsection-header">Display Preferences</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<label class="lt-kv-label" for="rowsPerPage">Rows per page</label>
<span class="lt-kv-value">
<select id="rowsPerPage" class="lt-select lt-select-sm">
<option value="15">15</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="tableDensity">Table density</label>
<span class="lt-kv-value">
<select id="tableDensity" class="lt-select lt-select-sm">
<option value="compact">Compact</option>
<option value="normal" selected>Normal</option>
<option value="comfortable">Comfortable</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Default status filters</span>
<span class="lt-kv-value lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php foreach ($_lt_statuses as $sf) : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" name="defaultFilters" value="<?= htmlspecialchars($sf) ?>"
<?= in_array($sf, ['Open','Pending','In Progress']) ? 'checked' : '' ?>>
<?= htmlspecialchars($sf) ?>
</label>
<?php endforeach ?>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="userTimezone">Timezone</label>
<span class="lt-kv-value">
<select id="userTimezone" class="lt-select lt-select-sm">
<option value="America/New_York">Eastern (EST/EDT)</option>
<option value="America/Chicago">Central (CST/CDT)</option>
<option value="America/Denver">Mountain (MST/MDT)</option>
<option value="America/Los_Angeles">Pacific (PST/PDT)</option>
<option value="UTC">UTC</option>
<option value="Europe/London">London (GMT/BST)</option>
<option value="Europe/Paris">Paris (CET/CEST)</option>
</select>
<span class="lt-text-xs lt-text-muted">
Current: <?= htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC') ?>
</span>
</span>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Notifications</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<label class="lt-kv-label" for="notificationsEnabled">Browser notifications</label>
<span class="lt-kv-value">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="notificationsEnabled" checked> Enabled
</label>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="soundEffects">Sound effects</label>
<span class="lt-kv-value">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="soundEffects" checked> Enabled
</label>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="toastDuration">Toast duration</label>
<span class="lt-kv-value">
<select id="toastDuration" class="lt-select lt-select-sm">
<option value="3000" selected>3 seconds</option>
<option value="5000">5 seconds</option>
<option value="10000">10 seconds</option>
</select>
</span>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Keyboard Shortcuts</h4>
<div class="shortcuts-list lt-text-xs">
<div class="shortcut-item"><kbd>Ctrl/Cmd+K</kbd> Focus search</div>
<div class="shortcut-item"><kbd>Alt+S</kbd> Open settings</div>
<div class="shortcut-item"><kbd>ESC</kbd> Close modal</div>
<div class="shortcut-item"><kbd>?</kbd> Show shortcuts</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">User Information</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row"><span class="lt-kv-label">Display Name</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Username</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['username'] ?? '') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Email</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Role</span><span class="lt-kv-value"><?= ($GLOBALS['currentUser']['is_admin'] ?? false) ? '<span class="lt-badge lt-badge-admin">Admin</span>' : 'User' ?></span></div>
<div class="lt-kv-row">
<span class="lt-kv-label">Groups</span>
<span class="lt-kv-value">
<?php
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
if ($groups) :
foreach ($groups as $g) : ?>
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
<?php endforeach;
else : ?>
<span class="lt-text-muted">No groups assigned</span>
<?php endif ?>
</span>
</div>
</div>
</div>
</div><!-- /.lt-modal-body -->
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-primary" data-action="save-settings">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close data-action="close-settings">CANCEL</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
ADVANCED SEARCH MODAL
═══════════════════════════════════════════════════════════ -->
<div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="advancedSearchModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="advancedSearchModalTitle">[ FILTER ] ADVANCED SEARCH</span>
<button type="button" class="lt-modal-close" data-modal-close
data-action="close-advanced-search" aria-label="Close">&#x2715;</button>
</div>
<form id="advancedSearchForm">
<div class="lt-modal-body">
<div class="settings-section">
<h4 class="lt-subsection-header">Saved Filters</h4>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="saved-filters-select">Load Filter</label>
<div class="lt-flex lt-flex-gap-sm">
<select id="saved-filters-select" class="lt-select" style="flex:1"
data-action="load-saved-filter">
<option value="">— Select a saved filter —</option>
</select>
<button type="button" class="lt-btn lt-btn-sm" data-action="save-filter">SAVE</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="delete-filter">DEL</button>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Search Text</h4>
<div class="lt-form-group" style="margin:0">
<label class="lt-sr-only lt-label" for="adv-search-text">Search</label>
<input type="text" id="adv-search-text" class="lt-input"
placeholder="Search in title, description...">
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Date Range</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-created-from">Created From</label>
<span class="lt-kv-value"><input type="date" id="adv-created-from" class="lt-input lt-input-sm"></span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-created-to">Created To</label>
<span class="lt-kv-value"><input type="date" id="adv-created-to" class="lt-input lt-input-sm"></span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-updated-from">Updated From</label>
<span class="lt-kv-value"><input type="date" id="adv-updated-from" class="lt-input lt-input-sm"></span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-updated-to">Updated To</label>
<span class="lt-kv-value"><input type="date" id="adv-updated-to" class="lt-input lt-input-sm"></span>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Filters</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-status">Status</label>
<span class="lt-kv-value">
<select id="adv-status" class="lt-select" multiple size="4">
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-priority-min">Priority</label>
<span class="lt-kv-value lt-flex lt-flex-gap-sm lt-flex-align-center">
<select id="adv-priority-min" class="lt-select lt-select-sm">
<option value="">Any</option>
<?php foreach (range(1, 5) as $p) :
?><option value="<?= $p ?>">P<?= $p ?></option><?php
endforeach ?>
</select>
<span class="lt-text-xs lt-text-muted">to</span>
<select id="adv-priority-max" class="lt-select lt-select-sm">
<option value="">Any</option>
<?php foreach (range(1, 5) as $p) :
?><option value="<?= $p ?>">P<?= $p ?></option><?php
endforeach ?>
</select>
</span>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Users</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-created-by">Created By</label>
<span class="lt-kv-value">
<select id="adv-created-by" class="lt-select">
<option value="">Any User</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="adv-assigned-to">Assigned To</label>
<span class="lt-kv-value">
<select id="adv-assigned-to" class="lt-select">
<option value="">Any User</option>
<option value="unassigned">Unassigned</option>
</select>
</span>
</div>
</div>
</div>
</div><!-- /.lt-modal-body -->
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SEARCH</button>
<button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">RESET</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close
data-action="close-advanced-search">CANCEL</button>
</div>
</form>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
DASHBOARD INLINE SCRIPT
═══════════════════════════════════════════════════════════ -->
<script nonce="<?= $nonce ?>">
window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES']) ?>;
// Initialize keyboard and table navigation
if (window.lt) {
lt.keys.initDefaults();
lt.tableNav.init('tickets-table');
lt.statsFilter.init();
}
// Saved filter pills — load on page init
(function() {
lt.api.get('/api/saved_filters.php').then(function(data) {
if (!data.success || !data.filters || !data.filters.length) return;
var pillsWrap = document.getElementById('savedFilterPills');
if (!pillsWrap) return;
var html = '<span class="lt-text-xs lt-text-muted" style="align-self:center">Saved:</span>';
data.filters.slice(0, 8).forEach(function(f) {
var criteria = JSON.stringify(f.filter_criteria);
html += '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm saved-filter-pill" ' +
'data-criteria="' + lt.escHtml(criteria) + '" ' +
'title="Apply saved filter: ' + lt.escHtml(f.filter_name) + '">' +
lt.escHtml(f.filter_name) +
(f.is_default ? ' <span style="color:var(--accent-amber)">&#x2605;</span>' : '') +
'</button>';
});
pillsWrap.innerHTML = html;
pillsWrap.style.display = 'flex';
// Wire clicks: apply filter criteria as URL params
pillsWrap.querySelectorAll('.saved-filter-pill').forEach(function(btn) {
btn.addEventListener('click', function() {
try {
var c = JSON.parse(btn.dataset.criteria);
var params = new URLSearchParams();
if (c.search) params.set('search', c.search);
if (c.status && c.status.length) params.set('status', Array.isArray(c.status) ? c.status.join(',') : c.status);
if (c.priority_min) params.set('priority_min', c.priority_min);
if (c.priority_max) params.set('priority_max', c.priority_max);
if (c.assigned_to) params.set('assigned_to', c.assigned_to);
if (c.created_by) params.set('created_by', c.created_by);
if (c.created_from) params.set('created_from', c.created_from);
if (c.created_to) params.set('created_to', c.created_to);
window.location.href = '/?' + params.toString();
} catch(e) {}
});
});
}).catch(function() {});
})();
// Helper: get date in server timezone
function getServerDate() {
var now = new Date();
var serverTime = new Date(now.getTime() + (window.APP_TIMEZONE_OFFSET * 60000) + (now.getTimezoneOffset() * 60000));
return serverTime.getFullYear() + '-' +
String(serverTime.getMonth() + 1).padStart(2, '0') + '-' +
String(serverTime.getDate()).padStart(2, '0');
}
// Stat card click-to-filter
document.querySelectorAll('.lt-stat-card').forEach(function (card) {
card.addEventListener('click', function () {
var url = '/?';
var today = getServerDate();
if (card.classList.contains('stat-open')) url += 'status=Open,Pending,In+Progress';
else if (card.classList.contains('stat-critical')) url += 'status=Open,Pending,In+Progress&priority_max=1';
else if (card.classList.contains('stat-unassigned')) url += 'status=Open,Pending,In+Progress&assigned_to=unassigned';
else if (card.classList.contains('stat-today')) url += 'show_all=1&created_from=' + today + '&created_to=' + today;
else if (card.classList.contains('stat-resolved')) url += 'status=Closed&closed_from=' + today + '&closed_to=' + today;
else return;
window.location.href = url;
});
});
// Event delegation — handles ONLY cases NOT covered by dashboard.js
// (bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-select-all,
// toggle-row-checkbox, remove-filter, clear-all-filters, open/close/save-settings,
// open/toggle-export-menu, export-tickets, open-advanced-search are in dashboard.js)
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'close-advanced-search': closeAdvancedSearch(); break;
case 'reset-advanced-search': resetAdvancedSearch(); break;
case 'save-filter': saveCurrentFilter(); break;
case 'delete-filter': deleteSavedFilter(); break;
}
});
// Change event delegation
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'update-selection': updateSelectionCount(); break;
case 'load-saved-filter': loadSavedFilter(); break;
}
});
// Advanced search form submit — use wrapper so performAdvancedSearch is resolved at event time
// (advanced-search.js loads later via pageScripts in layout_footer.php)
var advForm = document.getElementById('advancedSearchForm');
if (advForm) advForm.addEventListener('submit', function(e) {
if (typeof performAdvancedSearch === 'function') performAdvancedSearch(e);
});
// ── Flatpickr date pickers on advanced search date fields ────────
(function initFlatpickr() {
function tryInit(tries) {
tries = tries || 0;
if (window.flatpickr) {
var fpOpts = {
dateFormat: 'Y-m-d',
theme: 'dark',
disableMobile: false,
onChange: function() {}
};
['adv-created-from','adv-created-to','adv-updated-from','adv-updated-to'].forEach(function(id) {
var el = document.getElementById(id);
if (el && !el._flatpickr) flatpickr(el, fpOpts);
});
} else if (tries < 20) {
setTimeout(function() { tryInit(tries + 1); }, 300);
}
}
tryInit();
})();
</script>
<!-- ═══════════════════════════════════════════════════════════
TICKET PREVIEW RIGHT DRAWER
Opens when a ticket title link is ctrl/cmd+clicked or via
the preview icon — shows summary without full navigation.
═══════════════════════════════════════════════════════════ -->
<aside class="lt-drawer-right" id="ticketPreviewDrawer" aria-hidden="true"
aria-label="Ticket preview" data-overlay="ticketPreviewDrawerOverlay">
<div class="lt-drawer-right-header">
<span class="lt-drawer-right-title" id="drawerTicketId">Ticket Preview</span>
<button type="button" class="lt-drawer-right-close" data-drawer-close aria-label="Close preview">&#x2715;</button>
</div>
<div class="lt-drawer-right-body" id="drawerBody">
<!-- Content injected by JS -->
</div>
<div class="lt-drawer-right-footer">
<a id="drawerOpenLink" href="#" class="lt-btn lt-btn-primary lt-btn-sm">Open Full Ticket</a>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-drawer-close>Close</button>
</div>
</aside>
<div class="lt-drawer-right-overlay" id="ticketPreviewDrawerOverlay"></div>
<script nonce="<?= $nonce ?>">
// ── Ticket Preview Drawer ──────────────────────────────────────────
(function() {
var drawer = document.getElementById('ticketPreviewDrawer');
var body = document.getElementById('drawerBody');
var idLabel = document.getElementById('drawerTicketId');
var openLink = document.getElementById('drawerOpenLink');
if (!drawer || !body) return;
var pLabels = { '1':'P1 — Critical', '2':'P2 — High', '3':'P3 — Medium', '4':'P4 — Low', '5':'P5 — Minimal' };
var dotClass = { 'Open':'lt-dot-up', 'In Progress':'lt-dot-warn', 'Pending':'lt-dot--orange', 'Closed':'lt-dot-idle' };
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function fmtAge(dateStr) {
var d = new Date(dateStr);
if (isNaN(d)) return dateStr;
var diff = Math.floor((Date.now() - d) / 1000);
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return Math.floor(diff/86400) + 'd ago';
}
function openDrawerFromRow(link) {
var href = link.getAttribute('href') || '';
var m = href.match(/\/ticket\/(\d+)/);
if (!m) return;
var ticketId = m[1];
if (openLink) openLink.href = href;
// Extract data from the table row (already rendered in DOM — no extra fetch needed)
var row = link.closest('tr');
var cells = row ? row.querySelectorAll('td') : [];
var hasCheckbox = row && row.querySelector('input[type="checkbox"]') !== null;
var o = hasCheckbox ? 1 : 0; // column offset for checkbox col
var priority = cells[1 + o] ? cells[1 + o].textContent.trim() : '';
var title = cells[2 + o] ? cells[2 + o].textContent.trim() : '';
var category = cells[3 + o] ? cells[3 + o].textContent.trim() : '';
var typeVal = cells[4 + o] ? cells[4 + o].textContent.trim() : '';
var status = cells[5 + o] ? cells[5 + o].textContent.trim().replace(/^\s*●\s*/, '') : '';
var createdBy = cells[6 + o] ? cells[6 + o].textContent.trim() : '';
var assignedTo= cells[7 + o] ? cells[7 + o].textContent.trim() : '';
var age = cells[8 + o] ? cells[8 + o].textContent.trim() : '';
if (idLabel) idLabel.textContent = '[ #' + esc(ticketId) + ' ]';
var dc = dotClass[status] || 'lt-dot-idle';
var pNum = priority.replace(/[^1-5]/g, '') || '?';
var pLabel = pLabels[pNum] || ('P' + pNum);
body.innerHTML =
'<div class="lt-frame" style="margin-bottom:0.75rem;padding:0.6rem 0.75rem">' +
'<div style="font-weight:700;margin-bottom:0.5rem;font-size:0.9rem;line-height:1.3">' + esc(title) + '</div>' +
'<div class="lt-kv-grid" style="margin-bottom:0">' +
'<div class="lt-kv-row"><span class="lt-kv-label">Status</span><span class="lt-kv-value"><span class="lt-dot ' + dc + '" style="display:inline-block;vertical-align:middle;margin-right:0.35rem"></span>' + esc(status) + '</span></div>' +
'<div class="lt-kv-row"><span class="lt-kv-label">Priority</span><span class="lt-kv-value">' + esc(pLabel) + '</span></div>' +
'<div class="lt-kv-row"><span class="lt-kv-label">Category</span><span class="lt-kv-value">' + esc(category||'—') + '</span></div>' +
'<div class="lt-kv-row"><span class="lt-kv-label">Type</span><span class="lt-kv-value">' + esc(typeVal||'—') + '</span></div>' +
'<div class="lt-kv-row"><span class="lt-kv-label">Assigned</span><span class="lt-kv-value">' + esc(assignedTo||'Unassigned') + '</span></div>' +
(createdBy ? '<div class="lt-kv-row"><span class="lt-kv-label">Created by</span><span class="lt-kv-value">' + esc(createdBy) + '</span></div>' : '') +
(age ? '<div class="lt-kv-row"><span class="lt-kv-label">Age</span><span class="lt-kv-value">' + esc(age) + '</span></div>' : '') +
'</div>' +
'</div>' +
'<p class="lt-text-muted lt-text-xs lt-text-center">Click "Open Full Ticket" for description, comments &amp; attachments.</p>';
lt.rightDrawer.open('ticketPreviewDrawer');
}
// Intercept clicks on .ticket-link with Ctrl/Cmd held → open drawer
// Normal left-click still navigates to full ticket page
document.addEventListener('click', function(e) {
var link = e.target.closest('.ticket-link');
if (!link) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
openDrawerFromRow(link);
}
});
// Add a small [⊙] peek icon after each title link for easy drawer access
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.ticket-link').forEach(function(link) {
var btn = document.createElement('button');
btn.type = 'button';
btn.title = 'Quick preview (Ctrl+click)';
btn.setAttribute('aria-label', 'Quick preview');
btn.innerHTML = '&#x29C9;';
btn.style.cssText = 'font-size:0.7rem;margin-left:0.3rem;opacity:0;border:none;background:none;cursor:pointer;color:var(--accent-cyan);vertical-align:middle;padding:0 0.1rem;line-height:1;transition:opacity 0.15s';
btn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
openDrawerFromRow(link);
});
link.addEventListener('mouseenter', function() { btn.style.opacity = '0.5'; });
link.addEventListener('mouseleave', function() { btn.style.opacity = '0'; });
link.parentNode.insertBefore(btn, link.nextSibling);
});
});
})();
</script>
<?php include __DIR__ . '/layout_footer.php'; ?>