8cb7cc0356
toggle-sidebar action was only in the DashboardView inline script, not in dashboard.js where toggleSidebar() is defined. Move it into the dashboard.js event delegation switch so it's guaranteed to fire. Also fix beta webhook: was using a different secret than production so Gitea pushes to development never triggered the beta deploy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1392 lines
71 KiB
PHP
1392 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>
|
||
<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">◀</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 ?>
|
||
|
||
<!-- 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">
|
||
<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="<?= (int)$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'; ?>
|