diff --git a/activityDb.js b/activityDb.js
index 368d22f..0bcac8d 100644
--- a/activityDb.js
+++ b/activityDb.js
@@ -88,6 +88,55 @@ db.exec(`
)
`);
+db.exec(`
+ CREATE TABLE IF NOT EXISTS dashboard_chart_lines (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ statuses TEXT NOT NULL,
+ types TEXT NOT NULL,
+ queues TEXT NOT NULL,
+ owners TEXT NOT NULL,
+ responsibles TEXT NOT NULL,
+ color TEXT,
+ is_visible INTEGER NOT NULL DEFAULT 1,
+ is_default INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+ )
+`);
+
+try {
+ db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN color TEXT`);
+} catch (e) {
+ // Already exists
+}
+
+try {
+ db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN is_visible INTEGER NOT NULL DEFAULT 1`);
+} catch (e) {
+ // Already exists
+}
+
+try {
+ const countRow = db.prepare("SELECT COUNT(*) AS count FROM dashboard_chart_lines").get();
+ if (countRow && countRow.count === 0) {
+ db.prepare(`
+ INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1)
+ `).run('Ticket aperti', JSON.stringify([1, 4, 6, 7, 8]), '[]', '[]', '[]', '[]', '#4f46e5');
+
+ db.prepare(`
+ INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1)
+ `).run('Ticket chiusi', JSON.stringify([2, 3, 10]), '[]', '[]', '[]', '[]', '#10b981');
+ } else {
+ // Update default ones color if not set yet
+ db.prepare(`UPDATE dashboard_chart_lines SET color = '#4f46e5' WHERE name = 'Ticket aperti' AND color IS NULL`).run();
+ db.prepare(`UPDATE dashboard_chart_lines SET color = '#10b981' WHERE name = 'Ticket chiusi' AND color IS NULL`).run();
+ }
+} catch (e) {
+ console.error("Error seeding default chart lines:", e.message);
+}
+
try {
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
} catch (e) {
diff --git a/package-lock.json b/package-lock.json
index d9b35f2..b97bdd8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,8 @@
"express": "^4.21.0",
"mysql2": "^3.22.5",
"nodemailer": "^9.0.3",
- "pg": "^8.13.0"
+ "pg": "^8.13.0",
+ "xlsx": "^0.18.5"
}
},
"node_modules/@types/node": {
@@ -41,6 +42,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/adler-32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -196,12 +206,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/cfb": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "crc-32": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
+ "node_modules/codepage": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -255,6 +287,18 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -508,6 +552,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/frac": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -1442,6 +1495,18 @@
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
+ "node_modules/ssf": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "frac": "~1.1.2"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1571,12 +1636,51 @@
"node": ">= 0.8"
}
},
+ "node_modules/wmf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/xlsx": {
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "cfb": "~1.2.1",
+ "codepage": "~1.15.0",
+ "crc-32": "~1.2.1",
+ "ssf": "~0.11.2",
+ "wmf": "~1.0.1",
+ "word": "~0.3.0"
+ },
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/package.json b/package.json
index 6470b64..73bf73e 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
"express": "^4.21.0",
"mysql2": "^3.22.5",
"nodemailer": "^9.0.3",
- "pg": "^8.13.0"
+ "pg": "^8.13.0",
+ "xlsx": "^0.18.5"
},
"keywords": [
"otrs",
diff --git a/public/css/style.css b/public/css/style.css
index ee0bb9c..92b6719 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -2566,4 +2566,67 @@ body {
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
+ border-bottom: none !important;
+}
+
+/* ============================================================
+ Advanced Dashboard Ticket Chart Custom Styles
+ ============================================================ */
+.multiselect-list {
+ display: flex;
+ flex-direction: column;
+ max-height: 140px;
+ overflow-y: auto;
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-sm);
+ padding: 4px;
+ gap: 2px;
+ background: var(--bg-primary);
+}
+.multiselect-item {
+ padding: 6px 10px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.82rem;
+ color: var(--text-secondary);
+ transition: background-color 0.15s, color 0.15s;
+ user-select: none;
+}
+.multiselect-item:hover {
+ background-color: var(--bg-hover, rgba(0,0,0,0.05));
+ color: var(--text-primary);
+}
+.multiselect-item.selected {
+ background-color: var(--accent-primary);
+ color: white;
+}
+.theme-dark .multiselect-item:hover,
+.theme-rosso .multiselect-item:hover,
+.theme-naturale .multiselect-item:hover,
+.theme-ice .multiselect-item:hover,
+.theme-autunno .multiselect-item:hover,
+.theme-fairytale .multiselect-item:hover {
+ background-color: rgba(255, 255, 255, 0.08);
+}
+.line-config-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 14px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ transition: border-color var(--transition-fast);
+}
+.line-config-row:hover {
+ border-color: var(--border-light);
+}
+.line-config-row .line-name {
+ font-weight: 500;
+ font-size: 0.9rem;
+ color: var(--text-primary);
+}
+.line-config-row .line-actions {
+ display: flex;
+ gap: 6px;
}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 258a811..a0ef817 100644
--- a/public/index.html
+++ b/public/index.html
@@ -192,6 +192,7 @@
+
diff --git a/public/js/views/dashboard.js b/public/js/views/dashboard.js
index 3333ab3..4906055 100644
--- a/public/js/views/dashboard.js
+++ b/public/js/views/dashboard.js
@@ -1,20 +1,136 @@
/**
* Dashboard View
- * Shows stats overview, distribution charts, and recent tickets.
+ * Shows stats overview, customizable advanced ticket trends chart, distribution charts, and recent tickets.
*/
+
+// Helper to calculate date boundaries
+function getDatesForPeriod(period, customFromVal, customToVal) {
+ let start_date, end_date;
+ const today = new Date();
+
+ if (period === 'last_week') {
+ const past = new Date();
+ past.setDate(today.getDate() - 7);
+ start_date = formatDateString(past);
+ end_date = formatDateString(today);
+ } else if (period === 'last_month') {
+ const past = new Date();
+ past.setDate(today.getDate() - 30);
+ start_date = formatDateString(past);
+ end_date = formatDateString(today);
+ } else {
+ start_date = customFromVal || formatDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
+ end_date = customToVal || formatDateString(today);
+ }
+ return { start_date, end_date };
+}
+
+function formatDateString(d) {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const dateVal = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${dateVal}`;
+}
+
+/**
+ * Dashboard View
+ * Shows stats overview, customizable advanced ticket trends chart, distribution charts, and recent tickets.
+ */
+
+// Helper to calculate date boundaries
+function getDatesForPeriod(period, customFromVal, customToVal) {
+ let start_date, end_date;
+ const today = new Date();
+
+ if (period === 'last_week') {
+ const past = new Date();
+ past.setDate(today.getDate() - 7);
+ start_date = formatDateString(past);
+ end_date = formatDateString(today);
+ } else if (period === 'last_month') {
+ const past = new Date();
+ past.setDate(today.getDate() - 30);
+ start_date = formatDateString(past);
+ end_date = formatDateString(today);
+ } else {
+ start_date = customFromVal || formatDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
+ end_date = customToVal || formatDateString(today);
+ }
+ return { start_date, end_date };
+}
+
+function formatDateString(d) {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const dateVal = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${dateVal}`;
+}
+
const DashboardView = {
+ chartInstance: null,
+ activeLines: [],
+
async render() {
const container = document.getElementById('view-container');
container.innerHTML = '
';
try {
+ // Ensure lookup tables are loaded in App.lookups
+ await App.ensureLookups();
+
const stats = await App.api('/api/dashboard/stats');
const maxByState = Math.max(...(stats.by_state || []).map(s => parseInt(s.count)), 1);
const maxByPriority = Math.max(...(stats.by_priority || []).map(s => parseInt(s.count)), 1);
const maxByQueue = Math.max(...(stats.by_queue || []).map(s => parseInt(s.count)), 1);
+ // Load saved chart period preferences
+ const savedPeriod = localStorage.getItem('otrs_chart_period') || 'last_week';
+ const savedDateFrom = localStorage.getItem('otrs_chart_date_from') || '';
+ const savedDateTo = localStorage.getItem('otrs_chart_date_to') || '';
+
container.innerHTML = `
+
+
+
@@ -133,8 +249,202 @@ const DashboardView = {
`}
+
+
+
+
+
+
Configura Serie di Dati
+
+
+
+
+
+
+ Serie di dati attive sul grafico:
+
+
+
+
+
+
+
+
+
+
+
`;
+ // Draw custom Chart.js lines
+ this.updateChartData();
+
+ // Bind controls listeners
+ const periodSelect = document.getElementById('chart-period-preset');
+ const customDatesDiv = document.getElementById('chart-custom-dates');
+ const dateFromInput = document.getElementById('chart-date-from');
+ const dateToInput = document.getElementById('chart-date-to');
+
+ periodSelect.addEventListener('change', () => {
+ if (periodSelect.value === 'custom') {
+ customDatesDiv.style.display = 'flex';
+ } else {
+ customDatesDiv.style.display = 'none';
+ this.updateChartData();
+ }
+ });
+
+ dateFromInput.addEventListener('change', () => {
+ if (dateFromInput.value && dateToInput.value) {
+ this.updateChartData();
+ }
+ });
+
+ dateToInput.addEventListener('change', () => {
+ if (dateFromInput.value && dateToInput.value) {
+ this.updateChartData();
+ }
+ });
+
+ // Bind Modal listeners
+ const modal = document.getElementById('chart-series-modal');
+ document.getElementById('configure-series-btn').addEventListener('click', () => {
+ modal.style.display = 'flex';
+ this.loadModalSeriesList();
+ });
+
+ document.getElementById('export-series-excel-btn').addEventListener('click', () => {
+ const periodSelect = document.getElementById('chart-period-preset');
+ const dateFromInput = document.getElementById('chart-date-from');
+ const dateToInput = document.getElementById('chart-date-to');
+ const { start_date, end_date } = getDatesForPeriod(periodSelect.value, dateFromInput.value, dateToInput.value);
+
+ const visibleSeriesIds = [];
+ if (this.chartInstance && this.activeLines) {
+ this.activeLines.forEach((line) => {
+ const datasetIndex = this.chartInstance.data.datasets.findIndex(ds => ds.label === line.name);
+ if (datasetIndex !== -1 && this.chartInstance.isDatasetVisible(datasetIndex)) {
+ visibleSeriesIds.push(line.id);
+ }
+ });
+ }
+
+ if (visibleSeriesIds.length === 0) {
+ Toast.error('Nessuna serie visibile da esportare!');
+ return;
+ }
+
+ const idsParam = visibleSeriesIds.join(',');
+ window.open(`/api/dashboard/export-excel?start_date=${start_date}&end_date=${end_date}&series_ids=${idsParam}`, '_blank');
+ });
+
+ document.getElementById('chart-series-modal-close').addEventListener('click', () => {
+ modal.style.display = 'none';
+ this.closeSeriesForm();
+ });
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ modal.style.display = 'none';
+ this.closeSeriesForm();
+ }
+ });
+
+ document.getElementById('btn-add-chart-series').addEventListener('click', () => {
+ this.openSeriesForm();
+ });
+
+ document.getElementById('btn-series-form-cancel').addEventListener('click', () => {
+ this.closeSeriesForm();
+ });
+
+ document.getElementById('modal-series-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const seriesId = document.getElementById('form-series-id').value;
+ const name = document.getElementById('form-series-name').value;
+ const color = document.getElementById('form-series-color').value;
+ const statuses = this.getSelectedIds('form-select-statuses');
+ const types = this.getSelectedIds('form-select-types');
+ const queues = this.getSelectedIds('form-select-queues');
+ const owners = this.getSelectedIds('form-select-owners');
+ const responsibles = this.getSelectedIds('form-select-responsibles');
+
+ // Preserve current visibility if editing
+ let is_visible = 1;
+ if (seriesId) {
+ const existing = this.activeLines.find(s => s.id === parseInt(seriesId, 10));
+ if (existing) is_visible = existing.is_visible;
+ }
+
+ const payload = { name, statuses, types, queues, owners, responsibles, color, is_visible };
+
+ try {
+ if (seriesId) {
+ await App.api(`/api/dashboard/chart-lines/${seriesId}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload)
+ });
+ Toast.success('Serie aggiornata!');
+ } else {
+ await App.api('/api/dashboard/chart-lines', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ });
+ Toast.success('Serie creata!');
+ }
+ this.closeSeriesForm();
+ this.loadModalSeriesList();
+ this.updateChartData();
+ } catch (err) {
+ Toast.error('Errore durante il salvataggio: ' + err.message);
+ }
+ });
+
// Animate bars after render
requestAnimationFrame(() => {
document.querySelectorAll('.dist-bar-fill').forEach(bar => {
@@ -184,4 +494,289 @@ const DashboardView = {
`;
}
},
+
+ async updateChartData() {
+ const periodPreset = document.getElementById('chart-period-preset').value;
+ const customFrom = document.getElementById('chart-date-from').value;
+ const customTo = document.getElementById('chart-date-to').value;
+
+ // Save selections
+ localStorage.setItem('otrs_chart_period', periodPreset);
+ if (customFrom) localStorage.setItem('otrs_chart_date_from', customFrom);
+ if (customTo) localStorage.setItem('otrs_chart_date_to', customTo);
+
+ const { start_date, end_date } = getDatesForPeriod(periodPreset, customFrom, customTo);
+
+ try {
+ const data = await App.api(`/api/dashboard/chart-data?start_date=${start_date}&end_date=${end_date}`);
+ this.activeLines = data.lines;
+ this.drawChart(data.labels, data.lines);
+ } catch (err) {
+ console.error("Error loading chart data:", err);
+ const wrapper = document.querySelector('.chart-canvas-wrapper');
+ if (wrapper) {
+ wrapper.innerHTML = `
+
+ ⚠️ Errore durante il caricamento dei dati del grafico
+ ${App.escapeHtml(err.message)}
+
+ `;
+ }
+ }
+ },
+
+ drawChart(labels, lines) {
+ const canvas = document.getElementById('dashboard-ticket-chart');
+ if (!canvas) return;
+
+ if (this.chartInstance) {
+ this.chartInstance.destroy();
+ }
+
+ const isDarkMode = document.body.className.includes('theme-') && !document.body.className.includes('theme-light');
+ const textColor = isDarkMode ? '#a6adc8' : '#4b5563';
+ const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)';
+
+ const datasets = lines.map((line) => {
+ const color = line.color || '#4f46e5';
+ return {
+ label: line.name,
+ data: line.data,
+ borderColor: color,
+ backgroundColor: color + '15',
+ borderWidth: 2.5,
+ tension: 0.3,
+ fill: true,
+ pointBackgroundColor: color,
+ pointBorderColor: '#fff',
+ pointHoverRadius: 6,
+ pointRadius: 4,
+ };
+ });
+
+ this.chartInstance = new Chart(canvas, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: datasets
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'top',
+ labels: {
+ color: textColor,
+ font: {
+ family: 'Inter',
+ size: 12,
+ weight: '500'
+ },
+ padding: 15,
+ }
+ },
+ tooltip: {
+ mode: 'index',
+ intersect: false,
+ backgroundColor: isDarkMode ? '#1e1e2e' : '#fff',
+ titleColor: isDarkMode ? '#cdd6f4' : '#0f172a',
+ bodyColor: isDarkMode ? '#a6adc8' : '#475569',
+ borderColor: isDarkMode ? '#313244' : '#e2e8f0',
+ borderWidth: 1,
+ padding: 10,
+ titleFont: { family: 'Inter', weight: '600' },
+ bodyFont: { family: 'Inter' },
+ }
+ },
+ scales: {
+ x: {
+ grid: {
+ color: gridColor,
+ drawBorder: false,
+ },
+ ticks: {
+ color: textColor,
+ font: { family: 'Inter', size: 10 },
+ maxRotation: 45,
+ minRotation: 0,
+ }
+ },
+ y: {
+ grid: {
+ color: gridColor,
+ drawBorder: false,
+ },
+ ticks: {
+ color: textColor,
+ font: { family: 'Inter', size: 10 },
+ stepSize: 1,
+ precision: 0,
+ },
+ beginAtZero: true
+ }
+ }
+ }
+ });
+ },
+
+ async loadModalSeriesList() {
+ const listContainer = document.getElementById('modal-series-list');
+ if (!listContainer) return;
+
+ listContainer.innerHTML = '';
+
+ try {
+ const lines = await App.api('/api/dashboard/chart-lines');
+ this.activeLines = lines;
+
+ listContainer.innerHTML = lines.map(line => `
+
+
+
+ ${App.escapeHtml(line.name)} ${line.is_default ? '(Predefinita)' : ''}
+
+
+
+
+ ${!line.is_default ? `` : ''}
+
+
+ `).join('');
+
+ // Bind actions
+ listContainer.querySelectorAll('.btn-edit-series').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const lineId = parseInt(btn.dataset.id);
+ const line = this.activeLines.find(l => l.id === lineId);
+ if (line) this.openSeriesForm(line);
+ });
+ });
+
+ listContainer.querySelectorAll('.btn-toggle-visibility').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const lineId = parseInt(btn.dataset.id);
+ const series = this.activeLines.find(l => l.id === lineId);
+ if (series) {
+ const nextVisibility = series.is_visible ? 0 : 1;
+ try {
+ await App.api(`/api/dashboard/chart-lines/${lineId}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ name: series.name,
+ statuses: series.statuses,
+ types: series.types,
+ queues: series.queues,
+ owners: series.owners,
+ responsibles: series.responsibles,
+ color: series.color,
+ is_visible: nextVisibility
+ })
+ });
+ Toast.success(nextVisibility ? 'Serie attivata!' : 'Serie disattivata!');
+ this.loadModalSeriesList();
+ this.updateChartData();
+ } catch (err) {
+ Toast.error('Errore durante l\'aggiornamento dello stato: ' + err.message);
+ }
+ }
+ });
+ });
+
+ listContainer.querySelectorAll('.btn-delete-series').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const lineId = parseInt(btn.dataset.id);
+ const confirmed = await App.confirm('Elimina serie', 'Sei sicuro di voler eliminare questa serie dal grafico?');
+ if (!confirmed) return;
+
+ try {
+ await App.api(`/api/dashboard/chart-lines/${lineId}`, { method: 'DELETE' });
+ Toast.success('Serie eliminata con successo!');
+ this.loadModalSeriesList();
+ this.updateChartData();
+ } catch (err) {
+ Toast.error('Errore durante l\'eliminazione: ' + err.message);
+ }
+ });
+ });
+ } catch (err) {
+ listContainer.innerHTML = `Errore: ${App.escapeHtml(err.message)}
`;
+ }
+ },
+
+ openSeriesForm(line = null) {
+ const listSection = document.getElementById('modal-series-list-section');
+ const formSection = document.getElementById('modal-series-form');
+
+ listSection.style.display = 'none';
+ formSection.style.display = 'flex';
+
+ const idInput = document.getElementById('form-series-id');
+ const nameInput = document.getElementById('form-series-name');
+ const colorInput = document.getElementById('form-series-color');
+
+ const statuses = line ? line.statuses : [];
+ const types = line ? line.types : [];
+ const queues = line ? line.queues : [];
+ const owners = line ? line.owners : [];
+ const responsibles = line ? line.responsibles : [];
+
+ idInput.value = line ? line.id : '';
+ nameInput.value = line ? line.name : '';
+ colorInput.value = line ? line.color : '#4f46e5';
+
+ this.renderMultiselect('form-select-statuses', App.lookups.states || [], statuses);
+ this.renderMultiselect('form-select-types', App.lookups.types || [], types);
+ this.renderMultiselect('form-select-queues', App.lookups.queues || [], queues);
+ this.renderMultiselect('form-select-owners', App.lookups.users || [], owners, 'user');
+ this.renderMultiselect('form-select-responsibles', App.lookups.users || [], responsibles, 'user');
+ },
+
+ closeSeriesForm() {
+ const listSection = document.getElementById('modal-series-list-section');
+ const formSection = document.getElementById('modal-series-form');
+ if (listSection && formSection) {
+ listSection.style.display = 'block';
+ formSection.style.display = 'none';
+ }
+ },
+
+ renderMultiselect(containerId, items, selectedIds, type = 'normal') {
+ const container = document.getElementById(containerId);
+ if (!container) return;
+
+ const sorted = [...items].sort((a, b) => {
+ const nameA = type === 'user' ? `${a.last_name || ''} ${a.first_name || ''}` : (a.name || '');
+ const nameB = type === 'user' ? `${b.last_name || ''} ${b.first_name || ''}` : (b.name || '');
+ return nameA.localeCompare(nameB, 'it', { sensitivity: 'base' });
+ });
+
+ container.innerHTML = sorted.map(item => {
+ const id = item.id;
+ const isSelected = selectedIds.includes(id) || selectedIds.includes(String(id)) || selectedIds.includes(parseInt(id));
+ let label = item.name;
+ if (type === 'user') {
+ label = `${item.first_name || ''} ${item.last_name || ''} (${item.login || ''})`;
+ }
+ return `${App.escapeHtml(label)}
`;
+ }).join('');
+
+ // Bind item click
+ container.querySelectorAll('.multiselect-item').forEach(el => {
+ el.addEventListener('click', () => {
+ el.classList.toggle('selected');
+ });
+ });
+ },
+
+ getSelectedIds(containerId) {
+ const container = document.getElementById(containerId);
+ if (!container) return [];
+ return Array.from(container.querySelectorAll('.multiselect-item.selected')).map(el => {
+ const id = el.dataset.id;
+ return isNaN(parseInt(id)) ? id : parseInt(id);
+ });
+ }
};
diff --git a/routes/dashboard.js b/routes/dashboard.js
index a47070a..437e6c7 100644
--- a/routes/dashboard.js
+++ b/routes/dashboard.js
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { db } = require('../activityDb');
+const XLSX = require('xlsx');
const ALGORITHM = 'aes-256-cbc';
const SECRET_KEY = crypto.createHash('sha256').update(process.env.CRYPTO_KEY || 'default_secret_key_12345').digest();
@@ -235,29 +236,348 @@ router.get('/settings', (req, res) => {
}
});
-// POST /api/dashboard/settings — Save settings for the agent
-router.post('/settings', (req, res) => {
- const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
- const { preview_limit, tickets_per_page } = req.body;
-
+// GET /api/dashboard/chart-lines
+router.get('/chart-lines', (req, res) => {
try {
- let currentSettings = { preview_limit: 10, tickets_per_page: 50 };
- const row = db.prepare("SELECT preview_limit, tickets_per_page FROM agent_settings WHERE agent_id = ?").get(activeAgentId);
- if (row) {
- currentSettings = row;
+ const lines = db.prepare("SELECT * FROM dashboard_chart_lines ORDER BY is_default DESC, id ASC").all();
+ const formatted = lines.map(line => ({
+ ...line,
+ statuses: JSON.parse(line.statuses || '[]'),
+ types: JSON.parse(line.types || '[]'),
+ queues: JSON.parse(line.queues || '[]'),
+ owners: JSON.parse(line.owners || '[]'),
+ responsibles: JSON.parse(line.responsibles || '[]')
+ }));
+ res.json(formatted);
+ } catch (err) {
+ console.error('Error fetching chart lines:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// POST /api/dashboard/chart-lines
+router.post('/chart-lines', (req, res) => {
+ const { name, statuses, types, queues, owners, responsibles, color, is_visible } = req.body;
+ if (!name) return res.status(400).json({ error: 'Name is required' });
+
+ try {
+ const info = db.prepare(`
+ INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
+ `).run(
+ name,
+ JSON.stringify(statuses || []),
+ JSON.stringify(types || []),
+ JSON.stringify(queues || []),
+ JSON.stringify(owners || []),
+ JSON.stringify(responsibles || []),
+ color || '#4f46e5',
+ is_visible !== undefined ? parseInt(is_visible, 10) : 1
+ );
+ res.json({ success: true, id: info.lastInsertRowid });
+ } catch (err) {
+ console.error('Error saving chart line:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// PUT /api/dashboard/chart-lines/:id
+router.put('/chart-lines/:id', (req, res) => {
+ const { id } = req.params;
+ const { name, statuses, types, queues, owners, responsibles, color, is_visible } = req.body;
+ if (!name) return res.status(400).json({ error: 'Name is required' });
+
+ try {
+ const info = db.prepare(`
+ UPDATE dashboard_chart_lines
+ SET name = ?, statuses = ?, types = ?, queues = ?, owners = ?, responsibles = ?, color = ?, is_visible = ?
+ WHERE id = ?
+ `).run(
+ name,
+ JSON.stringify(statuses || []),
+ JSON.stringify(types || []),
+ JSON.stringify(queues || []),
+ JSON.stringify(owners || []),
+ JSON.stringify(responsibles || []),
+ color || '#4f46e5',
+ is_visible !== undefined ? parseInt(is_visible, 10) : 1,
+ id
+ );
+ if (info.changes === 0) return res.status(404).json({ error: 'Line not found' });
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Error updating chart line:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// DELETE /api/dashboard/chart-lines/:id
+router.delete('/chart-lines/:id', (req, res) => {
+ const { id } = req.params;
+ try {
+ const line = db.prepare("SELECT is_default FROM dashboard_chart_lines WHERE id = ?").get(id);
+ if (!line) return res.status(404).json({ error: 'Line not found' });
+ if (line.is_default === 1) {
+ return res.status(400).json({ error: 'Cannot delete default line' });
}
- const newPreviewLimit = preview_limit !== undefined ? parseInt(preview_limit, 10) : currentSettings.preview_limit;
- const newTicketsPerPage = tickets_per_page !== undefined ? parseInt(tickets_per_page, 10) : currentSettings.tickets_per_page;
-
- db.prepare(`
- INSERT OR REPLACE INTO agent_settings (agent_id, preview_limit, tickets_per_page)
- VALUES (?, ?, ?)
- `).run(activeAgentId, newPreviewLimit, newTicketsPerPage);
-
- res.json({ success: true, preview_limit: newPreviewLimit, tickets_per_page: newTicketsPerPage });
+ db.prepare("DELETE FROM dashboard_chart_lines WHERE id = ?").run(id);
+ res.json({ success: true });
} catch (err) {
- console.error('Error saving agent settings:', err);
+ console.error('Error deleting chart line:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// GET /api/dashboard/chart-data
+router.get('/chart-data', async (req, res) => {
+ const { start_date, end_date } = req.query;
+ if (!start_date || !end_date) {
+ return res.status(400).json({ error: 'start_date and end_date are required' });
+ }
+
+ try {
+ // Only query data series that are configured as visible
+ const lines = db.prepare("SELECT * FROM dashboard_chart_lines WHERE is_visible = 1 ORDER BY is_default DESC, id ASC").all();
+
+ const dates = [];
+ let curr = new Date(start_date);
+ const endLimit = new Date(end_date);
+ while (curr <= endLimit) {
+ const y = curr.getFullYear();
+ const m = String(curr.getMonth() + 1).padStart(2, '0');
+ const d = String(curr.getDate()).padStart(2, '0');
+ dates.push(`${y}-${m}-${d}`);
+ curr.setDate(curr.getDate() + 1);
+ }
+
+ const linesData = await Promise.all(lines.map(async (line) => {
+ const conditions = [];
+ const params = [];
+ let paramIdx = 1;
+
+ conditions.push(`t.create_time >= $${paramIdx++}`);
+ params.push(start_date + ' 00:00:00');
+ conditions.push(`t.create_time <= $${paramIdx++}`);
+ params.push(end_date + ' 23:59:59');
+
+ const statuses = JSON.parse(line.statuses || '[]');
+ if (statuses.length > 0) {
+ const placeholders = statuses.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.ticket_state_id IN (${placeholders})`);
+ params.push(...statuses.map(id => parseInt(id)));
+ }
+
+ const types = JSON.parse(line.types || '[]');
+ if (types.length > 0) {
+ const placeholders = types.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.type_id IN (${placeholders})`);
+ params.push(...types.map(id => parseInt(id)));
+ }
+
+ const queues = JSON.parse(line.queues || '[]');
+ if (queues.length > 0) {
+ const placeholders = queues.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.queue_id IN (${placeholders})`);
+ params.push(...queues.map(id => parseInt(id)));
+ }
+
+ const owners = JSON.parse(line.owners || '[]');
+ if (owners.length > 0) {
+ const placeholders = owners.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.user_id IN (${placeholders})`);
+ params.push(...owners.map(id => parseInt(id)));
+ }
+
+ const responsibles = JSON.parse(line.responsibles || '[]');
+ if (responsibles.length > 0) {
+ const placeholders = responsibles.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.responsible_user_id IN (${placeholders})`);
+ params.push(...responsibles.map(id => parseInt(id)));
+ }
+
+ const sql = `
+ SELECT CAST(t.create_time AS DATE) AS date_val, COUNT(*) AS count
+ FROM ticket t
+ WHERE ${conditions.join(' AND ')}
+ GROUP BY CAST(t.create_time AS DATE)
+ `;
+
+ const result = await pool.query(sql, params);
+
+ const countsByDate = {};
+ for (const row of result.rows) {
+ let dateStr = row.date_val;
+ if (dateStr instanceof Date) {
+ const y = dateStr.getFullYear();
+ const m = String(dateStr.getMonth() + 1).padStart(2, '0');
+ const d = String(dateStr.getDate()).padStart(2, '0');
+ dateStr = `${y}-${m}-${d}`;
+ } else if (typeof dateStr === 'string') {
+ dateStr = dateStr.split('T')[0];
+ }
+ countsByDate[dateStr] = parseInt(row.count) || 0;
+ }
+
+ const data = dates.map(d => countsByDate[d] || 0);
+
+ return {
+ id: line.id,
+ name: line.name,
+ color: line.color,
+ is_default: line.is_default,
+ data
+ };
+ }));
+
+ res.json({
+ labels: dates,
+ lines: linesData
+ });
+ } catch (err) {
+ console.error('Error generating chart data:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// GET /api/dashboard/export-excel
+router.get('/export-excel', async (req, res) => {
+ const { start_date, end_date, series_ids } = req.query;
+ if (!start_date || !end_date) {
+ return res.status(400).json({ error: 'start_date and end_date are required' });
+ }
+
+ try {
+ let lines = [];
+ if (series_ids) {
+ const ids = series_ids.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id));
+ if (ids.length > 0) {
+ const placeholders = ids.map(() => '?').join(', ');
+ lines = db.prepare(`SELECT * FROM dashboard_chart_lines WHERE is_visible = 1 AND id IN (${placeholders}) ORDER BY is_default DESC, id ASC`).all(...ids);
+ }
+ } else {
+ lines = db.prepare("SELECT * FROM dashboard_chart_lines WHERE is_visible = 1 ORDER BY is_default DESC, id ASC").all();
+ }
+
+ const wb = XLSX.utils.book_new();
+ const allRows = [];
+
+ for (const line of lines) {
+ const conditions = [];
+ const params = [];
+ let paramIdx = 1;
+
+ conditions.push(`t.create_time >= $${paramIdx++}`);
+ params.push(start_date + ' 00:00:00');
+ conditions.push(`t.create_time <= $${paramIdx++}`);
+ params.push(end_date + ' 23:59:59');
+
+ const statuses = JSON.parse(line.statuses || '[]');
+ if (statuses.length > 0) {
+ const placeholders = statuses.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.ticket_state_id IN (${placeholders})`);
+ params.push(...statuses.map(id => parseInt(id)));
+ }
+
+ const types = JSON.parse(line.types || '[]');
+ if (types.length > 0) {
+ const placeholders = types.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.type_id IN (${placeholders})`);
+ params.push(...types.map(id => parseInt(id)));
+ }
+
+ const queues = JSON.parse(line.queues || '[]');
+ if (queues.length > 0) {
+ const placeholders = queues.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.queue_id IN (${placeholders})`);
+ params.push(...queues.map(id => parseInt(id)));
+ }
+
+ const owners = JSON.parse(line.owners || '[]');
+ if (owners.length > 0) {
+ const placeholders = owners.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.user_id IN (${placeholders})`);
+ params.push(...owners.map(id => parseInt(id)));
+ }
+
+ const responsibles = JSON.parse(line.responsibles || '[]');
+ if (responsibles.length > 0) {
+ const placeholders = responsibles.map(() => `$${paramIdx++}`).join(', ');
+ conditions.push(`t.responsible_user_id IN (${placeholders})`);
+ params.push(...responsibles.map(id => parseInt(id)));
+ }
+
+ const sql = `
+ SELECT
+ t.id AS ticket_id,
+ t.tn,
+ t.title,
+ tt.name AS type_name,
+ ts.name AS state_name,
+ tst.name AS state_type_name,
+ q.name AS queue_name,
+ COALESCE(u.first_name || ' ' || u.last_name, u.login) AS owner_name,
+ COALESCE(ru.first_name || ' ' || ru.last_name, ru.login) AS responsible_name,
+ t.create_time,
+ t.change_time
+ FROM ticket t
+ LEFT JOIN ticket_type tt ON t.type_id = tt.id
+ LEFT JOIN ticket_state ts ON t.ticket_state_id = ts.id
+ LEFT JOIN ticket_state_type tst ON ts.type_id = tst.id
+ LEFT JOIN queue q ON t.queue_id = q.id
+ LEFT JOIN users u ON t.user_id = u.id
+ LEFT JOIN users ru ON t.responsible_user_id = ru.id
+ WHERE ${conditions.join(' AND ')}
+ ORDER BY t.create_time DESC
+ `;
+
+ const result = await pool.query(sql, params);
+
+ const rows = result.rows.map(t => {
+ const isClosed = t.state_type_name && (
+ t.state_type_name.toLowerCase().includes('closed') ||
+ t.state_name.toLowerCase().includes('closed') ||
+ t.state_name.toLowerCase().includes('chiuso') ||
+ t.state_name.toLowerCase().includes('chiusa')
+ );
+ const closureDate = isClosed ? t.change_time : '';
+ return {
+ 'Origine della serie': line.name,
+ 'ID Ticket': t.ticket_id,
+ 'Numero (TN)': t.tn,
+ 'Oggetto': t.title || '',
+ 'Tipo': t.type_name || '',
+ 'Stato': t.state_name || '',
+ 'Coda': t.queue_name || '',
+ 'Owner': t.owner_name || '',
+ 'Responsabile': t.responsible_name || '',
+ 'Data di Creazione': t.create_time,
+ 'Data di Chiusura': closureDate
+ };
+ });
+
+ allRows.push(...rows);
+
+ const ws = XLSX.utils.json_to_sheet(rows);
+ const cleanName = line.name.replace(/[\\\/\?\*\:\[\]]/g, '').slice(0, 30);
+ XLSX.utils.book_append_sheet(wb, ws, cleanName || `Serie ${line.id}`);
+ }
+
+ if (allRows.length > 0) {
+ // Sort combined rows by Data di Creazione descending
+ allRows.sort((a, b) => new Date(b['Data di Creazione']) - new Date(a['Data di Creazione']));
+ const wsAll = XLSX.utils.json_to_sheet(allRows);
+ wb.SheetNames.unshift('Tutte le serie');
+ wb.Sheets['Tutte le serie'] = wsAll;
+ }
+
+ const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
+
+ res.setHeader('Content-Disposition', `attachment; filename="Andamento_Ticket_${start_date}_${end_date}.xlsx"`);
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ res.send(buf);
+ } catch (err) {
+ console.error('Error exporting excel:', err);
res.status(500).json({ error: err.message });
}
});