Compare commits

..
11 Commits
Author SHA1 Message Date
VVER440 35888400ed fix: la pressione del pulsante di chiusura dei ticket nel gruppo 'chiudi a fine giornata' non provoca più l'attivazione dell'azione di consuntivazione a fine giornata 2026-08-11 08:25:06 +02:00
VVER440 922a23c062 feat: reso programma indipendente. fix: ora il filtro owner della pagina ticket a mio carico segue l'agente selezionato (l'asciamo stare che la funzione di selezione dell'agente non dovrebbe proprio esistere XD) 2026-08-11 07:57:58 +02:00
VVER440 57175922c2 fix: corretta ora di creazione dei ticket che non considerava il fusorario. 2026-07-29 22:59:27 +02:00
VVER440 e2482159bc feat: aggiunto gruppo predefinito per la chiusura dei ticket al momento di consuntivare la giornata. 2026-07-29 22:50:49 +02:00
VVER440 99c4f450f4 feat: aggiunta possibilità di inserire massivamente i ticket selezionati all'interno dei gruppi. 2026-07-29 22:37:09 +02:00
VVER440 0cbd3ccdfc fix: nella pagina di creazione di un nuovo ticket, coda e cliente ora non sono più precompilati dal primo campo della lista. 2026-07-29 22:29:39 +02:00
VVER440 5ef7e66be5 style: modificato colore pulsante per ticket selezionati. (insomma questo è un vero commit). 2026-07-29 22:20:59 +02:00
VVER440 38b2645f6f fix: resa più rapida la navigazione fra i ticket, torna alla lista non ricarica tutto. 2026-07-29 22:11:48 +02:00
VVER440 7243b3da49 fix: la ricerca fulltext parte solamente alla pressione del pulsante invio. feat: aggiunta possibilità di specificare una data per le nuove note. 2026-07-29 21:52:18 +02:00
VVER440 9527998b7a fix: corretto funzionamento dei filtri per gli stati aperti dei ticket (ignora lo stato dei ticket) 2026-07-21 08:11:16 +02:00
VVER440 f36788510e feat: aggiunta possiblità di visualizzare un grafico temporale dei ticket, con configurazione avanzata delle serie dei dati ed esportazione dei ticket della serie. 2026-07-20 23:22:08 +02:00
17 changed files with 2801 additions and 121 deletions
+1
View File
@@ -3,3 +3,4 @@ node_modules/
internal.db
internal.db-shm
internal.db-wal
dist
+61 -1
View File
@@ -8,7 +8,8 @@ const Database = require('better-sqlite3');
const path = require('path');
const crypto = require('crypto');
const DB_PATH = path.join(__dirname, 'internal.db');
const baseDir = process.pkg ? path.dirname(process.execPath) : __dirname;
const DB_PATH = path.join(baseDir, 'internal.db');
const db = new Database(DB_PATH);
// Ensure WAL mode for better concurrent access
@@ -88,6 +89,65 @@ 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,
bypass_state_filter 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 {
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN bypass_state_filter INTEGER NOT NULL DEFAULT 0`);
} 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, bypass_state_filter)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 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, bypass_state_filter)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 0)
`).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();
// Set default Ticket aperti to bypass state filter
db.prepare(`UPDATE dashboard_chart_lines SET bypass_state_filter = 1 WHERE name = 'Ticket aperti'`).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) {
+34
View File
@@ -0,0 +1,34 @@
@echo off
title Compilazione OTRS Turbo Executable (.exe)
echo Compilazione di OTRS Turbo in corso...
cd /d "%~dp0"
echo [1/3] Preparazione binding nativo SQLite per Node 18 (ABI 108)...
pushd "node_modules\better-sqlite3"
call npx prebuild-install --target=18.5.0 --runtime=node --platform=win32 --arch=x64 --force >nul 2>&1
popd
echo [2/3] Creazione eseguibile otrs-turbo.exe...
call npm run build:exe
echo [3/3] Copia asset e file distribuzionali in dist...
if exist "node_modules\better-sqlite3\build\Release\better_sqlite3.node" (
copy "node_modules\better-sqlite3\build\Release\better_sqlite3.node" "dist\better_sqlite3.node" /Y >nul
)
if not exist "dist\.env" (
if exist ".env.example" (
copy ".env.example" "dist\.env" /Y >nul
)
)
echo.
echo ========================================================
echo Operazione completata con successo!
echo Cartella distribuzionale 'dist' pronta per l'uso:
echo - dist\otrs-turbo.exe
echo - dist\better_sqlite3.node
echo - dist\.env
echo - dist\avvia.bat
echo ========================================================
pause
+1325 -11
View File
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -3,23 +3,42 @@
"version": "1.0.0",
"description": "Modern fast interface for OTRS ticket management - direct database access",
"main": "server.js",
"bin": "server.js",
"pkg": {
"scripts": [
"routes/*.js",
"utils/*.js",
"*.js"
],
"assets": [
"public/**/*"
],
"targets": [
"node18-win-x64"
]
},
"scripts": {
"start": "node server.js",
"dev": "npx -y nodemon server.js"
"dev": "npx -y nodemon server.js",
"build:exe": "npx pkg@5.8.1 . --targets node18-win-x64 --output dist/otrs-turbo.exe"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"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",
"ticket",
"helpdesk"
],
"license": "AGPL-3.0"
"license": "AGPL-3.0",
"devDependencies": {
"pkg": "^5.8.1"
}
}
+82
View File
@@ -354,6 +354,25 @@ body {
letter-spacing: -0.02em;
}
.btn-brand-action {
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 6px;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.btn-brand-action:hover {
color: var(--error);
background: var(--error-bg);
border-color: rgba(220, 38, 38, 0.2);
}
.nav-menu {
list-style: none;
padding: var(--space-md);
@@ -2566,4 +2585,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;
}
+12 -1
View File
@@ -18,10 +18,20 @@
<body>
<!-- Sidebar Navigation -->
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px;">
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px; display:flex; align-items:center; justify-content:space-between;">
<div style="display:flex; align-items:center; gap:var(--space-md);">
<div class="brand-icon"></div>
<span class="brand-text">OTRS Turbo</span>
</div>
<button id="btn-close-end-of-day" class="btn-brand-action" title="Chiudi i ticket inseriti nel gruppo CHIUDI A FINE GIORNATA">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px; height:18px;">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
<path d="M9 12h6M9 16h4" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="sidebar-timer" id="daily-timer"
style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
<span class="timer-display">0 / 480 | 480</span>
@@ -192,6 +202,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>
+42 -13
View File
@@ -156,20 +156,8 @@ const App = {
}
};
let timeout;
searchInput.addEventListener('input', () => {
toggleClearBtn();
clearTimeout(timeout);
timeout = setTimeout(() => {
const hash = window.location.hash;
if (hash.startsWith('#/tickets') && !hash.includes('/new') && !hash.match(/#\/tickets\/\d+/)) {
TicketListView.currentPage = 1;
TicketListView.render();
} else {
// Navigate to ticket list with search
window.location.hash = '#/tickets';
}
}, 350);
});
searchInput.addEventListener('keydown', (e) => {
@@ -216,6 +204,34 @@ const App = {
refreshBtn.addEventListener('click', () => this.refreshLookups());
}
// Bind button close end of day tickets
const btnCloseEod = document.getElementById('btn-close-end-of-day');
if (btnCloseEod) {
btnCloseEod.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const confirmed = await this.confirm(
'Chiusura Ticket Fine Giornata',
'Sei sicuro di voler chiudere tutti i ticket nel gruppo "CHIUDI A FINE GIORNATA"?\nI ticket rimarranno nel gruppo.'
);
if (!confirmed) return;
try {
const res = await this.api('/api/groups/close-end-of-day', { method: 'POST' });
if (res.count > 0) {
Toast.success(res.message);
} else {
Toast.info(res.message);
}
this.updateSidebarBadges();
this.route();
} catch (err) {
Toast.error('Errore durante la chiusura dei ticket: ' + err.message);
}
});
}
// Initial route
window.addEventListener('resize', () => this.updateHeaderHeight());
setTimeout(() => this.updateHeaderHeight(), 100);
@@ -493,11 +509,21 @@ const App = {
localStorage.setItem('activeAgentId', select.value);
}
if (typeof Filters !== 'undefined' && Filters.allStates && Filters.allStates.my) {
Filters.allStates.my.user_id = select.value;
}
// Handle dropdown change event
select.addEventListener('change', () => {
localStorage.setItem('activeAgentId', select.value);
const newAgentId = select.value;
localStorage.setItem('activeAgentId', newAgentId);
if (typeof Filters !== 'undefined' && Filters.allStates && Filters.allStates.my) {
Filters.allStates.my.user_id = newAgentId;
localStorage.setItem('otrs_turbo_filters_my', JSON.stringify(Filters.allStates.my));
}
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
this.updateDailyTimer();
this.updateSidebarBadges();
this.route();
});
@@ -624,6 +650,9 @@ const App = {
}
const triggerAction = async (e) => {
if (e && e.target && e.target.closest('#btn-close-end-of-day')) {
return;
}
e.preventDefault();
e.stopPropagation();
const confirmed = await this.confirm(
+13 -4
View File
@@ -55,11 +55,10 @@ const Filters = {
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
if (savedMy) {
Object.assign(this.allStates.my, JSON.parse(savedMy));
} else {
// Default to active agent if not saved yet
}
// Always sync mode 'my' user_id to activeAgentId
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.allStates.my.user_id = activeAgentId;
}
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
if (savedCache) {
@@ -325,7 +324,10 @@ const Filters = {
<span class="filter-label">A Data/Ora</span>
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
</div>
<div class="filters-actions">
<div class="filters-actions" style="display:flex; gap:6px; align-items:center;">
<button class="btn btn-primary btn-xs" id="filter-refresh" title="Aggiorna lista ticket" style="display:inline-flex; align-items:center; gap:4px; height:26px; padding:0 10px; font-size:0.78rem;">
🔄 Aggiorna
</button>
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
</div>
</div>
@@ -622,6 +624,13 @@ const Filters = {
});
});
const refreshBtn = document.getElementById('filter-refresh');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
if (onFilterChange) onFilterChange();
});
}
const resetBtn = document.getElementById('filter-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
+606 -1
View File
@@ -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,211 @@ 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; align-items:center; gap:var(--space-sm); margin-top:4px; background:var(--bg-offset); padding: var(--space-sm); border-radius: var(--radius-md); border: 1px dashed var(--border-light);">
<input type="checkbox" id="form-series-bypass-state" style="width:18px; height:18px; cursor:pointer; margin:0;">
<label for="form-series-bypass-state" style="font-weight:500; font-size:0.85rem; cursor:pointer; user-select:none; margin:0; display:flex; flex-direction:column;">
<span>Ignora lo stato dei ticket</span>
<span style="font-size:0.75rem; color:var(--text-tertiary); font-weight:normal;">Se attivo, mostra tutti i ticket creati nel periodo indipendentemente dal loro stato attuale.</span>
</label>
</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');
const bypass_state_filter = document.getElementById('form-series-bypass-state').checked ? 1 : 0;
// 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, bypass_state_filter };
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 +503,290 @@ 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';
document.getElementById('form-series-bypass-state').checked = line ? !!line.bypass_state_filter : false;
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);
});
}
};
+1 -36
View File
@@ -331,7 +331,7 @@ const TicketCreateView = {
}
} else {
setTimeout(async () => {
// 1. Owner & Responsible pre-population with active agent
// Active agent pre-population for Owner & Responsible
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
if (activeAgent) {
@@ -344,41 +344,6 @@ const TicketCreateView = {
responsibleIdInput.value = activeAgent.id;
}
}
// 2. Customer User pre-population with first match
try {
const companies = await App.api('/api/customer-companies/search?q=');
if (companies.length > 0 && customerIdInput && companySearchInput) {
const defaultCompany = companies[0];
customerIdInput.value = defaultCompany.customer_id;
companySearchInput.value = defaultCompany.customer_id;
// Search users for this company
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
if (users.length > 0 && userSearchInput && customerUserIdInput) {
const defaultUser = users[0];
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
customerUserIdInput.value = defaultUser.login;
} else {
// Fallback: use company name as customer user ID
userSearchInput.value = defaultCompany.name;
customerUserIdInput.value = defaultCompany.customer_id;
}
}
} catch (err) {
console.error('Error pre-populating defaults:', err);
}
// 3. Queue pre-population
try {
const queues = await App.api('/api/queues/search?q=');
if (queues.length > 0 && queueSearchInput && queueIdInput) {
queueSearchInput.value = queues[0].name;
queueIdInput.value = queues[0].id;
}
} catch (err) {
console.error('Error pre-populating queues:', err);
}
}, 50);
}
+17
View File
@@ -263,6 +263,7 @@ const TicketDetailView = {
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
</div>
<div style="display:flex; gap:var(--space-md); align-items:center;">
<input type="datetime-local" class="note-subject-input" id="note-create-time" title="Data/ora nota (opzionale per retrodatazione)" style="width:185px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.82rem;" />
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
@@ -830,6 +831,7 @@ const TicketDetailView = {
}
let subject = document.getElementById('note-subject').value.trim();
const time_unit = document.getElementById('note-time-units').value.trim();
const note_create_time = document.getElementById('note-create-time')?.value;
// If body is empty but subject is provided, fill body with subject text
if (!body && subject) {
@@ -869,7 +871,22 @@ const TicketDetailView = {
});
this.noteAttachments = []; // Clear attachments
if (note_create_time && res.article_id) {
try {
const formattedDate = note_create_time.replace('T', ' ');
await App.api(`/api/tickets/articles/${res.article_id}/retrodata-article`, {
method: 'POST',
body: JSON.stringify({ create_time: formattedDate })
});
Toast.success('Nota aggiunta e retrodatata!');
} catch (retErr) {
Toast.warning('Nota aggiunta, ma errore durante la retrodatazione: ' + retErr.message);
}
} else {
Toast.success(res.message || 'Nota aggiunta!');
}
App.clearDraft(this.ticketId, 'note');
App.updateDailyTimer();
this.render(this.ticketId);
+106 -9
View File
@@ -13,7 +13,9 @@ const TicketListView = {
async render() {
const container = document.getElementById('view-container');
if (!container.querySelector('.ticket-table-wrapper') && !container.querySelector('.ticket-table')) {
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
}
try {
// Fetch lookups for filter dropdowns
@@ -34,7 +36,7 @@ const TicketListView = {
Filters.currentMode = isMyTickets ? 'my' : 'general';
Filters.load(); // Load state for current mode
if (isMyTickets && !Filters.state.user_id) {
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
Filters.state.user_id = activeAgentId;
Filters.save();
@@ -106,15 +108,18 @@ const TicketListView = {
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
</select>
</div>
<div class="filter-group" style="position:relative;">
<span class="filter-label">Cliente</span>
<input type="text" class="form-input filter-select" id="batch-customer-search" placeholder="Cerca cliente..." autocomplete="off" style="width:160px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
<input type="hidden" id="batch-customer-user-id" />
<input type="hidden" id="batch-customer-id" />
<div id="batch-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></div>
${(App.lookups.types || []).length > 0 ? `
<div class="filter-group">
<span class="filter-label">Tipo</span>
<select class="filter-select" id="batch-type">
<option value=""></option>
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
</select>
</div>
` : ''}
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: var(--accent-secondary); border-color: var(--accent-secondary); margin-left: 8px;">Unisci Selezionati</button>
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
</div>
@@ -306,6 +311,12 @@ const TicketListView = {
batchApply.addEventListener('click', () => this.applyBatch());
}
// Batch add to group
const batchAddGroup = document.getElementById('batch-add-group');
if (batchAddGroup) {
batchAddGroup.addEventListener('click', () => this.addToGroup());
}
// Batch merge (Issue #7)
const batchMerge = document.getElementById('batch-merge');
if (batchMerge) {
@@ -439,6 +450,8 @@ const TicketListView = {
if (batchOwner) batchOwner.value = '';
const batchResponsible = document.getElementById('batch-responsible');
if (batchResponsible) batchResponsible.value = '';
const batchType = document.getElementById('batch-type');
if (batchType) batchType.value = '';
this.updateBatchBar();
});
@@ -591,6 +604,12 @@ const TicketListView = {
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
}
// Enable/disable add to group button
const addGroupBtn = document.getElementById('batch-add-group');
if (addGroupBtn) {
addGroupBtn.disabled = this.selectedIds.size === 0;
}
// Enable/disable merge button
const mergeBtn = document.getElementById('batch-merge');
if (mergeBtn) {
@@ -620,6 +639,7 @@ const TicketListView = {
const batchQueue = document.getElementById('batch-queue')?.value;
const batchOwner = document.getElementById('batch-owner')?.value;
const batchResponsible = document.getElementById('batch-responsible')?.value;
const batchType = document.getElementById('batch-type')?.value;
const batchCustomerSearch = document.getElementById('batch-customer-search')?.value.trim();
let batchCustomerUserId = document.getElementById('batch-customer-user-id')?.value;
let batchCustomerId = document.getElementById('batch-customer-id')?.value;
@@ -633,6 +653,7 @@ const TicketListView = {
if (batchState) updates.ticket_state_id = parseInt(batchState);
if (batchQueue) updates.queue_id = parseInt(batchQueue);
if (batchType) updates.type_id = parseInt(batchType);
if (batchOwner) updates.user_id = parseInt(batchOwner);
if (batchResponsible) {
updates.responsible_user_id = parseInt(batchResponsible);
@@ -706,10 +727,86 @@ const TicketListView = {
Toast.success(res.message || 'Ticket uniti con successo');
this.selectedIds.clear();
this.selectedOrder = [];
this.render();
} catch (err) {
Toast.error('Errore durante l\'unione: ' + err.message);
this.updateBatchBar();
}
},
async addToGroup() {
if (this.selectedIds.size === 0) return;
try {
const groups = await App.api('/api/groups');
if (!groups || groups.length === 0) {
Toast.warning('non sono presenti gruppi');
return;
}
// Show group selection prompt/dialog
const groupOptions = groups.map(g => `<option value="${g.id}">${App.escapeHtml(g.nome)}</option>`).join('');
const dialogHtml = `
<div id="batch-group-modal" style="position: fixed; inset: 0; z-index: 8000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center;">
<div style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 400px; max-width: 90vw; padding: var(--space-lg); box-shadow: var(--shadow-lg);">
<h4 style="margin: 0 0 var(--space-md) 0; font-size: 1rem; font-weight: 600; color: var(--text-primary);">Aggiungi a Gruppo</h4>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: var(--space-md);">Seleziona il gruppo a cui aggiungere i <strong>${this.selectedIds.size}</strong> ticket selezionati:</p>
<select id="batch-group-select" class="form-select" style="width: 100%; margin-bottom: var(--space-lg);">
${groupOptions}
</select>
<div style="display: flex; gap: var(--space-sm); justify-content: flex-end;">
<button class="btn btn-ghost btn-sm" id="batch-group-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="batch-group-confirm">Aggiungi</button>
</div>
</div>
</div>
`;
// Append modal to body
const modalContainer = document.createElement('div');
modalContainer.innerHTML = dialogHtml;
document.body.appendChild(modalContainer);
const closeModal = () => modalContainer.remove();
document.getElementById('batch-group-cancel').addEventListener('click', closeModal);
document.getElementById('batch-group-confirm').addEventListener('click', async () => {
const groupId = document.getElementById('batch-group-select').value;
if (!groupId) return;
const confirmBtn = document.getElementById('batch-group-confirm');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Aggiunta...';
let addedCount = 0;
let errorsCount = 0;
for (const ticketId of this.selectedIds) {
try {
await App.api(`/api/groups/${groupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: ticketId })
});
addedCount++;
} catch (err) {
// Conflict (already in group) or other errors
errorsCount++;
}
}
closeModal();
if (addedCount > 0) {
Toast.success(`${addedCount} ticket aggiunti al gruppo!`);
this.selectedIds.clear();
this.selectedOrder = [];
this.render();
} else if (errorsCount > 0) {
Toast.warning('I ticket selezionati appartengono già a questo gruppo.');
}
});
} catch (err) {
Toast.error('Errore durante il recupero dei gruppi: ' + err.message);
}
},
};
+340 -18
View File
@@ -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,350 @@ 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 {
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, bypass_state_filter } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
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 info = db.prepare(`
INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default, bypass_state_filter)
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,
bypass_state_filter !== undefined ? parseInt(bypass_state_filter, 10) : 0
);
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, bypass_state_filter } = 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 = ?, bypass_state_filter = ?
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,
bypass_state_filter !== undefined ? parseInt(bypass_state_filter, 10) : 0,
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 && !line.bypass_state_filter) {
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 && !line.bypass_state_filter) {
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 });
}
});
+89 -2
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const pool = require('../db');
const { db } = require('../activityDb');
const { db, logAttivita } = require('../activityDb');
// Helper to fetch details of a list of tickets from OTRS DB
async function fetchTicketsDetails(ticketIds) {
@@ -34,11 +34,20 @@ async function fetchTicketsDetails(ticketIds) {
// GET /api/groups - List all groups with member counts
router.get('/', (req, res) => {
try {
// Ensure default group exists
const defaultGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
if (!defaultGroup) {
db.prepare(`
INSERT INTO ticket_groups (nome, descrizione)
VALUES (?, ?)
`).run('CHIUDI A FINE GIORNATA', 'I ticket in questo gruppo verranno chiusi automaticamente alla consuntivazione di fine giornata');
}
const groups = db.prepare(`
SELECT g.*,
(SELECT COUNT(*) FROM ticket_group_members WHERE group_id = g.id) AS member_count
FROM ticket_groups g
ORDER BY g.nome ASC
ORDER BY CASE WHEN UPPER(g.nome) = 'CHIUDI A FINE GIORNATA' THEN 1 ELSE 0 END ASC, g.nome ASC
`).all();
res.json(groups);
@@ -237,5 +246,83 @@ router.delete('/:id/tickets/:ticket_id', (req, res) => {
res.status(500).json({ error: 'Errore nella rimozione del ticket', message: err.message });
}
});
// POST /api/groups/close-end-of-day - Close all tickets in 'CHIUDI A FINE GIORNATA' group
router.post('/close-end-of-day', async (req, res) => {
try {
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
// Find default group 'CHIUDI A FINE GIORNATA'
const closeGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
if (!closeGroup) {
return res.status(404).json({ error: 'Gruppo "CHIUDI A FINE GIORNATA" non trovato' });
}
const groupMembers = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(closeGroup.id);
if (groupMembers.length === 0) {
return res.json({ success: true, count: 0, message: 'Nessun ticket presente nel gruppo "CHIUDI A FINE GIORNATA"' });
}
const groupTicketIds = groupMembers.map(m => m.ticket_id);
const placeholders = groupTicketIds.map((_, i) => `$${i + 1}`).join(', ');
// Get open tickets in group (not already in a closed state)
const openTicketsQuery = `
SELECT t.id
FROM ticket t
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE t.id IN (${placeholders})
AND LOWER(tst.name) NOT LIKE '%closed%'
`;
const openRes = await pool.query(openTicketsQuery, groupTicketIds);
const openTicketIds = openRes.rows.map(r => r.id);
if (openTicketIds.length === 0) {
return res.json({ success: true, count: 0, message: 'Tutti i ticket nel gruppo "CHIUDI A FINE GIORNATA" risultano già chiusi' });
}
// Resolve closed state ID (closed successful / closed fallback)
const closedStateRes = await pool.query(
`SELECT id FROM ticket_state WHERE name = 'closed successful' OR name = 'chiuso con successo' LIMIT 1`
);
let stateId;
if (closedStateRes.rows.length > 0) {
stateId = closedStateRes.rows[0].id;
} else {
const fallbackRes = await pool.query(
`SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'closed' LIMIT 1`
);
stateId = fallbackRes.rows[0]?.id || 2;
}
const updatePlaceholders = openTicketIds.map((_, i) => `$${i + 1}`).join(', ');
const updateQuery = `
UPDATE ticket
SET ticket_state_id = $${openTicketIds.length + 1},
ticket_lock_id = 1,
change_time = NOW(),
change_by = $${openTicketIds.length + 2}
WHERE id IN (${updatePlaceholders})
`;
await pool.query(updateQuery, [...openTicketIds, stateId, operatorId]);
// Log activity
logAttivita({
agente_id: operatorId,
titolo_azione: 'Chiusura ticket gruppo CHIUDI A FINE GIORNATA',
azione: { closed_count: openTicketIds.length, ticket_ids: openTicketIds },
esito: 'successo',
});
res.json({
success: true,
count: openTicketIds.length,
message: `${openTicketIds.length} ticket del gruppo "CHIUDI A FINE GIORNATA" chius${openTicketIds.length === 1 ? 'o' : 'i'} con successo!`
});
} catch (err) {
console.error('Errore nella chiusura ticket fine giornata:', err);
res.status(500).json({ error: 'Errore nella chiusura ticket', message: err.message });
}
});
module.exports = router;
+38 -12
View File
@@ -530,6 +530,8 @@ router.post('/', async (req, res) => {
// Operator user for create_by (X-Agent-ID header or default to 1)
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const localNow = getLocalTimestamp();
const ticketResult = await client.query(
`INSERT INTO ticket (
tn, title, queue_id, ticket_lock_id, type_id,
@@ -550,14 +552,14 @@ router.post('/', async (req, res) => {
0, 0,
0, 0,
0,
NOW(), $12, NOW(), $12
$12, $13, $12, $13
) RETURNING id, tn`,
[
tn, title, queue_id, lockId, type_id || null,
user_id || 1, responsibleUserId,
priority_id, state_id,
customer_id || null, customer_user_id || null,
operatorId
localNow, operatorId
]
);
@@ -578,13 +580,13 @@ router.post('/', async (req, res) => {
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
NOW(), $9, NOW(), $9
$9, $10, $9, $10
)`,
[
`%%`,
historyTypeId, ticketId, type_id || 1, queue_id,
user_id || 1, priority_id, state_id,
operatorId
localNow, operatorId
]
);
@@ -643,9 +645,9 @@ router.post('/', async (req, res) => {
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, 0, 1, NOW(), $4, NOW(), $4
$1, $2, $3, 0, 1, $4, $5, $4, $5
) RETURNING id`,
[ticketId, senderTypeId, channelId, operatorId]
[ticketId, senderTypeId, channelId, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
@@ -660,9 +662,9 @@ router.post('/', async (req, res) => {
) VALUES (
$1, $2, '', $3, $4,
$5, EXTRACT(EPOCH FROM NOW())::INTEGER,
NOW(), $6, NOW(), $6
$6, $7, $6, $7
)`,
[articleId, customerFrom, subject || title, finalBody, contentType, operatorId]
[articleId, customerFrom, subject || title, finalBody, contentType, localNow, operatorId]
);
// If HTML content, create article_data_mime_attachment for OTRS CE HTML rendering (file-1)
@@ -678,9 +680,9 @@ router.post('/', async (req, res) => {
create_time, create_by, change_time, change_by
) VALUES (
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
NOW(), $4, NOW(), $4
$4, $5, $4, $5
)`,
[articleId, String(contentSize), base64Body, operatorId]
[articleId, String(contentSize), base64Body, localNow, operatorId]
);
}
@@ -692,13 +694,14 @@ router.post('/', async (req, res) => {
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $4, 'attachment', $5, NOW(), $6, NOW(), $6)`,
) VALUES ($1, $2, $3, $4, 'attachment', $5, $6, $7, $6, $7)`,
[
articleId,
att.filename,
contentBuffer.length,
att.content_type || 'application/octet-stream',
att.content, // OTRS CE expects base64 string directly
localNow,
operatorId
]
);
@@ -1417,6 +1420,7 @@ router.patch('/batch/update', async (req, res) => {
if (updates.ticket_state_id !== undefined) ticketFields.StateID = updates.ticket_state_id;
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
@@ -1471,7 +1475,7 @@ router.patch('/batch/update', async (req, res) => {
try {
await client.query('BEGIN');
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'responsible_user_id', 'customer_id', 'customer_user_id'];
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'type_id', 'user_id', 'responsible_user_id', 'customer_id', 'customer_user_id'];
const setClauses = [];
const setParams = [];
let pIdx = 1;
@@ -2185,6 +2189,28 @@ router.post('/auto-time', async (req, res) => {
[ticketId, articleId, remaining, operatorId]
);
// 6. Close tickets in default group 'CHIUDI A FINE GIORNATA'
try {
const closeGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
if (closeGroup) {
const groupMembers = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(closeGroup.id);
if (groupMembers.length > 0) {
const groupTicketIds = groupMembers.map(m => m.ticket_id);
const idPlaceholders = groupTicketIds.map((_, i) => `$${i + 1}`).join(', ');
// Update ticket state to closed in OTRS DB
await client.query(
`UPDATE ticket SET ticket_state_id = $${groupTicketIds.length + 1}, change_time = NOW(), change_by = $${groupTicketIds.length + 2} WHERE id IN (${idPlaceholders})`,
[...groupTicketIds, stateId, operatorId]
);
console.log(`[Auto-Time] Closed ${groupTicketIds.length} tickets from group 'CHIUDI A FINE GIORNATA'`);
}
}
} catch (grpCloseErr) {
console.error('[Auto-Time] Error closing tickets from CHIUDI A FINE GIORNATA group:', grpCloseErr);
}
await client.query('COMMIT');
// Log activity
+4 -2
View File
@@ -1,7 +1,9 @@
require('dotenv').config({ path: require('path').resolve(__dirname, '.env') });
const path = require('path');
const baseDir = process.pkg ? path.dirname(process.execPath) : __dirname;
require('dotenv').config({ path: path.resolve(baseDir, '.env') });
require('dotenv').config({ path: path.resolve(__dirname, '.env') });
const express = require('express');
const cors = require('cors');
const path = require('path');
const ticketsRouter = require('./routes/tickets');
const lookupsRouter = require('./routes/lookups');