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
+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>
<!-- ═══════════════════════════════════════════════════════════