a3fbad19c9
- dashboard.js: use String(cb.value) instead of parseInt() in
getSelectedTicketIds() so zero-padded IDs like 000123456 are
preserved when sent to bulk_operation.php
- DashboardView.php: remove (int) cast on data-ticket-id attribute
for quick-status button; was stripping leading zeros
- TicketView.php: remove (int) cast on export URL ticket_id param
- update_ticket.php: preserve ticket_id as string via trim((string)...)
- add_comment.php: preserve ticket_id as string; validate with
ctype_digit instead of (int) cast so comments are stored with the
canonical zero-padded ID matching the tickets table
- export_tickets.php: validate singleId as string to avoid stripping
leading zeros in the export endpoint
- notifications.php: preserve ticket_id strings in URLs and ticket
ownership checks; index myTicketIds by both int and string forms
for robust lookup regardless of how audit_log stored the ID
- TicketController.php: fix inline dependency insert — column was
wrong (depends_on_ticket_id → depends_on_id) and bind types were
wrong ("iii" → "ssi"); feature was silently broken
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1388 lines
71 KiB
PHP
1388 lines
71 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 = [
|
||
"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: ' . 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];
|
||
}
|
||
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">⏱</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'] ?? [])
|
||
))) ?>;
|
||
|
||
function makeDonut(canvasId, data, colorMap) {
|
||
var ctx = document.getElementById(canvasId);
|
||
if (!ctx || !data.length) return;
|
||
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,
|
||
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; } } }
|
||
},
|
||
cutout: '68%'
|
||
}
|
||
});
|
||
}
|
||
|
||
function makeBar(canvasId, data) {
|
||
var ctx = document.getElementById(canvasId);
|
||
if (!ctx || !data.length) return;
|
||
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,
|
||
plugins: { legend: { display: false } },
|
||
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>▸</span> Team Workload
|
||
<span class="lt-text-xs lt-text-muted" style="font-weight:normal">— 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">
|
||
▤ 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">
|
||
⊕ 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">⋮⋮ 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">✕</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>) ▾
|
||
</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">
|
||
↓ CSV
|
||
</button>
|
||
<button type="button" class="lt-dropdown-item" data-action="export-tickets" data-format="json">
|
||
↓ 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">✕</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>
|
||
— <?= $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 ▾</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">📭</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">~</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">«</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">…</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">…</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">»</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">✕</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">✕</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)">★</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">✕</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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
|
||
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 lt-text-center">Click "Open Full Ticket" for description, comments & 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 = '⧉';
|
||
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'; ?>
|