Compare commits

...
38 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
Gabriele Cimaschi 5b914eb198 feat: funzione per l'invio della posta elettronica da otrs-turbo. fix: corretta consuntivazione automatica. feat: aggiunto link apri in otrs anche nel numero del ticket in cima 2026-07-08 21:46:11 +02:00
Gabriele Cimaschi 9a91c3d9f5 fix: correzione doppia consuntivazione note. fix: utenti ldap. feat: ricerca per customer useers. feat: possiblità di rimuovere le note 2026-07-08 12:52:30 +02:00
VVER440 3b06609fcb feat: aggiunta consuntivazione automatica tramite variabili env. style: aggiunto bordino anche attorno al tempo nel caso di overperformance 2026-07-08 08:26:10 +02:00
VVER440 fd78b7e283 feat: possibilità di ingrandire gli elenchi delle pagine dashbaord, ticket e ticket a mio carico 2026-07-08 08:00:02 +02:00
VVER440 f9cbfc63b9 fix: disabilitato funzionamento tab nell'editor html 2026-07-08 07:30:27 +02:00
VVER440 d39c9895b4 feat: script per criptare frasi motivazionali/demotivazionali e spostate all'interno del sqllite. Mantenuti file txt per compatibilità. Aggiunta soglia di attivazione nel env 2026-07-08 03:09:44 +02:00
VVER440 e6c3a6a43f fix: corretto veramente ldap e d altro 2026-07-08 02:27:38 +02:00
VVER440 882a1949a1 fix: corretta associazione dopo aggiunta ldap 2026-07-08 01:21:15 +02:00
VVER440 78a26fe970 fix: verificare, ma dovrebbe essere corretta la lattura da ldap 2026-07-08 00:58:52 +02:00
VVER440 bf8b4c387e feat: aggiunta tracciatura delle modifiche effettuate 2026-07-07 21:51:58 +02:00
VVER440 3f6d2cc3a8 style: riviste parti dell'interfaccia dei ticket. feat: aggiunta possiblità di modificare il tempo inserito nelle note ed aggiunta voce per tempo nel menu azioni rapide 2026-07-07 21:14:24 +02:00
VVER440 7b0b29e6c6 fix: corretta modifica massiva scassata da claude. fix: e possibile inserire clienti non esistenti come in otrs. 2026-07-07 08:15:29 +02:00
VVER440 b3570d6757 feat: aggiunta possiblità di modificare il cliente 2026-07-07 08:14:07 +02:00
36 changed files with 10974 additions and 419 deletions
+5
View File
@@ -0,0 +1,5 @@
---
trigger: always_on
---
Non usare le notifiche native dei browser.
+1 -2
View File
@@ -1,6 +1,5 @@
---
trigger: always_on
glob:
description:
---
Per affrontare una issue di git tieni in considerazione anche i commenti interni.
+40
View File
@@ -24,3 +24,43 @@ DAILY_TARGET_TIME=480
#Variabile per la numerazione dei ticket
OTRS_SYSTEM_ID=10
OTRS_COUNTER_PADDING=6
# Intervallo in ore per la sincronizzazione automatica LDAP (default 24 ore)
LDAP_SYNC_INTERVAL_HOURS=24
# Imposta a true per forzare l'aggiornamento diretto del DB per tutte le modifiche ai ticket bypassando l'API REST (esclusa la ricerca LDAP)
FORCE_DB_UPDATE=false
# Tempo di attesa in millisecondi prima della verifica a database dopo l'inserimento nota via API (default 3000)
OTRS_API_FALLBACK_WAIT_MS=3000
# Chiave per cifrare le frasi nel database locale (NON CANCELLARE O MODIFICARE SE CI SONO DATI CRIPTATI)
CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc
# Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%)
PHRASE_THRESHOLD=70
# Configurazioni per la consuntivazione automatica fine giornata
AUTO_TIME_MIN_HOUR=18:00
AUTO_TIME_QUEUE=Assistenza
AUTO_TIME_TYPE=Default
AUTO_TIME_TITLE=Consuntivazione Automatica fine giornata
AUTO_TIME_SUBJECT=Consuntivazione automatica ore mancanti
AUTO_TIME_BODY=Consuntivazione eseguita automaticamente per il completamento delle ore lavorative giornaliere.
AUTO_TIME_CUSTOMER_USER=client_generic
# --- Microsoft Graph API per invio email (metodo primario - Exchange con 2FA) ---
AZURE_TENANT_ID=your_tenant_id_here
AZURE_CLIENT_ID=your_client_id_here
AZURE_CLIENT_SECRET=your_client_secret_here
AZURE_MAIL_SENDER=helpdesk@example.com
# --- SMTP Classico (fallback se Graph API non disponibile - lasciare vuoto per disabilitare) ---
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=helpdesk@example.com
# --- BCC automatico OTRS per tracciamento ticket ---
OTRS_MAIL_BCC=helpdesk@example.com
+4
View File
@@ -1,2 +1,6 @@
node_modules/
.env
internal.db
internal.db-shm
internal.db-wal
dist
+191
View File
@@ -0,0 +1,191 @@
/**
* activityDb.js
* Local SQLite activity log database.
* Opens (or creates) internal.db and provides logAttivita() for logging
* every significant action sent to OTRS.
*/
const Database = require('better-sqlite3');
const path = require('path');
const crypto = require('crypto');
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
db.pragma('journal_mode = WAL');
// Create table if it does not exist
db.exec(`
CREATE TABLE IF NOT EXISTS attivita (
id TEXT PRIMARY KEY,
agente_id INTEGER NOT NULL DEFAULT 0,
agente_nome TEXT NOT NULL DEFAULT '',
titolo_azione TEXT NOT NULL,
azione TEXT NOT NULL DEFAULT '{}',
esito TEXT NOT NULL DEFAULT 'successo',
creato_il DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS agent_settings (
agent_id INTEGER PRIMARY KEY,
preview_limit INTEGER NOT NULL DEFAULT 10,
tickets_per_page INTEGER NOT NULL DEFAULT 50
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS email_signatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
name TEXT NOT NULL,
body_html TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
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 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) {
// Column already exists
}
/**
* Generate a UUID v7 (time-ordered).
*/
function uuidV7() {
const tsMs = BigInt(Date.now());
const tsMsHex = tsMs.toString(16).padStart(12, '0');
const rand = crypto.randomBytes(10).toString('hex');
const p1 = tsMsHex.slice(0, 8);
const p2 = tsMsHex.slice(8, 12);
const p3 = '7' + rand.slice(0, 3);
const p4 = ((parseInt(rand.slice(3, 4), 16) & 0x3) | 0x8).toString(16) + rand.slice(4, 7);
const p5 = rand.slice(7, 19);
return `${p1}-${p2}-${p3}-${p4}-${p5}`;
}
const insertStmt = db.prepare(`
INSERT INTO attivita (id, agente_id, agente_nome, titolo_azione, azione, esito)
VALUES (?, ?, ?, ?, ?, ?)
`);
/**
* Log an activity record.
* @param {Object} params
*/
function logAttivita({ agente_id = 0, agente_nome = '', titolo_azione, azione = {}, esito = 'successo' }) {
try {
const id = uuidV7();
const azioneStr = typeof azione === 'string' ? azione : JSON.stringify(azione, null, 2);
insertStmt.run(id, agente_id, agente_nome, titolo_azione, azioneStr, esito);
} catch (err) {
console.error('[activityDb] Failed to log activity:', err.message);
}
}
module.exports = { db, logAttivita };
+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) => {
+1755 -4
View File
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -3,21 +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": "^11.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"mysql2": "^3.22.5",
"pg": "^8.13.0"
"nodemailer": "^9.0.3",
"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"
}
}
+310 -13
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);
@@ -487,6 +506,7 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
min-width: 0;
}
/* ---- Topbar ---- */
@@ -568,6 +588,7 @@ body {
flex: 1;
padding: var(--space-xl);
animation: fadeIn var(--transition-base);
min-width: 0;
}
@keyframes fadeIn {
@@ -903,6 +924,36 @@ body {
color: var(--accent-primary);
}
/* Queue Custom Tooltip */
.ticket-table td.queue-cell {
position: relative;
overflow: visible !important;
}
.queue-tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
left: 50%;
bottom: 100%;
transform: translate(-50%, -6px);
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
padding: 6px 12px;
border-radius: var(--radius-md);
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
box-shadow: var(--shadow-lg);
z-index: 1000;
transition: opacity 0.05s ease, visibility 0.05s ease;
pointer-events: none;
}
.queue-cell:hover .queue-tooltip {
visibility: visible;
opacity: 1;
}
.ticket-table td {
padding: 10px var(--space-md);
border-bottom: 1px solid var(--border-subtle);
@@ -1067,6 +1118,12 @@ body {
backdrop-filter: blur(12px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
position: relative;
z-index: 20;
}
.state-multiselect-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.filter-group {
@@ -1119,13 +1176,19 @@ body {
.batch-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
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 {
@@ -1793,21 +1856,22 @@ body {
top: 100%;
left: 0;
right: 0;
background: #1f2937;
border: 1px solid #4b5563;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
max-height: 220px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(8px);
}
.autocomplete-suggestion-item {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid rgba(156, 163, 175, 0.15);
border-bottom: 1px solid var(--border-subtle);
font-size: 0.85rem;
color: #f3f4f6;
color: var(--text-primary);
transition: all var(--transition-fast);
}
@@ -1817,7 +1881,7 @@ body {
}
.autocomplete-suggestion-item:hover span {
color: #e5e7eb !important;
color: #ffffff !important;
}
.autocomplete-suggestion-item:last-child {
@@ -1943,8 +2007,8 @@ body {
}
.bulk-select option {
background-color: #111827;
/* Sfondo scuro per gli stati e i tipi */
background-color: var(--bg-secondary);
/* Sfondo per gli stati e i tipi */
color: var(--text-primary);
}
@@ -1955,23 +2019,24 @@ body {
width: max-content;
min-width: 100%;
max-width: 400px;
background: #1f2937;
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
max-height: 380px;
/* Incrementata l'altezza per contenere più elementi */
overflow-y: auto;
z-index: 999;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.6);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(8px);
}
.bulk-suggestions-item {
padding: 6px 10px;
/* Compatto */
cursor: pointer;
border-bottom: 1px solid rgba(156, 163, 175, 0.1);
border-bottom: 1px solid var(--border-subtle);
font-size: 0.8rem;
color: #e5e7eb;
color: var(--text-primary);
}
.bulk-suggestions-item:hover {
@@ -1979,6 +2044,10 @@ body {
color: white;
}
.bulk-suggestions-item:hover span {
color: white !important;
}
.bulk-suggestions-item:last-child {
border-bottom: none;
}
@@ -2352,3 +2421,231 @@ body {
transform: translateY(-50%) translateX(0);
pointer-events: auto;
}
/* ---- Overperformance Glow ---- */
@keyframes overperformance-glow {
0% {
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6);
}
50% {
box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), inset 0 0 30px rgba(239, 68, 68, 0.4);
border-color: rgba(239, 68, 68, 1);
}
100% {
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6);
}
}
.sidebar-brand.glow {
animation: overperformance-glow 2s infinite;
background: rgba(239, 68, 68, 0.1) !important;
border: 1px solid rgba(239, 68, 68, 0.6) !important;
border-radius: var(--radius-md);
margin: var(--space-sm);
padding: 12px !important;
transition: all 0.5s ease;
}
@keyframes autotime-pulse {
0% {
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
border-color: rgba(16, 112, 202, 0.5);
}
50% {
box-shadow: 0 0 15px rgba(16, 112, 202, 0.8), inset 0 0 5px rgba(16, 112, 202, 0.3);
border-color: rgba(16, 112, 202, 0.9);
background: rgba(16, 112, 202, 0.15);
}
100% {
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
border-color: rgba(16, 112, 202, 0.5);
}
}
.sidebar-brand.clickable-auto-time {
cursor: pointer;
animation: autotime-pulse 2.5s infinite ease-in-out;
border: 1px dashed var(--accent-primary) !important;
border-radius: var(--radius-md);
margin: var(--space-xs) var(--space-sm);
padding: 8px 12px !important;
transition: all 0.3s ease;
}
.sidebar-brand.clickable-auto-time:hover {
filter: brightness(1.2);
transform: translateY(-1px);
}
.sidebar-timer.clickable-auto-time {
cursor: pointer;
animation: autotime-pulse 2.5s infinite ease-in-out;
border: 1px dashed var(--accent-primary) !important;
border-radius: var(--radius-md);
margin: var(--space-xs) var(--space-sm);
padding: 8px 12px !important;
transition: all 0.3s ease;
}
.sidebar-timer.clickable-auto-time:hover {
filter: brightness(1.2);
transform: translateY(-1px);
}
@keyframes overperformance-alarm-pulse {
0% {
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
border-color: rgba(239, 68, 68, 0.6);
}
50% {
box-shadow: 0 0 15px rgba(239, 68, 68, 0.8), inset 0 0 5px rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.9);
background: rgba(239, 68, 68, 0.15) !important;
}
100% {
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
border-color: rgba(239, 68, 68, 0.6);
}
}
.sidebar-timer.overperformance-alarm {
animation: overperformance-alarm-pulse 1.5s infinite ease-in-out;
border: 1px solid rgba(239, 68, 68, 0.6) !important;
border-radius: var(--radius-md);
margin: var(--space-xs) var(--space-sm);
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;
}
+62 -6
View File
@@ -18,11 +18,22 @@
<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>
<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;">
<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>
</div>
@@ -59,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">
@@ -79,6 +101,25 @@
<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">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>Storico Attività Turbo</span>
</a>
</li>
</ul>
<div class="sidebar-footer">
@@ -92,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);">
@@ -122,9 +164,15 @@
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input type="text" class="search-input" id="global-search" placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
<button id="global-search-clear" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-muted); display: none; align-items: center; justify-content: center;" title="Cancella ricerca">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;display:block;"><path d="M18 6L6 18M6 6l12 12"/></svg>
<input type="text" class="search-input" id="global-search"
placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
<button id="global-search-clear"
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-muted); display: none; align-items: center; justify-content: center;"
title="Cancella ricerca">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="width:14px;height:14px;display:block;">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
@@ -134,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 -->
@@ -151,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>
@@ -158,6 +210,10 @@
<script src="/js/views/ticketDetail.js"></script>
<script src="/js/views/ticketCreate.js"></script>
<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/mailManagement.js"></script>
<script src="/js/views/ticketGroups.js"></script>
<script src="/js/app.js"></script>
</body>
+531 -27
View File
@@ -13,12 +13,134 @@ 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);
},
/** Initialize the application */
init() {
this.initTheme();
this.loadDemotivationalPhrases();
this.loadMotivationalPhrases();
this.loadTabs();
Toast.init();
// Hash-based SPA router
@@ -34,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) => {
@@ -94,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 {
@@ -108,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');
@@ -138,6 +305,21 @@ 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 === '#/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];
document.getElementById('nav-tickets')?.classList.add('active');
@@ -181,25 +363,29 @@ const App = {
if (cached) {
try {
this.lookups = JSON.parse(cached);
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined || !this.lookups.customerUsers || !this.lookups.customer_users_version_1) {
throw new Error('Outdated config cache (missing autoTimeMinHour or customerUsers)');
}
this.lookupsLoaded = true;
return;
} catch (e) {
console.warn('Failed to parse cached lookups, reloading...', e);
console.warn('Failed to parse cached lookups, reloading...', e.message);
}
}
}
try {
const [queues, states, priorities, users, types, config] = await Promise.all([
const [queues, states, priorities, users, types, config, customerUsers] = await Promise.all([
this.api('/api/queues'),
this.api('/api/states'),
this.api('/api/priorities'),
this.api('/api/users'),
this.api('/api/types'),
this.api('/api/config').catch(() => ({ defaultAgentLogin: '' })),
this.api('/api/customer-users/search?q=').catch(() => []),
]);
this.lookups = { queues, states, priorities, users, types, config };
this.lookups = { queues, states, priorities, users, types, config, customerUsers, customer_users_version_1: true };
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
this.lookupsLoaded = true;
} catch (err) {
@@ -218,6 +404,9 @@ const App = {
}
try {
// Sync LDAP customer users to local DB cache
await this.api('/api/customer-users/sync', { method: 'POST' });
await this.ensureLookups(true);
await this.initAgentSelector();
Toast.success('Dati locali (code, utenti, ecc.) aggiornati con successo!');
@@ -320,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();
});
@@ -379,26 +578,24 @@ const App = {
}
},
/** Load demotivational phrases from txt file */
/** Load demotivational phrases from SQLite cache */
async loadDemotivationalPhrases() {
try {
const res = await fetch('/demotivational.txt');
const res = await fetch('/api/dashboard/phrases?tipo=demotivational');
if (res.ok) {
const text = await res.text();
this.demotivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
this.demotivationalPhrases = await res.json();
}
} catch (e) {
console.warn('Failed to load demotivational phrases:', e);
}
},
/** Load motivational phrases from txt file */
/** Load motivational phrases from SQLite cache */
async loadMotivationalPhrases() {
try {
const res = await fetch('/motivational.txt');
const res = await fetch('/api/dashboard/phrases?tipo=motivational');
if (res.ok) {
const text = await res.text();
this.motivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
this.motivationalPhrases = await res.json();
}
} catch (e) {
console.warn('Failed to load motivational phrases:', e);
@@ -417,10 +614,93 @@ const App = {
const data = await this.api('/api/users/time-today');
const todayTime = typeof data.totalToday === 'number' ? data.totalToday : 0;
const remaining = Math.max(0, targetTime - todayTime);
const percentage = Math.min(100, Math.round((todayTime / targetTime) * 100));
const mathematicalPercentage = Math.round((todayTime / targetTime) * 100);
const hasOverperformance = mathematicalPercentage > 100;
const percentage = Math.min(100, mathematicalPercentage);
const brandEl = document.querySelector('.sidebar-brand');
if (brandEl) {
if (hasOverperformance) {
brandEl.classList.add('glow');
} else {
brandEl.classList.remove('glow');
}
}
if (timerEl) {
if (hasOverperformance) {
timerEl.classList.add('overperformance-alarm');
} else {
timerEl.classList.remove('overperformance-alarm');
}
}
const minTimeStr = this.lookups.config?.autoTimeMinHour || '18:00';
const [minHour, minMin] = minTimeStr.split(':').map(x => parseInt(x, 10));
const now = new Date();
const currentHour = now.getHours();
const currentMin = now.getMinutes();
let isPastTime = false;
if (currentHour > minHour) {
isPastTime = true;
} else if (currentHour === minHour && currentMin >= minMin) {
isPastTime = true;
}
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(
'Consuntivazione Automatica',
`Sei sicuro di voler effettuare la consuntivazione automatica di ${remaining} minuti rimanenti di oggi? Verrà creato un ticket chiuso a tuo carico.`
);
if (!confirmed) return;
try {
Toast.success('Consuntivazione in corso...');
const res = await this.api('/api/tickets/auto-time', { method: 'POST' });
this.updateDailyTimer();
this.route();
await this.alert('Consuntivazione Completata', res.message || 'La consuntivazione automatica è stata completata con successo.');
} catch (err) {
Toast.error('Errore consuntivazione automatica: ' + err.message);
}
};
if (isPastTime && remaining > 0) {
if (brandEl) {
brandEl.classList.add('clickable-auto-time');
brandEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m)`);
brandEl.onclick = triggerAction;
}
if (timerEl) {
timerEl.classList.add('clickable-auto-time');
//timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`);
timerEl.onclick = triggerAction;
}
} else {
if (brandEl) {
brandEl.classList.remove('clickable-auto-time');
brandEl.removeAttribute('title');
brandEl.onclick = null;
}
if (timerEl) {
timerEl.classList.remove('clickable-auto-time');
timerEl.removeAttribute('title');
timerEl.onclick = null;
}
}
let phrase = "";
const isDemotivational = percentage >= 70;
const phraseThreshold = this.lookups.config?.phraseThreshold || 70;
const isDemotivational = percentage >= phraseThreshold;
if (isDemotivational) {
if (this.demotivationalPhrases.length > 0) {
@@ -450,11 +730,16 @@ const App = {
<div class="timer-tooltip-border"></div>
<div style="font-size:0.75rem; font-weight:600; color:var(--text-primary); margin-bottom:4px; display:flex; justify-content:space-between;">
<span>Progresso Giornaliero</span>
<strong>${percentage}%</strong>
<strong>${mathematicalPercentage}%</strong>
</div>
<div style="background:var(--border-light); border-radius:var(--radius-full); height:10px; width:100%; overflow:hidden; border:1px solid var(--border-subtle);">
<div style="width:${percentage}%; background:linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); height:100%; border-radius:inherit; transition: width 0.3s ease;"></div>
</div>
${hasOverperformance ? `
<div style="font-size:0.72rem; color:var(--error); font-weight:700; line-height:1.3; text-transform:uppercase; margin-top:8px; border-top:1px solid var(--border-subtle); padding-top:8px; text-align:center; animation: pulse 1.5s infinite;">
⚠️ Rilevata una overperformance allontanarsi dalla postazione immediatamente
</div>
` : ''}
<div style="font-size:0.72rem; color:var(--text-secondary); line-height:1.35; font-style:italic; margin-top:6px; border-top:1px solid var(--border-subtle); padding-top:6px; text-align:center;">
"${phrase}"
</div>
@@ -485,6 +770,225 @@ const App = {
console.warn('Failed to update sidebar badges:', e);
}
},
/** Custom confirm dialog in the center of the screen */
confirm(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 = 'confirm-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-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
<button id="confirm-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="confirm-btn-ok" class="btn btn-danger" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Conferma'}</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
// Trigger animations
requestAnimationFrame(() => {
overlay.style.opacity = '1';
card.style.transform = 'scale(1)';
});
const cleanUp = (result) => {
overlay.style.opacity = '0';
card.style.transform = 'scale(0.9)';
setTimeout(() => {
overlay.remove();
resolve(result);
}, 200);
};
const btnCancel = card.querySelector('#confirm-btn-cancel');
const btnOk = card.querySelector('#confirm-btn-ok');
btnCancel.addEventListener('click', () => cleanUp(false));
btnOk.addEventListener('click', () => cleanUp(true));
// Close on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanUp(false);
});
});
},
/** Custom alert dialog in the center of the screen */
alert(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 = 'alert-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-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
<div style="display: flex; justify-content: flex-end;">
<button id="alert-btn-ok" class="btn btn-primary" style="height: 36px; font-size: 0.9rem; padding: 0 20px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.okText || 'OK'}</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
card.style.transform = 'scale(1)';
});
const cleanUp = () => {
overlay.style.opacity = '0';
card.style.transform = 'scale(0.9)';
setTimeout(() => {
overlay.remove();
resolve();
}, 200);
};
card.querySelector('#alert-btn-ok').addEventListener('click', cleanUp);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanUp();
});
});
},
/** 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
+684 -44
View File
@@ -3,21 +3,66 @@
* Manages ticket list filter state and renders filter dropdowns.
*/
const Filters = {
state: {
currentMode: 'general', // 'general' or 'my'
allStates: {
general: {
queue_id: '',
state_id: '',
priority_id: '',
user_id: '',
customer_user_id: '',
date_from: '',
date_to: '',
},
my: {
queue_id: '',
state_id: '',
priority_id: '',
user_id: '',
customer_user_id: '',
date_from: '',
date_to: '',
}
},
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];
},
set state(val) {
this.allStates[this.currentMode] = val;
},
/** Load saved filters from localStorage */
load() {
try {
const saved = localStorage.getItem('otrs_turbo_filters');
if (saved) {
Object.assign(this.state, JSON.parse(saved));
const savedGeneral = localStorage.getItem('otrs_turbo_filters_general');
if (savedGeneral) {
Object.assign(this.allStates.general, JSON.parse(savedGeneral));
}
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
if (savedMy) {
Object.assign(this.allStates.my, JSON.parse(savedMy));
}
// 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 */ }
},
@@ -25,13 +70,17 @@ const Filters = {
/** Save filters to localStorage */
save() {
try {
localStorage.setItem('otrs_turbo_filters', JSON.stringify(this.state));
localStorage.setItem(`otrs_turbo_filters_${this.currentMode}`, JSON.stringify(this.state));
} catch (e) { /* ignore */ }
},
/** Reset all filters */
reset() {
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', customer_user_id: '', date_from: '', date_to: '' };
if (this.currentMode === 'my') {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.state.user_id = activeAgentId;
}
this.save();
},
@@ -44,101 +93,692 @@ const Filters = {
return params;
},
/** 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 = (itemsList || [])
.filter(item => selectedList.includes(String(item[idField])))
.map(item => typeof labelField === 'function' ? labelField(item) : item[labelField]);
if (selectedNames.length === 0) {
return 'Tutti';
}
if (selectedNames.length <= 2) {
return selectedNames.join(', ');
} else {
return `${selectedNames.length} selezionati`;
}
},
getCustomerUserMultiselectLabel(lookups) {
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(',') : []);
if (selectedList.length === 0) {
return 'Tutti';
}
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`;
}
},
/** Update customer user label DOM element dynamically */
updateCustomerUserMultiselectLabel(lookups) {
const labelEl = document.getElementById('customer-user-multiselect-label');
if (labelEl) {
labelEl.textContent = this.getCustomerUserMultiselectLabel(lookups);
}
},
/**
* Render filter bar HTML.
* @param {Object} lookups - { queues, states, priorities, users }
* @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>`;
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>
<select class="filter-select" data-filter="state_id" id="filter-state">
<option value="">Tutti</option>
${makeOptions(lookups.states || [], 'id', 'name', this.state.state_id)}
</select>
<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="filter-group">
<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;">
<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 = 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="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 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>
<!-- 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: 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;">
<input type="text" id="customer-user-search-input" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
<div id="customer-user-items-container" style="display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
<!-- Populated dynamically -->
</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="customer-user-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs" id="customer-user-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</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]');
selects.forEach(sel => {
if (isMyTickets && sel.dataset.filter === 'user_id') {
sel.disabled = true;
} else {
sel.disabled = false;
}
sel.addEventListener('change', (e) => {
this.selectedPresetId = null;
this.state[e.target.dataset.filter] = e.target.value;
this.save();
if (onFilterChange) onFilterChange();
});
});
// 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;
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());
// 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 selection clicks
if (itemsContainer) {
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const isActive = item.classList.toggle('active');
if (isActive) {
item.style.background = 'var(--accent-primary)';
item.style.color = '#fff';
} else {
item.style.background = '';
item.style.color = '';
}
});
});
}
// Apply selection (OK click)
if (okBtn) {
okBtn.addEventListener('click', () => {
const activeItems = itemsContainer.querySelectorAll('.ms-item.active');
const ids = Array.from(activeItems).map(item => item.dataset.value);
this.selectedPresetId = null;
this.state[filterKey] = ids.join(',');
this.save();
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', () => {
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
this.selectedPresetId = null;
this.state[filterKey] = '';
this.save();
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');
const cuPopover = document.getElementById('customer-user-multiselect-popover');
const cuSearchInput = document.getElementById('customer-user-search-input');
const cuItemsContainer = document.getElementById('customer-user-items-container');
const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
let activeSearchController = null;
const renderCustomerUserItems = (searchResults = null) => {
if (!cuItemsContainer) return;
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 uniqueSelectedLogins = Array.from(new Set(selectedList)).filter(Boolean);
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>
`;
});
} 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(',') : []);
const idx = currentSelected.indexOf(login);
if (idx > -1) {
currentSelected.splice(idx, 1);
} else {
currentSelected.push(login);
this.customerCache[login] = displayName;
this.saveCustomerCache();
}
this.state.customer_user_id = currentSelected.join(',');
renderCustomerUserItems(searchResults);
});
});
};
if (cuDropdown && cuPopover) {
cuDropdown.addEventListener('click', (e) => {
e.stopPropagation();
// Close other popovers
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';
if (!isOpen) {
if (cuSearchInput) {
cuSearchInput.value = '';
}
renderCustomerUserItems();
setTimeout(() => {
if (cuSearchInput) cuSearchInput.focus();
}, 50);
}
});
cuPopover.addEventListener('click', (e) => e.stopPropagation());
let cuSearchDebounce;
if (cuSearchInput) {
cuSearchInput.addEventListener('input', () => {
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';
if (onFilterChange) onFilterChange();
});
}
if (cuClearBtn) {
cuClearBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.state.customer_user_id = '';
this.save();
this.updateCustomerUserMultiselectLabel(App.lookups);
if (cuSearchInput) cuSearchInput.value = '';
renderCustomerUserItems();
if (cuPopover) cuPopover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
}
// 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';
// Reset highlight states in popovers
document.querySelectorAll('.ms-items-container .ms-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
// Also clear customer user multiselect items and label
const cuPopover = document.getElementById('customer-user-multiselect-popover');
if (cuPopover) {
const searchInput = cuPopover.querySelector('#customer-user-search-input');
if (searchInput) searchInput.value = '';
cuPopover.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
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 {
s.value = '';
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;
}
});
}
},
+213
View File
@@ -0,0 +1,213 @@
/**
* ActivityLogView — Storico Attività
* Displays the local SQLite activity log with filters, dual pagination,
* and an expandable JSON detail panel.
*/
const ActivityLogView = {
currentPage: 1,
perPage: 50,
filters: { esito: '', agente_id: '', da: '', a: '' },
async render() {
this.currentPage = 1;
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento storico...</p></div>';
await this._draw();
},
async _draw() {
const container = document.getElementById('view-container');
try {
const params = new URLSearchParams({
page: this.currentPage,
per_page: this.perPage,
});
if (this.filters.esito) params.set('esito', this.filters.esito);
if (this.filters.agente_id) params.set('agente_id', this.filters.agente_id);
if (this.filters.da) params.set('da', this.filters.da);
if (this.filters.a) params.set('a', this.filters.a);
const data = await App.api(`/api/attivita?${params}`);
const { rows, total, page, per_page, total_pages } = data;
container.innerHTML = this._buildHtml(rows, total, page, per_page, total_pages);
this._bind();
} catch (err) {
container.innerHTML = `<div class="empty-state"><p style="color:var(--danger)">Errore caricamento: ${App.escapeHtml(err.message)}</p></div>`;
}
},
_buildHtml(rows, total, page, per_page, total_pages) {
const filtersHtml = this._buildFilters();
const paginationHtml = this._buildPagination(total, page, per_page, total_pages);
const tableHtml = rows.length === 0
? `<div class="empty-state" style="padding:var(--space-2xl);text-align:center;color:var(--text-muted);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px;height:48px;margin:0 auto var(--space-md);display:block;opacity:0.4;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<p>Nessuna attività registrata.</p>
</div>`
: `<div class="table-wrapper" style="overflow-x:auto;">
<table class="tickets-table" id="activity-table" style="width:100%;">
<thead>
<tr>
<th style="width:160px;">Data/Ora</th>
<th style="width:160px;">Agente</th>
<th style="width:180px;">Azione</th>
<th style="min-width:60px;text-align:center;">Esito</th>
<th style="width:48px;text-align:center;">≡</th>
</tr>
</thead>
<tbody>
${rows.map(r => this._buildRow(r)).join('')}
</tbody>
</table>
</div>`;
return `
<div class="view-header" style="padding:var(--space-md) var(--space-xl);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--space-sm);border-bottom:1px solid var(--border-subtle);">
<div style="display:flex;align-items:center;gap:var(--space-sm);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px;height:20px;color:var(--primary);"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span style="font-weight:600;font-size:1rem;">Storico Attività</span>
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);font-size:0.75rem;padding:2px 8px;border-radius:12px;">${total} record</span>
</div>
</div>
${filtersHtml}
${paginationHtml}
${tableHtml}
${rows.length > 0 ? paginationHtml.replace(/id="pagination-top"/g,'id="pagination-bottom"') : ''}
`;
},
_buildRow(r) {
const dt = r.creato_il ? new Date(r.creato_il).toLocaleString('it-IT') : '—';
const esitoBadge = r.esito === 'successo'
? `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(34,197,94,0.15);color:#16a34a;font-size:0.75rem;font-weight:600;">✓ successo</span>`
: `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(239,68,68,0.15);color:#dc2626;font-size:0.75rem;font-weight:600;">✕ errore</span>`;
return `
<tr id="row-${r.id}" data-id="${r.id}">
<td style="font-size:0.8rem;color:var(--text-secondary);white-space:nowrap;">${dt}</td>
<td style="font-size:0.85rem;">${App.escapeHtml(r.agente_nome || '—')}</td>
<td style="font-size:0.85rem;font-weight:500;">${App.escapeHtml(r.titolo_azione)}</td>
<td style="text-align:center;">${esitoBadge}</td>
<td style="text-align:center;">
<button class="btn btn-ghost btn-sm act-detail-btn" data-id="${r.id}" title="Mostra dettaglio" style="padding:4px 8px;font-size:0.85rem;">≡</button>
</td>
</tr>
<tr id="detail-${r.id}" class="act-detail-row" style="display:none;">
<td colspan="5" style="padding:0 var(--space-md) var(--space-md);background:var(--bg-secondary);">
<pre style="margin:0;padding:var(--space-md);background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:0.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-primary);border:1px solid var(--border-subtle);">${App.escapeHtml(this._prettyJson(r.azione))}</pre>
</td>
</tr>
`;
},
_prettyJson(str) {
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch (_) {
return str || '';
}
},
_buildFilters() {
return `
<div id="activity-filters" style="display:flex;flex-wrap:wrap;gap:var(--space-sm);padding:var(--space-md) var(--space-xl);border-bottom:1px solid var(--border-subtle);background:var(--bg-secondary);align-items:flex-end;">
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">Esito</label>
<select id="filter-esito" class="form-select" style="min-width:120px;height:36px;font-size:0.85rem;padding:6px 28px 6px 10px;">
<option value="">Tutti</option>
<option value="successo" ${this.filters.esito === 'successo' ? 'selected' : ''}>✓ Successo</option>
<option value="errore" ${this.filters.esito === 'errore' ? 'selected' : ''}>✕ Errore</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">Da data</label>
<input type="datetime-local" id="filter-da" class="form-input" value="${this.filters.da}" style="height:36px;font-size:0.85rem;padding:6px 10px;">
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">A data</label>
<input type="datetime-local" id="filter-a" class="form-input" value="${this.filters.a}" style="height:36px;font-size:0.85rem;padding:6px 10px;">
</div>
<div style="display:flex;gap:var(--space-xs);align-self:flex-end;">
<button id="btn-apply-filters" class="btn btn-primary btn-sm" style="height:36px;">Applica</button>
<button id="btn-reset-filters" class="btn btn-ghost btn-sm" style="height:36px;">Reset</button>
</div>
</div>
`;
},
_buildPagination(total, page, per_page, total_pages) {
if (total_pages <= 1) return '';
const from = (page - 1) * per_page + 1;
const to = Math.min(page * per_page, total);
const pageBtn = (p, label, disabled, active) => {
const isDisabled = disabled || p === page;
return `<button class="btn btn-ghost btn-sm page-btn" data-page="${p}"
style="min-width:36px;height:32px;${active ? 'background:var(--primary);color:#fff;' : ''}${isDisabled ? 'opacity:0.4;pointer-events:none;' : ''}"
${disabled || active ? 'disabled' : ''}>${label}</button>`;
};
const pages = [];
pages.push(pageBtn(1, '«', page === 1, false));
pages.push(pageBtn(page - 1, '', page === 1, false));
const rangeStart = Math.max(1, page - 2);
const rangeEnd = Math.min(total_pages, page + 2);
for (let p = rangeStart; p <= rangeEnd; p++) {
pages.push(pageBtn(p, p, false, p === page));
}
pages.push(pageBtn(page + 1, '', page === total_pages, false));
pages.push(pageBtn(total_pages, '»', page === total_pages, false));
return `
<div id="pagination-top" style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-sm) var(--space-xl);flex-wrap:wrap;gap:var(--space-sm);">
<span style="font-size:0.8rem;color:var(--text-muted);">Record ${from}${to} di ${total}</span>
<div style="display:flex;gap:4px;flex-wrap:wrap;">${pages.join('')}</div>
</div>
`;
},
_bind() {
// Filter apply
document.getElementById('btn-apply-filters')?.addEventListener('click', () => {
this.filters.esito = document.getElementById('filter-esito')?.value || '';
this.filters.da = document.getElementById('filter-da')?.value || '';
this.filters.a = document.getElementById('filter-a')?.value || '';
this.currentPage = 1;
this._draw();
});
// Filter reset
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
this.filters = { esito: '', agente_id: '', da: '', a: '' };
this.currentPage = 1;
this._draw();
});
// Detail expand/collapse
document.querySelectorAll('.act-detail-btn').forEach(btn => {
btn.addEventListener('click', () => {
const rowId = btn.dataset.id;
const detailRow = document.getElementById(`detail-${rowId}`);
if (!detailRow) return;
const isOpen = detailRow.style.display !== 'none';
detailRow.style.display = isOpen ? 'none' : 'table-row';
btn.textContent = isOpen ? '≡' : '✕';
});
});
// Pagination buttons (top and bottom share same .page-btn class)
document.querySelectorAll('.page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const p = parseInt(btn.dataset.page, 10);
if (!isNaN(p)) {
this.currentPage = p;
this._draw();
document.getElementById('view-container')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
},
};
+637 -2
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">
@@ -88,7 +204,19 @@ const DashboardView = {
<!-- Recent Tickets -->
<div class="card">
<div class="card-title">Ticket Recenti</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md); flex-wrap:wrap; gap:var(--space-xs);">
<div class="card-title" style="margin-bottom:0;">Ticket Recenti</div>
<div style="display:flex; align-items:center; gap:var(--space-xs); font-size:0.85rem; color:var(--text-secondary);">
<span>Mostra:</span>
<select id="dashboard-preview-limit" class="form-select" style="padding: 4px 28px 4px 8px; font-size: 0.8rem; height: 28px; min-width: 70px; margin: 0; background-position: right 8px center; border-color: var(--border-light);">
<option value="5" ${stats.preview_limit === 5 ? 'selected' : ''}>5</option>
<option value="10" ${stats.preview_limit === 10 ? 'selected' : ''}>10</option>
<option value="20" ${stats.preview_limit === 20 ? 'selected' : ''}>20</option>
<option value="50" ${stats.preview_limit === 50 ? 'selected' : ''}>50</option>
<option value="100" ${stats.preview_limit === 100 ? 'selected' : ''}>100</option>
</select>
</div>
</div>
${(stats.recent_tickets || []).length > 0 ? `
<table class="recent-tickets-table">
<thead>
@@ -121,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 => {
@@ -132,6 +463,24 @@ const DashboardView = {
});
});
// Bind preview limit change event
const limitSelect = document.getElementById('dashboard-preview-limit');
if (limitSelect) {
limitSelect.addEventListener('change', async () => {
const newLimit = parseInt(limitSelect.value, 10);
try {
await App.api('/api/dashboard/settings', {
method: 'POST',
body: JSON.stringify({ preview_limit: newLimit }),
});
Toast.success(`Limite anteprima aggiornato a ${newLimit} ticket!`);
this.render();
} catch (err) {
Toast.error('Errore durante il salvataggio dell\'impostazione: ' + err.message);
}
});
}
// Update open ticket count in sidebar badge
const badge = document.getElementById('open-ticket-count');
if (badge) {
@@ -154,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);
});
}
};
+657
View File
@@ -0,0 +1,657 @@
/**
* emailCompose.js
* Modal per la composizione e invio email dal contesto di un ticket.
* Utilizza Quill.js per l'editor HTML, supporta allegati e immagini inline.
*/
const EmailCompose = (() => {
let quillEditor = null;
let attachmentsList = [];
let currentOptions = {};
let toTagsCtrl = null;
let ccTagsCtrl = null;
let bccTagsCtrl = null;
// ── CSS ──────────────────────────────────────────────────────────────────────
function injectStyles() {
if (document.getElementById('email-compose-styles')) return;
const style = document.createElement('style');
style.id = 'email-compose-styles';
style.textContent = `
#email-compose-overlay {
position: fixed; inset: 0; z-index: 9000;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
#email-compose-modal {
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl, 14px);
box-shadow: 0 24px 80px rgba(0,0,0,0.35);
width: min(860px, 95vw);
max-height: 92vh;
display: flex; flex-direction: column;
animation: slideUp 0.18s ease;
}
@keyframes slideUp { from { transform: translateY(20px); opacity:0 } to { transform: translateY(0); opacity:1 } }
#email-compose-modal .ec-header {
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border-subtle);
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
#email-compose-modal .ec-title {
font-size: 1rem; font-weight: 600; color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
}
#email-compose-modal .ec-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
display: flex; flex-direction: column; gap: 12px;
}
#email-compose-modal .ec-field {
display: flex; flex-direction: column; gap: 4px;
}
#email-compose-modal .ec-label {
font-size: 0.75rem; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.04em;
}
#email-compose-modal .ec-tags-input {
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 6px 10px; min-height: 36px; cursor: text;
}
#email-compose-modal .ec-tags-input:focus-within {
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;
border-radius: 4px; padding: 2px 6px; font-size: 0.78rem;
}
#email-compose-modal .ec-tag button {
background: none; border: none; color: rgba(255,255,255,0.8);
cursor: pointer; padding: 0; line-height: 1; font-size: 0.9rem;
}
#email-compose-modal .ec-tag button:hover { color: #fff; }
#email-compose-modal .ec-tag-input {
border: none; outline: none; background: transparent;
font-size: 0.88rem; color: var(--text-primary);
min-width: 160px; flex: 1;
}
#email-compose-modal .ec-editor-wrapper {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-tertiary);
}
#email-compose-modal .ec-editor-wrapper .ql-toolbar {
border: none; border-bottom: 1px solid var(--border-subtle);
background: var(--bg-secondary);
}
#email-compose-modal .ec-editor-wrapper .ql-container {
border: none; min-height: 220px; font-size: 0.9rem;
}
#email-compose-modal .ec-attachments-zone {
border: 2px dashed var(--border-subtle);
border-radius: var(--radius-md);
padding: 12px 14px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-size: 0.82rem; color: var(--text-muted);
}
#email-compose-modal .ec-attachments-zone:hover,
#email-compose-modal .ec-attachments-zone.drag-over {
border-color: var(--accent-primary);
background: rgba(var(--accent-rgb,99,102,241),0.06);
color: var(--text-secondary);
}
#email-compose-modal .ec-file-list {
display: flex; flex-direction: column; gap: 4px;
}
#email-compose-modal .ec-file-item {
display: flex; align-items: center; gap: 8px;
font-size: 0.82rem; padding: 4px 8px;
background: var(--bg-secondary); border-radius: var(--radius-sm);
color: var(--text-secondary);
}
#email-compose-modal .ec-file-item button {
margin-left: auto; background: none; border: none; cursor: pointer;
color: var(--text-muted); font-size: 0.85rem; padding: 0 2px;
}
#email-compose-modal .ec-file-item button:hover { color: var(--error); }
#email-compose-modal .ec-footer {
padding: 12px 20px;
border-top: 1px solid var(--border-subtle);
display: flex; justify-content: space-between; align-items: center;
flex-shrink: 0; gap: 10px;
}
#email-compose-modal select.ec-select {
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 6px 12px; font-size: 0.85rem;
color: var(--text-primary); min-width: 180px;
}
`;
document.head.appendChild(style);
}
// ── Tag Input Helper ──────────────────────────────────────────────────────────
function makeTagInput(containerId, initialEmails = [], onFocus = null, controllers = null) {
const container = document.getElementById(containerId);
const tags = [...initialEmails];
function render() {
const inputEl = container.querySelector('.ec-tag-input');
const currentVal = inputEl ? inputEl.value : '';
container.innerHTML = '';
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.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();
});
container.appendChild(tagEl);
});
const input = document.createElement('input');
input.className = 'ec-tag-input';
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();
const val = input.value.trim().replace(/,$/, '');
if (val && !tags.includes(val)) tags.push(val);
input.value = '';
render();
} else if (e.key === 'Backspace' && !input.value && tags.length) {
tags.pop();
render();
}
});
input.addEventListener('blur', () => {
if (input.value.trim()) {
const val = input.value.trim().replace(/,$/, '');
if (val && !tags.includes(val)) tags.push(val);
input.value = '';
render();
}
});
container.appendChild(input);
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();
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 ──────────────────────────────────────────────────────────
function buildModal() {
const overlay = document.createElement('div');
overlay.id = 'email-compose-overlay';
overlay.innerHTML = `
<div id="email-compose-modal">
<div class="ec-header">
<div class="ec-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;">
<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
</div>
<button id="ec-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;font-size:1.2rem;" title="Chiudi">✕</button>
</div>
<div class="ec-body">
<div class="ec-field">
<label class="ec-label">A (To)</label>
<div class="ec-tags-input" id="ec-to-container"></div>
</div>
<div class="ec-field">
<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" />
</div>
<div style="display:flex; gap:16px;">
<div class="ec-field" style="flex:1;">
<label class="ec-label">Firma</label>
<select id="ec-signature-select" class="ec-select" style="width:100%; min-width:unset;">
<option value="">— Nessuna firma —</option>
</select>
</div>
<div class="ec-field" style="flex:1;">
<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="0">No (BCC automatico)</option>
<option value="1">Sì</option>
</select>
</div>
</div>
<div class="ec-field">
<label class="ec-label">Corpo</label>
<div class="ec-editor-wrapper">
<div id="ec-quill-editor"></div>
</div>
</div>
<div class="ec-field">
<label class="ec-label">Allegati</label>
<div class="ec-attachments-zone" id="ec-drop-zone">
📎 Trascina file qui o clicca per selezionare
</div>
<input type="file" id="ec-file-input" multiple style="display:none;" />
<div class="ec-file-list" id="ec-file-list"></div>
</div>
</div>
<div class="ec-footer">
<button class="btn btn-ghost btn-sm" id="ec-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="ec-send" style="display:flex;align-items:center;gap:6px;">
<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>
Invia
</button>
</div>
</div>
`;
return overlay;
}
// ── File List Renderer ────────────────────────────────────────────────────────
function renderFileList() {
const list = document.getElementById('ec-file-list');
if (!list) return;
list.innerHTML = attachmentsList.map((f, idx) => `
<div class="ec-file-item">
📎 <strong>${App.escapeHtml(f.filename)}</strong>
<span style="color:var(--text-muted);font-size:0.75rem;">(${Math.round(f.content.length * 0.75 / 1024)} KB)</span>
<button data-idx="${idx}" title="Rimuovi">✕</button>
</div>
`).join('');
list.querySelectorAll('button[data-idx]').forEach(btn => {
btn.addEventListener('click', () => {
attachmentsList.splice(parseInt(btn.dataset.idx, 10), 1);
renderFileList();
});
});
}
// ── Load Signatures ───────────────────────────────────────────────────────────
async function loadSignatures(agentId, selectEl) {
try {
const sigs = await App.api(`/api/email/signatures?agent_id=${agentId}`);
selectEl.innerHTML = '<option value="">— Nessuna firma —</option>';
sigs.forEach(sig => {
const opt = document.createElement('option');
opt.value = sig.id;
opt.textContent = sig.name + (sig.is_default ? ' ★' : '');
opt.dataset.html = sig.body_html;
selectEl.appendChild(opt);
});
// Pre-select default
const defSig = sigs.find(s => s.is_default);
if (defSig) {
selectEl.value = defSig.id;
return defSig.body_html;
}
} catch (e) { console.warn('[EmailCompose] Signatures load error:', e); }
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();
attachmentsList = [];
currentOptions = options;
// Remove existing
const existing = document.getElementById('email-compose-overlay');
if (existing) existing.remove();
const overlay = buildModal();
document.body.appendChild(overlay);
// 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 ? `[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', {
theme: 'snow',
placeholder: 'Scrivi il testo della email...',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'header': [1, 2, 3, false] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['link', 'image'],
[{ 'color': [] }, { 'background': [] }],
['clean']
],
}
});
// 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 {
initialHtml += '<p><br></p>';
}
if (defaultSigHtml) {
initialHtml += '<!-- sig -->' + defaultSigHtml;
}
}
quillEditor.clipboard.dangerouslyPasteHTML(initialHtml);
quillEditor.setSelection(0, 0);
// Signature change
sigSelect.addEventListener('change', () => {
const selectedOpt = sigSelect.options[sigSelect.selectedIndex];
const sigHtml = selectedOpt ? (selectedOpt.dataset.html || '') : '';
// Replace signature: get current body, strip old signature (after first <br>), append new
const currentHtml = quillEditor.root.innerHTML;
const sigMarker = '<!-- sig -->';
const baseHtml = currentHtml.includes(sigMarker)
? currentHtml.split(sigMarker)[0]
: currentHtml;
const newHtml = baseHtml + (sigHtml ? sigMarker + sigHtml : '');
quillEditor.clipboard.dangerouslyPasteHTML(newHtml);
});
// File drag & drop
const dropZone = document.getElementById('ec-drop-zone');
const fileInput = document.getElementById('ec-file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault(); dropZone.classList.remove('drag-over');
processFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', (e) => {
processFiles(Array.from(e.target.files));
fileInput.value = '';
});
// Close handlers
document.getElementById('ec-close').addEventListener('click', close);
document.getElementById('ec-cancel').addEventListener('click', 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, bccTagsCtrl));
}
function processFiles(files) {
for (const file of files) {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
attachmentsList.push({ filename: file.name, content: base64, contentType: file.type });
renderFileList();
};
reader.readAsDataURL(file);
}
}
// ── Send ─────────────────────────────────────────────────────────────────────
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 : '';
if (!to.length) { Toast.warning('Inserisci almeno un destinatario (campo A)'); return; }
if (!subject) { Toast.warning('Inserisci l\'oggetto della email'); return; }
const sendBtn = document.getElementById('ec-send');
sendBtn.disabled = true;
sendBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Invio...';
try {
const agentId = App.currentAgentId || 0;
const payload = {
ticketId: currentOptions.ticketId,
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', {
method: 'POST',
body: JSON.stringify(payload),
});
Toast.success(`Email inviata a ${to.join(', ')}`);
App.clearDraft(currentOptions.ticketId, 'email');
close();
} catch (err) {
Toast.error('Errore invio email: ' + err.message);
sendBtn.disabled = false;
sendBtn.innerHTML = '<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> Invia';
}
}
// ── 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 = [];
}
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;
+11 -3
View File
@@ -665,9 +665,17 @@ const TicketBulkView = {
const type_id = document.getElementById(`bulk-type-${id}`).value;
const ownerId = document.getElementById(`bulk-owner-${id}`).value;
const responsibleId = document.getElementById(`bulk-responsible-${id}`).value;
const customerId = document.getElementById(`bulk-customer-${id}`).value;
const customerUserId = document.getElementById(`bulk-customer-user-id-${id}`).value;
const customerSearch = document.getElementById(`bulk-customer-search-${id}`).value;
let customerId = document.getElementById(`bulk-customer-${id}`).value;
let customerUserId = document.getElementById(`bulk-customer-user-id-${id}`).value;
const customerSearch = document.getElementById(`bulk-customer-search-${id}`).value.trim();
if (!customerUserId && customerSearch) {
customerUserId = customerSearch;
if (!customerId) {
customerId = customerSearch;
}
}
const subject = document.getElementById(`bulk-subject-${id}`).value.trim();
const body = document.getElementById(`bulk-body-${id}`).value.trim();
const priority_id = document.getElementById(`bulk-priority-${id}`).value;
+59 -53
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">
@@ -235,7 +235,17 @@ const TicketCreateView = {
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link', 'image'],
['clean']
]
],
keyboard: {
bindings: {
tab: {
key: 'Tab',
handler: function() {
return true;
}
}
}
}
}
});
@@ -243,6 +253,14 @@ const TicketCreateView = {
if (this.savedState && this.savedState.body) {
this.quill.root.innerHTML = this.savedState.body;
}
// Prevent tab navigation on toolbar items
const toolbar = container.querySelector('.ql-toolbar');
if (toolbar) {
toolbar.querySelectorAll('button, select, span[role="button"], input').forEach(el => {
el.setAttribute('tabindex', '-1');
});
}
} else {
this.quill = null;
}
@@ -306,7 +324,6 @@ const TicketCreateView = {
document.getElementById('create-company-search').value = this.savedState.company_search;
document.getElementById('create-priority').value = this.savedState.priority_id;
document.getElementById('create-subject').value = this.savedState.subject;
document.getElementById('create-body').value = this.savedState.body;
if (this.savedState.isAdvancedVisible) {
advancedOptions.style.display = 'grid';
@@ -314,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) {
@@ -327,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=cliente');
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);
}
@@ -380,15 +362,16 @@ const TicketCreateView = {
userSearchInput.addEventListener('input', () => {
clearTimeout(userDebounce);
const q = userSearchInput.value.trim();
if (q.length < 2) {
userSuggestionsDiv.style.display = 'none';
customerUserIdInput.value = '';
return;
}
userDebounce = setTimeout(async () => {
try {
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
console.log('[Frontend LDAP Search] Users returned:', users);
// Prevent race conditions: discard results if the input value has changed
if (userSearchInput.value.trim() !== q) {
console.log('[Frontend LDAP Search] Discarding stale results for query:', q);
return;
}
if (users.length === 0) {
userSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
userSuggestionsDiv.style.display = 'block';
@@ -396,17 +379,18 @@ const TicketCreateView = {
}
userSuggestionsDiv.innerHTML = users.map(u => `
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login)}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login || '')}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml((u.first_name + ' ' + u.last_name).trim() || u.login || '')}">
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login || '')} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
</div>
`).join('');
userSuggestionsDiv.style.display = 'block';
// Bind click
// Use mousedown instead of click to fire before blur event
userSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.login) {
item.addEventListener('click', () => {
item.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent input from losing focus before value is set
userSearchInput.value = item.dataset.name;
customerUserIdInput.value = item.dataset.login;
userSuggestionsDiv.style.display = 'none';
@@ -424,7 +408,18 @@ const TicketCreateView = {
}
}, 300);
});
userSearchInput.addEventListener('focus', () => {
if (!customerUserIdInput.value) {
// Only search if no user is selected yet
userSearchInput.dispatchEvent(new Event('input'));
}
});
userSearchInput.addEventListener('blur', () => {
// Small delay to allow mousedown on item to fire first
setTimeout(() => { userSuggestionsDiv.style.display = 'none'; }, 150);
});
}
// Owner Autocomplete (dynamic backend search)
let ownerDebounce;
@@ -537,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
@@ -589,9 +587,17 @@ const TicketCreateView = {
const priority_id = document.getElementById('create-priority').value;
const type_id = document.getElementById('create-type')?.value;
const customerId = customerIdInput.value;
const userSearchVal = userSearchInput.value.trim();
let customerId = customerIdInput.value;
let customerUserId = customerUserIdInput.value;
if (!customerUserId && userSearchVal) {
customerUserId = userSearchVal;
if (!customerId) {
customerId = userSearchVal;
}
}
const ownerId = ownerIdInput.value;
const responsibleId = responsibleIdInput.value;
File diff suppressed because it is too large Load Diff
+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);
}
};
+450 -32
View File
@@ -13,17 +13,33 @@ 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
await App.ensureLookups();
// Fetch agent settings for tickets_per_page
try {
const settings = await App.api('/api/dashboard/settings');
if (settings && settings.tickets_per_page) {
this.perPage = settings.tickets_per_page;
}
} catch (err) {
console.warn('Failed to load agent settings:', err);
}
// Build query params
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
Filters.currentMode = isMyTickets ? 'my' : 'general';
Filters.load(); // Load state for current mode
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
Filters.state.user_id = activeAgentId;
Filters.save();
}
const params = Filters.toQueryParams();
@@ -63,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>
@@ -71,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>
@@ -85,43 +101,79 @@ 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">
<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>
${Filters.renderBar(App.lookups)}
<!-- Pagination Top -->
${this.renderPagination(page, per_page, total, total_pages, true)}
<!-- Ticket Table -->
<div class="ticket-table-wrapper">
<table class="ticket-table" id="ticket-table">
<thead>
<tr>
<th class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
<th style="width: 75px;" class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
<th style="width: 125px;" class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
<th style="width: 90px;" class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
<th class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
<th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
<th>Owner</th>
<th>Cliente</th>
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
<th style="width: 130px;" class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
<th style="width: 120px;">Owner</th>
<th style="width: 140px;">Cliente</th>
<th style="width: 80px;" class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
</tr>
</thead>
<tbody>
${tickets.length > 0 ? tickets.map(t => `
${tickets.length > 0 ? tickets.map(t => {
const displayQueue = t.queue_name.includes('::') ? t.queue_name.split('::').pop() : t.queue_name;
const shortQueue = displayQueue.length > 15 ? displayQueue.substring(0, 12) + '...' : displayQueue;
return `
<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"><a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a></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>
<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><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
<td><span class="badge badge-queue">${t.queue_name}</span></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>
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
</tr>
`).join('') : `
`;
}).join('') : `
<tr>
<td colspan="7">
<td colspan="8">
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<div class="empty-state-text">Nessun ticket trovato</div>
@@ -134,13 +186,34 @@ const TicketListView = {
</table>
</div>
<!-- Pagination -->
${total_pages > 1 ? `
<div class="pagination">
<!-- Pagination Bottom -->
${this.renderPagination(page, per_page, total, total_pages, false)}
`;
},
renderPagination(page, per_page, total, total_pages, isTop) {
const marginStyle = isTop ? 'margin-bottom: var(--space-md); margin-top: 0;' : 'margin-top: var(--space-md); margin-bottom: 0;';
const limitSelectHtml = `
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary); margin-right:var(--space-md);">
<span>Righe:</span>
<select class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
<option value="10" ${per_page === 10 ? 'selected' : ''}>10</option>
<option value="20" ${per_page === 20 ? 'selected' : ''}>20</option>
<option value="50" ${per_page === 50 ? 'selected' : ''}>50</option>
<option value="100" ${per_page === 100 ? 'selected' : ''}>100</option>
<option value="200" ${per_page === 200 ? 'selected' : ''}>200</option>
</select>
</div>
`;
if (total_pages > 1) {
return `
<div class="pagination" style="${marginStyle}">
<div class="pagination-info">
Mostrando ${((page - 1) * per_page) + 1}${Math.min(page * per_page, total)} di ${total} ticket
</div>
<div class="pagination-controls">
${limitSelectHtml}
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}></button>
${this.renderPageButtons(page, total_pages)}
@@ -148,13 +221,17 @@ const TicketListView = {
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
</div>
</div>
` : `
<div class="pagination">
<div class="pagination-info">${total} ticket totali</div>
<div></div>
</div>
`}
`;
} else {
return `
<div class="pagination" style="${marginStyle}">
<div class="pagination-info">${total} ticket totali</div>
<div class="pagination-controls">
${limitSelectHtml}
</div>
</div>
`;
}
},
renderPageButtons(current, total) {
@@ -234,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) {
@@ -277,10 +434,140 @@ const TicketListView = {
tr.classList.remove('selected');
tr.classList.remove('first-selected');
});
const batchCustomerSearch = document.getElementById('batch-customer-search');
const batchCustomerUserId = document.getElementById('batch-customer-user-id');
const batchCustomerId = document.getElementById('batch-customer-id');
if (batchCustomerSearch) batchCustomerSearch.value = '';
if (batchCustomerUserId) batchCustomerUserId.value = '';
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();
});
}
// Batch Customer User Autocomplete
const batchCustomerSearchInput = document.getElementById('batch-customer-search');
const batchCustomerSuggestionsDiv = document.getElementById('batch-customer-suggestions');
const batchCustomerUserIdInput = document.getElementById('batch-customer-user-id');
const batchCustomerIdInput = document.getElementById('batch-customer-id');
let batchCustomerDebounce;
if (batchCustomerSearchInput) {
batchCustomerSearchInput.addEventListener('input', () => {
clearTimeout(batchCustomerDebounce);
const q = batchCustomerSearchInput.value.trim();
// Do not block empty query to allow all results on focus
batchCustomerDebounce = setTimeout(async () => {
try {
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
if (users.length === 0) {
batchCustomerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
batchCustomerSuggestionsDiv.style.display = 'block';
return;
}
batchCustomerSuggestionsDiv.innerHTML = users.map(u => `
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login)}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
</div>
`).join('');
batchCustomerSuggestionsDiv.style.display = 'block';
// Bind click
batchCustomerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.login) {
item.addEventListener('click', () => {
batchCustomerSearchInput.value = item.dataset.name;
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = item.dataset.login;
if (batchCustomerIdInput) batchCustomerIdInput.value = item.dataset.customerId || '';
batchCustomerSuggestionsDiv.style.display = 'none';
});
}
});
} catch (err) {
console.error(err);
}
}, 300);
});
batchCustomerSearchInput.addEventListener('focus', () => {
batchCustomerSearchInput.value = '';
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = '';
if (batchCustomerIdInput) batchCustomerIdInput.value = '';
batchCustomerSearchInput.dispatchEvent(new Event('input'));
});
}
// 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
document.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
btn.addEventListener('click', () => {
@@ -290,6 +577,25 @@ const TicketListView = {
this.render();
});
});
// Page size change
document.querySelectorAll('.ticket-per-page-select').forEach(select => {
select.addEventListener('change', async () => {
const newLimit = parseInt(select.value, 10);
try {
await App.api('/api/dashboard/settings', {
method: 'POST',
body: JSON.stringify({ tickets_per_page: newLimit }),
});
Toast.success(`Righe per pagina aggiornate a ${newLimit}!`);
this.perPage = newLimit;
this.currentPage = 1;
this.render();
} catch (err) {
Toast.error('Errore durante il salvataggio dell\'impostazione: ' + err.message);
}
});
});
},
updateBatchBar() {
@@ -298,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) {
@@ -307,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() {
@@ -316,10 +638,30 @@ 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;
if (!batchCustomerUserId && batchCustomerSearch) {
batchCustomerUserId = batchCustomerSearch;
if (!batchCustomerId) {
batchCustomerId = batchCustomerSearch;
}
}
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;
if (Object.keys(updates).length === 0) {
Toast.warning('Seleziona almeno un campo da modificare');
@@ -364,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 {
@@ -385,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);
}
},
};
+66
View File
@@ -0,0 +1,66 @@
/**
* routes/activity.js
* GET /api/attivita — Paginates and filters the local SQLite activity log.
*/
const express = require('express');
const router = express.Router();
const { db } = require('../activityDb');
router.get('/', (req, res) => {
try {
const {
page = 1,
per_page = 50,
agente_id,
esito,
da,
a,
} = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const perPageNum = Math.min(200, Math.max(1, parseInt(per_page, 10)));
const offset = (pageNum - 1) * perPageNum;
const conditions = [];
const params = [];
if (agente_id) {
conditions.push('agente_id = ?');
params.push(parseInt(agente_id, 10));
}
if (esito) {
conditions.push('esito = ?');
params.push(esito);
}
if (da) {
conditions.push('creato_il >= ?');
params.push(da);
}
if (a) {
conditions.push('creato_il <= ?');
params.push(a);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const totalRow = db.prepare(`SELECT COUNT(*) AS cnt FROM attivita ${whereClause}`).get(...params);
const total = totalRow ? totalRow.cnt : 0;
const rows = db
.prepare(`SELECT * FROM attivita ${whereClause} ORDER BY creato_il DESC LIMIT ? OFFSET ?`)
.all(...params, perPageNum, offset);
res.json({
rows,
total,
page: pageNum,
per_page: perPageNum,
total_pages: Math.ceil(total / perPageNum),
});
} catch (err) {
console.error('[activity] Error fetching activities:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+470 -2
View File
@@ -1,10 +1,91 @@
const express = require('express');
const router = express.Router();
const pool = require('../db');
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();
const IV_LENGTH = 16;
function encrypt(text) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
function decrypt(text) {
const textParts = text.split(':');
const iv = Buffer.from(textParts.shift(), 'hex');
const encryptedText = Buffer.from(textParts.join(':'), 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Ensure SQLite table exists for phrases
db.exec(`
CREATE TABLE IF NOT EXISTS frasi_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT,
testo TEXT
)
`);
// Seed function to import plain-text files into SQLite cache
function seedPhrases() {
try {
const countRow = db.prepare("SELECT COUNT(*) AS count FROM frasi_cache").get();
if (countRow.count === 0) {
console.log('[Phrases Seed] SQLite frasi_cache is empty. Seeding...');
const seedFile = (fileName, type) => {
const txtPath = path.join(__dirname, `../public/${fileName}`);
if (fs.existsSync(txtPath)) {
const text = fs.readFileSync(txtPath, 'utf8');
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
const insertStmt = db.prepare("INSERT INTO frasi_cache (tipo, testo) VALUES (?, ?)");
db.transaction(() => {
for (const line of lines) {
insertStmt.run(type, encrypt(line));
}
})();
console.log(`[Phrases Seed] Successfully seeded ${lines.length} encrypted ${type} phrases.`);
}
};
seedFile('demotivational.txt', 'demotivational');
seedFile('motivational.txt', 'motivational');
}
} catch (err) {
console.error('[Phrases Seed] Seeding failed:', err.message);
}
}
seedPhrases();
// GET /api/dashboard/stats — Dashboard statistics
router.get('/stats', async (req, res) => {
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
let previewLimit = 10;
try {
const row = db.prepare("SELECT preview_limit FROM agent_settings WHERE agent_id = ?").get(activeAgentId);
if (row) {
previewLimit = row.preview_limit;
}
} catch (err) {
console.error('Error reading agent_settings:', err.message);
}
try {
// All queries in parallel for speed
const [
@@ -69,7 +150,7 @@ router.get('/stats', async (req, res) => {
JOIN ticket_state_type tst ON ts.type_id = tst.id
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')`
),
// 10 most recent tickets
// Custom most recent tickets based on agent settings
pool.query(
`SELECT t.id, t.tn, t.title, ts.name AS state_name,
tp.name AS priority_name, tp.color AS priority_color,
@@ -79,7 +160,8 @@ router.get('/stats', async (req, res) => {
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
JOIN queue q ON t.queue_id = q.id
ORDER BY t.create_time DESC
LIMIT 10`
LIMIT $1`,
[previewLimit]
),
// Escalated tickets
pool.query(
@@ -109,6 +191,7 @@ router.get('/stats', async (req, res) => {
recent_tickets: recentTickets.rows,
escalated: parseInt(escalated.rows[0].count),
total_my_open: parseInt(myOpenCount.rows[0].count),
preview_limit: previewLimit,
});
} catch (err) {
console.error('Error fetching dashboard stats:', err);
@@ -116,4 +199,389 @@ router.get('/stats', async (req, res) => {
}
});
// GET /api/dashboard/phrases — Get decrypted phrases from SQLite cache
router.get('/phrases', (req, res) => {
const { tipo = 'demotivational' } = req.query;
try {
const rows = db.prepare("SELECT testo FROM frasi_cache WHERE tipo = ?").all(tipo);
const decryptedPhrases = rows.map(row => {
try {
return decrypt(row.testo);
} catch (decErr) {
console.warn('[Decrypt Phrase] Failed to decrypt phrase:', decErr.message);
return null;
}
}).filter(Boolean);
res.json(decryptedPhrases);
} catch (err) {
console.error('Error fetching decrypted phrases:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/dashboard/settings — Retrieve settings for the agent
router.get('/settings', (req, res) => {
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
try {
const row = db.prepare("SELECT preview_limit, tickets_per_page FROM agent_settings WHERE agent_id = ?").get(activeAgentId);
if (row) {
res.json(row);
} else {
res.json({ preview_limit: 10, tickets_per_page: 50 });
}
} catch (err) {
console.error('Error reading agent settings:', err);
res.status(500).json({ error: err.message });
}
});
// 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 {
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' });
}
db.prepare("DELETE FROM dashboard_chart_lines WHERE id = ?").run(id);
res.json({ success: true });
} catch (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 });
}
});
module.exports = router;
+424
View File
@@ -0,0 +1,424 @@
'use strict';
const express = require('express');
const router = express.Router();
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
router.get('/signatures', (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, body_html, is_default, created_at, updated_at
FROM email_signatures
WHERE agent_id = ?
ORDER BY is_default DESC, name ASC
`).all(parseInt(agent_id, 10));
res.json(rows);
} catch (err) {
console.error('[Email] Error listing signatures:', err);
res.status(500).json({ error: err.message });
}
});
// POST /api/email/signatures — create a new signature
router.post('/signatures', (req, res) => {
try {
const { agent_id, name, body_html, is_default = 0 } = req.body;
if (!agent_id || !name) return res.status(400).json({ error: 'agent_id e name sono obbligatori' });
// If new signature is default, reset others for this agent
if (is_default) {
db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10));
}
const result = db.prepare(`
INSERT INTO email_signatures (agent_id, name, body_html, is_default)
VALUES (?, ?, ?, ?)
`).run(parseInt(agent_id, 10), name, body_html || '', is_default ? 1 : 0);
res.json({ id: result.lastInsertRowid, agent_id, name, body_html, is_default });
} catch (err) {
console.error('[Email] Error creating signature:', err);
res.status(500).json({ error: err.message });
}
});
// PUT /api/email/signatures/:id — update a signature
router.put('/signatures/:id', (req, res) => {
try {
const { id } = req.params;
const { agent_id, name, body_html, is_default } = req.body;
// If new signature is default, reset others for this agent
if (is_default && agent_id) {
db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10));
}
db.prepare(`
UPDATE email_signatures
SET name = ?, body_html = ?, is_default = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?
`).run(name, body_html || '', is_default ? 1 : 0, parseInt(id, 10));
const updated = db.prepare(`SELECT * FROM email_signatures WHERE id = ?`).get(parseInt(id, 10));
res.json(updated);
} catch (err) {
console.error('[Email] Error updating signature:', err);
res.status(500).json({ error: err.message });
}
});
// PATCH /api/email/signatures/:id/default — set a signature as default
router.patch('/signatures/:id/default', (req, res) => {
try {
const { id } = req.params;
const { agent_id } = req.body;
if (agent_id) {
db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10));
}
db.prepare(`
UPDATE email_signatures SET is_default = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?
`).run(parseInt(id, 10));
res.json({ success: true });
} catch (err) {
console.error('[Email] Error setting default signature:', err);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/email/signatures/:id — delete a signature
router.delete('/signatures/:id', (req, res) => {
try {
const { id } = req.params;
db.prepare(`DELETE FROM email_signatures WHERE id = ?`).run(parseInt(id, 10));
res.json({ success: true });
} catch (err) {
console.error('[Email] Error deleting signature:', err);
res.status(500).json({ error: err.message });
}
});
// ─── 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
router.post('/send', async (req, res) => {
const {
ticketId,
to,
cc = [],
bcc = [],
subject: customSubject,
bodyHtml,
attachments = [],
inlineImages = [],
agentId,
agentName = 'Agente',
keepHelpdeskCopy = true,
inReplyTo,
references,
} = req.body;
if (!ticketId) return res.status(400).json({ error: 'ticketId è obbligatorio' });
if (!to || !to.length) return res.status(400).json({ error: 'Il campo "to" è obbligatorio' });
if (!bodyHtml) return res.status(400).json({ error: 'Il corpo della email è obbligatorio' });
try {
// 1. Fetch ticket number and title for subject
const ticketResult = await pool.query(
`SELECT tn, title FROM ticket WHERE id = $1`,
[ticketId]
);
if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' });
const { tn, title } = ticketResult.rows[0];
const subject = customSubject || `[Ticket#${tn}] Re: ${title}`;
// 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true)
const bccList = [...bcc];
if (keepHelpdeskCopy) {
const otrsBcc = process.env.OTRS_MAIL_BCC;
if (otrsBcc && !bccList.includes(otrsBcc)) bccList.push(otrsBcc);
}
// 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}"`;
});
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 FROM users WHERE id = $1`, [agentId])
: null;
const agentLogin = agentLoginResult?.rows[0]?.login || 'system';
// 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) {
agentEmail = prefRes.rows[0].preferences_value || '';
}
}
const aFrom = agentEmail ? `"${agentLogin}" <${agentEmail}>` : `"${agentLogin}" <${process.env.AZURE_MAIL_SENDER || process.env.SMTP_FROM || 'helpdesk@example.com'}>`;
const toList = to.join(', ');
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, create_time, create_by, change_time, change_by
) VALUES (
$1, 1, 1, 1, $3, $2, $3, $2
) RETURNING id`,
[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_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] Articolo OTRS non inserito a database o non indicizzato (non bloccante):', noteErr.message);
}
console.log(`[Email] ✅ Email inviata per ticket #${tn} a: ${to.join(', ')}`);
res.json({ success: true, subject, to });
} catch (err) {
console.error('[Email] ❌ Errore invio email:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+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;
+417 -11
View File
@@ -1,6 +1,23 @@
const express = require('express');
const router = express.Router();
const pool = require('../db');
const { db } = require('../activityDb');
// Ensure SQLite tables for customer user cache exist in internal.db
db.exec(`
CREATE TABLE IF NOT EXISTS customer_user_cache (
login TEXT PRIMARY KEY,
email TEXT,
first_name TEXT,
last_name TEXT,
customer_id TEXT,
phone TEXT
);
CREATE TABLE IF NOT EXISTS sync_status (
key TEXT PRIMARY KEY,
val TEXT
);
`);
// Helper for OTRS CE GenericInterface REST API calls
async function otrsRequest(method, path, bodyData = {}) {
@@ -19,7 +36,7 @@ async function otrsRequest(method, path, bodyData = {}) {
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(url, {
@@ -34,11 +51,15 @@ async function otrsRequest(method, path, bodyData = {}) {
const errorText = await response.text();
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
}
return await response.json();
const result = await response.json();
if (result && result.Error) {
throw new Error(`OTRS API Error: ${result.Error.ErrorMessage} (${result.Error.ErrorCode})`);
}
return result;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('OTRS REST API request timed out (6s limit exceeded)');
throw new Error('OTRS REST API request timed out (15s limit exceeded)');
}
throw err;
}
@@ -111,10 +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
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',
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
agentEmail: agentEmail
});
});
@@ -168,10 +205,46 @@ router.get('/lock-types', async (req, res) => {
// GET /api/customer-companies/search — Search customer companies
router.get('/customer-companies/search', async (req, res) => {
try {
const { q } = req.query;
const { q = '' } = req.query;
// 1. Fetch from local SQLite LDAP cache
let localRows = [];
try {
if (!q) {
return res.json([]);
localRows = db.prepare(`
SELECT DISTINCT customer_id AS customer_id, customer_id AS name
FROM customer_user_cache
WHERE customer_id IS NOT NULL AND customer_id != ''
ORDER BY customer_id
LIMIT 500
`).all();
} else {
const searchTerm = `%${q}%`;
localRows = db.prepare(`
SELECT DISTINCT customer_id AS customer_id, customer_id AS name
FROM customer_user_cache
WHERE customer_id IS NOT NULL AND customer_id != '' AND customer_id LIKE ?
ORDER BY customer_id
LIMIT 500
`).all(searchTerm);
}
} catch (e) {
console.warn('Failed to query local customer cache:', e.message);
}
// 2. Fetch from OTRS Postgres DB
let dbRows = [];
try {
if (!q) {
const result = await pool.query(
`SELECT customer_id, name
FROM customer_company
WHERE valid_id = 1
ORDER BY name
LIMIT 500`
);
dbRows = result.rows;
} else {
const searchTerm = `%${q}%`;
const result = await pool.query(
`SELECT customer_id, name
@@ -181,21 +254,228 @@ router.get('/customer-companies/search', async (req, res) => {
name ILIKE $1
)
ORDER BY name
LIMIT 20`,
LIMIT 500`,
[searchTerm]
);
res.json(result.rows);
dbRows = result.rows;
}
} catch (e) {
console.warn('Failed to query OTRS customer_company table:', e.message);
}
// 3. Merge results and remove duplicates by customer_id
const seen = new Set();
const merged = [];
// Prioritize OTRS database rows (which might have better names)
for (const row of dbRows) {
const cid = String(row.customer_id).trim();
if (cid && !seen.has(cid.toLowerCase())) {
seen.add(cid.toLowerCase());
merged.push({ customer_id: cid, name: row.name || cid });
}
}
// Add local LDAP rows
for (const row of localRows) {
const cid = String(row.customer_id).trim();
if (cid && !seen.has(cid.toLowerCase())) {
seen.add(cid.toLowerCase());
merged.push({ customer_id: cid, name: row.name || cid });
}
}
// Sort alphabetically by name
merged.sort((a, b) => a.name.localeCompare(b.name, 'it', { sensitivity: 'base' }));
res.json(merged.slice(0, 500));
} catch (err) {
console.error('Error searching customer companies:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/customer-users/search — Search customer users (always from local DB)
// GET /api/customer-users/search — Search customer users (supports LDAP via REST, fallbacks to DB)
router.get('/customer-users/search', async (req, res) => {
try {
const { q = '', customer_company_id } = req.query;
// If q is empty, we return a merged list for populating filter dropdowns
if (!q) {
let localRows = [];
try {
if (customer_company_id) {
localRows = db.prepare(`
SELECT login, email, first_name, last_name, customer_id
FROM customer_user_cache
WHERE customer_id = ?
ORDER BY last_name, first_name
LIMIT 1000
`).all(customer_company_id);
} else {
localRows = db.prepare(`
SELECT login, email, first_name, last_name, customer_id
FROM customer_user_cache
ORDER BY last_name, first_name
LIMIT 1000
`).all();
}
} catch (e) {
console.warn('Failed to query local customer user cache:', e.message);
}
let dbRows = [];
try {
let queryText = `
SELECT login, email, first_name, last_name, customer_id
FROM customer_user
WHERE valid_id = 1
`;
let queryParams = [];
if (customer_company_id) {
queryText += ` AND customer_id = $1`;
queryParams.push(customer_company_id);
}
queryText += ` ORDER BY last_name, first_name LIMIT 1000`;
const result = await pool.query(queryText, queryParams);
dbRows = result.rows;
} catch (e) {
console.warn('Failed to query OTRS customer_user table:', e.message);
}
// Merge and deduplicate by login
const seen = new Set();
const merged = [];
for (const row of dbRows) {
const login = String(row.login).trim();
if (login && !seen.has(login.toLowerCase())) {
seen.add(login.toLowerCase());
merged.push({
login,
email: row.email || '',
first_name: row.first_name || '',
last_name: row.last_name || '',
customer_id: row.customer_id || ''
});
}
}
for (const row of localRows) {
const login = String(row.login).trim();
if (login && !seen.has(login.toLowerCase())) {
seen.add(login.toLowerCase());
merged.push({
login,
email: row.email || '',
first_name: row.first_name || '',
last_name: row.last_name || '',
customer_id: row.customer_id || ''
});
}
}
// Sort alphabetically by last name, first name
merged.sort((a, b) => {
const nameA = `${a.last_name} ${a.first_name}`.trim();
const nameB = `${b.last_name} ${b.first_name}`.trim();
return nameA.localeCompare(nameB, 'it', { sensitivity: 'base' });
});
return res.json(merged.slice(0, 1000));
}
// 1. Try to search via OTRS GenericInterface REST API if configured
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
try {
// Query the local SQLite cache table first
const searchTerm = q ? `%${q}%` : '%';
let rows;
if (customer_company_id) {
rows = db.prepare(`
SELECT login, email, first_name, last_name, customer_id
FROM customer_user_cache
WHERE customer_id = ? AND (
login LIKE ? OR
email LIKE ? OR
first_name LIKE ? OR
last_name LIKE ?
)
ORDER BY last_name, first_name
LIMIT 20
`).all(customer_company_id, searchTerm, searchTerm, searchTerm, searchTerm);
} else {
rows = db.prepare(`
SELECT login, email, first_name, last_name, customer_id
FROM customer_user_cache
WHERE login LIKE ? OR
email LIKE ? OR
first_name LIKE ? OR
last_name LIKE ?
ORDER BY last_name, first_name
LIMIT 20
`).all(searchTerm, searchTerm, searchTerm, searchTerm);
}
if (rows.length > 0) {
return res.json(rows);
}
} catch (dbErr) {
console.warn('[LDAP Cache Search] SQLite query failed, falling back to live API:', dbErr.message);
}
// Fallback to live API search if cache is not yet populated
try {
const searchPattern = q ? `*${q}*` : '*';
const searchPayload = {
Search: searchPattern,
Valid: 1
};
if (customer_company_id) {
searchPayload.CustomerID = customer_company_id;
}
const searchResult = await otrsRequest('POST', '/CustomerUserSearch', searchPayload);
const searchData = (searchResult && searchResult.Data) || searchResult;
if (searchData && searchData.CustomerUserID) {
let logins = searchData.CustomerUserID;
if (!Array.isArray(logins)) {
logins = [logins];
}
logins = logins.slice(0, 20); // limit to 20 results
// Fetch details for each login in parallel
const detailsPromises = logins.map(async (login) => {
try {
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
const getData = (getResult && getResult.Data) || getResult;
if (getData && getData.CustomerUser) {
const u = getData.CustomerUser;
return {
login: u.UserLogin,
email: u.UserEmail || '',
first_name: u.UserFirstname || '',
last_name: u.UserLastname || '',
customer_id: u.UserCustomerID || ''
};
}
} catch (getErr) {
console.warn(`Failed to fetch details for customer user ${login}:`, getErr.message);
}
return null;
});
const users = (await Promise.all(detailsPromises)).filter(u => u !== null);
return res.json(users);
}
} catch (restErr) {
console.warn('Failed to search customer users via REST API, falling back to database query:', restErr.message);
}
}
// 2. Fallback to local DB query
try {
let queryText;
let queryParams;
if (q) {
@@ -302,4 +582,130 @@ router.get('/states/search', async (req, res) => {
}
});
// Helper function to sync customer users from LDAP (OTRS API) into SQLite cache
async function syncCustomerUsers() {
if (!process.env.OTRS_API_URL || !process.env.OTRS_API_USER) {
console.log('[LDAP Sync] OTRS API not configured, skipping customer user sync.');
return 0;
}
console.log('[LDAP Sync] Starting customer user synchronization...');
try {
const searchResult = await otrsRequest('POST', '/CustomerUserSearch', { Search: '*', Valid: 1 });
const searchData = (searchResult && searchResult.Data) || searchResult;
if (!searchData || !searchData.CustomerUserID) {
console.log('[LDAP Sync] No customer users found to sync.');
return 0;
}
let logins = searchData.CustomerUserID;
if (!Array.isArray(logins)) {
logins = [logins];
}
console.log(`[LDAP Sync] Found ${logins.length} customer users. Fetching details...`);
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// Prepare SQLite insert statement with upsert logic
const upsertStmt = db.prepare(`
INSERT INTO customer_user_cache (login, email, first_name, last_name, customer_id, phone)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login) DO UPDATE SET
email=excluded.email,
first_name=excluded.first_name,
last_name=excluded.last_name,
customer_id=excluded.customer_id,
phone=excluded.phone
`);
let syncCount = 0;
// Fetch details in batches of 3 to avoid overloading the OTRS CGI server
const batchSize = 3;
for (let i = 0; i < logins.length; i += batchSize) {
const batchLogins = logins.slice(i, i + batchSize);
await Promise.all(batchLogins.map(async (login) => {
let retries = 2;
while (retries >= 0) {
try {
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
const getData = (getResult && getResult.Data) || getResult;
if (getData && getData.CustomerUser) {
const u = getData.CustomerUser;
// Run insertion in SQLite cache table
upsertStmt.run(
u.UserLogin,
u.UserEmail || '',
u.UserFirstname || '',
u.UserLastname || '',
u.UserCustomerID || '',
u.UserPhone || ''
);
syncCount++;
}
break; // Success, break retry loop
} catch (getErr) {
if (retries === 0) {
console.warn(`[LDAP Sync] Failed to sync customer user ${login} after retries:`, getErr.message);
} else {
await delay(200); // Wait 200ms before retrying
}
retries--;
}
}
}));
// Add a small delay between batches
await delay(50);
}
// Update last sync timestamp in SQLite sync_status table
db.prepare(`
INSERT INTO sync_status (key, val)
VALUES ('last_ldap_sync_time', ?)
ON CONFLICT(key) DO UPDATE SET val=excluded.val
`).run(new Date().toISOString());
console.log(`[LDAP Sync] Completed. Synced ${syncCount} users to SQLite customer_user_cache.`);
return syncCount;
} catch (err) {
console.error('[LDAP Sync] Error during customer user sync:', err);
throw err;
}
}
// POST /api/customer-users/sync — Sync customer users from LDAP
router.post('/customer-users/sync', async (req, res) => {
try {
const count = await syncCustomerUsers();
res.json({ message: `Sincronizzazione completata! ${count} utenti sincronizzati.`, count });
} catch (err) {
console.warn('[LDAP Sync] Sincronizzazione non riuscita:', err.message);
res.json({ message: `Sincronizzazione LDAP ignorata o non disponibile: ${err.message}`, count: 0 });
}
});
// Run LDAP synchronization on startup if last sync was > LDAP_SYNC_INTERVAL_HOURS ago
setTimeout(() => {
try {
const row = db.prepare("SELECT val FROM sync_status WHERE key = 'last_ldap_sync_time'").get();
let shouldSync = true;
if (row && row.val) {
const lastSync = new Date(row.val);
const intervalHours = parseFloat(process.env.LDAP_SYNC_INTERVAL_HOURS) || 24;
const thresholdTime = new Date(Date.now() - intervalHours * 60 * 60 * 1000);
if (lastSync > thresholdTime) {
shouldSync = false;
console.log(`[LDAP Sync] Last sync was on ${lastSync.toLocaleString()} (Threshold: ${intervalHours}h). Skipping auto-sync at startup.`);
}
}
if (shouldSync) {
syncCustomerUsers().catch(err => console.error('Startup LDAP sync failed:', err));
}
} catch (err) {
console.error('[LDAP Sync] Failed to check LDAP sync status:', err.message);
}
}, 5000);
module.exports = router;
+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;
+1178 -77
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
const crypto = require('crypto');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const ALGORITHM = 'aes-256-cbc';
const SECRET_KEY = crypto.createHash('sha256').update(process.env.CRYPTO_KEY || 'default_secret_key_12345').digest();
const encryptedText = process.argv[2];
if (!encryptedText) {
console.log('Utilizzo: node decrypt.js "testo_criptato:valore"');
process.exit(1);
}
try {
const textParts = encryptedText.split(':');
if (textParts.length < 2) {
throw new Error('Formato testo cifrato non valido (manca il separatore ":")');
}
const iv = Buffer.from(textParts.shift(), 'hex');
const encrypted = Buffer.from(textParts.join(':'), 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
console.log('Testo Criptato: ', encryptedText);
console.log('Testo Decriptato:', decrypted);
} catch (err) {
console.error('Errore durante la decriptazione:', err.message);
process.exit(1);
}
+22
View File
@@ -0,0 +1,22 @@
const crypto = require('crypto');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const ALGORITHM = 'aes-256-cbc';
const SECRET_KEY = crypto.createHash('sha256').update(process.env.CRYPTO_KEY || 'default_secret_key_12345').digest();
const IV_LENGTH = 16;
const text = process.argv[2];
if (!text) {
console.log('Utilizzo: node encrypt.js "testo da criptare"');
process.exit(1);
}
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const result = iv.toString('hex') + ':' + encrypted;
console.log('Testo Originale:', text);
console.log('Testo Criptato: ', result);
+12 -2
View File
@@ -1,11 +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;
@@ -21,6 +27,10 @@ app.use(express.static(path.join(__dirname, 'public')));
app.use('/api/tickets', ticketsRouter);
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) => {
BIN
View File
Binary file not shown.
+163
View File
@@ -0,0 +1,163 @@
/**
* utils/graphMailer.js
* Invia email tramite Microsoft Graph API (per Exchange con 2FA/OAuth2).
* Gestisce automaticamente il token OAuth2 con cache e refresh.
*/
'use strict';
let cachedToken = null;
let tokenExpiresAt = 0;
/**
* Ottiene un access token OAuth2 da Microsoft (client credentials flow).
* Il token viene cachato per circa 55 minuti per evitare richieste continue.
*/
async function getAccessToken() {
const now = Date.now();
if (cachedToken && now < tokenExpiresAt) {
return cachedToken;
}
const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } = process.env;
const url = `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: AZURE_CLIENT_ID,
client_secret: AZURE_CLIENT_SECRET,
scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials',
});
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`[Graph Auth] Errore ottenendo token OAuth2: ${res.status} ${errText}`);
}
const data = await res.json();
cachedToken = data.access_token;
// Scade in data.expires_in secondi, refresh 5 minuti prima
tokenExpiresAt = now + (data.expires_in - 300) * 1000;
return cachedToken;
}
/**
* Invia una email tramite Microsoft Graph API.
*
* @param {Object} options
* @param {string[]} options.to - Destinatari (array di email)
* @param {string[]} [options.cc] - CC (array di email)
* @param {string[]} [options.bcc] - BCC (array di email)
* @param {string} options.subject - Oggetto email
* @param {string} options.bodyHtml - Corpo HTML
* @param {Array} [options.attachments] - [{ filename, content (base64), contentType }]
* @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }]
* @returns {Promise<void>}
*/
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');
const token = await getAccessToken();
const toRecipients = to.map(addr => ({
emailAddress: { address: addr }
}));
const ccRecipients = cc.map(addr => ({
emailAddress: { address: addr }
}));
const bccRecipients = bcc.map(addr => ({
emailAddress: { address: addr }
}));
// Costruisce gli allegati (file + immagini inline)
const allAttachments = [
...attachments.map(a => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: a.filename,
contentType: a.contentType || 'application/octet-stream',
contentBytes: a.content, // già base64
})),
...inlineImages.map(img => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: img.cid,
contentId: img.cid,
contentType: img.contentType || 'image/png',
contentBytes: img.content, // già base64
isInline: true,
})),
];
const headers = [];
if (inReplyTo) {
headers.push({ name: 'In-Reply-To', value: inReplyTo });
}
if (references) {
headers.push({ name: 'References', value: references });
}
const payload = {
message: {
subject,
body: {
contentType: 'HTML',
content: bodyHtml,
},
toRecipients,
ccRecipients,
bccRecipients,
attachments: allAttachments,
internetMessageHeaders: headers.length ? headers : undefined,
},
saveToSentItems: true,
};
const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.status === 202) {
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();
throw new Error(`[Graph Mail] Errore invio email: ${res.status} ${errText}`);
}
module.exports = { sendMail };
+35
View File
@@ -0,0 +1,35 @@
/**
* utils/mailer.js
* Factory: seleziona il metodo di invio email corretto in base alla configurazione .env.
* Priorità: Graph API (se AZURE_TENANT_ID configurato) → SMTP (se SMTP_HOST configurato)
*/
'use strict';
function getMailer() {
if (process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET) {
return require('./graphMailer');
}
if (process.env.SMTP_HOST) {
return require('./smtpMailer');
}
throw new Error('[Mailer] Nessun metodo di invio email configurato. Impostare AZURE_TENANT_ID oppure SMTP_HOST nel .env.');
}
/**
* Invia una email usando il metodo configurato (Graph API o SMTP).
*
* @param {Object} options
* @param {string[]} options.to
* @param {string[]} [options.cc]
* @param {string[]} [options.bcc]
* @param {string} options.subject
* @param {string} options.bodyHtml
* @param {Array} [options.attachments]
* @param {Array} [options.inlineImages]
*/
async function sendMail(options) {
const mailer = getMailer();
return mailer.sendMail(options);
}
module.exports = { sendMail };
+64
View File
@@ -0,0 +1,64 @@
/**
* utils/smtpMailer.js
* Fallback SMTP per l'invio email via nodemailer.
* Usato se AZURE_TENANT_ID non è configurato ma SMTP_HOST lo è.
*/
'use strict';
const nodemailer = require('nodemailer');
let _transporter = null;
function getTransporter() {
if (_transporter) return _transporter;
_transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
return _transporter;
}
/**
* Invia una email tramite SMTP (nodemailer).
* Stessa interfaccia di graphMailer.sendMail.
*/
async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) {
const transporter = getTransporter();
const mailOptions = {
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: to.join(', '),
cc: cc.length ? cc.join(', ') : undefined,
bcc: bcc.length ? bcc.join(', ') : undefined,
subject,
html: bodyHtml,
inReplyTo,
references,
messageId,
attachments: [
...attachments.map(a => ({
filename: a.filename,
content: Buffer.from(a.content, 'base64'),
contentType: a.contentType || 'application/octet-stream',
})),
...inlineImages.map(img => ({
filename: img.cid,
cid: img.cid,
content: Buffer.from(img.content, 'base64'),
contentType: img.contentType || 'image/png',
})),
],
};
await transporter.sendMail(mailOptions);
return { internetMessageId: messageId };
}
module.exports = { sendMail };