feat: aggiunta possiblità di visualizzare un grafico temporale dei ticket, con configurazione avanzata delle serie dei dati ed esportazione dei ticket della serie.
This commit is contained in:
@@ -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) {
|
||||
|
||||
Generated
+105
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -192,6 +192,7 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="/js/components/toast.js"></script>
|
||||
<script src="/js/components/filters.js"></script>
|
||||
<script src="/js/views/dashboard.js"></script>
|
||||
|
||||
@@ -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 = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
|
||||
|
||||
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 = `
|
||||
<!-- Custom Ticket Trends Chart Card -->
|
||||
<div class="card chart-card" style="margin-bottom: var(--space-md);">
|
||||
<div class="chart-header" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:var(--space-md); margin-bottom:var(--space-md);">
|
||||
<div class="card-title" style="margin-bottom:0; display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
||||
<span>Andamento Ticket</span>
|
||||
<button class="btn btn-ghost btn-sm" id="configure-series-btn" style="padding:4px 8px; font-size:0.8rem; display:flex; align-items:center; gap:4px; height:28px;" title="Configura serie di dati del grafico">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
<span>Configura serie di dati</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" id="export-series-excel-btn" style="padding:4px 8px; font-size:0.8rem; display:flex; align-items:center; gap:4px; height:28px;" title="Esporta dati in Excel (XLSX)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||
</svg>
|
||||
<span>Esporta in Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Date controls -->
|
||||
<div class="chart-controls" style="display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
||||
<select id="chart-period-preset" class="form-select" style="height:32px; font-size:0.85rem; padding: 4px 28px 4px 8px; margin:0; width:170px; background-position: right 8px center;">
|
||||
<option value="last_week" ${savedPeriod === 'last_week' ? 'selected' : ''}>Ultima Settimana</option>
|
||||
<option value="last_month" ${savedPeriod === 'last_month' ? 'selected' : ''}>Ultimo Mese</option>
|
||||
<option value="custom" ${savedPeriod === 'custom' ? 'selected' : ''}>Periodo Personalizzato</option>
|
||||
</select>
|
||||
|
||||
<div id="chart-custom-dates" style="display:${savedPeriod === 'custom' ? 'flex' : 'none'}; align-items:center; gap:6px;">
|
||||
<span style="font-size:0.8rem; color:var(--text-secondary);">Da:</span>
|
||||
<input type="date" id="chart-date-from" class="form-input" value="${savedDateFrom}" style="height:32px; font-size:0.85rem; padding:4px 8px; margin:0; width:135px;">
|
||||
<span style="font-size:0.8rem; color:var(--text-secondary);">A:</span>
|
||||
<input type="date" id="chart-date-to" class="form-input" value="${savedDateTo}" style="height:32px; font-size:0.85rem; padding:4px 8px; margin:0; width:135px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-canvas-wrapper" style="position:relative; height:300px; width:100%;">
|
||||
<canvas id="dashboard-ticket-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card accent">
|
||||
@@ -133,8 +249,202 @@ const DashboardView = {
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- MODAL: Configure Series -->
|
||||
<div id="chart-series-modal" style="display:none; position:fixed; inset:0; z-index:8000; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); align-items:center; justify-content:center; padding: 20px;">
|
||||
<div class="card" style="width:100%; max-width:680px; background:var(--bg-card); max-height: 90vh; display:flex; flex-direction:column; padding: var(--space-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); border: 1px solid var(--border-light);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-subtle); padding-bottom:var(--space-sm); margin-bottom:var(--space-md); flex-shrink:0;">
|
||||
<h3 style="margin:0; font-size:1.1rem; font-weight:600; color:var(--text-primary);">Configura Serie di Dati</h3>
|
||||
<button id="chart-series-modal-close" style="background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted); padding:4px;">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Series List -->
|
||||
<div id="modal-series-list-section" style="overflow-y:auto; flex-grow:1;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md);">
|
||||
<span style="font-size:0.85rem; color:var(--text-secondary);">Serie di dati attive sul grafico:</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-add-chart-series">+ Aggiungi Serie</button>
|
||||
</div>
|
||||
<div id="modal-series-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||
<!-- populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Editor Form (hidden by default) -->
|
||||
<form id="modal-series-form" style="display:none; flex-direction:column; gap:var(--space-md); overflow-y:auto; flex-grow:1;">
|
||||
<input type="hidden" id="form-series-id">
|
||||
|
||||
<div style="display:grid; grid-template-columns: 2fr 1fr; gap:var(--space-md);">
|
||||
<div>
|
||||
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Nome Serie (legenda)</label>
|
||||
<input type="text" id="form-series-name" class="form-input" placeholder="Es. Ticket urgenti" required style="width:100%;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Colore Grafico</label>
|
||||
<input type="color" id="form-series-color" class="form-input" style="width:100%; height:36px; padding: 2px; cursor: pointer; border-radius: var(--radius-sm); border: 1px solid var(--border-light);" value="#4f46e5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:var(--space-md);">
|
||||
<div>
|
||||
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Stato (selezione multipla)</label>
|
||||
<div class="multiselect-list" id="form-select-statuses"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Tipo (selezione multipla)</label>
|
||||
<div class="multiselect-list" id="form-select-types"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:var(--space-md);">
|
||||
<div>
|
||||
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Coda (selezione multipla)</label>
|
||||
<div class="multiselect-list" id="form-select-queues"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Proprietario (selezione multipla)</label>
|
||||
<div class="multiselect-list" id="form-select-owners"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Responsabile (selezione multipla)</label>
|
||||
<div class="multiselect-list" id="form-select-responsibles"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:flex-end; gap:var(--space-sm); border-top:1px solid var(--border-subtle); padding-top:var(--space-md); margin-top:var(--space-xs); flex-shrink:0;">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="btn-series-form-cancel">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Salva Serie</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-secondary); gap:8px;">
|
||||
<span>⚠️ Errore durante il caricamento dei dati del grafico</span>
|
||||
<small style="color:var(--text-muted);">${App.escapeHtml(err.message)}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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 = '<div style="text-align:center; padding:10px;"><div class="spinner" style="width:20px;height:20px;"></div></div>';
|
||||
|
||||
try {
|
||||
const lines = await App.api('/api/dashboard/chart-lines');
|
||||
this.activeLines = lines;
|
||||
|
||||
listContainer.innerHTML = lines.map(line => `
|
||||
<div class="line-config-row series-config-row" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<span class="series-color-dot" style="width:14px; height:14px; border-radius:50%; background-color:${line.color || '#4f46e5'}; border:1px solid var(--border-light); display:inline-block;"></span>
|
||||
<span class="line-name" style="${!line.is_visible ? 'text-decoration:line-through; color:var(--text-muted);' : ''}">${App.escapeHtml(line.name)} ${line.is_default ? '<small style="color:var(--text-muted);font-weight:normal;">(Predefinita)</small>' : ''}</span>
|
||||
</div>
|
||||
<div class="line-actions" style="display:flex; align-items:center; gap:6px;">
|
||||
<button class="btn btn-ghost btn-sm btn-toggle-visibility" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem;" title="${line.is_visible ? 'Nascondi' : 'Mostra'}">
|
||||
${line.is_visible ? '👁️ Nascondi' : '❌ Mostra'}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-edit-series" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem;">Modifica</button>
|
||||
${!line.is_default ? `<button class="btn btn-danger btn-sm btn-delete-series" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem; background-color: var(--error); color: white; border: none;">Elimina</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).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 = `<div style="color:var(--error); font-size:0.85rem; padding:10px;">Errore: ${App.escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
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 `<div class="multiselect-item ${isSelected ? 'selected' : ''}" data-id="${id}">${App.escapeHtml(label)}</div>`;
|
||||
}).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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+339
-19
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user