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:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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