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;
|
letter-spacing: 0.08em;
|
||||||
color: var(--text-muted);
|
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 {
|
} else {
|
||||||
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
@@ -631,7 +631,7 @@ function performBulkAssign() {
|
|||||||
} else {
|
} else {
|
||||||
lt.toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
@@ -707,7 +707,7 @@ function performBulkPriority() {
|
|||||||
} else {
|
} else {
|
||||||
lt.toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
|
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 {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
@@ -810,7 +810,7 @@ function performBulkStatusChange() {
|
|||||||
} else {
|
} else {
|
||||||
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
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 {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
@@ -869,7 +869,7 @@ function performBulkDelete() {
|
|||||||
closeBulkDeleteModal();
|
closeBulkDeleteModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
lt.toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
|
lt.toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
@@ -993,7 +993,7 @@ function performQuickStatusChange(ticketId) {
|
|||||||
closeQuickStatusModal();
|
closeQuickStatusModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
lt.toast.success(`Status updated to ${newStatus}`, 3000);
|
lt.toast.success(`Status updated to ${newStatus}`, 3000);
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
@@ -1079,7 +1079,7 @@ function performQuickAssign(ticketId) {
|
|||||||
closeQuickAssignModal();
|
closeQuickAssignModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
lt.toast.success('Assignment updated', 3000);
|
lt.toast.success('Assignment updated', 3000);
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
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.
|
* 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.
|
* 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() {
|
function dashboardAutoRefresh() {
|
||||||
// Don't interrupt the user if a modal is open
|
// Don't interrupt the user if a modal is open
|
||||||
if (document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) return;
|
if (document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) return;
|
||||||
// Don't interrupt if focus is in a text input
|
// Don't interrupt if focus is in a text input
|
||||||
const tag = document.activeElement?.tagName;
|
const tag = document.activeElement?.tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
|
showTableSkeleton(6);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
|
|||||||
// Content Security Policy - restricts where resources can be loaded from
|
// 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
|
// 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
|
// 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
|
// Prevent clickjacking by disallowing framing
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: DENY");
|
||||||
|
|||||||
+157
-1
@@ -15,13 +15,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$pageTitle = 'Dashboard';
|
$pageTitle = 'Dashboard';
|
||||||
$activeNav = 'dashboard';
|
$activeNav = 'dashboard';
|
||||||
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
$_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 = [
|
$pageScripts = [
|
||||||
"/assets/js/markdown.js?v={$_v}",
|
"/assets/js/markdown.js?v={$_v}",
|
||||||
"/assets/js/dashboard.js?v={$_v}",
|
"/assets/js/dashboard.js?v={$_v}",
|
||||||
"/assets/js/advanced-search.js?v={$_v}",
|
"/assets/js/advanced-search.js?v={$_v}",
|
||||||
"/assets/js/keyboard-shortcuts.js?v={$_v}",
|
"/assets/js/keyboard-shortcuts.js?v={$_v}",
|
||||||
"/assets/js/settings.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 ────────────────────────────────────────────────────────
|
// ── Pagination helpers ────────────────────────────────────────────────────────
|
||||||
@@ -168,6 +173,135 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?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'])): ?>
|
<?php if (!empty($stats['by_assignee'])): ?>
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
TEAM WORKLOAD PANEL
|
TEAM WORKLOAD PANEL
|
||||||
@@ -1027,6 +1161,28 @@ document.addEventListener('change', function (e) {
|
|||||||
// Advanced search form submit
|
// Advanced search form submit
|
||||||
var advForm = document.getElementById('advancedSearchForm');
|
var advForm = document.getElementById('advancedSearchForm');
|
||||||
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user