feat: Chart.js donut/bar charts, Flatpickr dates, skeleton loaders, CSP update

- DashboardView: Charts row with 3 panels (priority donut, status donut, category bar)
  using Chart.js from CDN; data passed inline from PHP stats; TDS color palette
- DashboardView: Flatpickr date picker on advanced search date fields with TDS theme overrides
- dashboard.js: showTableSkeleton() shows lt-skeleton-row during filter-triggered reloads
  and auto-refresh; called before all location.reload() with delay
- dashboard.css: Flatpickr TDS theme overrides (dark BG, monospace font, TDS accent colors)
- SecurityHeadersMiddleware: Added cdn.jsdelivr.net to script-src and style-src CSP
  to allow Chart.js and Flatpickr from CDN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 17:45:02 -04:00
parent c15defc09b
commit 04b019a8e1
4 changed files with 219 additions and 9 deletions
+36
View File
@@ -361,3 +361,39 @@ kbd {
letter-spacing: 0.08em;
color: var(--text-muted);
}
/* ── Flatpickr TDS theme overrides ──────────────────────────────── */
.flatpickr-calendar {
background: var(--bg-secondary, #0a0e14) !important;
border: 1px solid var(--border-color, rgba(0,255,65,0.25)) !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.6) !important;
border-radius: 0 !important;
font-family: var(--font-mono, monospace) !important;
}
.flatpickr-day {
color: var(--text-secondary, #8fa3b1) !important;
border-radius: 0 !important;
}
.flatpickr-day.today {
border-color: var(--accent-cyan, #00d4ff) !important;
color: var(--accent-cyan, #00d4ff) !important;
}
.flatpickr-day.selected, .flatpickr-day.selected:hover {
background: var(--accent-orange, #ff8c00) !important;
border-color: var(--accent-orange, #ff8c00) !important;
color: #000 !important;
}
.flatpickr-day:hover {
background: rgba(0,212,255,0.1) !important;
color: var(--text-primary, #e8f4f8) !important;
}
.flatpickr-months, .flatpickr-weekdays {
background: var(--bg-tertiary, #1a1f2e) !important;
}
.flatpickr-current-month, .flatpickr-weekday {
color: var(--text-muted, #5a7a8a) !important;
font-family: var(--font-mono, monospace) !important;
}
.flatpickr-prev-month svg path, .flatpickr-next-month svg path {
fill: var(--text-muted) !important;
}
+25 -7
View File
@@ -535,7 +535,7 @@ function performBulkCloseAction(ticketIds) {
} else {
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
@@ -631,7 +631,7 @@ function performBulkAssign() {
} else {
lt.toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
@@ -707,7 +707,7 @@ function performBulkPriority() {
} else {
lt.toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
@@ -810,7 +810,7 @@ function performBulkStatusChange() {
} else {
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
@@ -869,7 +869,7 @@ function performBulkDelete() {
closeBulkDeleteModal();
if (data.success) {
lt.toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
setTimeout(() => window.location.reload(), 1500);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
@@ -993,7 +993,7 @@ function performQuickStatusChange(ticketId) {
closeQuickStatusModal();
if (data.success) {
lt.toast.success(`Status updated to ${newStatus}`, 3000);
setTimeout(() => window.location.reload(), 1000);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
@@ -1079,7 +1079,7 @@ function performQuickAssign(ticketId) {
closeQuickAssignModal();
if (data.success) {
lt.toast.success('Assignment updated', 3000);
setTimeout(() => window.location.reload(), 1000);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
}
@@ -1455,12 +1455,30 @@ function hideLoadingOverlay(element) {
* Reload the dashboard, but skip if a modal is open or user is typing.
* Registered with lt.autoRefresh so it runs every 5 minutes automatically.
*/
/**
* Replace table body rows with skeleton placeholders before a page reload.
* Gives visual feedback that a reload is in progress.
*/
function showTableSkeleton(rowCount) {
rowCount = rowCount || 5;
const tbody = document.querySelector('#tickets-table tbody');
if (!tbody) return;
let html = '';
for (let i = 0; i < rowCount; i++) {
html += '<tr class="lt-skeleton-row" aria-hidden="true">' +
'<td><div class="lt-skeleton" style="height:0.8rem;width:100%"></div></td>'.repeat(6) +
'</tr>';
}
tbody.innerHTML = html;
}
function dashboardAutoRefresh() {
// Don't interrupt the user if a modal is open
if (document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) return;
// Don't interrupt if focus is in a text input
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
showTableSkeleton(6);
window.location.reload();
}
+1 -1
View File
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
// Content Security Policy - restricts where resources can be loaded from
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
// All inline event handlers have been refactored to use addEventListener with data-action attributes
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
// Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY");
+157 -1
View File
@@ -15,13 +15,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Dashboard';
$activeNav = 'dashboard';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$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 ────────────────────────────────────────────────────────
@@ -168,6 +173,135 @@ include __DIR__ . '/layout_header.php';
</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 has-lt-overlay" 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="height:180px;display:flex;align-items:center;justify-content:center">
<canvas id="chartPriority" aria-label="Priority distribution donut chart" role="img"></canvas>
</div>
</div>
<div class="lt-frame has-lt-overlay" 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="height:180px;display:flex;align-items:center;justify-content:center">
<canvas id="chartStatus" aria-label="Status breakdown donut chart" role="img"></canvas>
</div>
</div>
<div class="lt-frame has-lt-overlay" 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="height:180px;display:flex;align-items:center;justify-content:center">
<canvas id="chartCategory" aria-label="Category breakdown bar chart" role="img"></canvas>
</div>
</div>
</div>
<script>
// ── 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 };
// Remove loading overlays
['chartPriorityWrap','chartStatusWrap','chartCategoryWrap'].forEach(function(id) {
var el = document.getElementById(id);
if (el) { el.classList.remove('has-lt-overlay'); var ov = el.querySelector('.lt-loading-overlay'); if (ov) ov.remove(); }
});
makeDonut('chartPriority', priorityData, pColors);
makeDonut('chartStatus', statusData, sColors);
makeBar('chartCategory', categoryData.slice(0, 8));
});
})();
</script>
<?php if (!empty($stats['by_assignee'])): ?>
<!-- ═══════════════════════════════════════════════════════════
TEAM WORKLOAD PANEL
@@ -1027,6 +1161,28 @@ document.addEventListener('change', function (e) {
// Advanced search form submit
var advForm = document.getElementById('advancedSearchForm');
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
// ── 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>
<!-- ═══════════════════════════════════════════════════════════