From f36788510e944865f3065bfb54d7bc11803a900b Mon Sep 17 00:00:00 2001 From: Gabriele Cimaschi Date: Mon, 20 Jul 2026 23:22:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20aggiunta=20possiblit=C3=A0=20di=20visua?= =?UTF-8?q?lizzare=20un=20grafico=20temporale=20dei=20ticket,=20con=20conf?= =?UTF-8?q?igurazione=20avanzata=20delle=20serie=20dei=20dati=20ed=20espor?= =?UTF-8?q?tazione=20dei=20ticket=20della=20serie.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- activityDb.js | 49 +++ package-lock.json | 106 ++++++- package.json | 3 +- public/css/style.css | 63 ++++ public/index.html | 1 + public/js/views/dashboard.js | 597 ++++++++++++++++++++++++++++++++++- routes/dashboard.js | 358 +++++++++++++++++++-- 7 files changed, 1155 insertions(+), 22 deletions(-) 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 = '

Caricamento dashboard...

'; 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 = ` + +
+
+
+ Andamento Ticket + + +
+ + +
+ + +
+ Da: + + A: + +
+
+
+ +
+ +
+
+
@@ -133,8 +249,202 @@ const DashboardView = {
`}
+ + + `; + // 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 }); } });