Compare commits

..
25 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
VVER440 b484f93640 feat: menu a tendina ricercabile il campo la coda, del menu delle azioni rapide della pagina ticket e a mio carico. 2026-07-15 21:15:09 +02:00
VVER440 b90d2b2732 fix: corretta gestione proprietario e responsaibile da pagina ticket e a mio carico 2026-07-15 21:09:08 +02:00
VVER440 1bbe561673 fix: tentativo di fix per il riconoscimento del ticket ma otrs è stronzo-merda. feat: nella pagina di anteprima del ticket, aggiunto pulsante per aprire in una scheda interna. 2026-07-15 00:07:34 +02:00
VVER440 47d11c311e style: aggiunte iconcine per copia numero ticket e titolo nella pagina di riepilogo del ticket e copia titolo nella pagina ticket/a mio carico. style: rimosso link apri in otrs sul titolo dell'header di un ticket 2026-07-14 08:24:44 +02:00
VVER440 964241d8ec fix: visualizzazione delle immagini nelle note e mail inviate. fix: corretto veramente problema del fusorario delle mail. feat: tooltip fullscreen per le immagini. 2026-07-13 22:08:53 +02:00
VVER440 a7fb2c22f0 fix: probelma con caricamento allegati nelle note. Il problema non era mai stato risolto da quando si è passati alle apiotrs 2026-07-13 21:26:40 +02:00
VVER440 7020112e8a fix: corretto issue 20 e 28. torna alla lista rimanda alla lista e non più alla pagina precedente e i link non aprono più una nuova scheda 2026-07-13 19:34:44 +02:00
VVER440 afe6e8d652 fix: la selezione degli elementi dei menu a tendina ticke e a mio carico ora è piu rapida (non serve cliccare su ok) 2026-07-13 08:08:47 +02:00
VVER440 df203d8de3 fix: corretto posizionamento barra selezione rapida dopo l'aggiunta delle schede 2026-07-12 23:27:18 +02:00
VVER440 748f777a31 fix: aggiunta nota ticket unito. feat: possiblità di visualizzare ticket in schede. feat: possibilità di creare un nuovo gruppo dall'interfaccia del ticket. 2026-07-12 19:17:07 +02:00
Gabriele Cimaschi e764c37d46 fix: modificato in elenco a discesa il selettore della coda della pagina nuovo ticket. style: ottimizzazioni visive delle code 2026-07-10 16:28:32 +02:00
Gabriele Cimaschi 191f9a407b fix: corretto invio da mail (scrittura diretta nel db). feat: aggiunta gestione di gruppi interni per l'invio delle mail 2026-07-09 22:36:46 +02:00
Gabriele Cimaschi 725bdaeae4 fix: ora per l'invio delle mail considera le eventuali persone in copia nella comunicazione selezionata 2026-07-09 13:02:04 +02:00
VVER440 a89605b888 feat: gestione gruppi di ticket. feat: miglioramento filtri ticket a mio carico e ticket. feat: possiblità di copiare il numero del tocket con pulsante copia 2026-07-09 08:36:03 +02:00
28 changed files with 6360 additions and 757 deletions
+5
View File
@@ -0,0 +1,5 @@
---
trigger: always_on
---
Non usare le notifiche native dei browser.
+1
View File
@@ -3,3 +3,4 @@ node_modules/
internal.db
internal.db-shm
internal.db-wal
dist
+102 -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
@@ -47,6 +48,106 @@ db.exec(`
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
descrizione TEXT,
master_ticket_id INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_group_members (
group_id INTEGER NOT NULL,
ticket_id INTEGER NOT NULL,
PRIMARY KEY (group_id, ticket_id),
FOREIGN KEY (group_id) REFERENCES ticket_groups (id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS filter_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
name TEXT NOT NULL,
page_mode TEXT NOT NULL,
filters_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS email_address_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
name TEXT NOT NULL,
emails TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
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
+31 -1
View File
@@ -1,4 +1,16 @@
const { Pool } = require('pg');
const { Pool, types } = require('pg');
// Override parser for TIMESTAMP WITHOUT TIME ZONE (type 1114) to return Date in UTC timezone
types.setTypeParser(1114, function(stringValue) {
if (!stringValue) return null;
// If it already has a timezone indicator or 'Z', parse normally
if (stringValue.endsWith('Z') || stringValue.includes('+') || stringValue.includes('-')) {
return new Date(stringValue);
}
// Standard OTRS timestamps are stored as UTC without offset (YYYY-MM-DD HH:mm:ss).
// Appending 'Z' tells JS engine to parse as UTC instead of local time.
return new Date(stringValue.replace(' ', 'T') + 'Z');
});
const dbType = (process.env.DB_TYPE || 'postgres').toLowerCase();
@@ -112,6 +124,16 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
connectionLimit: 20,
idleTimeout: 30000,
connectTimeout: 5000,
timezone: '+00:00', // Parse dates from DB as UTC
});
// Ensure the session timezone is UTC for database functions like NOW()
this.mysqlPool.on('connection', (connection) => {
connection.query("SET time_zone = '+00:00'", (err) => {
if (err) {
console.error('[DB] Errore nell\'impostazione della time_zone UTC per MariaDB/MySQL:', err);
}
});
});
}
@@ -157,6 +179,14 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
options: '-c timezone=UTC', // Ensure connection timezone is UTC
});
// Ensure connection timezone is UTC via query fallback
pool.on('connect', (client) => {
client.query("SET TIME ZONE 'UTC'").catch(err => {
console.error('[DB] Errore nell\'impostazione della timezone UTC per Postgres:', err);
});
});
pool.on('error', (err) => {
+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"
}
}
+156 -1
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);
@@ -1161,10 +1180,15 @@ body {
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
background: linear-gradient(135deg, rgba(160, 65, 71, 0.15), rgba(160, 65, 71, 0.15));
background-color: var(--bg-card);
background-image: linear-gradient(135deg, rgba(160, 65, 71, 0.15), rgba(160, 65, 71, 0.15));
border: 1px solid var(--border-accent);
border-radius: var(--radius-lg);
animation: slideDown 0.2s ease-out;
position: sticky;
top: var(--topbar-total-height, var(--topbar-height));
z-index: 45;
box-shadow: var(--shadow-md);
}
@keyframes slideDown {
@@ -2494,3 +2518,134 @@ body {
padding: 8px 12px !important;
transition: all 0.3s ease;
}
/* ---- Ticket Tab Bar System ---- */
.tabs-bar {
display: flex;
flex-wrap: wrap; /* Wraps to new line if too many tabs */
align-items: stretch;
background: var(--bg-secondary);
padding: 0;
}
.tab-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: none;
border-right: 1px solid var(--border-subtle);
border-radius: 0 !important; /* Square corners */
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
margin: 0 !important;
}
.tab-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.tab-item.active {
background: var(--bg-primary); /* Blend with main content background */
color: var(--accent-primary);
border-bottom: 2px solid var(--accent-primary);
}
.tab-item .tab-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
font-size: 0.75rem;
line-height: 1;
opacity: 0.7;
transition: all 0.15s;
}
.tab-item .tab-close:hover {
opacity: 1;
color: var(--error);
}
/* ---- Full Bleed Ticket View Area ---- */
#view-container.ticket-view-active {
padding: 0 !important;
}
#view-container.ticket-view-active .back-link {
margin: var(--space-md) var(--space-xl) var(--space-xs);
display: inline-flex;
}
#view-container.ticket-view-active .ticket-detail {
border-radius: 0 !important;
border: none !important;
}
#view-container.ticket-view-active .ticket-detail .card {
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;
}
+40 -12
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>
@@ -60,6 +70,17 @@
<span class="nav-badge" id="my-ticket-count"></span>
</a>
</li>
<li>
<a href="#/tickets/groups" class="nav-link" data-view="ticket-groups" id="nav-ticket-groups">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Gruppi Ticket</span>
</a>
</li>
<li>
<a href="#/tickets/new" class="nav-link" data-view="new-ticket" id="nav-new-ticket">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -80,6 +101,16 @@
<span>Apertura Massiva</span>
</a>
</li>
<hr>
<li>
<a href="#/mail-management" class="nav-link" data-view="mail-management" id="nav-mail-management">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
<span>Gestione Mail</span>
</a>
</li>
<li>
<a href="#/activity" class="nav-link" data-view="activity" id="nav-activity">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -89,15 +120,6 @@
<span>Storico Attività Turbo</span>
</a>
</li>
<li>
<a href="#/signatures" class="nav-link" data-view="signatures" id="nav-signatures">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
<span>Firme Email</span>
</a>
</li>
</ul>
<div class="sidebar-footer">
@@ -111,7 +133,8 @@
<!-- Main Content -->
<main class="main-content" id="main-content">
<!-- Top Bar -->
<header class="topbar" id="topbar">
<header class="topbar" id="topbar" style="height: auto; padding: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0;">
<div class="topbar-main" style="display: flex; align-items: center; justify-content: space-between; width: 100%; height: var(--topbar-height); padding: 0 var(--space-xl);">
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
<h1 class="page-title" id="page-title">Dashboard</h1>
<div style="display:flex; align-items:center; gap:var(--space-xs);">
@@ -159,6 +182,9 @@
Nuovo
</button>
</div>
</div>
<!-- Tabs Bar -->
<div id="tabs-bar" class="tabs-bar" style="display:none; border-top: 1px solid var(--border-subtle);"></div>
</header>
<!-- View Container -->
@@ -176,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>
@@ -185,7 +212,8 @@
<script src="/js/views/ticketBulk.js"></script>
<script src="/js/views/activityLog.js"></script>
<script src="/js/views/emailCompose.js"></script>
<script src="/js/views/signatures.js"></script>
<script src="/js/views/mailManagement.js"></script>
<script src="/js/views/ticketGroups.js"></script>
<script src="/js/app.js"></script>
</body>
+286 -18
View File
@@ -13,6 +13,123 @@ const App = {
lookupsLoaded: false,
demotivationalPhrases: [],
motivationalPhrases: [],
drafts: {},
tabs: [],
lastListView: '#/tickets',
loadTabs() {
try {
const saved = localStorage.getItem('otrs_turbo_tabs');
if (saved) this.tabs = JSON.parse(saved);
const savedDrafts = localStorage.getItem('otrs_turbo_drafts');
if (savedDrafts) this.drafts = JSON.parse(savedDrafts);
} catch (e) {}
this.renderTabs();
},
saveTabs() {
localStorage.setItem('otrs_turbo_tabs', JSON.stringify(this.tabs));
this.renderTabs();
},
saveDraft(ticketId, draft) {
if (!this.drafts[ticketId]) this.drafts[ticketId] = {};
this.drafts[ticketId][draft.type] = draft;
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
},
getDraft(ticketId, type) {
return this.drafts[ticketId] ? this.drafts[ticketId][type] : null;
},
clearDraft(ticketId, type) {
if (this.drafts[ticketId] && this.drafts[ticketId][type]) {
delete this.drafts[ticketId][type];
if (Object.keys(this.drafts[ticketId]).length === 0) {
delete this.drafts[ticketId];
}
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
this.renderTabs();
}
},
openTab(id, tn, title) {
const exists = this.tabs.find(t => String(t.id) === String(id));
if (!exists) {
this.tabs.push({ id, tn, title });
this.saveTabs();
}
},
addTabWithoutRedirect(id, tn, title) {
const exists = this.tabs.find(t => String(t.id) === String(id));
if (!exists) {
this.tabs.push({ id, tn, title });
this.saveTabs();
} else {
if (title && exists.title !== title) {
exists.title = title;
this.saveTabs();
} else {
this.renderTabs();
}
}
},
closeTab(id, e) {
if (e) e.stopPropagation();
this.tabs = this.tabs.filter(t => String(t.id) !== String(id));
this.saveTabs();
// Clear drafts for closed tab
delete this.drafts[id];
localStorage.setItem('otrs_turbo_drafts', JSON.stringify(this.drafts));
const hash = window.location.hash;
if (hash === `#/tickets/${id}`) {
if (this.tabs.length > 0) {
window.location.hash = `#/tickets/${this.tabs[this.tabs.length - 1].id}`;
} else {
window.location.hash = '#/tickets';
}
}
},
renderTabs() {
const bar = document.getElementById('tabs-bar');
if (!bar) return;
if (this.tabs.length === 0) {
bar.style.display = 'none';
this.updateHeaderHeight();
return;
}
bar.style.display = 'flex';
const currentHash = window.location.hash;
bar.innerHTML = this.tabs.map(t => {
const isActive = currentHash === `#/tickets/${t.id}`;
const hasEmailDraft = this.getDraft(t.id, 'email');
const emailIconHtml = hasEmailDraft ? `<span style="color:#22c55e; margin-right:4px;" title="Bozza email presente">✉️</span>` : '';
const displayTitle = t.title ? (t.title.length > 25 ? t.title.substring(0, 22) + '...' : t.title) : `#${t.tn}`;
return `
<div class="tab-item ${isActive ? 'active' : ''}" onclick="window.location.hash = '#/tickets/${t.id}'" title="${App.escapeHtml(t.title || '')}">
${emailIconHtml}
<span>${App.escapeHtml(displayTitle)}</span>
<button class="tab-close" onclick="App.closeTab(${t.id}, event)">✕</button>
</div>
`;
}).join('');
this.updateHeaderHeight();
},
updateHeaderHeight() {
const topbar = document.getElementById('topbar');
if (topbar) {
const height = topbar.offsetHeight;
document.documentElement.style.setProperty('--topbar-total-height', `${height}px`);
}
},
get currentAgentId() {
return parseInt(localStorage.getItem('activeAgentId') || '1', 10);
@@ -23,6 +140,7 @@ const App = {
this.initTheme();
this.loadDemotivationalPhrases();
this.loadMotivationalPhrases();
this.loadTabs();
Toast.init();
// Hash-based SPA router
@@ -38,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) => {
@@ -98,7 +204,38 @@ 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);
if (!window.location.hash || window.location.hash === '#/') {
window.location.hash = '#/dashboard';
} else {
@@ -112,6 +249,32 @@ const App = {
const hash = fullHash.split('?')[0];
const titleEl = document.getElementById('page-title');
if (hash === '#/tickets' || hash === '#/tickets/my') {
this.lastListView = hash;
}
// Save draft for previous ticket before routing
if (typeof TicketDetailView !== 'undefined' && TicketDetailView.ticketId) {
TicketDetailView.saveDraft();
if (typeof EmailCompose !== 'undefined') {
EmailCompose.saveDraft();
const overlay = document.getElementById('email-compose-overlay');
if (overlay) overlay.remove();
}
}
this.renderTabs();
this.updateHeaderHeight();
const container = document.getElementById('view-container');
if (container) {
if (hash.match(/^#\/tickets\/(\d+)$/)) {
container.classList.add('ticket-view-active');
} else {
container.classList.remove('ticket-view-active');
}
}
// Update active nav link
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
@@ -142,15 +305,20 @@ const App = {
titleEl.textContent = 'Apertura Massiva Ticket';
TicketBulkView.render();
} else if (hash === '#/tickets/groups') {
document.getElementById('nav-ticket-groups')?.classList.add('active');
titleEl.textContent = 'Gruppi ticket';
TicketGroupsView.render();
} else if (hash === '#/activity') {
document.getElementById('nav-activity')?.classList.add('active');
titleEl.textContent = 'Storico Attività';
ActivityLogView.render();
} else if (hash === '#/signatures') {
document.getElementById('nav-signatures')?.classList.add('active');
titleEl.textContent = 'Firme Email';
SignaturesView.render();
} else if (hash === '#/mail-management') {
document.getElementById('nav-mail-management')?.classList.add('active');
titleEl.textContent = 'Gestione Mail';
MailManagementView.render();
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
@@ -341,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();
});
@@ -472,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(
@@ -500,7 +681,7 @@ const App = {
if (timerEl) {
timerEl.classList.add('clickable-auto-time');
timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`);
//timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`);
timerEl.onclick = triggerAction;
}
} else {
@@ -721,6 +902,93 @@ const App = {
});
});
},
/** Custom prompt dialog in the center of the screen */
prompt(title, message, options = {}) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.backdropFilter = 'blur(4px)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '99999';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.2s ease';
const card = document.createElement('div');
card.style.background = 'var(--bg-card, #1e1e2e)';
card.style.border = '1px solid var(--border-subtle, #313244)';
card.style.borderRadius = 'var(--radius-lg, 12px)';
card.style.padding = 'var(--space-lg, 24px)';
card.style.width = '100%';
card.style.maxWidth = '400px';
card.style.boxShadow = 'var(--shadow-lg, 0 10px 30px rgba(0,0,0,0.5))';
card.style.transform = 'scale(0.9)';
card.style.transition = 'transform 0.2s ease';
card.className = 'prompt-dialog-card';
card.innerHTML = `
<h3 style="margin-top: 0; margin-bottom: var(--space-xs, 8px); color: var(--text-primary, #cdd6f4); font-size: 1.2rem; font-weight: 600;">${title}</h3>
<p style="margin-bottom: var(--space-sm, 12px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
<input type="text" id="prompt-input-field" class="form-input" value="${options.defaultValue || ''}" placeholder="${options.placeholder || ''}" style="width: 100%; margin-bottom: var(--space-md, 16px); box-sizing: border-box;" />
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
<button id="prompt-btn-cancel" class="btn btn-ghost" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px);">${options.cancelText || 'Annulla'}</button>
<button id="prompt-btn-ok" class="btn btn-primary" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Salva'}</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const input = card.querySelector('#prompt-input-field');
requestAnimationFrame(() => {
overlay.style.opacity = '1';
card.style.transform = 'scale(1)';
setTimeout(() => {
if (input) input.focus();
}, 50);
});
const cleanUp = (resultValue) => {
overlay.style.opacity = '0';
card.style.transform = 'scale(0.9)';
setTimeout(() => {
overlay.remove();
resolve(resultValue);
}, 200);
};
const btnCancel = card.querySelector('#prompt-btn-cancel');
const btnOk = card.querySelector('#prompt-btn-ok');
btnCancel.addEventListener('click', () => cleanUp(null));
btnOk.addEventListener('click', () => {
const val = input ? input.value : '';
cleanUp(val);
});
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
btnOk.click();
} else if (e.key === 'Escape') {
btnCancel.click();
}
});
}
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanUp(null);
});
});
},
};
// Start the app when DOM is ready
+432 -121
View File
@@ -26,6 +26,16 @@ const Filters = {
}
},
customerCache: {},
presets: [],
selectedPresetId: null,
saveCustomerCache() {
try {
localStorage.setItem('otrs_turbo_customer_cache', JSON.stringify(this.customerCache));
} catch (e) { /* ignore */ }
},
// Dynamic state getter based on active mode
get state() {
return this.allStates[this.currentMode];
@@ -45,10 +55,14 @@ 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) {
this.customerCache = JSON.parse(savedCache);
}
} catch (e) { /* ignore */ }
},
@@ -79,37 +93,31 @@ const Filters = {
return params;
},
/** Helper to compute trigger button label text */
getStateMultiselectLabel(lookups) {
const selectedList = Array.isArray(this.state.state_id)
? this.state.state_id
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
/** Helper to compute trigger button label text for multiselect dropdowns */
getMultiselectLabel(selectedVal, itemsList, labelField = 'name', idField = 'id') {
const selectedList = Array.isArray(selectedVal)
? selectedVal
: (typeof selectedVal === 'string' && selectedVal ? selectedVal.split(',') : []);
if (selectedList.length === 0) {
return 'Tutti';
}
const selectedNames = (lookups.states || [])
.filter(s => selectedList.includes(String(s.id)))
.map(s => s.name);
if (selectedNames.length === (lookups.states || []).length) {
const selectedNames = (itemsList || [])
.filter(item => selectedList.includes(String(item[idField])))
.map(item => typeof labelField === 'function' ? labelField(item) : item[labelField]);
if (selectedNames.length === 0) {
return 'Tutti';
} else if (selectedNames.length <= 2) {
}
if (selectedNames.length <= 2) {
return selectedNames.join(', ');
} else {
return `${selectedNames.length} selezionati`;
}
},
/** Update label DOM element dynamically */
updateStateMultiselectLabel(lookups) {
const labelEl = document.getElementById('state-multiselect-label');
if (labelEl) {
labelEl.textContent = this.getStateMultiselectLabel(lookups);
}
},
/** Helper to compute trigger button label text for customer users */
getCustomerUserMultiselectLabel(lookups) {
const selectedList = Array.isArray(this.state.customer_user_id)
? this.state.customer_user_id
@@ -118,13 +126,22 @@ const Filters = {
if (selectedList.length === 0) {
return 'Tutti';
}
const selectedNames = (lookups.customerUsers || [])
.filter(u => selectedList.includes(String(u.login)))
.map(u => `${u.last_name} ${u.first_name}`);
if (selectedNames.length === (lookups.customerUsers || []).length) {
return 'Tutti';
} else if (selectedNames.length <= 2) {
const selectedNames = selectedList.map(login => {
if (this.customerCache && this.customerCache[login]) {
return this.customerCache[login];
}
const found = (lookups.customerUsers || []).find(u => String(u.login) === String(login));
if (found) {
const fullName = `${found.last_name} ${found.first_name}`.trim();
this.customerCache[login] = fullName;
this.saveCustomerCache();
return fullName;
}
return login;
});
if (selectedNames.length <= 2) {
return selectedNames.join(', ');
} else {
return `${selectedNames.length} selezionati`;
@@ -145,72 +162,147 @@ const Filters = {
* @returns {string} HTML string
*/
renderBar(lookups) {
const makeOptions = (items, valueKey, labelKey, selectedVal) => {
return items.map(item => {
const val = item[valueKey];
const label = typeof labelKey === 'function' ? labelKey(item) : item[labelKey];
const sel = String(val) === String(selectedVal) ? 'selected' : '';
return `<option value="${val}" ${sel}>${label}</option>`;
}).join('');
};
const currentLabel = this.getStateMultiselectLabel(lookups);
const stateLabel = this.getMultiselectLabel(this.state.state_id, lookups.states);
const queueLabel = this.getMultiselectLabel(this.state.queue_id, lookups.queues);
const priorityLabel = this.getMultiselectLabel(this.state.priority_id, lookups.priorities);
const ownerLabel = this.getMultiselectLabel(this.state.user_id, lookups.users, u => `${u.first_name} ${u.last_name}`);
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups);
const presetOptions = (this.presets || []).map(p => {
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
}).join('');
return `
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<span class="filter-label">Preset</span>
<div style="display:flex; gap:4px; align-items:center;">
<select class="form-select" id="filter-presets-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; min-width:130px; border-color:var(--border-subtle);">
<option value="">-- Nessuno --</option>
${presetOptions}
</select>
<button class="btn btn-ghost btn-xs" id="btn-save-preset" style="height:28px; padding:0 8px; font-size:0.75rem;" title="Salva filtri attuali come preset">Salva</button>
<button class="btn btn-ghost btn-xs" id="btn-delete-preset" style="height:28px; padding:0 8px; font-size:0.75rem; color:var(--danger);" title="Elimina il preset selezionato">Elimina</button>
</div>
</div>
<!-- Stato -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Stato</span>
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:130px;">${App.escapeHtml(currentLabel)}</span>
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 110px; max-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(stateLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<div style="display:flex; flex-direction:column; gap:4px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
<input type="text" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.states || []).map(s => {
const selectedList = Array.isArray(this.state.state_id)
? this.state.state_id
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(s.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="state-multiselect-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(s.name)}
</div>
`;
}).join('')}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
<button type="button" class="btn btn-ghost btn-xs" id="state-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs" id="state-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<div class="filter-group">
<!-- Coda -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Coda</span>
<select class="filter-select" data-filter="queue_id" id="filter-queue">
<option value="">Tutte</option>
${makeOptions(lookups.queues || [], 'id', 'name', this.state.queue_id)}
</select>
<div class="multiselect-dropdown" id="queue-multiselect-dropdown" style="min-width: 120px; max-width: 150px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="queue-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(queueLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="filter-group">
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.queues || []).map(q => {
const selectedList = String(this.state.queue_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(q.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${q.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(q.name)}
</div>
`;
}).join('')}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<!-- Priorità -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Priorità</span>
<select class="filter-select" data-filter="priority_id" id="filter-priority">
<option value="">Tutte</option>
${makeOptions(lookups.priorities || [], 'id', 'name', this.state.priority_id)}
</select>
<div class="multiselect-dropdown" id="priority-multiselect-dropdown" style="min-width: 90px; max-width: 120px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="priority-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(priorityLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="filter-group">
<div class="multiselect-popover" id="priority-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 200px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.priorities || []).map(p => {
const selectedList = String(this.state.priority_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(p.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${p.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(p.name)}
</div>
`;
}).join('')}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<!-- Owner -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Owner</span>
<select class="filter-select" data-filter="user_id" id="filter-owner">
<option value="">Tutti</option>
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
</select>
<div class="multiselect-dropdown" id="owner-multiselect-dropdown" style="min-width: 120px; max-width: 150px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="owner-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(ownerLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="owner-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 240px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<input type="text" class="form-input ms-search" placeholder="Cerca owner..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.users || []).map(u => {
const selectedList = String(this.state.user_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(u.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${u.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}
</div>
`;
}).join('')}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<div class="filter-group" style="position:relative;">
<span class="filter-label">Utente Cliente</span>
<div class="multiselect-dropdown" id="customer-user-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="customer-user-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:180px;">${App.escapeHtml(customerUserLabel)}</span>
<div class="multiselect-dropdown" id="customer-user-multiselect-dropdown" style="min-width: 130px; max-width: 160px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="customer-user-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(customerUserLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
@@ -226,20 +318,22 @@ const Filters = {
</div>
<div class="filter-group">
<span class="filter-label">Da Data/Ora</span>
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 170px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
</div>
<div class="filter-group">
<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: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
<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>
`;
},
/** Bind change events to filter selects */
bindEvents(onFilterChange) {
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
const selects = document.querySelectorAll('.filter-select[data-filter]');
@@ -247,33 +341,58 @@ const Filters = {
sel.disabled = false;
sel.addEventListener('change', (e) => {
this.selectedPresetId = null;
this.state[e.target.dataset.filter] = e.target.value;
this.save();
if (onFilterChange) onFilterChange();
});
});
// Multiselect dropdown toggle event
const dropdown = document.getElementById('state-multiselect-dropdown');
const popover = document.getElementById('state-multiselect-popover');
const okBtn = document.getElementById('state-multiselect-ok');
const clearBtn = document.getElementById('state-multiselect-clear');
// Helper to bind standard multiselect popover events
const bindStandardMultiselect = (filterKey, dropdownId, popoverId, labelElId, itemsList, labelField, idField = 'id') => {
const dropdown = document.getElementById(dropdownId);
const popover = document.getElementById(popoverId);
if (!dropdown || !popover) return;
if (dropdown && popover) {
const searchInput = popover.querySelector('.ms-search');
const itemsContainer = popover.querySelector('.ms-items-container');
const okBtn = popover.querySelector('.ms-ok');
const clearBtn = popover.querySelector('.ms-clear');
// Toggle popover visibility
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
// Close all other popovers
document.querySelectorAll('.multiselect-popover').forEach(p => {
if (p !== popover) p.style.display = 'none';
});
const isOpen = popover.style.display === 'block';
popover.style.display = isOpen ? 'none' : 'block';
if (!isOpen && searchInput) {
searchInput.value = '';
searchInput.dispatchEvent(new Event('input'));
setTimeout(() => searchInput.focus(), 50);
}
});
popover.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
popover.style.display = 'none';
// Search matching items
if (searchInput && itemsContainer) {
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(q) ? 'block' : 'none';
});
});
}
// Bind item clicks
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
// Bind selection clicks
if (itemsContainer) {
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const isActive = item.classList.toggle('active');
@@ -288,32 +407,51 @@ const Filters = {
});
}
// Apply selection (OK click)
if (okBtn) {
okBtn.addEventListener('click', () => {
const activeItems = popover.querySelectorAll('.state-multiselect-item.active');
const activeItems = itemsContainer.querySelectorAll('.ms-item.active');
const ids = Array.from(activeItems).map(item => item.dataset.value);
this.state.state_id = ids.join(',');
this.selectedPresetId = null;
this.state[filterKey] = ids.join(',');
this.save();
this.updateStateMultiselectLabel(App.lookups);
const labelEl = document.getElementById(labelElId);
if (labelEl) {
labelEl.textContent = this.getMultiselectLabel(this.state[filterKey], itemsList, labelField, idField);
}
popover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
}
// Reset selection
if (clearBtn) {
clearBtn.addEventListener('click', () => {
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
this.state.state_id = '';
this.selectedPresetId = null;
this.state[filterKey] = '';
this.save();
this.updateStateMultiselectLabel(App.lookups);
const labelEl = document.getElementById(labelElId);
if (labelEl) {
labelEl.textContent = 'Tutti';
}
popover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
}
};
// Bind standard multiselects
bindStandardMultiselect('state_id', 'state-multiselect-dropdown', 'state-multiselect-popover', 'state-multiselect-label', App.lookups.states, 'name');
bindStandardMultiselect('queue_id', 'queue-multiselect-dropdown', 'queue-multiselect-popover', 'queue-multiselect-label', App.lookups.queues, 'name');
bindStandardMultiselect('priority_id', 'priority-multiselect-dropdown', 'priority-multiselect-popover', 'priority-multiselect-label', App.lookups.priorities, 'name');
bindStandardMultiselect('user_id', 'owner-multiselect-dropdown', 'owner-multiselect-popover', 'owner-multiselect-label', App.lookups.users, u => `${u.first_name} ${u.last_name}`);
// Customer User Multiselect Popover binding
const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
@@ -323,33 +461,61 @@ const Filters = {
const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
const renderCustomerUserItems = () => {
let activeSearchController = null;
const renderCustomerUserItems = (searchResults = null) => {
if (!cuItemsContainer) return;
const q = (cuSearchInput ? cuSearchInput.value : '').toLowerCase().trim();
const selectedList = Array.isArray(this.state.customer_user_id)
? this.state.customer_user_id
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
const filtered = (App.lookups.customerUsers || []).filter(u => {
const fullName = `${u.last_name} ${u.first_name} (${u.login})`.toLowerCase();
return fullName.includes(q);
});
const uniqueSelectedLogins = Array.from(new Set(selectedList)).filter(Boolean);
cuItemsContainer.innerHTML = filtered.map(u => {
const isSelected = selectedList.includes(String(u.login));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="customer-user-multiselect-item ${isSelected ? 'active' : ''}" data-value="${u.login}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
let html = '';
// 1. Show selected items at the top
if (uniqueSelectedLogins.length > 0) {
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--accent-primary); text-transform:uppercase; padding: 2px var(--space-sm); border-bottom:1px solid var(--border-subtle); margin-bottom:4px;">Selezionati</div>`;
uniqueSelectedLogins.forEach(login => {
const displayName = this.customerCache[login] || login;
html += `
<div class="customer-user-multiselect-item active" data-value="${login}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; background: var(--accent-primary); color: #fff; margin-bottom: 2px;">
${App.escapeHtml(displayName)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(login)})</span>
</div>
`;
});
}
// 2. Show search results below
let listToRender = searchResults || App.lookups.customerUsers || [];
listToRender = listToRender.filter(u => !uniqueSelectedLogins.includes(String(u.login)));
if (listToRender.length > 0) {
if (uniqueSelectedLogins.length > 0) {
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--text-secondary); text-transform:uppercase; padding: 4px var(--space-sm) 2px; border-bottom:1px solid var(--border-subtle); margin-top:6px; margin-bottom:4px;">Risultati</div>`;
}
listToRender.forEach(u => {
const displayName = `${u.last_name} ${u.first_name}`.trim() || u.login;
html += `
<div class="customer-user-multiselect-item" data-value="${u.login}" data-display-name="${displayName}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; margin-bottom: 2px;">
${App.escapeHtml(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span>
</div>
`;
}).join('');
});
} else if (uniqueSelectedLogins.length === 0) {
html = `<div style="text-align:center; padding:var(--space-md); color:var(--text-muted); font-size:0.8rem;">Cerca digitando sopra...</div>`;
}
cuItemsContainer.innerHTML = html;
// Bind clicks to items
cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const login = item.dataset.value;
const displayName = item.dataset.displayName || this.customerCache[login] || login;
const currentSelected = Array.isArray(this.state.customer_user_id)
? [...this.state.customer_user_id]
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
@@ -357,16 +523,13 @@ const Filters = {
const idx = currentSelected.indexOf(login);
if (idx > -1) {
currentSelected.splice(idx, 1);
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
} else {
currentSelected.push(login);
item.classList.add('active');
item.style.background = 'var(--accent-primary)';
item.style.color = '#fff';
this.customerCache[login] = displayName;
this.saveCustomerCache();
}
this.state.customer_user_id = currentSelected.join(',');
renderCustomerUserItems(searchResults);
});
});
};
@@ -376,8 +539,9 @@ const Filters = {
e.stopPropagation();
// Close other popovers
const statePopover = document.getElementById('state-multiselect-popover');
if (statePopover) statePopover.style.display = 'none';
document.querySelectorAll('.multiselect-popover').forEach(p => {
if (p !== cuPopover) p.style.display = 'none';
});
const isOpen = cuPopover.style.display === 'block';
cuPopover.style.display = isOpen ? 'none' : 'block';
@@ -394,19 +558,38 @@ const Filters = {
cuPopover.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
cuPopover.style.display = 'none';
});
let cuSearchDebounce;
if (cuSearchInput) {
cuSearchInput.addEventListener('input', () => {
renderCustomerUserItems();
clearTimeout(cuSearchDebounce);
const q = cuSearchInput.value.trim();
cuSearchDebounce = setTimeout(async () => {
if (activeSearchController) activeSearchController.abort();
activeSearchController = new AbortController();
try {
cuItemsContainer.innerHTML = '<div style="display:flex; justify-content:center; padding:12px;"><div class="spinner" style="width:18px;height:18px;border-width:2px;"></div></div>';
const users = await fetch(`/api/customer-users/search?q=${encodeURIComponent(q)}`, {
signal: activeSearchController.signal
}).then(r => r.json());
renderCustomerUserItems(users);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search failed:', err);
renderCustomerUserItems([]);
}
}
}, 300);
});
}
}
if (cuOkBtn) {
cuOkBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.save();
this.updateCustomerUserMultiselectLabel(App.lookups);
if (cuPopover) cuPopover.style.display = 'none';
@@ -416,6 +599,7 @@ const Filters = {
if (cuClearBtn) {
cuClearBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.state.customer_user_id = '';
this.save();
this.updateCustomerUserMultiselectLabel(App.lookups);
@@ -426,33 +610,44 @@ const Filters = {
});
}
// Close all popovers on backdrop click and confirm selection
document.addEventListener('click', () => {
document.querySelectorAll('.multiselect-popover').forEach(p => {
if (p.style.display === 'block') {
const okBtn = p.querySelector('.ms-ok, #customer-user-multiselect-ok');
if (okBtn) {
okBtn.click();
} else {
p.style.display = 'none';
}
}
});
});
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', () => {
this.selectedPresetId = null;
this.reset();
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.state.user_id = activeAgentId;
this.save();
}
selects.forEach(s => {
if (isMyTickets && s.dataset.filter === 'user_id') {
s.value = localStorage.getItem('activeAgentId') || '1';
} else {
s.value = '';
}
});
// Also clear state multiselect items and label
const statePopover = document.getElementById('state-multiselect-popover');
if (statePopover) {
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
// Reset highlight states in popovers
document.querySelectorAll('.ms-items-container .ms-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
}
this.updateStateMultiselectLabel(App.lookups);
// Also clear customer user multiselect items and label
const cuPopover = document.getElementById('customer-user-multiselect-popover');
@@ -465,11 +660,127 @@ const Filters = {
item.style.color = '';
});
}
// Recompute labels
document.getElementById('state-multiselect-label').textContent = 'Tutti';
document.getElementById('queue-multiselect-label').textContent = 'Tutti';
document.getElementById('priority-multiselect-label').textContent = 'Tutti';
document.getElementById('owner-multiselect-label').textContent = isMyTickets ? this.getMultiselectLabel(this.state.user_id, App.lookups.users, u => `${u.first_name} ${u.last_name}`) : 'Tutti';
this.updateCustomerUserMultiselectLabel(App.lookups);
if (onFilterChange) onFilterChange();
});
}
// Load and bind presets
const loadPresets = async () => {
try {
const agentId = localStorage.getItem('activeAgentId') || '1';
this.presets = await App.api(`/api/presets?page_mode=${this.currentMode}`);
const select = document.getElementById('filter-presets-select');
if (select) {
select.innerHTML = '<option value="">-- Nessuno --</option>' + this.presets.map(p => {
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
}).join('');
}
} catch (err) {
console.warn('Failed to load presets:', err);
}
};
loadPresets();
const selectPresets = document.getElementById('filter-presets-select');
if (selectPresets) {
selectPresets.addEventListener('change', (e) => {
const presetId = e.target.value;
if (!presetId) {
this.selectedPresetId = null;
const resetBtn = document.getElementById('filter-reset');
if (resetBtn) {
resetBtn.click();
} else {
this.reset();
if (onFilterChange) onFilterChange();
}
return;
}
const preset = this.presets.find(p => String(p.id) === String(presetId));
if (preset) {
try {
const filters = JSON.parse(preset.filters_json);
this.state = Object.assign({
queue_id: '',
state_id: '',
priority_id: '',
user_id: '',
customer_user_id: '',
date_from: '',
date_to: ''
}, filters);
this.selectedPresetId = preset.id;
this.save();
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nel caricamento del preset: ' + err.message);
}
}
});
}
const btnSavePreset = document.getElementById('btn-save-preset');
if (btnSavePreset) {
btnSavePreset.addEventListener('click', async () => {
const name = await App.prompt('Nuovo Preset', 'Inserisci il nome per questo preset di filtri:');
if (!name || !name.trim()) return;
try {
btnSavePreset.disabled = true;
const newPreset = await App.api('/api/presets', {
method: 'POST',
body: JSON.stringify({
name: name.trim(),
page_mode: this.currentMode,
filters: this.state
})
});
Toast.success('Preset salvato con successo!');
this.selectedPresetId = newPreset.id;
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nel salvataggio del preset: ' + err.message);
btnSavePreset.disabled = false;
}
});
}
const btnDeletePreset = document.getElementById('btn-delete-preset');
if (btnDeletePreset) {
btnDeletePreset.addEventListener('click', async () => {
const select = document.getElementById('filter-presets-select');
const presetId = select ? select.value : '';
if (!presetId) {
Toast.warning('Seleziona prima un preset da eliminare');
return;
}
const ok = await App.confirm('Elimina Preset', 'Sei sicuro di voler eliminare questo preset?');
if (!ok) return;
try {
btnDeletePreset.disabled = true;
await App.api(`/api/presets/${presetId}`, { method: 'DELETE' });
Toast.success('Preset eliminato con successo');
this.selectedPresetId = null;
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nell\'eliminazione del preset: ' + err.message);
btnDeletePreset.disabled = false;
}
});
}
},
};
+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);
});
}
};
+205 -18
View File
@@ -8,6 +8,9 @@ const EmailCompose = (() => {
let quillEditor = null;
let attachmentsList = [];
let currentOptions = {};
let toTagsCtrl = null;
let ccTagsCtrl = null;
let bccTagsCtrl = null;
// ── CSS ──────────────────────────────────────────────────────────────────────
function injectStyles() {
@@ -70,6 +73,10 @@ const EmailCompose = (() => {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb,99,102,241),0.12);
}
#email-compose-modal .ec-tags-input.drag-over {
border-color: var(--accent-primary);
background: rgba(99,102,241,0.06);
}
#email-compose-modal .ec-tag {
display: inline-flex; align-items: center; gap: 4px;
background: var(--accent-primary); color: #fff;
@@ -145,7 +152,7 @@ const EmailCompose = (() => {
}
// ── Tag Input Helper ──────────────────────────────────────────────────────────
function makeTagInput(containerId, initialEmails = []) {
function makeTagInput(containerId, initialEmails = [], onFocus = null, controllers = null) {
const container = document.getElementById(containerId);
const tags = [...initialEmails];
@@ -156,8 +163,16 @@ const EmailCompose = (() => {
tags.forEach((email, idx) => {
const tagEl = document.createElement('span');
tagEl.className = 'ec-tag';
tagEl.draggable = true;
tagEl.innerHTML = `${App.escapeHtml(email)}<button type="button" data-idx="${idx}">✕</button>`;
tagEl.querySelector('button').addEventListener('click', () => {
tagEl.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', email);
e.dataTransfer.setData('source-container-id', containerId);
});
tagEl.querySelector('button').addEventListener('click', (e) => {
e.stopPropagation();
tags.splice(idx, 1);
render();
});
@@ -168,6 +183,11 @@ const EmailCompose = (() => {
input.type = 'text';
input.placeholder = tags.length ? '' : 'email@esempio.com, premi Invio';
input.value = currentVal;
if (onFocus) {
input.addEventListener('focus', onFocus);
}
input.addEventListener('keydown', (e) => {
if ((e.key === 'Enter' || e.key === ',') && input.value.trim()) {
e.preventDefault();
@@ -192,8 +212,48 @@ const EmailCompose = (() => {
container.addEventListener('click', () => input.focus());
}
container.addEventListener('dragover', (e) => {
e.preventDefault();
container.classList.add('drag-over');
});
container.addEventListener('dragleave', () => {
container.classList.remove('drag-over');
});
container.addEventListener('drop', (e) => {
e.preventDefault();
container.classList.remove('drag-over');
const email = e.dataTransfer.getData('text/plain');
const sourceContainerId = e.dataTransfer.getData('source-container-id');
if (email && sourceContainerId && sourceContainerId !== containerId && controllers) {
const sourceCtrl = controllers[sourceContainerId];
if (sourceCtrl) {
sourceCtrl.removeTag(email);
ctrl.addTag(email);
}
}
});
render();
return { getTags: () => [...tags], addTag: (email) => { if (!tags.includes(email)) { tags.push(email); render(); } } };
const ctrl = {
getTags: () => [...tags],
addTag: (email) => {
if (!tags.includes(email)) {
tags.push(email);
render();
}
},
removeTag: (email) => {
const idx = tags.indexOf(email);
if (idx > -1) {
tags.splice(idx, 1);
render();
}
}
};
return ctrl;
}
// ── Build Modal HTML ──────────────────────────────────────────────────────────
@@ -221,6 +281,10 @@ const EmailCompose = (() => {
<label class="ec-label">CC</label>
<div class="ec-tags-input" id="ec-cc-container"></div>
</div>
<div class="ec-field">
<label class="ec-label">BCC (CCN)</label>
<div class="ec-tags-input" id="ec-bcc-container"></div>
</div>
<div class="ec-field">
<label class="ec-label">Oggetto</label>
<input type="text" id="ec-subject" class="form-input" style="margin-bottom:0;" placeholder="Oggetto email" />
@@ -233,10 +297,16 @@ const EmailCompose = (() => {
</select>
</div>
<div class="ec-field" style="flex:1;">
<label class="ec-label">Tieni helpdesk in copia</label>
<label class="ec-label">Gruppi di Indirizzi</label>
<select id="ec-groups-select" class="ec-select" style="width:100%; min-width:unset;">
<option value="">— Inserisci gruppo... —</option>
</select>
</div>
<div class="ec-field" style="flex:1;">
<label class="ec-label">Helpdesk BCC (genera nuovo ticket)</label>
<select id="ec-helpdesk-cc-select" class="ec-select" style="width:100%; min-width:unset;">
<option value="1"> (BCC automatico)</option>
<option value="0">No</option>
<option value="0">No (BCC automatico)</option>
<option value="1"></option>
</select>
</div>
</div>
@@ -310,6 +380,23 @@ const EmailCompose = (() => {
return '';
}
// ── Load Address Groups ────────────────────────────────────────────────────────
async function loadAddressGroups(agentId, selectEl) {
try {
const groups = await App.api(`/api/email/address-groups?agent_id=${agentId}`);
selectEl.innerHTML = '<option value="">— Inserisci gruppo... —</option>';
groups.forEach(g => {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.name;
opt.dataset.emails = g.emails;
selectEl.appendChild(opt);
});
} catch (e) {
console.warn('[EmailCompose] Address groups load error:', e);
}
}
// ── Open ─────────────────────────────────────────────────────────────────────
async function open(options = {}) {
injectStyles();
@@ -323,21 +410,56 @@ const EmailCompose = (() => {
const overlay = buildModal();
document.body.appendChild(overlay);
// Init tag inputs
const initialTo = options.customerEmail ? [options.customerEmail] : [];
const toTagsCtrl = makeTagInput('ec-to-container', initialTo);
const ccTagsCtrl = makeTagInput('ec-cc-container', []);
// Init tag inputs with focus tracking
const draft = options.draft;
const initialTo = draft ? draft.to : (options.initialTo || (options.customerEmail ? [options.customerEmail] : []));
const initialCc = draft ? draft.cc : (options.initialCc || []);
const initialBcc = draft ? draft.bcc : [];
let lastFocusedCtrl = null;
const controllers = {};
toTagsCtrl = makeTagInput('ec-to-container', initialTo, () => { lastFocusedCtrl = toTagsCtrl; }, controllers);
ccTagsCtrl = makeTagInput('ec-cc-container', initialCc, () => { lastFocusedCtrl = ccTagsCtrl; }, controllers);
bccTagsCtrl = makeTagInput('ec-bcc-container', initialBcc, () => { lastFocusedCtrl = bccTagsCtrl; }, controllers);
controllers['ec-to-container'] = toTagsCtrl;
controllers['ec-cc-container'] = ccTagsCtrl;
controllers['ec-bcc-container'] = bccTagsCtrl;
lastFocusedCtrl = toTagsCtrl;
// Subject
const subjectEl = document.getElementById('ec-subject');
if (draft) {
subjectEl.value = draft.subject || '';
} else {
const tn = options.ticketTn || '';
const title = options.ticketTitle || '';
subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title;
// Signature select
subjectEl.value = tn ? `[Ticket#${tn}] Re: ${title}` : title;
}
const sigSelect = document.getElementById('ec-signature-select');
const groupsSelect = document.getElementById('ec-groups-select');
const agentId = App.currentAgentId || 0;
const defaultSigHtml = await loadSignatures(agentId, sigSelect);
await loadAddressGroups(agentId, groupsSelect);
if (draft) {
sigSelect.value = draft.signature || '';
document.getElementById('ec-helpdesk-cc-select').value = draft.helpdeskCc || '0';
}
groupsSelect.addEventListener('change', () => {
const selectedOpt = groupsSelect.options[groupsSelect.selectedIndex];
if (!selectedOpt || !selectedOpt.value) return;
const emailsStr = selectedOpt.dataset.emails || '';
const emails = emailsStr.split(',').map(e => e.trim()).filter(Boolean);
if (lastFocusedCtrl) {
emails.forEach(email => {
lastFocusedCtrl.addTag(email);
});
}
groupsSelect.value = '';
});
// Quill editor
quillEditor = new Quill('#ec-quill-editor', {
@@ -357,6 +479,13 @@ const EmailCompose = (() => {
// Insert initial body and signature
let initialHtml = '';
if (draft) {
if (draft.body) {
initialHtml = draft.body;
}
attachmentsList = draft.attachments || [];
renderFileList();
} else {
if (options.initialBodyHtml) {
initialHtml += options.initialBodyHtml;
} else {
@@ -365,6 +494,7 @@ const EmailCompose = (() => {
if (defaultSigHtml) {
initialHtml += '<!-- sig -->' + defaultSigHtml;
}
}
quillEditor.clipboard.dangerouslyPasteHTML(initialHtml);
quillEditor.setSelection(0, 0);
@@ -401,10 +531,18 @@ const EmailCompose = (() => {
// Close handlers
document.getElementById('ec-close').addEventListener('click', close);
document.getElementById('ec-cancel').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
saveDraft();
const ov = document.getElementById('email-compose-overlay');
if (ov) ov.remove();
if (quillEditor) { quillEditor = null; }
attachmentsList = [];
}
});
// Send
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl));
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl));
}
function processFiles(files) {
@@ -420,9 +558,10 @@ const EmailCompose = (() => {
}
// ── Send ─────────────────────────────────────────────────────────────────────
async function sendEmail(toCtrl, ccCtrl) {
async function sendEmail(toCtrl, ccCtrl, bccCtrl) {
const to = toCtrl.getTags();
const cc = ccCtrl.getTags();
const bcc = bccCtrl.getTags();
const subject = document.getElementById('ec-subject').value.trim();
const bodyHtml = quillEditor ? quillEditor.root.innerHTML : '';
@@ -437,10 +576,12 @@ const EmailCompose = (() => {
const agentId = App.currentAgentId || 0;
const payload = {
ticketId: currentOptions.ticketId,
to, cc, subject, bodyHtml,
to, cc, bcc, subject, bodyHtml,
attachments: attachmentsList,
agentId,
keepHelpdeskCopy: document.getElementById('ec-helpdesk-cc-select').value === '1',
inReplyTo: currentOptions.inReplyTo,
references: currentOptions.references,
};
const res = await App.api('/api/email/send', {
@@ -449,6 +590,7 @@ const EmailCompose = (() => {
});
Toast.success(`Email inviata a ${to.join(', ')}`);
App.clearDraft(currentOptions.ticketId, 'email');
close();
} catch (err) {
Toast.error('Errore invio email: ' + err.message);
@@ -459,12 +601,57 @@ const EmailCompose = (() => {
// ── Close ─────────────────────────────────────────────────────────────────────
function close() {
if (currentOptions.ticketId) {
App.clearDraft(currentOptions.ticketId, 'email');
}
const btn = document.getElementById('btn-open-email-compose');
if (btn) {
const svg = btn.querySelector('svg');
btn.innerHTML = '';
if (svg) btn.appendChild(svg);
btn.appendChild(document.createTextNode(' Invia Email'));
}
const overlay = document.getElementById('email-compose-overlay');
if (overlay) overlay.remove();
if (quillEditor) { quillEditor = null; }
attachmentsList = [];
}
return { open, close };
function saveDraft() {
const overlay = document.getElementById('email-compose-overlay');
if (!overlay || !currentOptions.ticketId) return;
const to = toTagsCtrl ? toTagsCtrl.getTags() : [];
const cc = ccTagsCtrl ? ccTagsCtrl.getTags() : [];
const bcc = bccTagsCtrl ? bccTagsCtrl.getTags() : [];
const subject = document.getElementById('ec-subject') ? document.getElementById('ec-subject').value.trim() : '';
const body = quillEditor ? quillEditor.root.innerHTML.trim() : '';
const signature = document.getElementById('ec-signature-select') ? document.getElementById('ec-signature-select').value : '';
const helpdeskCc = document.getElementById('ec-helpdesk-cc-select') ? document.getElementById('ec-helpdesk-cc-select').value : '0';
App.saveDraft(currentOptions.ticketId, {
type: 'email',
to,
cc,
bcc,
subject,
body,
signature,
helpdeskCc,
attachments: [...attachmentsList],
options: currentOptions
});
const btn = document.getElementById('btn-open-email-compose');
if (btn) {
const svg = btn.querySelector('svg');
btn.innerHTML = '';
if (svg) btn.appendChild(svg);
btn.appendChild(document.createTextNode(' Continua mail'));
}
App.renderTabs();
}
return { open, close, saveDraft };
})();
window.EmailCompose = EmailCompose;
+483
View File
@@ -0,0 +1,483 @@
/**
* mailManagement.js
* Pagina di gestione mail: include la gestione dei Gruppi di Indirizzi
* e la gestione delle firme email per l'agente attivo.
*/
const MailManagementView = {
agentId: null,
signatures: [],
groups: [],
editingSigId: null,
editingGroupId: null,
signatureQuill: null,
async render() {
this.agentId = App.currentAgentId || 0;
const container = document.getElementById('view-container');
// Inject styles for tooltip and layout
this.injectStyles();
container.innerHTML = `
<div style="max-width: 820px; margin: 0 auto; padding: var(--space-xl) var(--space-lg); display: flex; flex-direction: column; gap: var(--space-xl);">
<!-- SECTION 1: Address Groups -->
<div class="card" style="padding: var(--space-lg);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md);">
<div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--text-primary); display:flex; align-items:center; gap:8px;">👥 Gruppi di Indirizzi</h2>
<p style="margin:4px 0 0; font-size:0.82rem; color:var(--text-muted);">Crea gruppi di contatti da inserire rapidamente nei campi A, CC o BCC.</p>
</div>
<button class="btn btn-primary btn-sm" id="btn-new-group">
+ Nuovo Gruppo
</button>
</div>
<div id="groups-list" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--space-md);"></div>
</div>
<!-- SECTION 2: Signatures -->
<div class="card" style="padding: var(--space-lg);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md);">
<div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--text-primary); display:flex; align-items:center; gap:8px;">✉️ Le Mie Firme Email</h2>
<p style="margin:4px 0 0; font-size:0.82rem; color:var(--text-muted);">Gestisci le firme da allegare automaticamente alle tue email.</p>
</div>
<button class="btn btn-primary btn-sm" id="btn-new-signature">
+ Nuova Firma
</button>
</div>
<div id="signatures-list" style="display:flex; flex-direction:column; gap:var(--space-md);"></div>
</div>
<!-- MODAL: Signature Editor -->
<div id="signature-editor-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;">
<div style="background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-xl); box-shadow:0 24px 80px rgba(0,0,0,0.3); width:min(720px,94vw); max-height:90vh; display:flex; flex-direction:column;">
<div style="padding:16px 20px 12px; border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
<div style="font-size:1rem; font-weight:600; color:var(--text-primary);" id="sig-modal-title">Nuova Firma</div>
<button id="sig-modal-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1.2rem;">✕</button>
</div>
<div style="padding:16px 20px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
<div>
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Nome Firma</label>
<input type="text" id="sig-name" class="form-input" placeholder="es. Firma Professionale" style="margin-bottom:0; margin-top:4px;" />
</div>
<div>
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Contenuto</label>
<div style="margin-top:4px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); overflow:hidden; background:var(--bg-tertiary);">
<div id="sig-quill-editor" style="min-height:200px;"></div>
</div>
</div>
<label style="display:flex; align-items:center; gap:8px; font-size:0.88rem; color:var(--text-secondary); cursor:pointer;">
<input type="checkbox" id="sig-is-default" />
Imposta come firma predefinita
</label>
</div>
<div style="padding:12px 20px; border-top:1px solid var(--border-subtle); display:flex; justify-content:flex-end; gap:10px; flex-shrink:0;">
<button class="btn btn-ghost btn-sm" id="sig-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="sig-save">Salva Firma</button>
</div>
</div>
</div>
<!-- MODAL: Address Group Editor -->
<div id="group-editor-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;">
<div style="background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-xl); box-shadow:0 24px 80px rgba(0,0,0,0.3); width:min(520px,94vw); max-height:90vh; display:flex; flex-direction:column;">
<div style="padding:16px 20px 12px; border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
<div style="font-size:1rem; font-weight:600; color:var(--text-primary);" id="group-modal-title">Nuovo Gruppo</div>
<button id="group-modal-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1.2rem;">✕</button>
</div>
<div style="padding:16px 20px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
<div style="display: flex; flex-direction: column; gap: 4px;">
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Nome Gruppo</label>
<input type="text" id="group-name" class="form-input" placeholder="es. Sviluppo Interno" style="width: 100%; box-sizing: border-box; display: block; margin-bottom:0;" />
</div>
<div style="display: flex; flex-direction: column; gap: 4px;">
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Indirizzi Email (separati da virgola)</label>
<textarea id="group-emails" class="form-input" rows="4" placeholder="es. user1@test.com, user2@test.com" style="width: 100%; box-sizing: border-box; display: block; margin-bottom:0; resize:vertical; font-family:monospace; font-size:0.85rem; padding:8px 12px;"></textarea>
</div>
</div>
<div style="padding:12px 20px; border-top:1px solid var(--border-subtle); display:flex; justify-content:flex-end; gap:10px; flex-shrink:0;">
<button class="btn btn-ghost btn-sm" id="group-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="group-save">Salva Gruppo</button>
</div>
</div>
</div>
</div>
`;
// Signatures events
document.getElementById('btn-new-signature').addEventListener('click', () => this.openSigEditor(null));
// Address Groups events
document.getElementById('btn-new-group').addEventListener('click', () => this.openGroupEditor(null));
// Load data
await Promise.all([
this.loadSignatures(),
this.loadGroups()
]);
},
injectStyles() {
if (document.getElementById('mail-management-styles')) return;
const style = document.createElement('style');
style.id = 'mail-management-styles';
style.textContent = `
.g-card {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-md);
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
transition: transform 0.2s, box-shadow 0.2s;
}
.g-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
border-color: var(--accent-primary);
}
.g-tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: 105%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-tertiary, #2c2c3e);
border: 1px solid var(--border-light, #444);
color: var(--text-primary, #fff);
padding: 8px 12px;
border-radius: 6px;
font-size: 0.78rem;
z-index: 100;
white-space: pre-line;
box-shadow: var(--shadow-lg);
pointer-events: none;
transition: opacity 0.2s, visibility 0.2s;
max-width: 280px;
width: max-content;
}
.g-card:hover .g-tooltip {
visibility: visible;
opacity: 1;
}
`;
document.head.appendChild(style);
},
// ─── SIGNATURES LOGIC ────────────────────────────────────────────────────────
async loadSignatures() {
try {
this.signatures = await App.api(`/api/email/signatures?agent_id=${this.agentId}`);
this.renderSignatures();
} catch (err) {
Toast.error('Errore caricamento firme: ' + err.message);
}
},
renderSignatures() {
const list = document.getElementById('signatures-list');
if (!list) return;
if (!this.signatures.length) {
list.innerHTML = `
<div class="empty-state" style="padding:var(--space-md); border:1px dashed var(--border-subtle); border-radius:var(--radius-md);">
<div class="empty-state-icon" style="font-size:1.5rem;">✉️</div>
<div class="empty-state-text" style="font-size:0.9rem;">Nessuna firma configurata</div>
</div>
`;
return;
}
list.innerHTML = this.signatures.map(sig => `
<div class="card" style="padding: var(--space-md); background: var(--bg-secondary); border-color: var(--border-subtle);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:${sig.body_html ? 'var(--space-sm)' : '0'};">
<div>
<div style="font-weight:600; font-size:0.9rem; color:var(--text-primary); display:flex; align-items:center; gap:8px;">
${App.escapeHtml(sig.name)}
${sig.is_default ? '<span style="font-size:0.68rem; background:var(--accent-primary); color:#fff; padding:2px 7px; border-radius:20px;">Predefinita</span>' : ''}
</div>
</div>
<div style="display:flex; gap:6px; flex-shrink:0;">
${!sig.is_default ? `<button class="btn btn-ghost btn-sm sig-btn-default" data-id="${sig.id}" style="height:26px; font-size:0.75rem; padding:0 8px;">★ Predefinita</button>` : ''}
<button class="btn btn-ghost btn-sm sig-btn-edit" data-id="${sig.id}" style="height:26px; font-size:0.75rem; padding:0 8px;">✏️ Modifica</button>
<button class="btn btn-ghost btn-sm sig-btn-delete" data-id="${sig.id}" style="height:26px; font-size:0.75rem; padding:0 8px; color:var(--error);">🗑️</button>
</div>
</div>
${sig.body_html ? `
<div style="border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:8px 12px; background:var(--bg-tertiary); max-height:80px; overflow:hidden; position:relative;">
<div style="font-size:0.8rem; color:var(--text-secondary);">${sig.body_html}</div>
<div style="position:absolute;bottom:0;left:0;right:0;height:24px;background:linear-gradient(transparent,var(--bg-tertiary));"></div>
</div>
` : ''}
</div>
`).join('');
// Bind buttons
list.querySelectorAll('.sig-btn-edit').forEach(btn => {
btn.addEventListener('click', () => this.openSigEditor(parseInt(btn.dataset.id, 10)));
});
list.querySelectorAll('.sig-btn-delete').forEach(btn => {
btn.addEventListener('click', () => this.deleteSig(parseInt(btn.dataset.id, 10)));
});
list.querySelectorAll('.sig-btn-default').forEach(btn => {
btn.addEventListener('click', () => this.setDefaultSig(parseInt(btn.dataset.id, 10)));
});
},
openSigEditor(id) {
this.editingSigId = id;
const sig = id ? this.signatures.find(s => s.id === id) : null;
const modal = document.getElementById('signature-editor-modal');
modal.style.display = 'flex';
document.getElementById('sig-modal-title').textContent = id ? 'Modifica Firma' : 'Nuova Firma';
document.getElementById('sig-name').value = sig ? sig.name : '';
document.getElementById('sig-is-default').checked = sig ? !!sig.is_default : false;
// Init or reset Quill
if (this.signatureQuill) {
this.signatureQuill.root.innerHTML = sig ? (sig.body_html || '') : '';
} else {
this.signatureQuill = new Quill('#sig-quill-editor', {
theme: 'snow',
placeholder: 'Inserisci la tua firma...',
modules: {
toolbar: [
['bold', 'italic', 'underline'],
[{ 'color': [] }],
['link', 'image'],
['clean']
]
}
});
if (sig && sig.body_html) {
this.signatureQuill.clipboard.dangerouslyPasteHTML(sig.body_html);
}
}
document.getElementById('sig-modal-close').onclick = () => this.closeSigEditor();
document.getElementById('sig-cancel').onclick = () => this.closeSigEditor();
document.getElementById('sig-save').onclick = () => this.saveSig();
modal.onclick = (e) => { if (e.target === modal) this.closeSigEditor(); };
},
closeSigEditor() {
const modal = document.getElementById('signature-editor-modal');
if (modal) modal.style.display = 'none';
this.editingSigId = null;
},
async saveSig() {
const name = document.getElementById('sig-name').value.trim();
const body_html = this.signatureQuill ? this.signatureQuill.root.innerHTML : '';
const is_default = document.getElementById('sig-is-default').checked ? 1 : 0;
if (!name) { Toast.warning('Inserisci un nome per la firma'); return; }
const saveBtn = document.getElementById('sig-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Salvataggio...';
try {
const payload = { agent_id: this.agentId, name, body_html, is_default };
if (this.editingSigId) {
await App.api(`/api/email/signatures/${this.editingSigId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
Toast.success('Firma aggiornata');
} else {
await App.api('/api/email/signatures', {
method: 'POST',
body: JSON.stringify(payload),
});
Toast.success('Firma creata');
}
this.closeSigEditor();
await this.loadSignatures();
} catch (err) {
Toast.error('Errore salvataggio: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Salva Firma';
}
},
async deleteSig(id) {
const ok = await App.confirm('Elimina Firma', 'Sei sicuro di voler eliminare questa firma?');
if (!ok) return;
try {
await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' });
Toast.success('Firma eliminata');
await this.loadSignatures();
} catch (err) {
Toast.error('Errore eliminazione: ' + err.message);
}
},
async setDefaultSig(id) {
try {
await App.api(`/api/email/signatures/${id}/default`, {
method: 'PATCH',
body: JSON.stringify({ agent_id: this.agentId }),
});
Toast.success('Firma impostata come predefinita');
await this.loadSignatures();
} catch (err) {
Toast.error('Errore: ' + err.message);
}
},
// ─── ADDRESS GROUPS LOGIC ──────────────────────────────────────────────────
async loadGroups() {
try {
this.groups = await App.api(`/api/email/address-groups?agent_id=${this.agentId}`);
this.renderGroups();
} catch (err) {
Toast.error('Errore caricamento gruppi: ' + err.message);
}
},
renderGroups() {
const list = document.getElementById('groups-list');
if (!list) return;
if (!this.groups.length) {
list.innerHTML = `
<div class="empty-state" style="padding:var(--space-md); border:1px dashed var(--border-subtle); border-radius:var(--radius-md); grid-column: 1 / -1;">
<div class="empty-state-icon" style="font-size:1.5rem;">👥</div>
<div class="empty-state-text" style="font-size:0.9rem;">Nessun gruppo configurato</div>
</div>
`;
return;
}
list.innerHTML = this.groups.map(g => {
const emailList = g.emails.split(',').map(e => e.trim()).filter(Boolean);
const tooltipText = emailList.length ? emailList.join('\n') : '(nessun indirizzo)';
const count = emailList.length;
return `
<div class="g-card">
<div class="g-tooltip"><strong>Contatti (${count}):</strong>\n${App.escapeHtml(tooltipText)}</div>
<div>
<div style="font-weight:600; font-size:0.9rem; color:var(--text-primary);">${App.escapeHtml(g.name)}</div>
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">
${count} indirizz${count === 1 ? 'o' : 'i'} email
</div>
</div>
<div style="display:flex; gap:6px; justify-content: flex-end; margin-top:4px;">
<button class="btn btn-ghost btn-sm group-btn-edit" data-id="${g.id}" style="height:24px; font-size:0.72rem; padding:0 6px;">✏️</button>
<button class="btn btn-ghost btn-sm group-btn-delete" data-id="${g.id}" style="height:24px; font-size:0.72rem; padding:0 6px; color:var(--error);">🗑️</button>
</div>
</div>
`;
}).join('');
// Bind group buttons
list.querySelectorAll('.group-btn-edit').forEach(btn => {
btn.addEventListener('click', () => this.openGroupEditor(parseInt(btn.dataset.id, 10)));
});
list.querySelectorAll('.group-btn-delete').forEach(btn => {
btn.addEventListener('click', () => this.deleteGroup(parseInt(btn.dataset.id, 10)));
});
},
openGroupEditor(id) {
this.editingGroupId = id;
const group = id ? this.groups.find(g => g.id === id) : null;
const modal = document.getElementById('group-editor-modal');
modal.style.display = 'flex';
document.getElementById('group-modal-title').textContent = id ? 'Modifica Gruppo' : 'Nuovo Gruppo';
document.getElementById('group-name').value = group ? group.name : '';
document.getElementById('group-emails').value = group ? group.emails : '';
document.getElementById('group-modal-close').onclick = () => this.closeGroupEditor();
document.getElementById('group-cancel').onclick = () => this.closeGroupEditor();
document.getElementById('group-save').onclick = () => this.saveGroup();
modal.onclick = (e) => { if (e.target === modal) this.closeGroupEditor(); };
},
closeGroupEditor() {
const modal = document.getElementById('group-editor-modal');
if (modal) modal.style.display = 'none';
this.editingGroupId = null;
},
async saveGroup() {
const name = document.getElementById('group-name').value.trim();
const emails = document.getElementById('group-emails').value.trim();
if (!name) { Toast.warning('Inserisci un nome per il gruppo'); return; }
if (!emails) { Toast.warning('Inserisci almeno un indirizzo email'); return; }
const parsedEmails = emails.split(',').map(e => e.trim()).filter(Boolean);
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const invalid = parsedEmails.filter(e => !emailRegex.test(e));
if (invalid.length > 0) {
Toast.warning('I seguenti indirizzi non sono validi: ' + invalid.join(', '));
return;
}
const saveBtn = document.getElementById('group-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Salvataggio...';
try {
const payload = { agent_id: this.agentId, name, emails: parsedEmails.join(', ') };
if (this.editingGroupId) {
await App.api(`/api/email/address-groups/${this.editingGroupId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
Toast.success('Gruppo aggiornato');
} else {
await App.api('/api/email/address-groups', {
method: 'POST',
body: JSON.stringify(payload),
});
Toast.success('Gruppo creato');
}
this.closeGroupEditor();
await this.loadGroups();
} catch (err) {
Toast.error('Errore salvataggio: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Salva Gruppo';
}
},
async deleteGroup(id) {
const ok = await App.confirm('Elimina Gruppo', 'Sei sicuro di voler eliminare questo gruppo di indirizzi?');
if (!ok) return;
try {
await App.api(`/api/email/address-groups/${id}`, { method: 'DELETE' });
Toast.success('Gruppo eliminato');
await this.loadGroups();
} catch (err) {
Toast.error('Errore eliminazione: ' + err.message);
}
}
};
window.MailManagementView = MailManagementView;
-238
View File
@@ -1,238 +0,0 @@
/**
* signatures.js
* Pagina di gestione delle firme email per agente.
* Usa Quill.js come editor WYSIWYG.
*/
const SignaturesView = {
agentId: null,
signatures: [],
editingId: null,
signatureQuill: null,
async render() {
this.agentId = App.currentAgentId || 0;
const container = document.getElementById('view-container');
container.innerHTML = `
<div style="max-width: 820px; margin: 0 auto; padding: var(--space-xl) var(--space-lg);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-xl);">
<div>
<h2 style="margin:0; font-size:1.25rem; font-weight:700; color:var(--text-primary);">✉️ Le Mie Firme Email</h2>
<p style="margin:4px 0 0; font-size:0.85rem; color:var(--text-muted);">Gestisci le firme da allegare automaticamente alle email inviate dal ticket.</p>
</div>
<button class="btn btn-primary btn-sm" id="btn-new-signature">
+ Nuova Firma
</button>
</div>
<!-- Lista firme -->
<div id="signatures-list" style="display:flex; flex-direction:column; gap:var(--space-md);"></div>
<!-- Modal Editor -->
<div id="signature-editor-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;">
<div style="background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-xl); box-shadow:0 24px 80px rgba(0,0,0,0.3); width:min(720px,94vw); max-height:90vh; display:flex; flex-direction:column;">
<div style="padding:16px 20px 12px; border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
<div style="font-size:1rem; font-weight:600; color:var(--text-primary);" id="sig-modal-title">Nuova Firma</div>
<button id="sig-modal-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1.2rem;">✕</button>
</div>
<div style="padding:16px 20px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
<div>
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Nome Firma</label>
<input type="text" id="sig-name" class="form-input" placeholder="es. Firma Professionale" style="margin-bottom:0; margin-top:4px;" />
</div>
<div>
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Contenuto</label>
<div style="margin-top:4px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); overflow:hidden; background:var(--bg-tertiary);">
<div id="sig-quill-editor" style="min-height:200px;"></div>
</div>
</div>
<label style="display:flex; align-items:center; gap:8px; font-size:0.88rem; color:var(--text-secondary); cursor:pointer;">
<input type="checkbox" id="sig-is-default" />
Imposta come firma predefinita
</label>
</div>
<div style="padding:12px 20px; border-top:1px solid var(--border-subtle); display:flex; justify-content:flex-end; gap:10px; flex-shrink:0;">
<button class="btn btn-ghost btn-sm" id="sig-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="sig-save">Salva Firma</button>
</div>
</div>
</div>
</div>
`;
document.getElementById('btn-new-signature').addEventListener('click', () => this.openEditor(null));
await this.loadSignatures();
},
async loadSignatures() {
try {
this.signatures = await App.api(`/api/email/signatures?agent_id=${this.agentId}`);
this.renderList();
} catch (err) {
Toast.error('Errore caricamento firme: ' + err.message);
}
},
renderList() {
const list = document.getElementById('signatures-list');
if (!list) return;
if (!this.signatures.length) {
list.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">✉️</div>
<div class="empty-state-text">Nessuna firma configurata</div>
<div class="empty-state-sub">Crea la tua prima firma per velocizzare l'invio delle email.</div>
</div>
`;
return;
}
list.innerHTML = this.signatures.map(sig => `
<div class="card" style="padding: var(--space-lg);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:${sig.body_html ? 'var(--space-md)' : '0'};">
<div>
<div style="font-weight:600; font-size:0.95rem; color:var(--text-primary); display:flex; align-items:center; gap:8px;">
${App.escapeHtml(sig.name)}
${sig.is_default ? '<span style="font-size:0.72rem; background:var(--accent-primary); color:#fff; padding:2px 7px; border-radius:20px;">Predefinita</span>' : ''}
</div>
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:2px;">
Creata: ${new Date(sig.created_at).toLocaleDateString('it-IT')}
</div>
</div>
<div style="display:flex; gap:6px; flex-shrink:0;">
${!sig.is_default ? `<button class="btn btn-ghost btn-sm sig-btn-default" data-id="${sig.id}" style="height:30px; font-size:0.78rem;">★ Predefinita</button>` : ''}
<button class="btn btn-ghost btn-sm sig-btn-edit" data-id="${sig.id}" style="height:30px; font-size:0.78rem;">✏️ Modifica</button>
<button class="btn btn-ghost btn-sm sig-btn-delete" data-id="${sig.id}" style="height:30px; font-size:0.78rem; color:var(--error);">🗑️</button>
</div>
</div>
${sig.body_html ? `
<div style="border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:10px 14px; background:var(--bg-secondary); max-height:120px; overflow:hidden; position:relative;">
<div style="font-size:0.82rem; color:var(--text-secondary);">${sig.body_html}</div>
<div style="position:absolute;bottom:0;left:0;right:0;height:40px;background:linear-gradient(transparent,var(--bg-secondary));"></div>
</div>
` : ''}
</div>
`).join('');
// Bind buttons
list.querySelectorAll('.sig-btn-edit').forEach(btn => {
btn.addEventListener('click', () => this.openEditor(parseInt(btn.dataset.id, 10)));
});
list.querySelectorAll('.sig-btn-delete').forEach(btn => {
btn.addEventListener('click', () => this.deleteSig(parseInt(btn.dataset.id, 10)));
});
list.querySelectorAll('.sig-btn-default').forEach(btn => {
btn.addEventListener('click', () => this.setDefault(parseInt(btn.dataset.id, 10)));
});
},
openEditor(id) {
this.editingId = id;
const sig = id ? this.signatures.find(s => s.id === id) : null;
const modal = document.getElementById('signature-editor-modal');
modal.style.display = 'flex';
document.getElementById('sig-modal-title').textContent = id ? 'Modifica Firma' : 'Nuova Firma';
document.getElementById('sig-name').value = sig ? sig.name : '';
document.getElementById('sig-is-default').checked = sig ? !!sig.is_default : false;
// Init or reset Quill
if (this.signatureQuill) {
this.signatureQuill.root.innerHTML = sig ? (sig.body_html || '') : '';
} else {
this.signatureQuill = new Quill('#sig-quill-editor', {
theme: 'snow',
placeholder: 'Inserisci la tua firma...',
modules: {
toolbar: [
['bold', 'italic', 'underline'],
[{ 'color': [] }],
['link', 'image'],
['clean']
]
}
});
if (sig && sig.body_html) {
this.signatureQuill.clipboard.dangerouslyPasteHTML(sig.body_html);
}
}
document.getElementById('sig-modal-close').onclick = () => this.closeEditor();
document.getElementById('sig-cancel').onclick = () => this.closeEditor();
document.getElementById('sig-save').onclick = () => this.saveSig();
modal.onclick = (e) => { if (e.target === modal) this.closeEditor(); };
},
closeEditor() {
const modal = document.getElementById('signature-editor-modal');
if (modal) modal.style.display = 'none';
this.editingId = null;
},
async saveSig() {
const name = document.getElementById('sig-name').value.trim();
const body_html = this.signatureQuill ? this.signatureQuill.root.innerHTML : '';
const is_default = document.getElementById('sig-is-default').checked ? 1 : 0;
if (!name) { Toast.warning('Inserisci un nome per la firma'); return; }
const saveBtn = document.getElementById('sig-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Salvataggio...';
try {
const payload = { agent_id: this.agentId, name, body_html, is_default };
if (this.editingId) {
await App.api(`/api/email/signatures/${this.editingId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
Toast.success('Firma aggiornata');
} else {
await App.api('/api/email/signatures', {
method: 'POST',
body: JSON.stringify(payload),
});
Toast.success('Firma creata');
}
this.closeEditor();
await this.loadSignatures();
} catch (err) {
Toast.error('Errore salvataggio: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Salva Firma';
}
},
async deleteSig(id) {
if (!confirm('Eliminare questa firma?')) return;
try {
await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' });
Toast.success('Firma eliminata');
await this.loadSignatures();
} catch (err) {
Toast.error('Errore eliminazione: ' + err.message);
}
},
async setDefault(id) {
try {
await App.api(`/api/email/signatures/${id}/default`, {
method: 'PATCH',
body: JSON.stringify({ agent_id: this.agentId }),
});
Toast.success('Firma impostata come predefinita');
await this.loadSignatures();
} catch (err) {
Toast.error('Errore: ' + err.message);
}
}
};
window.SignaturesView = SignaturesView;
+9 -41
View File
@@ -135,7 +135,7 @@ const TicketCreateView = {
<label class="form-label">Coda <span class="required">*</span></label>
<input type="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" />
<input type="hidden" id="create-queue" />
<div id="queue-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
<div id="queue-suggestions" class="autocomplete-suggestions" style="display:none; width: 450px; max-width: 600px; z-index: 1005;"></div>
</div>
<div class="form-group">
@@ -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);
}
@@ -567,11 +532,14 @@ const TicketCreateView = {
return;
}
queueSuggestionsDiv.innerHTML = queues.map(q => `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}">
<strong>${App.escapeHtml(q.name)}</strong>
queueSuggestionsDiv.innerHTML = queues.map(q => {
const displayName = q.name.replace(/::/g, ' ');
return `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}" style="padding: 6px 12px; font-size: 0.78rem; line-height: 1.25;">
${App.escapeHtml(displayName)}
</div>
`).join('');
`;
}).join('');
queueSuggestionsDiv.style.display = 'block';
// Bind click
+437 -33
View File
@@ -8,6 +8,25 @@ const TicketDetailView = {
originalValues: {},
noteAttachments: [],
saveDraft() {
if (!this.ticketId) return;
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
const time_unit = document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '';
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0) {
App.saveDraft(this.ticketId, {
type: 'note',
body,
subject,
time_unit,
attachments: [...this.noteAttachments]
});
} else {
App.clearDraft(this.ticketId, 'note');
}
},
updateNoteAttachmentList() {
const listEl = document.getElementById('note-file-list');
if (!listEl) return;
@@ -30,6 +49,41 @@ const TicketDetailView = {
});
},
openImageLightbox(src) {
let overlay = document.getElementById('image-lightbox-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'image-lightbox-overlay';
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
opacity: 0;
transition: opacity 0.2s ease;
`;
overlay.innerHTML = `
<img id="image-lightbox-img" style="max-width: 90vw; max-height: 90vh; border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); cursor: default; transition: transform 0.2s ease;" />
`;
overlay.addEventListener('click', () => {
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 200);
});
overlay.querySelector('img').addEventListener('click', (e) => {
e.stopPropagation();
});
document.body.appendChild(overlay);
}
const imgEl = overlay.querySelector('img');
imgEl.src = src;
overlay.offsetHeight;
overlay.style.opacity = '1';
},
async render(id) {
this.ticketId = id;
this.noteAttachments = [];
@@ -41,6 +95,14 @@ const TicketDetailView = {
await App.ensureLookups();
const data = await App.api(`/api/tickets/${id}`);
const { ticket, articles, attachments } = data;
// No automatic tab creation here anymore
let groupsData = { asMaster: [], asMember: [] };
try {
groupsData = await App.api(`/api/groups/by-ticket/${id}`);
} catch (gErr) {
console.warn('Failed to load group associations for ticket', gErr);
}
const localISO = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
@@ -66,7 +128,9 @@ const TicketDetailView = {
ticket_state_id: ticket.ticket_state_id,
ticket_priority_id: ticket.ticket_priority_id,
queue_id: ticket.queue_id,
queue_name: ticket.queue_name,
user_id: ticket.user_id,
responsible_user_id: ticket.responsible_user_id,
type_id: ticket.type_id,
customer_id: ticket.customer_id,
customer_user_id: ticket.customer_user_id,
@@ -74,8 +138,11 @@ const TicketDetailView = {
customer_last: ticket.customer_last,
};
const hasEmailDraft = App.getDraft(id, 'email');
const emailBtnText = hasEmailDraft ? 'Continua mail' : 'Invia Email';
container.innerHTML = `
<a class="back-link" onclick="history.back()">
<a class="back-link" onclick="window.location.hash = App.lastListView || '#/tickets'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
@@ -86,13 +153,15 @@ const TicketDetailView = {
<div class="card">
<div class="ticket-header">
<div class="ticket-header-info">
<div class="ticket-number">#${ticket.tn}</div>
<h2 class="ticket-detail-title">
${data.otrsWebUrl ? `
<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; border-bottom:1px dashed transparent; transition:border-bottom 0.1s ease;" onmouseover="this.style.borderBottom='1px dashed var(--text-primary)'" onmouseout="this.style.borderBottom='transparent'" title="Apri in OTRS">
<div class="ticket-number" style="display:inline-flex; align-items:center; gap:6px;">
<span class="copy-ticket-btn" data-tn="${ticket.tn}" style="cursor: pointer; font-size: 0.85rem;" title="Copia numero ticket">📋</span>
#${ticket.tn}
-
</div>
<h2 class="ticket-detail-title" style="display:inline-flex; align-items:center; gap:6px; flex-wrap:wrap;">
<span class="copy-ticket-btn" data-tn="${App.escapeHtml(ticket.title || '')}" style="cursor: pointer; font-size: 0.85rem;" title="Copia titolo ticket">📋</span>
${App.escapeHtml(ticket.title || '(senza titolo)')}
</a>
` : App.escapeHtml(ticket.title || '(senza titolo)')}
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
</h2>
<div class="ticket-meta-badges">
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
@@ -124,19 +193,26 @@ const TicketDetailView = {
).join('')}
</select>
</div>
<div class="quick-edit-field">
<div class="quick-edit-field" style="position:relative;">
<label class="quick-edit-label">Coda</label>
<select class="quick-edit-select" id="qe-queue" data-field="queue_id">
${(App.lookups.queues || []).map(q =>
`<option value="${q.id}" ${q.id === ticket.queue_id ? 'selected' : ''}>${q.name}</option>`
).join('')}
</select>
<input type="text" class="quick-edit-select" id="qe-queue-search" placeholder="Cerca coda..." autocomplete="off" value="${App.escapeHtml(ticket.queue_name || '')}" style="background-image: none; cursor: text;" />
<input type="hidden" id="qe-queue" data-field="queue_id" value="${ticket.queue_id || ''}" />
<div id="qe-queue-suggestions" class="autocomplete-suggestions" style="display:none; width: 800px; max-width: 800px; z-index: 1005;"></div>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Owner</label>
<select class="quick-edit-select" id="qe-owner" data-field="user_id">
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Responsabile</label>
<select class="quick-edit-select" id="qe-responsible" data-field="responsible_user_id">
<option value="">—</option>
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.responsible_user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
).join('')}
</select>
</div>
@@ -187,11 +263,12 @@ 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 || '')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
Invia Email
${emailBtnText}
</button>
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
@@ -213,11 +290,33 @@ const TicketDetailView = {
<div class="articles-timeline">
${articles.length > 0 ? articles.map(a => {
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
let processedBody = a.a_body || '';
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
if (hasHtml) {
articleAttachments.forEach(att => {
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
processedBody = processedBody.replace(regex, `/api/tickets/attachments/${att.id}`);
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
processedBody = processedBody.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
}
});
}
const displayBody = this.htmlMode && hasHtml
? `<iframe srcdoc="${a.a_body.replace(/"/g, '&quot;')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
? `<iframe srcdoc="${processedBody.replace(/"/g, '&quot;')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1' && att.filename !== 'file-2');
const visibleAttachments = articleAttachments.filter(att => att.filename !== 'file-1' && att.filename !== 'file-2');
return `
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
@@ -262,10 +361,10 @@ const TicketDetailView = {
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
${displayBody}
${articleAttachments.length > 0 ? `
${visibleAttachments.length > 0 ? `
<div class="article-attachments">
${articleAttachments.map(att => `
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}">
${visibleAttachments.map(att => `
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}" data-is-image="${(att.content_type || '').startsWith('image/')}">
<span>📎</span>
<strong>${App.escapeHtml(att.filename)}</strong>
<span class="attachment-size">(${Math.round(att.content_size / 1024)} KB)</span>
@@ -327,12 +426,14 @@ const TicketDetailView = {
<span class="meta-value">${ticket.type_name}</span>
</div>
` : ''}
${ticket.responsible_first ? `
<div class="meta-row">
<span class="meta-label">Owner</span>
<span class="meta-value">${ticket.owner_first ? `${ticket.owner_first} ${ticket.owner_last}` : (ticket.owner_login || '—')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Responsabile</span>
<span class="meta-value">${ticket.responsible_first} ${ticket.responsible_last}</span>
<span class="meta-value">${ticket.responsible_first ? `${ticket.responsible_first} ${ticket.responsible_last}` : '—'}</span>
</div>
` : ''}
${totalTime > 0 ? `
<div class="meta-row">
<span class="meta-label">Tempo Totale</span>
@@ -380,6 +481,38 @@ const TicketDetailView = {
</div>
</div>
` : ''}
<!-- Panel per i Gruppi Ticket -->
<div class="sidebar-panel" id="ticket-groups-panel">
<div class="sidebar-panel-title">Gruppi Ticket</div>
<div id="ticket-groups-list" style="margin-bottom: var(--space-sm);">
${groupsData.asMaster.length === 0 && groupsData.asMember.length === 0 ? `
<div style="font-size:0.78rem; color:var(--text-muted); padding:4px 0;">Nessun gruppo associato</div>
` : ''}
${groupsData.asMaster.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--accent-primary); font-weight:bold;">👑 Master in:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
${groupsData.asMember.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--text-secondary); font-weight:bold;">🔗 Membro di:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
</div>
<div style="border-top:1px solid var(--border-subtle); padding-top:var(--space-xs); margin-top:var(--space-xs);">
<label class="quick-edit-label">Associa a Gruppo</label>
<div style="display:flex; gap:4px; margin-top:4px;">
<select class="form-select" id="group-association-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; flex:1;">
<option value="">-- Seleziona --</option>
</select>
<button class="btn btn-primary btn-sm" id="btn-associate-to-group" style="height:28px; padding:0 8px; font-size:0.75rem;">+ Aggiungi</button>
</div>
</div>
</div>
</div>
</div>
`;
@@ -419,7 +552,23 @@ const TicketDetailView = {
this.noteQuill = null;
}
this.bindEvents(ticket, articles, container);
// Restore Note Draft
const noteDraft = App.getDraft(id, 'note');
if (noteDraft) {
if (this.noteQuill && noteDraft.body) {
this.noteQuill.root.innerHTML = noteDraft.body;
}
if (document.getElementById('note-subject')) {
document.getElementById('note-subject').value = noteDraft.subject || '';
}
if (document.getElementById('note-time-units')) {
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
}
this.noteAttachments = noteDraft.attachments || [];
this.updateNoteAttachmentList();
}
this.bindEvents(ticket, articles, container, groupsData, attachments);
} catch (err) {
container.innerHTML = `
@@ -433,9 +582,9 @@ const TicketDetailView = {
}
},
bindEvents(ticket, articles, container) {
bindEvents(ticket, articles, container, groupsData, attachments) {
// Quick-edit change detection
const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id');
const fields = document.querySelectorAll('.quick-edit-select:not(#qe-queue-search), #qe-queue, #qe-customer-user-id, #qe-customer-id');
const saveBtn = document.getElementById('qe-save');
const resetBtn = document.getElementById('qe-reset');
const timeUnitInput = document.getElementById('qe-time-unit');
@@ -447,7 +596,12 @@ const TicketDetailView = {
const original = String(this.originalValues[field] || '');
const current = el.value;
const changed = current !== original;
if (el.id === 'qe-queue') {
const searchInput = document.getElementById('qe-queue-search');
if (searchInput) searchInput.classList.toggle('changed', changed);
} else {
el.classList.toggle('changed', changed);
}
if (changed) hasChanges = true;
});
@@ -465,6 +619,68 @@ const TicketDetailView = {
timeUnitInput.addEventListener('change', checkChanges);
}
// Queue Autocomplete inside Quick Edit
const queueSearchInput = document.getElementById('qe-queue-search');
const queueSuggestionsDiv = document.getElementById('qe-queue-suggestions');
const queueIdInput = document.getElementById('qe-queue');
let queueDebounce;
if (queueSearchInput) {
queueSearchInput.addEventListener('input', () => {
clearTimeout(queueDebounce);
const q = queueSearchInput.value.trim();
queueDebounce = setTimeout(async () => {
try {
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
if (queueSearchInput.value.trim() !== q) {
return;
}
if (queues.length === 0) {
queueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
queueSuggestionsDiv.style.display = 'block';
return;
}
queueSuggestionsDiv.innerHTML = queues.map(q => {
const displayName = q.name.replace(/::/g, ' ');
return `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}" style="padding: 6px 12px; font-size: 0.78rem; line-height: 1.25;">
${App.escapeHtml(displayName)}
</div>
`;
}).join('');
queueSuggestionsDiv.style.display = 'block';
// Bind click/mousedown
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
item.addEventListener('mousedown', (e) => {
e.preventDefault();
queueSearchInput.value = item.dataset.name;
queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
queueIdInput.dispatchEvent(new Event('change'));
});
}
});
} catch (err) {
console.error(err);
}
}, 150);
});
queueSearchInput.addEventListener('focus', () => {
queueSearchInput.value = '';
queueIdInput.value = '';
queueSearchInput.dispatchEvent(new Event('input'));
});
queueSearchInput.addEventListener('blur', () => {
setTimeout(() => { queueSuggestionsDiv.style.display = 'none'; }, 150);
});
}
// Customer User Autocomplete inside Quick Edit
const customerSearchInput = document.getElementById('qe-customer-search');
const customerSuggestionsDiv = document.getElementById('qe-customer-suggestions');
@@ -535,6 +751,10 @@ const TicketDetailView = {
el.value = this.originalValues[el.dataset.field] || '';
el.classList.remove('changed');
});
if (queueSearchInput) {
queueSearchInput.classList.remove('changed');
queueSearchInput.value = this.originalValues['queue_name'] || '';
}
if (customerSearchInput) {
const first = this.originalValues['customer_first'] || '';
const last = this.originalValues['customer_last'] || '';
@@ -548,6 +768,12 @@ const TicketDetailView = {
// Save quick-edit
saveBtn.addEventListener('click', async () => {
const queueSearch = queueSearchInput ? queueSearchInput.value.trim() : '';
if (queueSearchInput && !queueIdInput.value) {
Toast.warning('Seleziona una coda valida dall\'elenco.');
return;
}
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
if (customerSearch && !customerUserIdInput.value) {
customerUserIdInput.value = customerSearch;
@@ -605,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) {
@@ -644,7 +871,23 @@ 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);
} catch (err) {
@@ -685,12 +928,21 @@ const TicketDetailView = {
if (btnEmailCompose) {
btnEmailCompose.addEventListener('click', () => {
if (window.EmailCompose) {
const tId = parseInt(btnEmailCompose.dataset.ticketId, 10);
const emailDraft = App.getDraft(tId, 'email');
if (emailDraft) {
EmailCompose.open({
ticketId: parseInt(btnEmailCompose.dataset.ticketId, 10),
...emailDraft.options,
draft: emailDraft
});
} else {
EmailCompose.open({
ticketId: tId,
ticketTn: btnEmailCompose.dataset.ticketTn,
ticketTitle: btnEmailCompose.dataset.ticketTitle,
customerEmail: btnEmailCompose.dataset.customerEmail,
});
}
} else {
Toast.error('Modulo email non disponibile');
}
@@ -706,7 +958,28 @@ const TicketDetailView = {
if (window.EmailCompose) {
const hasHtml = (article.a_content_type || '').toLowerCase().includes('html') || article.a_body.includes('</') || article.a_body.includes('/>');
const quotedBody = hasHtml ? article.a_body : App.escapeHtml(article.a_body || '').replace(/\n/g, '<br>');
let processedQuoted = article.a_body || '';
if (hasHtml) {
const articleAttachments = (attachments || []).filter(att => att.article_id === article.article_id);
articleAttachments.forEach(att => {
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
processedQuoted = processedQuoted.replace(regex, `/api/tickets/attachments/${att.id}`);
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
processedQuoted = processedQuoted.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
}
});
}
const quotedBody = hasHtml ? processedQuoted : App.escapeHtml(article.a_body || '').replace(/\n/g, '<br>');
const initialBodyHtml = `
<p><br></p>
@@ -717,19 +990,57 @@ const TicketDetailView = {
<p><br></p>
`;
// Extract sender email if possible for CC/To
let customerEmail = ticket.customer_email || '';
const matchEmail = (article.a_from || '').match(/<([^>]+)>/) || (article.a_from || '').match(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/);
if (matchEmail) {
customerEmail = matchEmail[1];
// Helper to extract email addresses from headers
const parseEmails = (str) => {
if (!str) return [];
return (str.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [])
.map(email => email.toLowerCase().trim());
};
const fromEmails = parseEmails(article.a_from);
const toEmails = parseEmails(article.a_to);
const ccEmails = parseEmails(article.a_cc);
// Exclude list (helpdesk and active agent)
const config = App.lookups.config || {};
const helpdeskEmail = (config.helpdeskEmail || 'helpdesk@pharmaidea.com').toLowerCase();
const agentEmail = (config.agentEmail || '').toLowerCase();
const excludeEmails = [helpdeskEmail, agentEmail].filter(Boolean);
const initialToSet = new Set();
fromEmails.forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk')) {
initialToSet.add(e);
}
});
if (initialToSet.size === 0 && ticket.customer_email) {
initialToSet.add(ticket.customer_email.toLowerCase());
}
const initialCcSet = new Set();
[...toEmails, ...ccEmails].forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk') && !initialToSet.has(e)) {
initialCcSet.add(e);
}
});
let inReplyTo = article.a_message_id || '';
let references = '';
if (article.a_references) {
references = article.a_references + (article.a_message_id ? ' ' + article.a_message_id : '');
} else if (article.a_message_id) {
references = article.a_message_id;
}
EmailCompose.open({
ticketId: ticket.id,
ticketTn: ticket.tn,
ticketTitle: ticket.title,
customerEmail: customerEmail,
initialTo: Array.from(initialToSet),
initialCc: Array.from(initialCcSet),
initialBodyHtml: initialBodyHtml,
inReplyTo: inReplyTo,
references: references,
});
} else {
Toast.error('Modulo email non disponibile');
@@ -931,5 +1242,98 @@ const TicketDetailView = {
this.render(this.ticketId);
});
}
// Populate Group Association dropdown
App.api('/api/groups').then(allGroups => {
const select = document.getElementById('group-association-select');
if (select) {
allGroups.forEach(g => {
const isMember = groupsData.asMember.some(m => m.id === g.id);
const isMaster = groupsData.asMaster.some(m => m.id === g.id);
if (!isMember && !isMaster) {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.nome;
select.appendChild(opt);
}
});
}
}).catch(err => console.warn('Failed to load groups for association dropdown', err));
const btnAssociate = document.getElementById('btn-associate-to-group');
if (btnAssociate) {
btnAssociate.addEventListener('click', async () => {
const select = document.getElementById('group-association-select');
const groupId = select.value;
if (!groupId) {
sessionStorage.setItem('otrs_create_group_with_master_tn', ticket.tn);
window.location.hash = '#/tickets/groups';
return;
}
try {
btnAssociate.disabled = true;
await App.api(`/api/groups/${groupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: this.ticketId })
});
Toast.success('Ticket associato al gruppo!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore associazione: ' + err.message);
btnAssociate.disabled = false;
}
});
}
// Inline image preview & lightbox binding
document.querySelectorAll('.articles-timeline iframe').forEach(iframe => {
const attachImageClick = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
doc.querySelectorAll('img').forEach(img => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(img.src);
});
});
}
} catch (err) {
console.warn('Cannot attach click listeners to iframe images', err);
}
};
iframe.addEventListener('load', attachImageClick);
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc && doc.readyState === 'complete') {
attachImageClick();
}
} catch (_) { }
});
document.querySelectorAll('.attachment-badge[data-is-image="true"]').forEach(badge => {
badge.style.cursor = 'zoom-in';
badge.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(badge.href);
});
});
document.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success('Copiato negli appunti!');
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
},
};
+587
View File
@@ -0,0 +1,587 @@
/**
* ticketGroups.js
* SPA View for managing Ticket Groups (mimics master/slave relationships).
*/
const TicketGroupsView = {
groups: [],
selectedGroupId: null,
selectedGroupData: null,
selectedTicketIds: new Set(), // For bulk actions (complying with no-checkboxes rule)
async render() {
const container = document.getElementById('view-container');
container.innerHTML = `
<div class="ticket-groups-container" style="display: flex; gap: var(--space-lg); min-height: calc(100vh - 120px); padding: var(--space-lg) 0;">
<!-- Left Sidebar: Groups List -->
<div class="groups-sidebar card" style="flex: 0 0 320px; display: flex; flex-direction: column; padding: var(--space-md); max-height: calc(100vh - 120px); overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 700; color: var(--text-primary);">🗂️ Gruppi Ticket</h3>
<button class="btn btn-primary btn-sm" id="btn-create-group" style="padding: 4px 10px; font-size: 0.8rem;">+ Nuovo</button>
</div>
<div id="groups-list-container" style="display: flex; flex-direction: column; gap: var(--space-xs); flex: 1;">
<div class="spinner-container" style="display: flex; justify-content: center; padding: var(--space-lg);">
<div class="spinner" style="width: 24px; height: 24px;"></div>
</div>
</div>
</div>
<!-- Right Content: Group Details -->
<div class="group-details-pane card" id="group-details-pane" style="flex: 1; padding: var(--space-lg); display: flex; flex-direction: column; max-height: calc(100vh - 120px); overflow-y: auto;">
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
<h4>Nessun gruppo selezionato</h4>
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.</p>
</div>
</div>
</div>
<!-- Create/Edit Group Modal -->
<div id="group-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;">
<div class="card" style="width: min(480px, 94vw); max-height: 90vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); padding: 0;">
<div style="padding: 16px 20px; border-bottom: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center;">
<h4 id="group-modal-title" style="margin: 0; font-size: 1.1rem; font-weight: 600;">Nuovo Gruppo</h4>
<button id="group-modal-close" style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);">✕</button>
</div>
<div style="padding: 20px; display: flex; flex-direction: column; gap: var(--space-md); overflow-y: auto;">
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Nome Gruppo</label>
<input type="text" id="group-name-input" class="form-input" placeholder="es. Disservizio Mail Server" style="margin-top: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Descrizione</label>
<textarea id="group-desc-input" class="form-input" placeholder="Breve descrizione o scopo di questo gruppo..." style="margin-top: 4px; min-height: 80px; resize: vertical;"></textarea>
</div>
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">ID Ticket Master (Opzionale)</label>
<input type="text" id="group-master-input" class="form-input" placeholder="ID o Numero ticket (es. 12345)" style="margin-top: 4px;" />
<span style="font-size: 0.75rem; color: var(--text-muted);">Il ticket master funge da riferimento principale.</span>
</div>
</div>
<div style="padding: 12px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: var(--space-sm);">
<button class="btn btn-ghost btn-sm" id="btn-group-modal-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="btn-group-modal-save">Salva</button>
</div>
</div>
</div>
`;
// Bind event listeners
document.getElementById('btn-create-group').addEventListener('click', () => this.openGroupModal());
document.getElementById('group-modal-close').addEventListener('click', () => this.closeGroupModal());
document.getElementById('btn-group-modal-cancel').addEventListener('click', () => this.closeGroupModal());
document.getElementById('btn-group-modal-save').addEventListener('click', () => this.saveGroup());
// Restore state from localStorage if available
const savedGroupId = localStorage.getItem('otrs_selected_group_id');
if (savedGroupId) {
this.selectedGroupId = parseInt(savedGroupId, 10);
}
await this.loadGroups();
const prefillMasterTn = sessionStorage.getItem('otrs_create_group_with_master_tn');
if (prefillMasterTn) {
sessionStorage.removeItem('otrs_create_group_with_master_tn');
this.openGroupModal();
const masterInput = document.getElementById('group-master-input');
if (masterInput) {
masterInput.value = prefillMasterTn;
}
} else if (this.selectedGroupId) {
this.selectGroup(this.selectedGroupId);
}
},
async loadGroups() {
try {
this.groups = await App.api('/api/groups');
this.renderGroupsList();
} catch (err) {
Toast.error('Errore nel caricamento dei gruppi: ' + err.message);
}
},
renderGroupsList() {
const listContainer = document.getElementById('groups-list-container');
if (!listContainer) return;
if (this.groups.length === 0) {
listContainer.innerHTML = `
<div style="text-align: center; padding: var(--space-lg); color: var(--text-muted); font-size: 0.85rem;">
Nessun gruppo presente
</div>
`;
return;
}
listContainer.innerHTML = this.groups.map(group => {
const isActive = group.id === this.selectedGroupId;
return `
<div class="group-item" data-id="${group.id}" style="
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
background: ${isActive ? 'var(--bg-tertiary)' : 'transparent'};
border: 1px solid ${isActive ? 'var(--accent-primary)' : 'transparent'};
">
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); margin-bottom: 2px;">
${App.escapeHtml(group.nome)}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin-bottom: 4px;">
${App.escapeHtml(group.descrizione || 'Nessuna descrizione')}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; color: var(--text-muted);">
<span>Master: ${group.master_ticket_id ? '#' + group.master_ticket_id : 'Nessuno'}</span>
<span style="background: var(--bg-secondary); padding: 1px 6px; border-radius: 10px;">${group.member_count} ticket</span>
</div>
</div>
`;
}).join('');
// Bind click events to items
listContainer.querySelectorAll('.group-item').forEach(item => {
item.addEventListener('click', () => {
const id = parseInt(item.dataset.id, 10);
this.selectGroup(id);
});
});
},
async selectGroup(id) {
this.selectedGroupId = id;
localStorage.setItem('otrs_selected_group_id', id);
this.selectedTicketIds.clear();
this.renderGroupsList();
const detailPane = document.getElementById('group-details-pane');
detailPane.innerHTML = `
<div style="display: flex; justify-content: center; align-items: center; flex: 1;">
<div class="spinner"></div>
</div>
`;
try {
this.selectedGroupData = await App.api(`/api/groups/${id}`);
this.renderGroupDetails();
} catch (err) {
Toast.error('Errore nel caricamento del dettaglio gruppo: ' + err.message);
detailPane.innerHTML = `
<div style="color: var(--danger); text-align: center; padding: var(--space-xl);">
Errore nel caricamento dei dati: ${err.message}
</div>
`;
}
},
renderGroupDetails() {
const detailPane = document.getElementById('group-details-pane');
if (!detailPane || !this.selectedGroupData) return;
const { group, masterTicket, memberTickets } = this.selectedGroupData;
let masterTicketHtml = '';
if (masterTicket) {
masterTicketHtml = `
<div class="card" style="border: 1px solid var(--accent-primary); background: var(--bg-secondary); padding: var(--space-md);">
<div style="display: flex; gap: var(--space-md); align-items: flex-start;">
<div style="flex: 1;">
<div style="font-weight: 700; font-size: 1rem; margin-bottom: var(--space-xs); display: flex; align-items: center; gap: 6px;">
<span class="copy-ticket-btn" data-tn="${masterTicket.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" title="Copia numero ticket">📋</span>
<a href="#/tickets/${masterTicket.id}" style="color: var(--text-primary); text-decoration: none; border-bottom: 1px dashed var(--text-muted);">
#${masterTicket.tn}${App.escapeHtml(masterTicket.title)}
</a>
</div>
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: var(--space-xs);">
<span class="badge badge-state" data-state-type="${(masterTicket.state_type || '').toLowerCase()}">${masterTicket.state_name}</span>
<span class="badge" style="background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem;">Coda: ${masterTicket.queue_name}</span>
<span style="font-size: 0.78rem; color: var(--text-muted);">Proprietario: ${masterTicket.owner_first ? `${masterTicket.owner_first} ${masterTicket.owner_last}` : masterTicket.owner_login}</span>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-xs); flex-shrink: 0;">
<span style="font-size: 0.7rem; font-weight: 700; color: var(--accent-primary); text-transform: uppercase; border: 1px solid var(--accent-primary); padding: 2px 6px; border-radius: var(--radius-sm); white-space: nowrap;">👑 Master Ticket</span>
<button class="btn btn-ghost btn-sm" id="btn-unlink-master" style="color: var(--danger); font-size: 0.8rem; height: 32px; padding: 4px 8px;" title="Rimuovi ruolo master">Scollega Master</button>
</div>
</div>
</div>
`;
} else {
masterTicketHtml = `
<div style="border: 1px dashed var(--border-subtle); padding: var(--space-md); border-radius: var(--radius-md); text-align: center; color: var(--text-muted); font-size: 0.85rem;">
Nessun master ticket assegnato. Imposta un ticket come master usando il tasto Modifica Gruppo o associa un ID/Numero valido.
</div>
`;
}
let membersListHtml = '';
if (memberTickets.length === 0) {
membersListHtml = `
<div style="text-align: center; padding: var(--space-xl); border: 1px dashed var(--border-subtle); border-radius: var(--radius-md); color: var(--text-muted); font-size: 0.88rem;">
Nessun ticket slave/membro associato a questo gruppo.
</div>
`;
} else {
membersListHtml = `
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
${memberTickets.map(t => {
const isSelected = this.selectedTicketIds.has(t.id);
return `
<div class="member-ticket-card" data-ticket-id="${t.id}" style="
border: 1px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-subtle)'};
background: ${isSelected ? 'var(--bg-tertiary)' : 'var(--bg-card)'};
padding: var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: space-between;
">
<div style="flex: 1;" onclick="event.stopPropagation();">
<div style="font-weight: 600; font-size: 0.9rem; margin-bottom: 2px; display: flex; align-items: center; gap: 6px;">
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
<a href="#/tickets/${t.id}" style="color: var(--text-primary); text-decoration: none;">
#${t.tn}${App.escapeHtml(t.title)}
</a>
</div>
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: 4px; font-size: 0.75rem;">
<span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}" style="font-size: 0.7rem; padding: 2px 6px;">${t.state_name}</span>
<span style="color: var(--text-muted);">Coda: ${t.queue_name}</span>
<span style="color: var(--text-muted);">Proprietario: ${t.owner_first ? `${t.owner_first} ${t.owner_last}` : t.owner_login}</span>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-remove-member" data-ticket-id="${t.id}" style="color: var(--danger); padding: 4px 8px; font-size: 0.8rem;" onclick="event.stopPropagation();">Rimuovi</button>
</div>
`;
}).join('')}
</div>
`;
}
detailPane.innerHTML = `
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-subtle); padding-bottom: var(--space-md); margin-bottom: var(--space-md);">
<div>
<h2 style="margin: 0; font-size: 1.3rem; font-weight: 700; color: var(--text-primary);">${App.escapeHtml(group.nome)}</h2>
<p style="margin: var(--space-xs) 0 0; font-size: 0.88rem; color: var(--text-secondary);">${App.escapeHtml(group.descrizione || 'Nessuna descrizione per questo gruppo.')}</p>
</div>
<div style="display: flex; gap: var(--space-sm);">
<button class="btn btn-ghost btn-sm" id="btn-edit-group" style="padding: 6px 12px; font-size: 0.82rem;">Modifica</button>
<button class="btn btn-ghost btn-sm" id="btn-delete-group" style="padding: 6px 12px; font-size: 0.82rem; color: var(--danger);">Elimina Gruppo</button>
</div>
</div>
<!-- Master Ticket Section -->
<div style="margin-bottom: var(--space-lg);">
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Master</h4>
${masterTicketHtml}
</div>
<!-- Quick Add Section -->
<div class="card" style="background: var(--bg-tertiary); padding: var(--space-md); margin-bottom: var(--space-lg);">
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Associa Nuovo Ticket Slave</h4>
<div style="display: flex; gap: var(--space-sm);">
<input type="text" id="quick-add-ticket-input" class="form-input" placeholder="Inserisci ID ticket o Numero ticket (es. 202310... o 12345)" style="margin: 0; font-size: 0.88rem; flex: 1;" />
<button class="btn btn-primary btn-sm" id="btn-quick-add-ticket">Associa Ticket</button>
</div>
</div>
<!-- Slave / Members Section -->
<div style="flex: 1; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs);">
<h4 style="margin: 0; font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Membri (${memberTickets.length})</h4>
${this.selectedTicketIds.size > 0 ? `
<div style="display:flex; gap:8px;">
<button class="btn btn-ghost btn-sm" id="btn-bulk-copy" style="font-size: 0.8rem; padding: 4px 8px;">Copia selezionati (${this.selectedTicketIds.size})</button>
<button class="btn btn-ghost btn-sm" id="btn-bulk-remove" style="color: var(--danger); font-size: 0.8rem; padding: 4px 8px;">Rimuovi selezionati (${this.selectedTicketIds.size})</button>
</div>
` : `
<span style="font-size: 0.75rem; color: var(--text-muted);">Clicca sulle schede per selezionare in blocco</span>
`}
</div>
<div style="flex: 1; overflow-y: auto;">
${membersListHtml}
</div>
</div>
`;
// Bind actions
document.getElementById('btn-edit-group').addEventListener('click', () => this.openGroupModal(group));
document.getElementById('btn-delete-group').addEventListener('click', () => this.deleteGroup(group.id));
if (masterTicket) {
document.getElementById('btn-unlink-master').addEventListener('click', () => this.unlinkMaster());
}
document.getElementById('btn-quick-add-ticket').addEventListener('click', () => this.addTicketToGroup());
document.getElementById('quick-add-ticket-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.addTicketToGroup();
});
// Individual removal buttons
detailPane.querySelectorAll('.btn-remove-member').forEach(btn => {
btn.addEventListener('click', () => {
const ticketId = parseInt(btn.dataset.ticketId, 10);
this.removeTicketFromGroup(ticketId);
});
});
// Multi-select cards logic (No-checkboxes rule)
detailPane.querySelectorAll('.member-ticket-card').forEach(card => {
card.addEventListener('click', () => {
const ticketId = parseInt(card.dataset.ticketId, 10);
if (this.selectedTicketIds.has(ticketId)) {
this.selectedTicketIds.delete(ticketId);
} else {
this.selectedTicketIds.add(ticketId);
}
this.renderGroupDetails();
});
});
// Bulk actions
const btnBulkRemove = document.getElementById('btn-bulk-remove');
if (btnBulkRemove) {
btnBulkRemove.addEventListener('click', () => this.bulkRemoveTickets());
}
const btnBulkCopy = document.getElementById('btn-bulk-copy');
if (btnBulkCopy) {
btnBulkCopy.addEventListener('click', (e) => {
e.stopPropagation();
const selectedTns = [];
this.selectedTicketIds.forEach(id => {
const ticket = memberTickets.find(t => t.id === id);
if (ticket && ticket.tn) {
selectedTns.push(ticket.tn);
}
});
if (selectedTns.length > 0) {
const textToCopy = selectedTns.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
}
// Individual copy buttons
detailPane.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success(`Numero ticket ${tn} copiato!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
},
openGroupModal(group = null) {
const modal = document.getElementById('group-modal');
const title = document.getElementById('group-modal-title');
const nameInput = document.getElementById('group-name-input');
const descInput = document.getElementById('group-desc-input');
const masterInput = document.getElementById('group-master-input');
if (group) {
title.textContent = 'Modifica Gruppo';
nameInput.value = group.nome || '';
descInput.value = group.descrizione || '';
masterInput.value = group.master_ticket_id || '';
modal.dataset.editId = group.id;
} else {
title.textContent = 'Nuovo Gruppo';
nameInput.value = '';
descInput.value = '';
masterInput.value = '';
delete modal.dataset.editId;
}
modal.style.display = 'flex';
nameInput.focus();
},
closeGroupModal() {
document.getElementById('group-modal').style.display = 'none';
},
async saveGroup() {
const modal = document.getElementById('group-modal');
const editId = modal.dataset.editId;
const nome = document.getElementById('group-name-input').value.trim();
const descrizione = document.getElementById('group-desc-input').value.trim();
const masterVal = document.getElementById('group-master-input').value.trim();
if (!nome) {
Toast.error('Il nome del gruppo è richiesto.');
return;
}
let masterTicketId = null;
if (masterVal) {
// Validate master ticket input (ID or Number)
try {
const res = await App.api(`/api/tickets?search=${masterVal}&per_page=1`);
if (res && res.tickets && res.tickets.length > 0) {
masterTicketId = res.tickets[0].id;
} else {
// Check if it's a direct database ID by querying search directly
if (/^\d+$/.test(masterVal)) {
masterTicketId = parseInt(masterVal, 10);
} else {
Toast.error('Ticket master non trovato.');
return;
}
}
} catch (err) {
if (/^\d+$/.test(masterVal)) {
masterTicketId = parseInt(masterVal, 10);
} else {
Toast.error('Errore nella verifica del ticket master: ' + err.message);
return;
}
}
}
const payload = { nome, descrizione, master_ticket_id: masterTicketId };
try {
if (editId) {
await App.api(`/api/groups/${editId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
Toast.success('Gruppo aggiornato con successo');
} else {
const newGroup = await App.api('/api/groups', {
method: 'POST',
body: JSON.stringify(payload)
});
this.selectedGroupId = newGroup.id;
Toast.success('Gruppo creato con successo');
}
this.closeGroupModal();
await this.loadGroups();
if (this.selectedGroupId) {
await this.selectGroup(this.selectedGroupId);
}
} catch (err) {
Toast.error('Errore nel salvataggio: ' + err.message);
}
},
async deleteGroup(id) {
const ok = await App.confirm('Elimina Gruppo', 'Sei sicuro di voler eliminare questo gruppo? Le associazioni dei ticket verranno rimosse, ma i ticket non saranno modificati.');
if (!ok) {
return;
}
try {
await App.api(`/api/groups/${id}`, { method: 'DELETE' });
Toast.success('Gruppo eliminato con successo');
this.selectedGroupId = null;
this.selectedGroupData = null;
localStorage.removeItem('otrs_selected_group_id');
await this.loadGroups();
document.getElementById('group-details-pane').innerHTML = `
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
<h4>Nessun gruppo selezionato</h4>
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni.</p>
</div>
`;
} catch (err) {
Toast.error('Errore nella cancellazione: ' + err.message);
}
},
async unlinkMaster() {
if (!this.selectedGroupData) return;
const { group } = this.selectedGroupData;
try {
await App.api(`/api/groups/${group.id}`, {
method: 'PUT',
body: JSON.stringify({
nome: group.nome,
descrizione: group.descrizione,
master_ticket_id: null
})
});
Toast.success('Ticket master scollegato con successo');
await this.selectGroup(group.id);
} catch (err) {
Toast.error('Errore nello scollegamento: ' + err.message);
}
},
async addTicketToGroup() {
const input = document.getElementById('quick-add-ticket-input');
const val = input.value.trim();
if (!val) return;
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: val })
});
Toast.success('Ticket associato correttamente');
input.value = '';
await this.selectGroup(this.selectedGroupId);
} catch (err) {
Toast.error('Errore nell\'associazione: ' + err.message);
}
},
async removeTicketFromGroup(ticketId) {
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
method: 'DELETE'
});
Toast.success('Ticket rimosso dal gruppo');
await this.selectGroup(this.selectedGroupId);
} catch (err) {
Toast.error('Errore nella rimozione: ' + err.message);
}
},
async bulkRemoveTickets() {
if (this.selectedTicketIds.size === 0) return;
const ok = await App.confirm('Rimuovi Ticket', `Vuoi rimuovere i ${this.selectedTicketIds.size} ticket selezionati da questo gruppo?`);
if (!ok) return;
let successCount = 0;
let failCount = 0;
for (const ticketId of this.selectedTicketIds) {
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
method: 'DELETE'
});
successCount++;
} catch (err) {
failCount++;
}
}
if (successCount > 0) {
Toast.success(`${successCount} ticket rimossi con successo.`);
}
if (failCount > 0) {
Toast.error(`Impossibile rimuovere ${failCount} ticket.`);
}
this.selectedTicketIds.clear();
await this.selectGroup(this.selectedGroupId);
}
};
+267 -16
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();
@@ -77,6 +79,7 @@ const TicketListView = {
<div style="display:flex; align-items:center; gap:var(--space-sm);">
<span class="batch-count" id="batch-count">0 selezionati</span>
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
</div>
<div class="filter-group">
<span class="filter-label">Stato</span>
@@ -85,12 +88,11 @@ const TicketListView = {
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
</select>
</div>
<div class="filter-group">
<div class="filter-group" style="position:relative;">
<span class="filter-label">Coda</span>
<select class="filter-select" id="batch-queue">
<option value="">—</option>
${(App.lookups.queues || []).map(q => `<option value="${q.id}">${q.name}</option>`).join('')}
</select>
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:160px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
<input type="hidden" id="batch-queue" />
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></div>
</div>
<div class="filter-group">
<span class="filter-label">Owner</span>
@@ -99,15 +101,26 @@ 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>
<div class="filter-group">
<span class="filter-label">Responsabile</span>
<select class="filter-select" id="batch-responsible">
<option value="">—</option>
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
</select>
</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>
@@ -139,17 +152,19 @@ const TicketListView = {
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
<td>
<span class="ticket-tn" style="display:inline-flex; align-items:center;">
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
${data.otrsBaseUrl ? `
<a href="${data.otrsBaseUrl}index.pl?Action=AgentTicketZoom;TicketID=${t.id}" target="_blank" title="Apri in OTRS" onclick="event.stopPropagation()" style="display:inline-flex; align-items:center; text-decoration:none;">
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; vertical-align:middle; line-height:16px;">O</span>
</a>
` : ''}
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
</span>
</td>
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
<td class="ticket-title-cell"><span class="copy-ticket-btn" data-tn="${t.title}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia titolo ticket">📋</span><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
<td class="queue-cell"><span class="badge badge-queue">${App.escapeHtml(shortQueue)}</span><div class="queue-tooltip">${App.escapeHtml(t.queue_name)}</div></td>
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
@@ -296,12 +311,92 @@ 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) {
batchMerge.addEventListener('click', () => this.mergeBatch());
}
// Batch open tabs
const batchOpenTabsBtn = document.getElementById('batch-open-tabs');
if (batchOpenTabsBtn) {
batchOpenTabsBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (this.selectedIds.size === 0) return;
let count = 0;
this.selectedIds.forEach(id => {
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
if (row) {
const tnLink = row.querySelector('.ticket-tn-link');
const titleLink = row.querySelector('.ticket-title-link');
const tn = tnLink ? tnLink.textContent.trim() : '';
const title = titleLink ? titleLink.textContent.trim() : '';
App.openTab(parseInt(id, 10), tn, title);
count++;
}
});
if (count > 0) {
Toast.success(`${count} ticket aperti in nuove schede!`);
this.selectedIds.clear();
this.selectedOrder = [];
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
tr.classList.remove('selected');
tr.classList.remove('first-selected');
});
this.updateBatchBar();
}
});
}
// Batch Copy Ticket Numbers
const batchCopyTns = document.getElementById('batch-copy-tns');
if (batchCopyTns) {
batchCopyTns.addEventListener('click', (e) => {
e.stopPropagation();
const selectedTns = [];
this.selectedIds.forEach(id => {
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
if (row) {
const tnLink = row.querySelector('.ticket-tn-link');
if (tnLink) {
selectedTns.push(tnLink.textContent.trim());
}
}
});
if (selectedTns.length > 0) {
const textToCopy = selectedTns.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
}
// Individual copy buttons
document.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success(`Numero ticket ${tn} copiato!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
// Batch select all visible
const batchSelectAll = document.getElementById('batch-select-all');
if (batchSelectAll) {
@@ -347,10 +442,16 @@ const TicketListView = {
if (batchCustomerId) batchCustomerId.value = '';
const batchState = document.getElementById('batch-state');
if (batchState) batchState.value = '';
const batchQueueSearch = document.getElementById('batch-queue-search');
if (batchQueueSearch) batchQueueSearch.value = '';
const batchQueue = document.getElementById('batch-queue');
if (batchQueue) batchQueue.value = '';
const batchOwner = document.getElementById('batch-owner');
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();
});
@@ -410,11 +511,61 @@ const TicketListView = {
});
}
// Batch Queue Autocomplete
const batchQueueSearchInput = document.getElementById('batch-queue-search');
const batchQueueSuggestionsDiv = document.getElementById('batch-queue-suggestions');
const batchQueueIdInput = document.getElementById('batch-queue');
let batchQueueDebounce;
if (batchQueueSearchInput) {
batchQueueSearchInput.addEventListener('input', () => {
clearTimeout(batchQueueDebounce);
const q = batchQueueSearchInput.value.trim();
batchQueueDebounce = setTimeout(async () => {
try {
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
if (queues.length === 0) {
batchQueueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
batchQueueSuggestionsDiv.style.display = 'block';
return;
}
batchQueueSuggestionsDiv.innerHTML = queues.map(queue => `
<div class="autocomplete-suggestion-item" data-id="${queue.id}" data-name="${App.escapeHtml(queue.name)}">
<strong>${App.escapeHtml(queue.name)}</strong>
</div>
`).join('');
batchQueueSuggestionsDiv.style.display = 'block';
// Bind click
batchQueueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
item.addEventListener('click', () => {
batchQueueSearchInput.value = item.dataset.name;
if (batchQueueIdInput) batchQueueIdInput.value = item.dataset.id;
batchQueueSuggestionsDiv.style.display = 'none';
});
});
} catch (err) {
console.error(err);
}
}, 300);
});
batchQueueSearchInput.addEventListener('focus', () => {
batchQueueSearchInput.value = '';
if (batchQueueIdInput) batchQueueIdInput.value = '';
batchQueueSearchInput.dispatchEvent(new Event('input'));
});
}
// Close suggestions on click outside
document.addEventListener('click', (e) => {
if (batchCustomerSearchInput && e.target !== batchCustomerSearchInput && e.target !== batchCustomerSuggestionsDiv) {
batchCustomerSuggestionsDiv.style.display = 'none';
}
if (batchQueueSearchInput && e.target !== batchQueueSearchInput && e.target !== batchQueueSuggestionsDiv) {
batchQueueSuggestionsDiv.style.display = 'none';
}
});
// Pagination
@@ -453,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) {
@@ -462,6 +619,16 @@ const TicketListView = {
mergeBtn.disabled = true;
}
}
// Enable/disable open tabs button
const openTabsBtn = document.getElementById('batch-open-tabs');
if (openTabsBtn) {
if (this.selectedIds.size > 0) {
openTabsBtn.disabled = false;
} else {
openTabsBtn.disabled = true;
}
}
},
async applyBatch() {
@@ -471,6 +638,8 @@ const TicketListView = {
const batchState = document.getElementById('batch-state')?.value;
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;
@@ -484,7 +653,13 @@ 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);
} else if (batchOwner) {
updates.responsible_user_id = parseInt(batchOwner);
}
if (batchCustomerUserId) updates.customer_user_id = batchCustomerUserId;
if (batchCustomerId) updates.customer_id = batchCustomerId;
@@ -531,7 +706,7 @@ const TicketListView = {
}
});
const confirmed = confirm(`Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`);
const confirmed = await App.confirm('Unione Ticket', `Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`);
if (!confirmed) return;
try {
@@ -552,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 });
}
});
+235 -27
View File
@@ -6,6 +6,13 @@ const pool = require('../db');
const { db } = require('../activityDb');
const { sendMail } = require('../utils/mailer');
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
function getLocalTimestamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// ─── SIGNATURES ────────────────────────────────────────────────────────────────
// GET /api/email/signatures — list signatures for a given agent
@@ -110,6 +117,82 @@ router.delete('/signatures/:id', (req, res) => {
}
});
// ─── ADDRESS GROUPS ────────────────────────────────────────────────────────────
// GET /api/email/address-groups — list groups for a given agent
router.get('/address-groups', (req, res) => {
try {
const { agent_id } = req.query;
if (!agent_id) return res.status(400).json({ error: 'agent_id è obbligatorio' });
const rows = db.prepare(`
SELECT id, name, emails, created_at, updated_at
FROM email_address_groups
WHERE agent_id = ?
ORDER BY name ASC
`).all(parseInt(agent_id, 10));
res.json(rows);
} catch (err) {
console.error('[Email] Error fetching address groups:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/email/address-groups — create a new group
router.post('/address-groups', (req, res) => {
try {
const { agent_id, name, emails } = req.body;
if (!agent_id || !name || !emails) {
return res.status(400).json({ error: 'Campi agent_id, name e emails sono obbligatori' });
}
const info = db.prepare(`
INSERT INTO email_address_groups (agent_id, name, emails)
VALUES (?, ?, ?)
`).run(parseInt(agent_id, 10), name.trim(), emails.trim());
res.json({ id: info.lastInsertRowid, success: true });
} catch (err) {
console.error('[Email] Error creating address group:', err);
res.status(500).json({ error: err.message });
}
});
// PUT /api/email/address-groups/:id — update a group
router.put('/address-groups/:id', (req, res) => {
try {
const { id } = req.params;
const { name, emails } = req.body;
if (!name || !emails) {
return res.status(400).json({ error: 'Campi name e emails sono obbligatori' });
}
db.prepare(`
UPDATE email_address_groups
SET name = ?, emails = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?
`).run(name.trim(), emails.trim(), parseInt(id, 10));
res.json({ success: true });
} catch (err) {
console.error('[Email] Error updating address group:', err);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/email/address-groups/:id — delete a group
router.delete('/address-groups/:id', (req, res) => {
try {
const { id } = req.params;
db.prepare(`DELETE FROM email_address_groups WHERE id = ?`).run(parseInt(id, 10));
res.json({ success: true });
} catch (err) {
console.error('[Email] Error deleting address group:', err);
res.status(500).json({ error: err.message });
}
});
// ─── SEND EMAIL ───────────────────────────────────────────────────────────────
// POST /api/email/send — send an email for a ticket
@@ -118,6 +201,7 @@ router.post('/send', async (req, res) => {
ticketId,
to,
cc = [],
bcc = [],
subject: customSubject,
bodyHtml,
attachments = [],
@@ -125,6 +209,8 @@ router.post('/send', async (req, res) => {
agentId,
agentName = 'Agente',
keepHelpdeskCopy = true,
inReplyTo,
references,
} = req.body;
if (!ticketId) return res.status(400).json({ error: 'ticketId è obbligatorio' });
@@ -140,68 +226,190 @@ router.post('/send', async (req, res) => {
if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' });
const { tn, title } = ticketResult.rows[0];
const subject = customSubject || `Re: [Ticket#${tn}] ${title}`;
const subject = customSubject || `[Ticket#${tn}] Re: ${title}`;
// 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true)
const bcc = [];
const bccList = [...bcc];
if (keepHelpdeskCopy) {
const otrsBcc = process.env.OTRS_MAIL_BCC;
if (otrsBcc) bcc.push(otrsBcc);
if (otrsBcc && !bccList.includes(otrsBcc)) bccList.push(otrsBcc);
}
// 3. Send via configured mailer (Graph API or SMTP)
await sendMail({ to, cc, bcc, subject, bodyHtml, attachments, inlineImages });
// Extract inline base64 images from bodyHtml and replace with CID references
const extractedInlineImages = [];
let processedBodyHtml = bodyHtml;
let cidCounter = 1;
processedBodyHtml = bodyHtml.replace(/src="data:([^;]+);base64,([^"]+)"/g, (match, contentType, base64Data) => {
let ext = 'png'; // default fallback
if (contentType) {
const parts = contentType.split('/');
if (parts.length === 2) {
ext = parts[1];
if (ext === 'jpeg') ext = 'jpg';
}
}
const cid = `inline-image-${Date.now()}-${cidCounter++}.${ext}`;
extractedInlineImages.push({
cid,
content: base64Data,
contentType
});
return `src="cid:${cid}"`;
});
// 4. Log internal note in OTRS ticket via DB (email sent record)
const finalInlineImages = [...inlineImages, ...extractedInlineImages];
// Generate unique Message-ID
const messageId = `<${Date.now()}.${Math.random().toString(36).substring(2)}@pharmaidea.com>`;
// 3. Send via configured mailer (Graph API or SMTP)
const mailResult = await sendMail({ to, cc, bcc: bccList, subject, bodyHtml: processedBodyHtml, attachments, inlineImages: finalInlineImages, inReplyTo, references, messageId });
const finalMessageId = (mailResult && mailResult.internetMessageId) || messageId;
// 4. Log article in OTRS ticket via DB as a standard Email article
try {
const now = Math.floor(Date.now() / 1000);
const agentLoginResult = agentId
? await pool.query(`SELECT login, first_name, last_name FROM users WHERE id = $1`, [agentId])
: { rows: [] };
? await pool.query(`SELECT login FROM users WHERE id = $1`, [agentId])
: null;
const agentLogin = agentLoginResult?.rows[0]?.login || 'system';
const agentUser = agentLoginResult.rows[0];
let agentEmail = 'agent@localhost';
// Query email from user_preferences for agent
let agentEmail = '';
if (agentId) {
const prefRes = await pool.query(
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
[agentId]
);
if (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) {
agentEmail = prefRes.rows[0].preferences_value;
if (prefRes.rows.length > 0) {
agentEmail = prefRes.rows[0].preferences_value || '';
}
}
const aFrom = agentUser
? `"${agentUser.first_name} ${agentUser.last_name}" <${agentEmail}>`
: agentName;
const aFrom = agentEmail ? `"${agentLogin}" <${agentEmail}>` : `"${agentLogin}" <${process.env.AZURE_MAIL_SENDER || process.env.SMTP_FROM || 'helpdesk@example.com'}>`;
const toList = to.join(', ');
const noteBody = `Email inviata a: ${toList}${cc.length ? `\nCC: ${cc.join(', ')}` : ''}`;
// Insert article via DB (internal note to log email dispatch)
const localNow = getLocalTimestamp();
// Insert article via DB metadata (Email channel=1, Visible to customer=1)
const artInsert = await pool.query(`
INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, a_from, a_to, a_subject, a_body,
content_path, incoming_time, create_time, create_by, change_time, change_by
is_visible_for_customer, create_time, create_by, change_time, change_by
) VALUES (
$1, 1, 2, 0, $2, $3, $4, $5,
'/', $6, NOW(), $7, NOW(), $7
$1, 1, 1, 1, $3, $2, $3, $2
) RETURNING id`,
[ticketId, aFrom, toList, `[Email inviata] ${subject}`, noteBody, now, agentId || 1]
[ticketId, agentId || 1, localNow]
);
const articleId = artInsert.rows[0]?.id;
if (articleId) {
// Write standard HTML MIME data
await pool.query(`
INSERT INTO article_data_mime (article_id, a_from, a_to, a_cc, a_subject, a_body, a_content_type, incoming_time, create_time, create_by, change_time, change_by)
VALUES ($1, $2, $3, $4, $5, $6, 'text/plain; charset=utf-8', $7, NOW(), $8, NOW(), $8)`,
[articleId, aFrom, toList, cc.join(', '), subject, noteBody, now, agentId || 1]
INSERT INTO article_data_mime (article_id, a_from, a_to, a_cc, a_bcc, a_subject, a_body, a_content_type, a_message_id, incoming_time, create_time, create_by, change_time, change_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'text/html; charset=utf-8', $8, $9, $11, $10, $11, $10)`,
[articleId, aFrom, toList, cc.join(', '), bccList.join(', '), subject, processedBodyHtml, finalMessageId, now, agentId || 1, localNow]
);
// Helper to strip HTML tags
const stripHtml = (html) => {
if (!html) return '';
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
};
// Helper to clean search index values
const cleanSearchValue = (str) => {
if (!str) return '';
return str.toLowerCase()
.replace(/[^\w\s@.+-]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
};
const plainBody = stripHtml(processedBodyHtml);
// 1. Write standard plain text version for client fallbacks
await pool.query(`
INSERT INTO article_data_mime_plain (article_id, body, create_time, create_by, change_time, change_by)
VALUES ($1, $2, $3, $4, $3, $4)`,
[articleId, plainBody, localNow, agentId || 1]
);
// 2. Populate OTRS fulltext search index (article_search_index)
const indexRows = [
{ key: 'MIMEBase_From', val: aFrom },
{ key: 'MIMEBase_To', val: toList },
{ key: 'MIMEBase_Subject', val: subject },
{ key: 'MIMEBase_Body', val: plainBody }
];
if (cc && cc.length) {
indexRows.push({ key: 'MIMEBase_Cc', val: cc.join(', ') });
}
for (const row of indexRows) {
if (row.val) {
await pool.query(`
INSERT INTO article_search_index (ticket_id, article_id, article_key, article_value)
VALUES ($1, $2, $3, $4)`,
[ticketId, articleId, row.key, cleanSearchValue(row.val)]
);
}
}
// 3. Write attachments (the special 'file-1' HTML body, normal ones, and inline images) to article_data_mime_attachment
const allAtts = [
{
filename: 'file-1',
contentType: 'text/html; charset="utf-8"',
content: Buffer.from(processedBodyHtml).toString('base64'),
disposition: '',
contentId: null
},
...attachments.map(a => ({
filename: a.filename,
contentType: a.contentType || 'application/octet-stream',
content: a.content, // base64
disposition: 'attachment',
contentId: null
})),
...extractedInlineImages.map(img => ({
filename: img.cid,
contentType: img.contentType || 'image/png',
content: img.content, // base64
disposition: 'inline',
contentId: `<${img.cid}>`
}))
];
for (const att of allAtts) {
try {
const byteSize = Buffer.from(att.content, 'base64').length;
await pool.query(`
INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type,
content_id, disposition, content,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $9, $8, $9, $8)`,
[
articleId,
att.filename,
byteSize,
att.contentType,
att.contentId,
att.disposition,
att.content, // base64 text directly
agentId || 1,
localNow
]
);
} catch (attErr) {
console.warn('[Email] Allegato non inserito:', attErr.message);
}
}
}
} catch (noteErr) {
console.warn('[Email] Nota interna OTRS non inserita (non bloccante):', noteErr.message);
console.warn('[Email] Articolo OTRS non inserito a database o non indicizzato (non bloccante):', noteErr.message);
}
console.log(`[Email] ✅ Email inviata per ticket #${tn} a: ${to.join(', ')}`);
+328
View File
@@ -0,0 +1,328 @@
const express = require('express');
const router = express.Router();
const pool = require('../db');
const { db, logAttivita } = require('../activityDb');
// Helper to fetch details of a list of tickets from OTRS DB
async function fetchTicketsDetails(ticketIds) {
if (!ticketIds || ticketIds.length === 0) return [];
try {
const placeholders = ticketIds.map((_, i) => `$${i + 1}`).join(', ');
const query = `
SELECT
t.id, t.tn, t.title,
t.queue_id, q.name AS queue_name,
t.ticket_state_id, ts.name AS state_name, tst.name AS state_type,
t.ticket_priority_id, tp.name AS priority_name,
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last, u.login AS owner_login
FROM ticket t
JOIN queue q ON t.queue_id = q.id
JOIN ticket_state ts ON t.ticket_state_id = ts.id
JOIN ticket_state_type tst ON ts.type_id = tst.id
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id IN (${placeholders})
`;
const res = await pool.query(query, ticketIds);
return res.rows;
} catch (err) {
console.error('Error fetching ticket details from OTRS DB:', err);
return [];
}
}
// 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 CASE WHEN UPPER(g.nome) = 'CHIUDI A FINE GIORNATA' THEN 1 ELSE 0 END ASC, g.nome ASC
`).all();
res.json(groups);
} catch (err) {
res.status(500).json({ error: 'Errore nel recupero dei gruppi', message: err.message });
}
});
// GET /api/groups/by-ticket/:ticket_id - Get groups associated to a specific ticket
router.get('/by-ticket/:ticket_id', (req, res) => {
try {
const { ticket_id } = req.params;
const ticketIdNum = parseInt(ticket_id, 10);
if (isNaN(ticketIdNum)) {
return res.status(400).json({ error: 'ID ticket non valido' });
}
const asMaster = db.prepare('SELECT id, nome, descrizione FROM ticket_groups WHERE master_ticket_id = ?').all(ticketIdNum);
const asMember = db.prepare(`
SELECT g.id, g.nome, g.descrizione
FROM ticket_groups g
JOIN ticket_group_members m ON g.id = m.group_id
WHERE m.ticket_id = ?
`).all(ticketIdNum);
res.json({ asMaster, asMember });
} catch (err) {
res.status(500).json({ error: 'Errore nel recupero dei gruppi del ticket', message: err.message });
}
});
// POST /api/groups - Create a new group
router.post('/', (req, res) => {
try {
const { nome, descrizione, master_ticket_id } = req.body;
if (!nome) {
return res.status(400).json({ error: 'Il nome del gruppo è obbligatorio' });
}
const masterId = master_ticket_id ? parseInt(master_ticket_id, 10) : null;
const info = db.prepare(`
INSERT INTO ticket_groups (nome, descrizione, master_ticket_id)
VALUES (?, ?, ?)
`).run(nome, descrizione || '', masterId);
res.json({ id: info.lastInsertRowid, nome, descrizione, master_ticket_id: masterId });
} catch (err) {
res.status(500).json({ error: 'Errore nella creazione del gruppo', message: err.message });
}
});
// GET /api/groups/:id - Detail of a single group
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const group = db.prepare('SELECT * FROM ticket_groups WHERE id = ?').get(id);
if (!group) {
return res.status(404).json({ error: 'Gruppo non trovato' });
}
// Get members list
const memberRows = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(id);
const memberIds = memberRows.map(r => r.ticket_id);
// Fetch details of master and member tickets from OTRS
let masterTicket = null;
if (group.master_ticket_id) {
const details = await fetchTicketsDetails([group.master_ticket_id]);
if (details.length > 0) {
masterTicket = details[0];
}
}
let memberTickets = [];
if (memberIds.length > 0) {
memberTickets = await fetchTicketsDetails(memberIds);
}
res.json({
group,
masterTicket,
memberTickets
});
} catch (err) {
res.status(500).json({ error: 'Errore nel recupero dei dettagli del gruppo', message: err.message });
}
});
// PUT /api/groups/:id - Update group info
router.put('/:id', (req, res) => {
try {
const { id } = req.params;
const { nome, descrizione, master_ticket_id } = req.body;
if (!nome) {
return res.status(400).json({ error: 'Il nome del gruppo è obbligatorio' });
}
const masterId = master_ticket_id ? parseInt(master_ticket_id, 10) : null;
const info = db.prepare(`
UPDATE ticket_groups
SET nome = ?, descrizione = ?, master_ticket_id = ?
WHERE id = ?
`).run(nome, descrizione || '', masterId, id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Gruppo non trovato o nessuna modifica' });
}
res.json({ id: parseInt(id, 10), nome, descrizione, master_ticket_id: masterId });
} catch (err) {
res.status(500).json({ error: 'Errore nell\'aggiornamento del gruppo', message: err.message });
}
});
// DELETE /api/groups/:id - Delete group
router.delete('/:id', (req, res) => {
try {
const { id } = req.params;
const info = db.prepare('DELETE FROM ticket_groups WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Gruppo non trovato' });
}
res.json({ success: true, message: 'Gruppo eliminato con successo' });
} catch (err) {
res.status(500).json({ error: 'Errore nella cancellazione del gruppo', message: err.message });
}
});
// POST /api/groups/:id/tickets - Add ticket(s) to group
router.post('/:id/tickets', async (req, res) => {
try {
const { id } = req.params;
const { ticket_identifier } = req.body; // Can be ticket ID or ticket number (tn)
if (!ticket_identifier) {
return res.status(400).json({ error: 'Identificativo ticket obbligatorio' });
}
const cleanIdentifier = String(ticket_identifier).trim();
let queryTicketSql;
let queryParams;
if (/^\d+$/.test(cleanIdentifier)) {
// It's a number - check if it matches id or tn
queryTicketSql = 'SELECT id, tn, title FROM ticket WHERE id = $1 OR tn = $2';
queryParams = [parseInt(cleanIdentifier, 10), cleanIdentifier];
} else {
// Check tn
queryTicketSql = 'SELECT id, tn, title FROM ticket WHERE tn = $1';
queryParams = [cleanIdentifier];
}
const otrsRes = await pool.query(queryTicketSql, queryParams);
if (otrsRes.rows.length === 0) {
return res.status(404).json({ error: `Ticket con identificativo '${cleanIdentifier}' non trovato` });
}
const ticket = otrsRes.rows[0];
// Check if group exists
const group = db.prepare('SELECT id FROM ticket_groups WHERE id = ?').get(id);
if (!group) {
return res.status(404).json({ error: 'Gruppo non trovato' });
}
// Insert to membership
try {
db.prepare(`
INSERT INTO ticket_group_members (group_id, ticket_id)
VALUES (?, ?)
`).run(id, ticket.id);
} catch (dbErr) {
if (dbErr.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
return res.status(409).json({ error: 'Il ticket appartiene già a questo gruppo' });
}
throw dbErr;
}
res.json({ success: true, ticket });
} catch (err) {
res.status(500).json({ error: 'Errore nell\'associazione del ticket', message: err.message });
}
});
// DELETE /api/groups/:id/tickets/:ticket_id - Remove ticket from group
router.delete('/:id/tickets/:ticket_id', (req, res) => {
try {
const { id, ticket_id } = req.params;
const info = db.prepare('DELETE FROM ticket_group_members WHERE group_id = ? AND ticket_id = ?').run(id, ticket_id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Associazione non trovata' });
}
res.json({ success: true, message: 'Ticket rimosso dal gruppo' });
} catch (err) {
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;
+16 -2
View File
@@ -132,12 +132,26 @@ router.get('/users', async (req, res) => {
});
// GET /api/config — Application config
router.get('/config', (req, res) => {
router.get('/config', async (req, res) => {
const agentId = parseInt(req.headers['x-agent-id'], 10) || 1;
let agentEmail = '';
try {
const prefRes = await pool.query(
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
[agentId]
);
if (prefRes.rows.length > 0) {
agentEmail = prefRes.rows[0].preferences_value || '';
}
} catch (_) {}
res.json({
defaultAgentLogin: process.env.OTRS_API_USER || '',
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480,
phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70,
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00'
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00',
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
agentEmail: agentEmail
});
});
+72
View File
@@ -0,0 +1,72 @@
const express = require('express');
const router = express.Router();
const { db } = require('../activityDb');
// GET /api/presets - Get all presets for the active agent and page mode
router.get('/', (req, res) => {
try {
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
const { page_mode } = req.query; // 'general' or 'my'
if (!page_mode) {
return res.status(400).json({ error: 'Il parametro page_mode è obbligatorio' });
}
const presets = db.prepare(`
SELECT * FROM filter_presets
WHERE agent_id = ? AND page_mode = ?
ORDER BY name ASC
`).all(agentId, page_mode);
res.json(presets);
} catch (err) {
res.status(500).json({ error: 'Errore nel caricamento dei preset', message: err.message });
}
});
// POST /api/presets - Save a new filter preset
router.post('/', (req, res) => {
try {
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
const { name, page_mode, filters } = req.body;
if (!name || !page_mode || !filters) {
return res.status(400).json({ error: 'I campi name, page_mode e filters sono obbligatori' });
}
const filtersJson = typeof filters === 'string' ? filters : JSON.stringify(filters);
const info = db.prepare(`
INSERT INTO filter_presets (agent_id, name, page_mode, filters_json)
VALUES (?, ?, ?, ?)
`).run(agentId, name, page_mode, filtersJson);
res.json({
id: info.lastInsertRowid,
agent_id: agentId,
name,
page_mode,
filters_json: filtersJson
});
} catch (err) {
res.status(500).json({ error: 'Errore nel salvataggio del preset', message: err.message });
}
});
// DELETE /api/presets/:id - Delete a preset
router.delete('/:id', (req, res) => {
try {
const { id } = req.params;
const info = db.prepare('DELETE FROM filter_presets WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Preset non trovato' });
}
res.json({ success: true, message: 'Preset eliminato con successo' });
} catch (err) {
res.status(500).json({ error: 'Errore nella rimozione del preset', message: err.message });
}
});
module.exports = router;
+142 -38
View File
@@ -3,6 +3,13 @@ const router = express.Router();
const pool = require('../db');
const { db, logAttivita } = require('../activityDb');
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
function getLocalTimestamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// Helper: resolve agent name from DB (best-effort, non-blocking)
async function resolveAgentName(agentId) {
try {
@@ -123,8 +130,21 @@ router.get('/', async (req, res) => {
}
if (queue_id) {
conditions.push(`t.queue_id = $${paramIdx++}`);
params.push(parseInt(queue_id));
let queueIds = [];
if (Array.isArray(queue_id)) {
queueIds = queue_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof queue_id === 'string') {
queueIds = queue_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(queue_id);
if (!isNaN(parsed)) queueIds.push(parsed);
}
if (queueIds.length > 0) {
const placeholders = queueIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.queue_id IN (${placeholders})`);
params.push(...queueIds);
}
}
if (state_id) {
let stateIds = [];
@@ -144,12 +164,38 @@ router.get('/', async (req, res) => {
}
}
if (priority_id) {
conditions.push(`t.ticket_priority_id = $${paramIdx++}`);
params.push(parseInt(priority_id));
let priorityIds = [];
if (Array.isArray(priority_id)) {
priorityIds = priority_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof priority_id === 'string') {
priorityIds = priority_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(priority_id);
if (!isNaN(parsed)) priorityIds.push(parsed);
}
if (priorityIds.length > 0) {
const placeholders = priorityIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.ticket_priority_id IN (${placeholders})`);
params.push(...priorityIds);
}
}
if (user_id) {
conditions.push(`t.user_id = $${paramIdx++}`);
params.push(parseInt(user_id));
let userIds = [];
if (Array.isArray(user_id)) {
userIds = user_id.map(id => parseInt(id)).filter(id => !isNaN(id));
} else if (typeof user_id === 'string') {
userIds = user_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
} else {
const parsed = parseInt(user_id);
if (!isNaN(parsed)) userIds.push(parsed);
}
if (userIds.length > 0) {
const placeholders = userIds.map(() => `$${paramIdx++}`).join(', ');
conditions.push(`t.user_id IN (${placeholders})`);
params.push(...userIds);
}
}
if (type_id) {
conditions.push(`t.type_id = $${paramIdx++}`);
@@ -388,7 +434,7 @@ router.get('/:id', async (req, res) => {
// Fetch attachments metadata for all articles in the ticket
const attachmentsResult = await pool.query(
`SELECT id, article_id, filename, content_size, content_type, disposition
`SELECT id, article_id, filename, content_size, content_type, disposition, content_id
FROM article_data_mime_attachment
WHERE article_id IN (
SELECT id FROM article WHERE ticket_id = $1
@@ -484,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,
@@ -504,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
]
);
@@ -532,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
]
);
@@ -597,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;
@@ -614,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)
@@ -632,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]
);
}
@@ -646,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
]
);
@@ -703,7 +752,7 @@ router.patch('/:id', async (req, res) => {
let current;
try {
const currentResult = await pool.query(
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, responsible_user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
FROM ticket WHERE id = $1`,
[id]
);
@@ -724,6 +773,7 @@ router.patch('/:id', async (req, res) => {
if (updates.ticket_priority_id !== undefined) ticketFields.PriorityID = updates.ticket_priority_id;
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_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.type_id !== undefined) ticketFields.TypeID = updates.type_id;
if (updates.title !== undefined) ticketFields.Title = updates.title;
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
@@ -815,7 +865,7 @@ router.patch('/:id', async (req, res) => {
const setParams = [];
let pIdx = 1;
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'type_id', 'title', 'ticket_lock_id', 'customer_id', 'customer_user_id'];
const allowedFields = ['ticket_state_id', 'ticket_priority_id', 'queue_id', 'user_id', 'responsible_user_id', 'type_id', 'title', 'ticket_lock_id', 'customer_id', 'customer_user_id'];
for (const field of allowedFields) {
if (updates[field] !== undefined && updates[field] !== current[field]) {
setClauses.push(`${field} = $${pIdx++}`);
@@ -856,6 +906,7 @@ router.patch('/:id', async (req, res) => {
ticket_priority_id: 'PriorityUpdate',
queue_id: 'Move',
user_id: 'OwnerUpdate',
responsible_user_id: 'ResponsibleUpdate',
type_id: 'TypeUpdate',
ticket_lock_id: 'Lock',
customer_id: 'CustomerUpdate',
@@ -1016,7 +1067,7 @@ router.get('/:id/articles', async (req, res) => {
ast.name AS sender_type,
cc.name AS channel_name,
adm.a_from, adm.a_to, adm.a_cc, adm.a_subject, adm.a_body,
adm.a_content_type, adm.incoming_time,
adm.a_content_type, adm.incoming_time, adm.a_message_id, adm.a_references,
a.create_time,
creator.first_name AS creator_first, creator.last_name AS creator_last,
ta.time_unit
@@ -1072,7 +1123,7 @@ router.post('/:id/articles', async (req, res) => {
}
if (attachments && Array.isArray(attachments)) {
payload.Article.Attachment = attachments.map(att => ({
payload.Attachment = attachments.map(att => ({
Content: att.content,
ContentType: att.content_type || 'application/octet-stream',
Filename: att.filename
@@ -1197,6 +1248,8 @@ router.post('/:id/articles', async (req, res) => {
);
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
const localNow = getLocalTimestamp();
// Create article
const articleResult = await client.query(
`INSERT INTO article (
@@ -1204,9 +1257,9 @@ router.post('/:id/articles', async (req, res) => {
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES (
$1, $2, $3, $4, 1, NOW(), $5, NOW(), $5
$1, $2, $3, $4, 1, $5, $6, $5, $6
) RETURNING id`,
[id, senderTypeId, channelId, is_visible_for_customer ? 1 : 0, operatorId]
[id, senderTypeId, channelId, is_visible_for_customer ? 1 : 0, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
@@ -1228,9 +1281,9 @@ router.post('/:id/articles', async (req, res) => {
$1, $2, '', '', '', '', $3, $4,
'', '', '',
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
NOW(), $7, NOW(), $7
$7, $8, $7, $8
)`,
[articleId, fromHeader, subject || 'Nota interna', body, contentType, contentPath, operatorId]
[articleId, fromHeader, subject || 'Nota interna', body, contentType, contentPath, localNow, operatorId]
);
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and base64 encoded text)
@@ -1245,9 +1298,9 @@ router.post('/:id/articles', 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]
);
// Insert additional attachments if any
@@ -1258,14 +1311,15 @@ router.post('/:id/articles', 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, $7, $6, $7, $6)`,
[
articleId,
att.filename,
contentBuffer.length,
att.content_type || 'application/octet-stream',
att.content, // base64 string directly
operatorId
operatorId,
localNow
]
);
}
@@ -1279,16 +1333,15 @@ router.post('/:id/articles', async (req, res) => {
`INSERT INTO time_accounting (
ticket_id, article_id, time_unit,
create_time, create_by, change_time, change_by
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
[id, articleId, parsedTime, operatorId]
) VALUES ($1, $2, $3, $5, $4, $5, $4)`,
[id, articleId, parsedTime, operatorId, localNow]
);
}
}
// Update ticket change_time
await client.query(
`UPDATE ticket SET change_time = NOW(), change_by = $1 WHERE id = $2`,
[operatorId, id]
`UPDATE ticket SET change_time = $1, change_by = $2 WHERE id = $3`,
[localNow, operatorId, id]
);
// Add history entry
@@ -1310,13 +1363,13 @@ router.post('/:id/articles', async (req, res) => {
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
NOW(), $10, NOW(), $10
$11, $10, $11, $10
)`,
[
`%%`,
htResult.rows[0].id, id, articleId, t.type_id || 1, t.queue_id,
t.user_id, t.ticket_priority_id, t.ticket_state_id,
operatorId
operatorId, localNow
]
);
}
@@ -1367,7 +1420,9 @@ 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;
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
@@ -1420,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', '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;
@@ -1756,6 +1811,33 @@ router.post('/merge', async (req, res) => {
)`,
[articleId, mergeNoteBody, contentPath, operatorId]
);
// Create internal system note inside source ticket B (the merged ticket)
const sourceArtResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES ($1, 1, 1, 0, 1, NOW(), $2, NOW(), $2) RETURNING id`,
[sourceId, operatorId]
);
const sourceArticleId = sourceArtResult.rows[0].id;
const sourceMergeNoteText = `Merged Ticket ${sourceTn} to ${targetTn}`;
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
a_message_id, a_in_reply_to, a_references,
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
$1, 'Sistema OTRS Turbo', '', '', '', '', $2, $2,
'', '', '',
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
NOW(), $4, NOW(), $4
)`,
[sourceArticleId, sourceMergeNoteText, contentPath, operatorId]
);
}
await client.query('COMMIT');
@@ -2107,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
+8 -2
View File
@@ -1,13 +1,17 @@
require('dotenv').config();
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');
const dashboardRouter = require('./routes/dashboard');
const activityRouter = require('./routes/activity');
const emailRouter = require('./routes/email');
const groupsRouter = require('./routes/groups');
const presetsRouter = require('./routes/presets');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -25,6 +29,8 @@ app.use('/api', lookupsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/attivita', activityRouter);
app.use('/api/email', emailRouter);
app.use('/api/groups', groupsRouter);
app.use('/api/presets', presetsRouter);
// SPA fallback — serve index.html for all non-API routes
app.get('*', (req, res) => {
+35 -3
View File
@@ -60,7 +60,7 @@ async function getAccessToken() {
* @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }]
* @returns {Promise<void>}
*/
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [] }) {
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
const sender = process.env.AZURE_MAIL_SENDER;
if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env');
@@ -94,6 +94,14 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
})),
];
const headers = [];
if (inReplyTo) {
headers.push({ name: 'In-Reply-To', value: inReplyTo });
}
if (references) {
headers.push({ name: 'References', value: references });
}
const payload = {
message: {
subject,
@@ -105,8 +113,9 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
ccRecipients,
bccRecipients,
attachments: allAttachments,
internetMessageHeaders: headers.length ? headers : undefined,
},
saveToSentItems: false,
saveToSentItems: true,
};
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`;
@@ -121,7 +130,30 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
});
if (res.status === 202) {
return; // Successo (Graph API risponde con 202 No Content)
let actualMessageId = messageId;
try {
// Wait 1.5 seconds for Exchange to process and place it in Sent Items
await new Promise(resolve => setTimeout(resolve, 1500));
const searchUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/mailFolders/sentItems/messages?$filter=subject eq '${subject.replace(/'/g, "''")}'&$top=1&$select=internetMessageId`;
const searchRes = await fetch(searchUrl, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData.value && searchData.value.length > 0) {
actualMessageId = searchData.value[0].internetMessageId;
}
}
} catch (searchErr) {
console.warn('[Graph Mailer] Failed to retrieve actual InternetMessageId from Sent Items:', searchErr.message);
}
return { internetMessageId: actualMessageId };
}
const errText = await res.text();
+5 -1
View File
@@ -29,7 +29,7 @@ function getTransporter() {
* Invia una email tramite SMTP (nodemailer).
* Stessa interfaccia di graphMailer.sendMail.
*/
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [] }) {
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
const transporter = getTransporter();
const mailOptions = {
@@ -39,6 +39,9 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
bcc: bcc.length ? bcc.join(', ') : undefined,
subject,
html: bodyHtml,
inReplyTo,
references,
messageId,
attachments: [
...attachments.map(a => ({
filename: a.filename,
@@ -55,6 +58,7 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
};
await transporter.sendMail(mailOptions);
return { internetMessageId: messageId };
}
module.exports = { sendMail };