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:
+157
-1
@@ -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>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user