Files
tinker_tickets/views/DashboardView.php
T
jared 3c29c6ee6f feat: SLA live timer, notification bell, lt-toggle MD, right drawer, kanban drag-drop
- TicketView: SLA banner now shows live HH:MM:SS elapsed + countdown via JS setInterval
  (previously showed static hours from PHP)
- TicketView: Markdown toggles in comment form replaced with lt-toggle switches
- layout_header: In-app notification bell (🔔) with dropdown panel for all users
- layout_footer: Notification JS — polls /api/notifications.php every 60s, badge count,
  mark-all-read, panel open/close with Escape/outside-click
- api/notifications.php (new): Returns assign/comment/status-change events from audit_log
  for current user's tickets and watched tickets; mark-read via user_preferences
- DashboardView: Ticket preview right drawer — Ctrl+click title or ⊙ peek button
  opens lt-drawer-right with ticket summary extracted from table row DOM
- DashboardView: lt.sortable wired on all 4 kanban columns (group='kanban')
  Cross-column drag = status change via POST /api/update_ticket.php with optimistic UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:21:21 -04:00

1154 lines
60 KiB
PHP

<?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 = ["/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}",
];
// ── 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: ' . htmlspecialchars($_GET['category'])];
}
if (!empty($_GET['type'])) {
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
}
if (!empty($_GET['assigned_to'])) {
$label = $_GET['assigned_to'] === 'unassigned' ? 'Unassigned' : 'User #' . htmlspecialchars($_GET['assigned_to']);
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $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>
<div class="lt-stat-card stat-time" title="Average resolution time" 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($stats['avg_resolution_hours'] ?? '—') ?>h</div>
<div class="lt-stat-label">Avg Resolution</div>
</div>
</div>
</div>
<?php endif ?>
<?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" onerror="this.style.display='none'">
<?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>
<button type="button" class="lt-sidebar-toggle"
data-action="toggle-sidebar"
data-sidebar-toggle="lt-sidebar"
aria-label="Collapse filter sidebar"
aria-expanded="true"
aria-controls="lt-sidebar">&#x25C0;</button>
</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 ?>
<div class="lt-btn-group" style="flex-direction:column">
<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>
<!-- Collapsed expand button -->
<button type="button" class="lt-sidebar-expand-btn" id="sidebarExpandBtn"
data-action="toggle-sidebar"
aria-label="Show filters" aria-expanded="false" aria-controls="lt-sidebar"
style="display:none">&#x25B6; Filters</button>
<!-- ─── MAIN CONTENT ─────────────────────────────────────── -->
<div class="lt-content">
<!-- Toolbar: search + export + count -->
<div class="lt-toolbar">
<div class="lt-toolbar-left">
<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">Ticket Queue</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">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]);
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
?>
<th scope="col" class="<?= $sortClass ?>"
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">
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
</td>
<td data-label="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" 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>
</td>
<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>
<td data-label="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" 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" 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="<?= (int)$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', c.status.join(','));
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 += 'status=Open,Pending,In+Progress&created_from=' + today + '&created_to=' + today;
else if (card.classList.contains('stat-resolved')) url += 'status=Closed&updated_from=' + today + '&updated_to=' + today;
else return;
window.location.href = url;
});
});
// Event delegation for click actions — only handles cases NOT covered by dashboard.js
// bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-*
// are all handled by dashboard.js to avoid double-firing (duplicate handlers = duplicate users in selects).
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'open-settings': openSettingsModal(); break;
case 'close-settings': closeSettingsModal(); break;
case 'save-settings': saveSettings(); break;
case 'manual-refresh': if (lt.autoRefresh) lt.autoRefresh.now(); break;
case 'toggle-sidebar': if (typeof toggleSidebar==='function') toggleSidebar(); break;
case 'open-advanced-search': openAdvancedSearch(); break;
case 'close-advanced-search': closeAdvancedSearch(); break;
case 'reset-advanced-search': resetAdvancedSearch(); break;
case 'toggle-export-menu': e.stopPropagation(); toggleExportMenu(e); break;
case 'export-tickets': e.preventDefault(); exportSelectedTickets(target.getAttribute('data-format')); break;
case 'save-filter': saveCurrentFilter(); break;
case 'delete-filter': deleteSavedFilter(); break;
case 'remove-filter': removeFilter(target.getAttribute('data-filter-type'), target.getAttribute('data-filter-value')); break;
case 'clear-all-filters': window.location.href = '/'; 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
var advForm = document.getElementById('advancedSearchForm');
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
</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>
// ── 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;'); }
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].querySelector('.ticket-link')?.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" style="text-align: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'; ?>