Compare commits

..
34 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
32 changed files with 9127 additions and 426 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
+1
View File
@@ -3,3 +3,4 @@ node_modules/
internal.db
internal.db-shm
internal.db-wal
dist
+128 -1
View File
@@ -8,7 +8,8 @@ const Database = require('better-sqlite3');
const path = require('path');
const crypto = require('crypto');
const DB_PATH = path.join(__dirname, 'internal.db');
const baseDir = process.pkg ? path.dirname(process.execPath) : __dirname;
const DB_PATH = path.join(baseDir, 'internal.db');
const db = new Database(DB_PATH);
// Ensure WAL mode for better concurrent access
@@ -27,6 +28,132 @@ db.exec(`
)
`);
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).
*/
+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) => {
+1335 -11
View File
File diff suppressed because it is too large Load Diff
+24 -4
View File
@@ -3,22 +3,42 @@
"version": "1.0.0",
"description": "Modern fast interface for OTRS ticket management - direct database access",
"main": "server.js",
"bin": "server.js",
"pkg": {
"scripts": [
"routes/*.js",
"utils/*.js",
"*.js"
],
"assets": [
"public/**/*"
],
"targets": [
"node18-win-x64"
]
},
"scripts": {
"start": "node server.js",
"dev": "npx -y nodemon server.js"
"dev": "npx -y nodemon server.js",
"build:exe": "npx pkg@5.8.1 . --targets node18-win-x64 --output dist/otrs-turbo.exe"
},
"dependencies": {
"better-sqlite3": "^12.11.1",
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"mysql2": "^3.22.5",
"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"
}
}
+256 -1
View File
@@ -354,6 +354,25 @@ body {
letter-spacing: -0.02em;
}
.btn-brand-action {
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 6px;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.btn-brand-action:hover {
color: var(--error);
background: var(--error-bg);
border-color: rgba(220, 38, 38, 0.2);
}
.nav-menu {
list-style: none;
padding: var(--space-md);
@@ -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 {
@@ -1155,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 {
@@ -2394,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;
}
+89 -43
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="brand-icon"></div>
<span class="brand-text">OTRS Turbo</span>
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px; display:flex; align-items:center; justify-content:space-between;">
<div style="display:flex; align-items:center; gap:var(--space-md);">
<div class="brand-icon"></div>
<span class="brand-text">OTRS Turbo</span>
</div>
<button id="btn-close-end-of-day" class="btn-brand-action" title="Chiudi i ticket inseriti nel gruppo CHIUDI A FINE GIORNATA">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px; height:18px;">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
<path d="M9 12h6M9 16h4" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="sidebar-timer" id="daily-timer" style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
<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,13 +101,23 @@
<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à</span>
<span>Storico Attività Turbo</span>
</a>
</li>
</ul>
@@ -101,48 +133,58 @@
<!-- Main Content -->
<main class="main-content" id="main-content">
<!-- Top Bar -->
<header class="topbar" id="topbar">
<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);">
<select class="form-select" id="active-agent-select"
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
<button class="btn btn-ghost btn-sm" id="refresh-lookups-btn"
style="height:36px; padding:0 10px; min-width:36px;" title="Aggiorna dati locali (code, utenti)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
<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);">
<select class="form-select" id="active-agent-select"
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
<button class="btn btn-ghost btn-sm" id="refresh-lookups-btn"
style="height:36px; padding:0 10px; min-width:36px;" title="Aggiorna dati locali (code, utenti)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
</svg>
</button>
<select class="form-select" id="theme-select"
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 130px; margin: 0; background-position: right 10px center; border-color: var(--border-light);">
<option value="light">Tema Chiaro</option>
<option value="dark">Tema Scuro</option>
<option value="rosso">Rosso</option>
<option value="naturale">Naturale</option>
<option value="ice">Ice</option>
<option value="autunno">Autunno</option>
<option value="fairytale">Fairytale</option>
</select>
</div>
</div>
<div class="topbar-right">
<div class="search-bar" id="global-search-container">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</button>
<select class="form-select" id="theme-select"
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 130px; margin: 0; background-position: right 10px center; border-color: var(--border-light);">
<option value="light">Tema Chiaro</option>
<option value="dark">Tema Scuro</option>
<option value="rosso">Rosso</option>
<option value="naturale">Naturale</option>
<option value="ice">Ice</option>
<option value="autunno">Autunno</option>
<option value="fairytale">Fairytale</option>
</select>
</div>
</div>
<div class="topbar-right">
<div class="search-bar" id="global-search-container">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
<path d="M12 5v14M5 12h14" />
</svg>
Nuovo
</button>
</div>
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
<path d="M12 5v14M5 12h14" />
</svg>
Nuovo
</button>
</div>
<!-- Tabs Bar -->
<div id="tabs-bar" class="tabs-bar" style="display:none; border-top: 1px solid var(--border-subtle);"></div>
</header>
<!-- View Container -->
@@ -160,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>
@@ -168,6 +211,9 @@
<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>
+526 -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,11 +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');
@@ -186,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) {
@@ -223,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!');
@@ -325,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();
});
@@ -384,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);
@@ -422,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) {
@@ -455,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>
@@ -490,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
+593 -108
View File
@@ -11,6 +11,7 @@ const Filters = {
state_id: '',
priority_id: '',
user_id: '',
customer_user_id: '',
date_from: '',
date_to: '',
},
@@ -19,11 +20,22 @@ const Filters = {
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];
@@ -43,10 +55,14 @@ const Filters = {
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
if (savedMy) {
Object.assign(this.allStates.my, JSON.parse(savedMy));
} else {
// Default to active agent if not saved yet
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.allStates.my.user_id = activeAgentId;
}
// 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 */ }
},
@@ -60,7 +76,7 @@ const Filters = {
/** 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;
@@ -77,33 +93,66 @@ const Filters = {
return params;
},
/** Helper to compute trigger button label text */
getStateMultiselectLabel(lookups) {
const selectedList = Array.isArray(this.state.state_id)
? this.state.state_id
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
/** Helper to compute trigger button label text for multiselect dropdowns */
getMultiselectLabel(selectedVal, itemsList, labelField = 'name', idField = 'id') {
const selectedList = Array.isArray(selectedVal)
? selectedVal
: (typeof selectedVal === 'string' && selectedVal ? selectedVal.split(',') : []);
if (selectedList.length === 0) {
return 'Tutti';
}
const selectedNames = (lookups.states || [])
.filter(s => selectedList.includes(String(s.id)))
.map(s => s.name);
if (selectedNames.length === (lookups.states || []).length) {
const selectedNames = (itemsList || [])
.filter(item => selectedList.includes(String(item[idField])))
.map(item => typeof labelField === 'function' ? labelField(item) : item[labelField]);
if (selectedNames.length === 0) {
return 'Tutti';
} else if (selectedNames.length <= 2) {
}
if (selectedNames.length <= 2) {
return selectedNames.join(', ');
} else {
return `${selectedNames.length} selezionati`;
}
},
/** Update label DOM element dynamically */
updateStateMultiselectLabel(lookups) {
const labelEl = document.getElementById('state-multiselect-label');
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.getStateMultiselectLabel(lookups);
labelEl.textContent = this.getCustomerUserMultiselectLabel(lookups);
}
},
@@ -113,83 +162,178 @@ const Filters = {
* @returns {string} HTML string
*/
renderBar(lookups) {
const makeOptions = (items, valueKey, labelKey, selectedVal) => {
return items.map(item => {
const val = item[valueKey];
const label = typeof labelKey === 'function' ? labelKey(item) : item[labelKey];
const sel = String(val) === String(selectedVal) ? 'selected' : '';
return `<option value="${val}" ${sel}>${label}</option>`;
}).join('');
};
const 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 currentLabel = this.getStateMultiselectLabel(lookups);
const presetOptions = (this.presets || []).map(p => {
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
}).join('');
return `
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<span class="filter-label">Preset</span>
<div style="display:flex; gap:4px; align-items:center;">
<select class="form-select" id="filter-presets-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; min-width:130px; border-color:var(--border-subtle);">
<option value="">-- Nessuno --</option>
${presetOptions}
</select>
<button class="btn btn-ghost btn-xs" id="btn-save-preset" style="height:28px; padding:0 8px; font-size:0.75rem;" title="Salva filtri attuali come preset">Salva</button>
<button class="btn btn-ghost btn-xs" id="btn-delete-preset" style="height:28px; padding:0 8px; font-size:0.75rem; color:var(--danger);" title="Elimina il preset selezionato">Elimina</button>
</div>
</div>
<!-- Stato -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Stato</span>
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:130px;">${App.escapeHtml(currentLabel)}</span>
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 110px; max-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(stateLabel)}</span>
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
</div>
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
<div style="display:flex; flex-direction:column; gap:4px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
<input type="text" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
${(lookups.states || []).map(s => {
const selectedList = Array.isArray(this.state.state_id)
? this.state.state_id
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
const isSelected = selectedList.includes(String(s.id));
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
return `
<div class="state-multiselect-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
${App.escapeHtml(s.name)}
</div>
`;
}).join('')}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
<button type="button" class="btn btn-ghost btn-xs" id="state-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs" id="state-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
</div>
</div>
</div>
<!-- Coda -->
<div class="filter-group" style="position:relative;">
<span class="filter-label">Coda</span>
<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="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>
<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="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>
<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">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>
<div class="filter-group">
<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>
<div class="filter-group">
<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>
<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]');
@@ -197,70 +341,292 @@ const Filters = {
sel.disabled = false;
sel.addEventListener('change', (e) => {
this.selectedPresetId = null;
this.state[e.target.dataset.filter] = e.target.value;
this.save();
if (onFilterChange) onFilterChange();
});
});
// Multiselect dropdown toggle event
const dropdown = document.getElementById('state-multiselect-dropdown');
const popover = document.getElementById('state-multiselect-popover');
const okBtn = document.getElementById('state-multiselect-ok');
const clearBtn = document.getElementById('state-multiselect-clear');
// Helper to bind standard multiselect popover events
const bindStandardMultiselect = (filterKey, dropdownId, popoverId, labelElId, itemsList, labelField, idField = 'id') => {
const dropdown = document.getElementById(dropdownId);
const popover = document.getElementById(popoverId);
if (!dropdown || !popover) return;
if (dropdown && popover) {
const searchInput = popover.querySelector('.ms-search');
const itemsContainer = popover.querySelector('.ms-items-container');
const okBtn = popover.querySelector('.ms-ok');
const clearBtn = popover.querySelector('.ms-clear');
// Toggle popover visibility
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
// Close all other popovers
document.querySelectorAll('.multiselect-popover').forEach(p => {
if (p !== popover) p.style.display = 'none';
});
const isOpen = popover.style.display === 'block';
popover.style.display = isOpen ? 'none' : 'block';
if (!isOpen && searchInput) {
searchInput.value = '';
searchInput.dispatchEvent(new Event('input'));
setTimeout(() => searchInput.focus(), 50);
}
});
popover.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
popover.style.display = 'none';
});
// Search matching items
if (searchInput && itemsContainer) {
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(q) ? 'block' : 'none';
});
});
}
// Bind item clicks
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
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 {
// 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 (okBtn) {
okBtn.addEventListener('click', () => {
const activeItems = popover.querySelectorAll('.state-multiselect-item.active');
const ids = Array.from(activeItems).map(item => item.dataset.value);
this.state.state_id = ids.join(',');
if (cuOkBtn) {
cuOkBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.save();
this.updateStateMultiselectLabel(App.lookups);
popover.style.display = 'none';
this.updateCustomerUserMultiselectLabel(App.lookups);
if (cuPopover) cuPopover.style.display = 'none';
if (onFilterChange) onFilterChange();
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
item.classList.remove('active');
item.style.background = '';
item.style.color = '';
});
this.state.state_id = '';
if (cuClearBtn) {
cuClearBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.state.customer_user_id = '';
this.save();
this.updateStateMultiselectLabel(App.lookups);
popover.style.display = 'none';
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();
});
}
@@ -268,34 +634,153 @@ const Filters = {
const resetBtn = document.getElementById('filter-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
this.selectedPresetId = null;
this.reset();
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.state.user_id = activeAgentId;
this.save();
}
selects.forEach(s => {
if (isMyTickets && s.dataset.filter === 'user_id') {
s.value = localStorage.getItem('activeAgentId') || '1';
} else {
s.value = '';
}
// 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 multiselect items and label
const statePopover = document.getElementById('state-multiselect-popover');
if (statePopover) {
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
// 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 = '';
});
}
this.updateStateMultiselectLabel(App.lookups);
// Recompute labels
document.getElementById('state-multiselect-label').textContent = 'Tutti';
document.getElementById('queue-multiselect-label').textContent = 'Tutti';
document.getElementById('priority-multiselect-label').textContent = 'Tutti';
document.getElementById('owner-multiselect-label').textContent = isMyTickets ? this.getMultiselectLabel(this.state.user_id, App.lookups.users, u => `${u.first_name} ${u.last_name}`) : 'Tutti';
this.updateCustomerUserMultiselectLabel(App.lookups);
if (onFilterChange) onFilterChange();
});
}
// Load and bind presets
const loadPresets = async () => {
try {
const agentId = localStorage.getItem('activeAgentId') || '1';
this.presets = await App.api(`/api/presets?page_mode=${this.currentMode}`);
const select = document.getElementById('filter-presets-select');
if (select) {
select.innerHTML = '<option value="">-- Nessuno --</option>' + this.presets.map(p => {
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
}).join('');
}
} catch (err) {
console.warn('Failed to load presets:', err);
}
};
loadPresets();
const selectPresets = document.getElementById('filter-presets-select');
if (selectPresets) {
selectPresets.addEventListener('change', (e) => {
const presetId = e.target.value;
if (!presetId) {
this.selectedPresetId = null;
const resetBtn = document.getElementById('filter-reset');
if (resetBtn) {
resetBtn.click();
} else {
this.reset();
if (onFilterChange) onFilterChange();
}
return;
}
const preset = this.presets.find(p => String(p.id) === String(presetId));
if (preset) {
try {
const filters = JSON.parse(preset.filters_json);
this.state = Object.assign({
queue_id: '',
state_id: '',
priority_id: '',
user_id: '',
customer_user_id: '',
date_from: '',
date_to: ''
}, filters);
this.selectedPresetId = preset.id;
this.save();
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nel caricamento del preset: ' + err.message);
}
}
});
}
const btnSavePreset = document.getElementById('btn-save-preset');
if (btnSavePreset) {
btnSavePreset.addEventListener('click', async () => {
const name = await App.prompt('Nuovo Preset', 'Inserisci il nome per questo preset di filtri:');
if (!name || !name.trim()) return;
try {
btnSavePreset.disabled = true;
const newPreset = await App.api('/api/presets', {
method: 'POST',
body: JSON.stringify({
name: name.trim(),
page_mode: this.currentMode,
filters: this.state
})
});
Toast.success('Preset salvato con successo!');
this.selectedPresetId = newPreset.id;
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nel salvataggio del preset: ' + err.message);
btnSavePreset.disabled = false;
}
});
}
const btnDeletePreset = document.getElementById('btn-delete-preset');
if (btnDeletePreset) {
btnDeletePreset.addEventListener('click', async () => {
const select = document.getElementById('filter-presets-select');
const presetId = select ? select.value : '';
if (!presetId) {
Toast.warning('Seleziona prima un preset da eliminare');
return;
}
const ok = await App.confirm('Elimina Preset', 'Sei sicuro di voler eliminare questo preset?');
if (!ok) return;
try {
btnDeletePreset.disabled = true;
await App.api(`/api/presets/${presetId}`, { method: 'DELETE' });
Toast.success('Preset eliminato con successo');
this.selectedPresetId = null;
if (onFilterChange) onFilterChange();
} catch (err) {
Toast.error('Errore nell\'eliminazione del preset: ' + err.message);
btnDeletePreset.disabled = false;
}
});
}
},
};
+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;
+41 -52
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;
}
}
}
}
}
});
@@ -314,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';
@@ -322,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) {
@@ -335,41 +344,6 @@ const TicketCreateView = {
responsibleIdInput.value = activeAgent.id;
}
}
// 2. Customer User pre-population with first match
try {
const companies = await App.api('/api/customer-companies/search?q=');
if (companies.length > 0 && customerIdInput && companySearchInput) {
const defaultCompany = companies[0];
customerIdInput.value = defaultCompany.customer_id;
companySearchInput.value = defaultCompany.customer_id;
// Search users for this company
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
if (users.length > 0 && userSearchInput && customerUserIdInput) {
const defaultUser = users[0];
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
customerUserIdInput.value = defaultUser.login;
} else {
// Fallback: use company name as customer user ID
userSearchInput.value = defaultCompany.name;
customerUserIdInput.value = defaultCompany.customer_id;
}
}
} catch (err) {
console.error('Error pre-populating defaults:', err);
}
// 3. Queue pre-population
try {
const queues = await App.api('/api/queues/search?q=');
if (queues.length > 0 && queueSearchInput && queueIdInput) {
queueSearchInput.value = queues[0].name;
queueIdInput.value = queues[0].id;
}
} catch (err) {
console.error('Error pre-populating queues:', err);
}
}, 50);
}
@@ -388,11 +362,16 @@ const TicketCreateView = {
userSearchInput.addEventListener('input', () => {
clearTimeout(userDebounce);
const q = userSearchInput.value.trim();
// Do not block empty query to allow all results on focus
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';
@@ -400,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';
@@ -429,12 +409,18 @@ const TicketCreateView = {
}, 300);
});
userSearchInput.addEventListener('focus', () => {
userSearchInput.value = '';
customerUserIdInput.value = '';
userSearchInput.dispatchEvent(new Event('input'));
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;
if (ownerSearchInput) {
@@ -546,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>
</div>
`).join('');
queueSuggestionsDiv.innerHTML = queues.map(q => {
const displayName = q.name.replace(/::/g, ' ');
return `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}" style="padding: 6px 12px; font-size: 0.78rem; line-height: 1.25;">
${App.escapeHtml(displayName)}
</div>
`;
}).join('');
queueSuggestionsDiv.style.display = 'block';
// Bind click
+559 -52
View File
@@ -8,6 +8,25 @@ const TicketDetailView = {
originalValues: {},
noteAttachments: [],
saveDraft() {
if (!this.ticketId) return;
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
const time_unit = document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '';
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0) {
App.saveDraft(this.ticketId, {
type: 'note',
body,
subject,
time_unit,
attachments: [...this.noteAttachments]
});
} else {
App.clearDraft(this.ticketId, 'note');
}
},
updateNoteAttachmentList() {
const listEl = document.getElementById('note-file-list');
if (!listEl) return;
@@ -30,6 +49,41 @@ const TicketDetailView = {
});
},
openImageLightbox(src) {
let overlay = document.getElementById('image-lightbox-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'image-lightbox-overlay';
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
opacity: 0;
transition: opacity 0.2s ease;
`;
overlay.innerHTML = `
<img id="image-lightbox-img" style="max-width: 90vw; max-height: 90vh; border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); cursor: default; transition: transform 0.2s ease;" />
`;
overlay.addEventListener('click', () => {
overlay.style.opacity = '0';
setTimeout(() => overlay.remove(), 200);
});
overlay.querySelector('img').addEventListener('click', (e) => {
e.stopPropagation();
});
document.body.appendChild(overlay);
}
const imgEl = overlay.querySelector('img');
imgEl.src = src;
overlay.offsetHeight;
overlay.style.opacity = '1';
},
async render(id) {
this.ticketId = id;
this.noteAttachments = [];
@@ -41,6 +95,14 @@ const TicketDetailView = {
await App.ensureLookups();
const data = await App.api(`/api/tickets/${id}`);
const { ticket, articles, attachments } = data;
// No automatic tab creation here anymore
let groupsData = { asMaster: [], asMember: [] };
try {
groupsData = await App.api(`/api/groups/by-ticket/${id}`);
} catch (gErr) {
console.warn('Failed to load group associations for ticket', gErr);
}
const localISO = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
@@ -52,17 +114,23 @@ const TicketDetailView = {
return sum + (isNaN(val) ? 0 : val);
}, 0);
// Display ticket number in the topbar
// Display ticket number in the topbar with OTRS link if available
const titleEl = document.getElementById('page-title');
if (titleEl) {
titleEl.innerHTML = `Ticket #${id}`;
if (data.otrsWebUrl) {
titleEl.innerHTML = `<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; display:inline-flex; align-items:center; gap:6px;" title="Apri in OTRS">Ticket #${ticket.tn || id} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;opacity:0.75;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg></a>`;
} else {
titleEl.innerHTML = `Ticket #${ticket.tn || id}`;
}
}
this.originalValues = {
ticket_state_id: ticket.ticket_state_id,
ticket_priority_id: ticket.ticket_priority_id,
queue_id: ticket.queue_id,
queue_name: ticket.queue_name,
user_id: ticket.user_id,
responsible_user_id: ticket.responsible_user_id,
type_id: ticket.type_id,
customer_id: ticket.customer_id,
customer_user_id: ticket.customer_user_id,
@@ -70,8 +138,11 @@ const TicketDetailView = {
customer_last: ticket.customer_last,
};
const hasEmailDraft = App.getDraft(id, 'email');
const emailBtnText = hasEmailDraft ? 'Continua mail' : 'Invia Email';
container.innerHTML = `
<a class="back-link" onclick="history.back()">
<a class="back-link" onclick="window.location.hash = App.lastListView || '#/tickets'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Torna alla lista
</a>
@@ -82,13 +153,15 @@ const TicketDetailView = {
<div class="card">
<div class="ticket-header">
<div class="ticket-header-info">
<div class="ticket-number">#${ticket.tn}</div>
<h2 class="ticket-detail-title">
${data.otrsWebUrl ? `
<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; border-bottom:1px dashed transparent; transition:border-bottom 0.1s ease;" onmouseover="this.style.borderBottom='1px dashed var(--text-primary)'" onmouseout="this.style.borderBottom='transparent'" title="Apri in OTRS">
${App.escapeHtml(ticket.title || '(senza titolo)')}
</a>
` : App.escapeHtml(ticket.title || '(senza titolo)')}
<div class="ticket-number" style="display:inline-flex; align-items:center; gap:6px;">
<span class="copy-ticket-btn" data-tn="${ticket.tn}" style="cursor: pointer; font-size: 0.85rem;" title="Copia numero ticket">📋</span>
#${ticket.tn}
-
</div>
<h2 class="ticket-detail-title" style="display:inline-flex; align-items:center; gap:6px; flex-wrap:wrap;">
<span class="copy-ticket-btn" data-tn="${App.escapeHtml(ticket.title || '')}" style="cursor: pointer; font-size: 0.85rem;" title="Copia titolo ticket">📋</span>
${App.escapeHtml(ticket.title || '(senza titolo)')}
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
</h2>
<div class="ticket-meta-badges">
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
@@ -120,19 +193,26 @@ const TicketDetailView = {
).join('')}
</select>
</div>
<div class="quick-edit-field">
<div class="quick-edit-field" style="position:relative;">
<label class="quick-edit-label">Coda</label>
<select class="quick-edit-select" id="qe-queue" data-field="queue_id">
${(App.lookups.queues || []).map(q =>
`<option value="${q.id}" ${q.id === ticket.queue_id ? 'selected' : ''}>${q.name}</option>`
).join('')}
</select>
<input type="text" class="quick-edit-select" id="qe-queue-search" placeholder="Cerca coda..." autocomplete="off" value="${App.escapeHtml(ticket.queue_name || '')}" style="background-image: none; cursor: text;" />
<input type="hidden" id="qe-queue" data-field="queue_id" value="${ticket.queue_id || ''}" />
<div id="qe-queue-suggestions" class="autocomplete-suggestions" style="display:none; width: 800px; max-width: 800px; z-index: 1005;"></div>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Owner</label>
<select class="quick-edit-select" id="qe-owner" data-field="user_id">
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Responsabile</label>
<select class="quick-edit-select" id="qe-responsible" data-field="responsible_user_id">
<option value=""></option>
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.responsible_user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
).join('')}
</select>
</div>
@@ -183,7 +263,13 @@ const TicketDetailView = {
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
</div>
<div style="display:flex; gap:var(--space-md); align-items:center;">
<input type="datetime-local" class="note-subject-input" id="note-create-time" title="Data/ora nota (opzionale per retrodatazione)" style="width:185px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.82rem;" />
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
${emailBtnText}
</button>
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
Invia Nota
@@ -204,11 +290,33 @@ const TicketDetailView = {
<div class="articles-timeline">
${articles.length > 0 ? articles.map(a => {
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
let processedBody = a.a_body || '';
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
if (hasHtml) {
articleAttachments.forEach(att => {
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
processedBody = processedBody.replace(regex, `/api/tickets/attachments/${att.id}`);
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
processedBody = processedBody.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
}
});
}
const displayBody = this.htmlMode && hasHtml
? `<iframe srcdoc="${a.a_body.replace(/"/g, '&quot;')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
? `<iframe srcdoc="${processedBody.replace(/"/g, '&quot;')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1');
const visibleAttachments = articleAttachments.filter(att => att.filename !== 'file-1' && att.filename !== 'file-2');
return `
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
@@ -218,6 +326,8 @@ const TicketDetailView = {
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
<button class="btn-delete-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Elimina Articolo">🗑</button>
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Rispondi via email (Quota questo articolo)"></button>
</div>
<div style="display:flex; gap: var(--space-sm); align-items:center;">
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
@@ -251,10 +361,10 @@ const TicketDetailView = {
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
${displayBody}
${articleAttachments.length > 0 ? `
${visibleAttachments.length > 0 ? `
<div class="article-attachments">
${articleAttachments.map(att => `
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}">
${visibleAttachments.map(att => `
<a href="/api/tickets/attachments/${att.id}" class="attachment-badge" target="_blank" download="${att.filename}" data-is-image="${(att.content_type || '').startsWith('image/')}">
<span>📎</span>
<strong>${App.escapeHtml(att.filename)}</strong>
<span class="attachment-size">(${Math.round(att.content_size / 1024)} KB)</span>
@@ -316,12 +426,14 @@ const TicketDetailView = {
<span class="meta-value">${ticket.type_name}</span>
</div>
` : ''}
${ticket.responsible_first ? `
<div class="meta-row">
<span class="meta-label">Responsabile</span>
<span class="meta-value">${ticket.responsible_first} ${ticket.responsible_last}</span>
</div>
` : ''}
<div class="meta-row">
<span class="meta-label">Owner</span>
<span class="meta-value">${ticket.owner_first ? `${ticket.owner_first} ${ticket.owner_last}` : (ticket.owner_login || '—')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Responsabile</span>
<span class="meta-value">${ticket.responsible_first ? `${ticket.responsible_first} ${ticket.responsible_last}` : '—'}</span>
</div>
${totalTime > 0 ? `
<div class="meta-row">
<span class="meta-label">Tempo Totale</span>
@@ -369,6 +481,38 @@ const TicketDetailView = {
</div>
</div>
` : ''}
<!-- Panel per i Gruppi Ticket -->
<div class="sidebar-panel" id="ticket-groups-panel">
<div class="sidebar-panel-title">Gruppi Ticket</div>
<div id="ticket-groups-list" style="margin-bottom: var(--space-sm);">
${groupsData.asMaster.length === 0 && groupsData.asMember.length === 0 ? `
<div style="font-size:0.78rem; color:var(--text-muted); padding:4px 0;">Nessun gruppo associato</div>
` : ''}
${groupsData.asMaster.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--accent-primary); font-weight:bold;">👑 Master in:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
${groupsData.asMember.map(g => `
<div style="font-size:0.8rem; margin-bottom:4px;">
<span style="color:var(--text-secondary); font-weight:bold;">🔗 Membro di:</span>
<a href="#/tickets/groups" onclick="localStorage.setItem('otrs_selected_group_id', ${g.id})" style="color:var(--text-primary); text-decoration:none; border-bottom:1px dashed var(--text-muted);">${App.escapeHtml(g.nome)}</a>
</div>
`).join('')}
</div>
<div style="border-top:1px solid var(--border-subtle); padding-top:var(--space-xs); margin-top:var(--space-xs);">
<label class="quick-edit-label">Associa a Gruppo</label>
<div style="display:flex; gap:4px; margin-top:4px;">
<select class="form-select" id="group-association-select" style="padding:4px 20px 4px 8px; font-size:0.78rem; height:28px; margin:0; flex:1;">
<option value="">-- Seleziona --</option>
</select>
<button class="btn btn-primary btn-sm" id="btn-associate-to-group" style="height:28px; padding:0 8px; font-size:0.75rem;">+ Aggiungi</button>
</div>
</div>
</div>
</div>
</div>
`;
@@ -380,10 +524,20 @@ const TicketDetailView = {
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['link', 'image'],
['clean']
]
],
keyboard: {
bindings: {
tab: {
key: 'Tab',
handler: function () {
return true;
}
}
}
}
}
});
@@ -398,7 +552,23 @@ const TicketDetailView = {
this.noteQuill = null;
}
this.bindEvents();
// Restore Note Draft
const noteDraft = App.getDraft(id, 'note');
if (noteDraft) {
if (this.noteQuill && noteDraft.body) {
this.noteQuill.root.innerHTML = noteDraft.body;
}
if (document.getElementById('note-subject')) {
document.getElementById('note-subject').value = noteDraft.subject || '';
}
if (document.getElementById('note-time-units')) {
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
}
this.noteAttachments = noteDraft.attachments || [];
this.updateNoteAttachmentList();
}
this.bindEvents(ticket, articles, container, groupsData, attachments);
} catch (err) {
container.innerHTML = `
@@ -412,9 +582,9 @@ const TicketDetailView = {
}
},
bindEvents() {
bindEvents(ticket, articles, container, groupsData, attachments) {
// Quick-edit change detection
const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id');
const fields = document.querySelectorAll('.quick-edit-select:not(#qe-queue-search), #qe-queue, #qe-customer-user-id, #qe-customer-id');
const saveBtn = document.getElementById('qe-save');
const resetBtn = document.getElementById('qe-reset');
const timeUnitInput = document.getElementById('qe-time-unit');
@@ -426,7 +596,12 @@ const TicketDetailView = {
const original = String(this.originalValues[field] || '');
const current = el.value;
const changed = current !== original;
el.classList.toggle('changed', changed);
if (el.id === 'qe-queue') {
const searchInput = document.getElementById('qe-queue-search');
if (searchInput) searchInput.classList.toggle('changed', changed);
} else {
el.classList.toggle('changed', changed);
}
if (changed) hasChanges = true;
});
@@ -444,6 +619,68 @@ const TicketDetailView = {
timeUnitInput.addEventListener('change', checkChanges);
}
// Queue Autocomplete inside Quick Edit
const queueSearchInput = document.getElementById('qe-queue-search');
const queueSuggestionsDiv = document.getElementById('qe-queue-suggestions');
const queueIdInput = document.getElementById('qe-queue');
let queueDebounce;
if (queueSearchInput) {
queueSearchInput.addEventListener('input', () => {
clearTimeout(queueDebounce);
const q = queueSearchInput.value.trim();
queueDebounce = setTimeout(async () => {
try {
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
if (queueSearchInput.value.trim() !== q) {
return;
}
if (queues.length === 0) {
queueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
queueSuggestionsDiv.style.display = 'block';
return;
}
queueSuggestionsDiv.innerHTML = queues.map(q => {
const displayName = q.name.replace(/::/g, ' ');
return `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}" style="padding: 6px 12px; font-size: 0.78rem; line-height: 1.25;">
${App.escapeHtml(displayName)}
</div>
`;
}).join('');
queueSuggestionsDiv.style.display = 'block';
// Bind click/mousedown
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
item.addEventListener('mousedown', (e) => {
e.preventDefault();
queueSearchInput.value = item.dataset.name;
queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
queueIdInput.dispatchEvent(new Event('change'));
});
}
});
} catch (err) {
console.error(err);
}
}, 150);
});
queueSearchInput.addEventListener('focus', () => {
queueSearchInput.value = '';
queueIdInput.value = '';
queueSearchInput.dispatchEvent(new Event('input'));
});
queueSearchInput.addEventListener('blur', () => {
setTimeout(() => { queueSuggestionsDiv.style.display = 'none'; }, 150);
});
}
// Customer User Autocomplete inside Quick Edit
const customerSearchInput = document.getElementById('qe-customer-search');
const customerSuggestionsDiv = document.getElementById('qe-customer-suggestions');
@@ -455,11 +692,16 @@ const TicketDetailView = {
customerSearchInput.addEventListener('input', () => {
clearTimeout(customerDebounce);
const q = customerSearchInput.value.trim();
// Do not block empty query to allow all results on focus
customerDebounce = setTimeout(async () => {
try {
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
console.log('[Frontend LDAP Search Detail] Users returned:', users);
// Prevent race conditions: discard results if the input value has changed
if (customerSearchInput.value.trim() !== q) {
console.log('[Frontend LDAP Search Detail] Discarding stale results for query:', q);
return;
}
if (users.length === 0) {
customerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
customerSuggestionsDiv.style.display = 'block';
@@ -467,17 +709,18 @@ const TicketDetailView = {
}
customerSuggestionsDiv.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('');
customerSuggestionsDiv.style.display = 'block';
// Bind click
// Use mousedown instead of click to fire before blur event
customerSuggestionsDiv.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
customerSearchInput.value = item.dataset.name;
customerUserIdInput.value = item.dataset.login;
customerIdInput.value = item.dataset.customerId || '';
@@ -493,28 +736,25 @@ const TicketDetailView = {
}, 300);
});
customerSearchInput.addEventListener('focus', () => {
customerSearchInput.value = '';
customerUserIdInput.value = '';
customerIdInput.value = '';
customerUserIdInput.dispatchEvent(new Event('change'));
customerIdInput.dispatchEvent(new Event('change'));
customerSearchInput.dispatchEvent(new Event('input'));
if (!customerUserIdInput.value) {
customerSearchInput.dispatchEvent(new Event('input'));
}
});
customerSearchInput.addEventListener('blur', () => {
setTimeout(() => { customerSuggestionsDiv.style.display = 'none'; }, 150);
});
}
// Close suggestions on click outside
document.addEventListener('click', (e) => {
if (customerSearchInput && e.target !== customerSearchInput && e.target !== customerSuggestionsDiv) {
customerSuggestionsDiv.style.display = 'none';
}
});
// Reset quick-edit
resetBtn.addEventListener('click', () => {
fields.forEach(el => {
el.value = this.originalValues[el.dataset.field] || '';
el.classList.remove('changed');
});
if (queueSearchInput) {
queueSearchInput.classList.remove('changed');
queueSearchInput.value = this.originalValues['queue_name'] || '';
}
if (customerSearchInput) {
const first = this.originalValues['customer_first'] || '';
const last = this.originalValues['customer_last'] || '';
@@ -528,6 +768,12 @@ const TicketDetailView = {
// Save quick-edit
saveBtn.addEventListener('click', async () => {
const queueSearch = queueSearchInput ? queueSearchInput.value.trim() : '';
if (queueSearchInput && !queueIdInput.value) {
Toast.warning('Seleziona una coda valida dall\'elenco.');
return;
}
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
if (customerSearch && !customerUserIdInput.value) {
customerUserIdInput.value = customerSearch;
@@ -585,6 +831,7 @@ const TicketDetailView = {
}
let subject = document.getElementById('note-subject').value.trim();
const time_unit = document.getElementById('note-time-units').value.trim();
const note_create_time = document.getElementById('note-create-time')?.value;
// If body is empty but subject is provided, fill body with subject text
if (!body && subject) {
@@ -624,7 +871,23 @@ const TicketDetailView = {
});
this.noteAttachments = []; // Clear attachments
Toast.success(res.message || 'Nota aggiunta!');
if (note_create_time && res.article_id) {
try {
const formattedDate = note_create_time.replace('T', ' ');
await App.api(`/api/tickets/articles/${res.article_id}/retrodata-article`, {
method: 'POST',
body: JSON.stringify({ create_time: formattedDate })
});
Toast.success('Nota aggiunta e retrodatata!');
} catch (retErr) {
Toast.warning('Nota aggiunta, ma errore durante la retrodatazione: ' + retErr.message);
}
} else {
Toast.success(res.message || 'Nota aggiunta!');
}
App.clearDraft(this.ticketId, 'note');
App.updateDailyTimer();
this.render(this.ticketId);
} catch (err) {
@@ -660,6 +923,131 @@ const TicketDetailView = {
});
}
// Email Compose Button
const btnEmailCompose = document.getElementById('btn-open-email-compose');
if (btnEmailCompose) {
btnEmailCompose.addEventListener('click', () => {
if (window.EmailCompose) {
const tId = parseInt(btnEmailCompose.dataset.ticketId, 10);
const emailDraft = App.getDraft(tId, 'email');
if (emailDraft) {
EmailCompose.open({
...emailDraft.options,
draft: emailDraft
});
} else {
EmailCompose.open({
ticketId: tId,
ticketTn: btnEmailCompose.dataset.ticketTn,
ticketTitle: btnEmailCompose.dataset.ticketTitle,
customerEmail: btnEmailCompose.dataset.customerEmail,
});
}
} else {
Toast.error('Modulo email non disponibile');
}
});
}
// Article Email Reply Buttons
document.querySelectorAll('.btn-email-article').forEach(btn => {
btn.addEventListener('click', () => {
const articleId = parseInt(btn.dataset.articleId, 10);
const article = articles.find(art => art.article_id == articleId);
if (!article) return;
if (window.EmailCompose) {
const hasHtml = (article.a_content_type || '').toLowerCase().includes('html') || article.a_body.includes('</') || article.a_body.includes('/>');
let processedQuoted = article.a_body || '';
if (hasHtml) {
const articleAttachments = (attachments || []).filter(att => att.article_id === article.article_id);
articleAttachments.forEach(att => {
if (att.content_id) {
const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
if (cleanCid) {
const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`cid:<?${escapedCid}>?`, 'gi');
processedQuoted = processedQuoted.replace(regex, `/api/tickets/attachments/${att.id}`);
}
}
if (att.filename) {
const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexFilename = new RegExp(`cid:<?${escapedFilename}>?`, 'gi');
processedQuoted = processedQuoted.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
}
});
}
const quotedBody = hasHtml ? processedQuoted : App.escapeHtml(article.a_body || '').replace(/\n/g, '<br>');
const initialBodyHtml = `
<p><br></p>
<p>Il ${App.formatDateTime(article.create_time)}, <strong>${App.escapeHtml(article.a_from || 'Sistema')}</strong> ha scritto:</p>
<blockquote style="border-left: 2px solid var(--border-subtle, #444); padding-left: 12px; margin-left: 8px; color: var(--text-secondary);">
${quotedBody}
</blockquote>
<p><br></p>
`;
// Helper to extract email addresses from headers
const parseEmails = (str) => {
if (!str) return [];
return (str.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [])
.map(email => email.toLowerCase().trim());
};
const fromEmails = parseEmails(article.a_from);
const toEmails = parseEmails(article.a_to);
const ccEmails = parseEmails(article.a_cc);
// Exclude list (helpdesk and active agent)
const config = App.lookups.config || {};
const helpdeskEmail = (config.helpdeskEmail || 'helpdesk@pharmaidea.com').toLowerCase();
const agentEmail = (config.agentEmail || '').toLowerCase();
const excludeEmails = [helpdeskEmail, agentEmail].filter(Boolean);
const initialToSet = new Set();
fromEmails.forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk')) {
initialToSet.add(e);
}
});
if (initialToSet.size === 0 && ticket.customer_email) {
initialToSet.add(ticket.customer_email.toLowerCase());
}
const initialCcSet = new Set();
[...toEmails, ...ccEmails].forEach(e => {
if (!excludeEmails.includes(e) && !e.includes('helpdesk') && !initialToSet.has(e)) {
initialCcSet.add(e);
}
});
let inReplyTo = article.a_message_id || '';
let references = '';
if (article.a_references) {
references = article.a_references + (article.a_message_id ? ' ' + article.a_message_id : '');
} else if (article.a_message_id) {
references = article.a_message_id;
}
EmailCompose.open({
ticketId: ticket.id,
ticketTn: ticket.tn,
ticketTitle: ticket.title,
initialTo: Array.from(initialToSet),
initialCc: Array.from(initialCcSet),
initialBodyHtml: initialBodyHtml,
inReplyTo: inReplyTo,
references: references,
});
} else {
Toast.error('Modulo email non disponibile');
}
});
});
// Retrodate Ticket Event Listeners
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
const ticketDateEditor = document.getElementById('ticket-date-editor');
@@ -818,6 +1206,32 @@ const TicketDetailView = {
p.style.display = 'none';
});
});
// Delete Article Event Listeners
document.querySelectorAll('.btn-delete-article').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const articleId = btn.dataset.articleId;
const ok = await App.confirm(
"Elimina Articolo",
"Sei sicuro di voler eliminare questa nota/articolo? Tutti i file allegati e i tempi ad esso associati verranno rimossi permanentemente.",
{ confirmText: 'Elimina', cancelText: 'Annulla' }
);
if (ok) {
try {
btn.disabled = true;
await App.api(`/api/tickets/articles/${articleId}`, {
method: 'DELETE'
});
Toast.success('Articolo/nota eliminato con successo!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore durante l\'eliminazione: ' + err.message);
btn.disabled = false;
}
}
});
});
// HTML Mode Toggle Listener
const htmlToggle = document.getElementById('html-toggle');
if (htmlToggle) {
@@ -828,5 +1242,98 @@ const TicketDetailView = {
this.render(this.ticketId);
});
}
// Populate Group Association dropdown
App.api('/api/groups').then(allGroups => {
const select = document.getElementById('group-association-select');
if (select) {
allGroups.forEach(g => {
const isMember = groupsData.asMember.some(m => m.id === g.id);
const isMaster = groupsData.asMaster.some(m => m.id === g.id);
if (!isMember && !isMaster) {
const opt = document.createElement('option');
opt.value = g.id;
opt.textContent = g.nome;
select.appendChild(opt);
}
});
}
}).catch(err => console.warn('Failed to load groups for association dropdown', err));
const btnAssociate = document.getElementById('btn-associate-to-group');
if (btnAssociate) {
btnAssociate.addEventListener('click', async () => {
const select = document.getElementById('group-association-select');
const groupId = select.value;
if (!groupId) {
sessionStorage.setItem('otrs_create_group_with_master_tn', ticket.tn);
window.location.hash = '#/tickets/groups';
return;
}
try {
btnAssociate.disabled = true;
await App.api(`/api/groups/${groupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: this.ticketId })
});
Toast.success('Ticket associato al gruppo!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore associazione: ' + err.message);
btnAssociate.disabled = false;
}
});
}
// Inline image preview & lightbox binding
document.querySelectorAll('.articles-timeline iframe').forEach(iframe => {
const attachImageClick = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) {
doc.querySelectorAll('img').forEach(img => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(img.src);
});
});
}
} catch (err) {
console.warn('Cannot attach click listeners to iframe images', err);
}
};
iframe.addEventListener('load', attachImageClick);
try {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc && doc.readyState === 'complete') {
attachImageClick();
}
} catch (_) { }
});
document.querySelectorAll('.attachment-badge[data-is-image="true"]').forEach(badge => {
badge.style.cursor = 'zoom-in';
badge.addEventListener('click', (e) => {
e.preventDefault();
this.openImageLightbox(badge.href);
});
});
document.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success('Copiato negli appunti!');
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
},
};
+587
View File
@@ -0,0 +1,587 @@
/**
* ticketGroups.js
* SPA View for managing Ticket Groups (mimics master/slave relationships).
*/
const TicketGroupsView = {
groups: [],
selectedGroupId: null,
selectedGroupData: null,
selectedTicketIds: new Set(), // For bulk actions (complying with no-checkboxes rule)
async render() {
const container = document.getElementById('view-container');
container.innerHTML = `
<div class="ticket-groups-container" style="display: flex; gap: var(--space-lg); min-height: calc(100vh - 120px); padding: var(--space-lg) 0;">
<!-- Left Sidebar: Groups List -->
<div class="groups-sidebar card" style="flex: 0 0 320px; display: flex; flex-direction: column; padding: var(--space-md); max-height: calc(100vh - 120px); overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 700; color: var(--text-primary);">🗂 Gruppi Ticket</h3>
<button class="btn btn-primary btn-sm" id="btn-create-group" style="padding: 4px 10px; font-size: 0.8rem;">+ Nuovo</button>
</div>
<div id="groups-list-container" style="display: flex; flex-direction: column; gap: var(--space-xs); flex: 1;">
<div class="spinner-container" style="display: flex; justify-content: center; padding: var(--space-lg);">
<div class="spinner" style="width: 24px; height: 24px;"></div>
</div>
</div>
</div>
<!-- Right Content: Group Details -->
<div class="group-details-pane card" id="group-details-pane" style="flex: 1; padding: var(--space-lg); display: flex; flex-direction: column; max-height: calc(100vh - 120px); overflow-y: auto;">
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
<h4>Nessun gruppo selezionato</h4>
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.</p>
</div>
</div>
</div>
<!-- Create/Edit Group Modal -->
<div id="group-modal" style="display: none; position: fixed; inset: 0; z-index: 8000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); align-items: center; justify-content: center;">
<div class="card" style="width: min(480px, 94vw); max-height: 90vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); padding: 0;">
<div style="padding: 16px 20px; border-bottom: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center;">
<h4 id="group-modal-title" style="margin: 0; font-size: 1.1rem; font-weight: 600;">Nuovo Gruppo</h4>
<button id="group-modal-close" style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);"></button>
</div>
<div style="padding: 20px; display: flex; flex-direction: column; gap: var(--space-md); overflow-y: auto;">
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Nome Gruppo</label>
<input type="text" id="group-name-input" class="form-input" placeholder="es. Disservizio Mail Server" style="margin-top: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Descrizione</label>
<textarea id="group-desc-input" class="form-input" placeholder="Breve descrizione o scopo di questo gruppo..." style="margin-top: 4px; min-height: 80px; resize: vertical;"></textarea>
</div>
<div>
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">ID Ticket Master (Opzionale)</label>
<input type="text" id="group-master-input" class="form-input" placeholder="ID o Numero ticket (es. 12345)" style="margin-top: 4px;" />
<span style="font-size: 0.75rem; color: var(--text-muted);">Il ticket master funge da riferimento principale.</span>
</div>
</div>
<div style="padding: 12px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: var(--space-sm);">
<button class="btn btn-ghost btn-sm" id="btn-group-modal-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="btn-group-modal-save">Salva</button>
</div>
</div>
</div>
`;
// Bind event listeners
document.getElementById('btn-create-group').addEventListener('click', () => this.openGroupModal());
document.getElementById('group-modal-close').addEventListener('click', () => this.closeGroupModal());
document.getElementById('btn-group-modal-cancel').addEventListener('click', () => this.closeGroupModal());
document.getElementById('btn-group-modal-save').addEventListener('click', () => this.saveGroup());
// Restore state from localStorage if available
const savedGroupId = localStorage.getItem('otrs_selected_group_id');
if (savedGroupId) {
this.selectedGroupId = parseInt(savedGroupId, 10);
}
await this.loadGroups();
const prefillMasterTn = sessionStorage.getItem('otrs_create_group_with_master_tn');
if (prefillMasterTn) {
sessionStorage.removeItem('otrs_create_group_with_master_tn');
this.openGroupModal();
const masterInput = document.getElementById('group-master-input');
if (masterInput) {
masterInput.value = prefillMasterTn;
}
} else if (this.selectedGroupId) {
this.selectGroup(this.selectedGroupId);
}
},
async loadGroups() {
try {
this.groups = await App.api('/api/groups');
this.renderGroupsList();
} catch (err) {
Toast.error('Errore nel caricamento dei gruppi: ' + err.message);
}
},
renderGroupsList() {
const listContainer = document.getElementById('groups-list-container');
if (!listContainer) return;
if (this.groups.length === 0) {
listContainer.innerHTML = `
<div style="text-align: center; padding: var(--space-lg); color: var(--text-muted); font-size: 0.85rem;">
Nessun gruppo presente
</div>
`;
return;
}
listContainer.innerHTML = this.groups.map(group => {
const isActive = group.id === this.selectedGroupId;
return `
<div class="group-item" data-id="${group.id}" style="
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
background: ${isActive ? 'var(--bg-tertiary)' : 'transparent'};
border: 1px solid ${isActive ? 'var(--accent-primary)' : 'transparent'};
">
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); margin-bottom: 2px;">
${App.escapeHtml(group.nome)}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin-bottom: 4px;">
${App.escapeHtml(group.descrizione || 'Nessuna descrizione')}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; color: var(--text-muted);">
<span>Master: ${group.master_ticket_id ? '#' + group.master_ticket_id : 'Nessuno'}</span>
<span style="background: var(--bg-secondary); padding: 1px 6px; border-radius: 10px;">${group.member_count} ticket</span>
</div>
</div>
`;
}).join('');
// Bind click events to items
listContainer.querySelectorAll('.group-item').forEach(item => {
item.addEventListener('click', () => {
const id = parseInt(item.dataset.id, 10);
this.selectGroup(id);
});
});
},
async selectGroup(id) {
this.selectedGroupId = id;
localStorage.setItem('otrs_selected_group_id', id);
this.selectedTicketIds.clear();
this.renderGroupsList();
const detailPane = document.getElementById('group-details-pane');
detailPane.innerHTML = `
<div style="display: flex; justify-content: center; align-items: center; flex: 1;">
<div class="spinner"></div>
</div>
`;
try {
this.selectedGroupData = await App.api(`/api/groups/${id}`);
this.renderGroupDetails();
} catch (err) {
Toast.error('Errore nel caricamento del dettaglio gruppo: ' + err.message);
detailPane.innerHTML = `
<div style="color: var(--danger); text-align: center; padding: var(--space-xl);">
Errore nel caricamento dei dati: ${err.message}
</div>
`;
}
},
renderGroupDetails() {
const detailPane = document.getElementById('group-details-pane');
if (!detailPane || !this.selectedGroupData) return;
const { group, masterTicket, memberTickets } = this.selectedGroupData;
let masterTicketHtml = '';
if (masterTicket) {
masterTicketHtml = `
<div class="card" style="border: 1px solid var(--accent-primary); background: var(--bg-secondary); padding: var(--space-md);">
<div style="display: flex; gap: var(--space-md); align-items: flex-start;">
<div style="flex: 1;">
<div style="font-weight: 700; font-size: 1rem; margin-bottom: var(--space-xs); display: flex; align-items: center; gap: 6px;">
<span class="copy-ticket-btn" data-tn="${masterTicket.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" title="Copia numero ticket">📋</span>
<a href="#/tickets/${masterTicket.id}" style="color: var(--text-primary); text-decoration: none; border-bottom: 1px dashed var(--text-muted);">
#${masterTicket.tn} ${App.escapeHtml(masterTicket.title)}
</a>
</div>
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: var(--space-xs);">
<span class="badge badge-state" data-state-type="${(masterTicket.state_type || '').toLowerCase()}">${masterTicket.state_name}</span>
<span class="badge" style="background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem;">Coda: ${masterTicket.queue_name}</span>
<span style="font-size: 0.78rem; color: var(--text-muted);">Proprietario: ${masterTicket.owner_first ? `${masterTicket.owner_first} ${masterTicket.owner_last}` : masterTicket.owner_login}</span>
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-xs); flex-shrink: 0;">
<span style="font-size: 0.7rem; font-weight: 700; color: var(--accent-primary); text-transform: uppercase; border: 1px solid var(--accent-primary); padding: 2px 6px; border-radius: var(--radius-sm); white-space: nowrap;">👑 Master Ticket</span>
<button class="btn btn-ghost btn-sm" id="btn-unlink-master" style="color: var(--danger); font-size: 0.8rem; height: 32px; padding: 4px 8px;" title="Rimuovi ruolo master">Scollega Master</button>
</div>
</div>
</div>
`;
} else {
masterTicketHtml = `
<div style="border: 1px dashed var(--border-subtle); padding: var(--space-md); border-radius: var(--radius-md); text-align: center; color: var(--text-muted); font-size: 0.85rem;">
Nessun master ticket assegnato. Imposta un ticket come master usando il tasto Modifica Gruppo o associa un ID/Numero valido.
</div>
`;
}
let membersListHtml = '';
if (memberTickets.length === 0) {
membersListHtml = `
<div style="text-align: center; padding: var(--space-xl); border: 1px dashed var(--border-subtle); border-radius: var(--radius-md); color: var(--text-muted); font-size: 0.88rem;">
Nessun ticket slave/membro associato a questo gruppo.
</div>
`;
} else {
membersListHtml = `
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
${memberTickets.map(t => {
const isSelected = this.selectedTicketIds.has(t.id);
return `
<div class="member-ticket-card" data-ticket-id="${t.id}" style="
border: 1px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-subtle)'};
background: ${isSelected ? 'var(--bg-tertiary)' : 'var(--bg-card)'};
padding: var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: space-between;
">
<div style="flex: 1;" onclick="event.stopPropagation();">
<div style="font-weight: 600; font-size: 0.9rem; margin-bottom: 2px; display: flex; align-items: center; gap: 6px;">
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
<a href="#/tickets/${t.id}" style="color: var(--text-primary); text-decoration: none;">
#${t.tn} ${App.escapeHtml(t.title)}
</a>
</div>
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: 4px; font-size: 0.75rem;">
<span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}" style="font-size: 0.7rem; padding: 2px 6px;">${t.state_name}</span>
<span style="color: var(--text-muted);">Coda: ${t.queue_name}</span>
<span style="color: var(--text-muted);">Proprietario: ${t.owner_first ? `${t.owner_first} ${t.owner_last}` : t.owner_login}</span>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-remove-member" data-ticket-id="${t.id}" style="color: var(--danger); padding: 4px 8px; font-size: 0.8rem;" onclick="event.stopPropagation();">Rimuovi</button>
</div>
`;
}).join('')}
</div>
`;
}
detailPane.innerHTML = `
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-subtle); padding-bottom: var(--space-md); margin-bottom: var(--space-md);">
<div>
<h2 style="margin: 0; font-size: 1.3rem; font-weight: 700; color: var(--text-primary);">${App.escapeHtml(group.nome)}</h2>
<p style="margin: var(--space-xs) 0 0; font-size: 0.88rem; color: var(--text-secondary);">${App.escapeHtml(group.descrizione || 'Nessuna descrizione per questo gruppo.')}</p>
</div>
<div style="display: flex; gap: var(--space-sm);">
<button class="btn btn-ghost btn-sm" id="btn-edit-group" style="padding: 6px 12px; font-size: 0.82rem;">Modifica</button>
<button class="btn btn-ghost btn-sm" id="btn-delete-group" style="padding: 6px 12px; font-size: 0.82rem; color: var(--danger);">Elimina Gruppo</button>
</div>
</div>
<!-- Master Ticket Section -->
<div style="margin-bottom: var(--space-lg);">
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Master</h4>
${masterTicketHtml}
</div>
<!-- Quick Add Section -->
<div class="card" style="background: var(--bg-tertiary); padding: var(--space-md); margin-bottom: var(--space-lg);">
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Associa Nuovo Ticket Slave</h4>
<div style="display: flex; gap: var(--space-sm);">
<input type="text" id="quick-add-ticket-input" class="form-input" placeholder="Inserisci ID ticket o Numero ticket (es. 202310... o 12345)" style="margin: 0; font-size: 0.88rem; flex: 1;" />
<button class="btn btn-primary btn-sm" id="btn-quick-add-ticket">Associa Ticket</button>
</div>
</div>
<!-- Slave / Members Section -->
<div style="flex: 1; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs);">
<h4 style="margin: 0; font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Membri (${memberTickets.length})</h4>
${this.selectedTicketIds.size > 0 ? `
<div style="display:flex; gap:8px;">
<button class="btn btn-ghost btn-sm" id="btn-bulk-copy" style="font-size: 0.8rem; padding: 4px 8px;">Copia selezionati (${this.selectedTicketIds.size})</button>
<button class="btn btn-ghost btn-sm" id="btn-bulk-remove" style="color: var(--danger); font-size: 0.8rem; padding: 4px 8px;">Rimuovi selezionati (${this.selectedTicketIds.size})</button>
</div>
` : `
<span style="font-size: 0.75rem; color: var(--text-muted);">Clicca sulle schede per selezionare in blocco</span>
`}
</div>
<div style="flex: 1; overflow-y: auto;">
${membersListHtml}
</div>
</div>
`;
// Bind actions
document.getElementById('btn-edit-group').addEventListener('click', () => this.openGroupModal(group));
document.getElementById('btn-delete-group').addEventListener('click', () => this.deleteGroup(group.id));
if (masterTicket) {
document.getElementById('btn-unlink-master').addEventListener('click', () => this.unlinkMaster());
}
document.getElementById('btn-quick-add-ticket').addEventListener('click', () => this.addTicketToGroup());
document.getElementById('quick-add-ticket-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.addTicketToGroup();
});
// Individual removal buttons
detailPane.querySelectorAll('.btn-remove-member').forEach(btn => {
btn.addEventListener('click', () => {
const ticketId = parseInt(btn.dataset.ticketId, 10);
this.removeTicketFromGroup(ticketId);
});
});
// Multi-select cards logic (No-checkboxes rule)
detailPane.querySelectorAll('.member-ticket-card').forEach(card => {
card.addEventListener('click', () => {
const ticketId = parseInt(card.dataset.ticketId, 10);
if (this.selectedTicketIds.has(ticketId)) {
this.selectedTicketIds.delete(ticketId);
} else {
this.selectedTicketIds.add(ticketId);
}
this.renderGroupDetails();
});
});
// Bulk actions
const btnBulkRemove = document.getElementById('btn-bulk-remove');
if (btnBulkRemove) {
btnBulkRemove.addEventListener('click', () => this.bulkRemoveTickets());
}
const btnBulkCopy = document.getElementById('btn-bulk-copy');
if (btnBulkCopy) {
btnBulkCopy.addEventListener('click', (e) => {
e.stopPropagation();
const selectedTns = [];
this.selectedTicketIds.forEach(id => {
const ticket = memberTickets.find(t => t.id === id);
if (ticket && ticket.tn) {
selectedTns.push(ticket.tn);
}
});
if (selectedTns.length > 0) {
const textToCopy = selectedTns.join('\n');
navigator.clipboard.writeText(textToCopy).then(() => {
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
}
// Individual copy buttons
detailPane.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success(`Numero ticket ${tn} copiato!`);
}).catch(err => {
Toast.error('Errore durante la copia: ' + err.message);
});
}
});
});
},
openGroupModal(group = null) {
const modal = document.getElementById('group-modal');
const title = document.getElementById('group-modal-title');
const nameInput = document.getElementById('group-name-input');
const descInput = document.getElementById('group-desc-input');
const masterInput = document.getElementById('group-master-input');
if (group) {
title.textContent = 'Modifica Gruppo';
nameInput.value = group.nome || '';
descInput.value = group.descrizione || '';
masterInput.value = group.master_ticket_id || '';
modal.dataset.editId = group.id;
} else {
title.textContent = 'Nuovo Gruppo';
nameInput.value = '';
descInput.value = '';
masterInput.value = '';
delete modal.dataset.editId;
}
modal.style.display = 'flex';
nameInput.focus();
},
closeGroupModal() {
document.getElementById('group-modal').style.display = 'none';
},
async saveGroup() {
const modal = document.getElementById('group-modal');
const editId = modal.dataset.editId;
const nome = document.getElementById('group-name-input').value.trim();
const descrizione = document.getElementById('group-desc-input').value.trim();
const masterVal = document.getElementById('group-master-input').value.trim();
if (!nome) {
Toast.error('Il nome del gruppo è richiesto.');
return;
}
let masterTicketId = null;
if (masterVal) {
// Validate master ticket input (ID or Number)
try {
const res = await App.api(`/api/tickets?search=${masterVal}&per_page=1`);
if (res && res.tickets && res.tickets.length > 0) {
masterTicketId = res.tickets[0].id;
} else {
// Check if it's a direct database ID by querying search directly
if (/^\d+$/.test(masterVal)) {
masterTicketId = parseInt(masterVal, 10);
} else {
Toast.error('Ticket master non trovato.');
return;
}
}
} catch (err) {
if (/^\d+$/.test(masterVal)) {
masterTicketId = parseInt(masterVal, 10);
} else {
Toast.error('Errore nella verifica del ticket master: ' + err.message);
return;
}
}
}
const payload = { nome, descrizione, master_ticket_id: masterTicketId };
try {
if (editId) {
await App.api(`/api/groups/${editId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
Toast.success('Gruppo aggiornato con successo');
} else {
const newGroup = await App.api('/api/groups', {
method: 'POST',
body: JSON.stringify(payload)
});
this.selectedGroupId = newGroup.id;
Toast.success('Gruppo creato con successo');
}
this.closeGroupModal();
await this.loadGroups();
if (this.selectedGroupId) {
await this.selectGroup(this.selectedGroupId);
}
} catch (err) {
Toast.error('Errore nel salvataggio: ' + err.message);
}
},
async deleteGroup(id) {
const ok = await App.confirm('Elimina Gruppo', 'Sei sicuro di voler eliminare questo gruppo? Le associazioni dei ticket verranno rimosse, ma i ticket non saranno modificati.');
if (!ok) {
return;
}
try {
await App.api(`/api/groups/${id}`, { method: 'DELETE' });
Toast.success('Gruppo eliminato con successo');
this.selectedGroupId = null;
this.selectedGroupData = null;
localStorage.removeItem('otrs_selected_group_id');
await this.loadGroups();
document.getElementById('group-details-pane').innerHTML = `
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
<h4>Nessun gruppo selezionato</h4>
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni.</p>
</div>
`;
} catch (err) {
Toast.error('Errore nella cancellazione: ' + err.message);
}
},
async unlinkMaster() {
if (!this.selectedGroupData) return;
const { group } = this.selectedGroupData;
try {
await App.api(`/api/groups/${group.id}`, {
method: 'PUT',
body: JSON.stringify({
nome: group.nome,
descrizione: group.descrizione,
master_ticket_id: null
})
});
Toast.success('Ticket master scollegato con successo');
await this.selectGroup(group.id);
} catch (err) {
Toast.error('Errore nello scollegamento: ' + err.message);
}
},
async addTicketToGroup() {
const input = document.getElementById('quick-add-ticket-input');
const val = input.value.trim();
if (!val) return;
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: val })
});
Toast.success('Ticket associato correttamente');
input.value = '';
await this.selectGroup(this.selectedGroupId);
} catch (err) {
Toast.error('Errore nell\'associazione: ' + err.message);
}
},
async removeTicketFromGroup(ticketId) {
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
method: 'DELETE'
});
Toast.success('Ticket rimosso dal gruppo');
await this.selectGroup(this.selectedGroupId);
} catch (err) {
Toast.error('Errore nella rimozione: ' + err.message);
}
},
async bulkRemoveTickets() {
if (this.selectedTicketIds.size === 0) return;
const ok = await App.confirm('Rimuovi Ticket', `Vuoi rimuovere i ${this.selectedTicketIds.size} ticket selezionati da questo gruppo?`);
if (!ok) return;
let successCount = 0;
let failCount = 0;
for (const ticketId of this.selectedTicketIds) {
try {
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
method: 'DELETE'
});
successCount++;
} catch (err) {
failCount++;
}
}
if (successCount > 0) {
Toast.success(`${successCount} ticket rimossi con successo.`);
}
if (failCount > 0) {
Toast.error(`Impossibile rimuovere ${failCount} ticket.`);
}
this.selectedTicketIds.clear();
await this.selectGroup(this.selectedGroupId);
}
};
+319 -23
View File
@@ -13,18 +13,30 @@ const TicketListView = {
async render() {
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
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 && !Filters.state.user_id) {
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
Filters.state.user_id = activeAgentId;
Filters.save();
@@ -67,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>
@@ -75,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>
@@ -89,15 +101,26 @@ const TicketListView = {
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
</select>
</div>
<div class="filter-group" style="position:relative;">
<span class="filter-label">Cliente</span>
<input type="text" class="form-input filter-select" id="batch-customer-search" placeholder="Cerca cliente..." autocomplete="off" style="width:160px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
<input type="hidden" id="batch-customer-user-id" />
<input type="hidden" id="batch-customer-id" />
<div id="batch-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></div>
<div class="filter-group">
<span class="filter-label">Responsabile</span>
<select class="filter-select" id="batch-responsible">
<option value=""></option>
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
</select>
</div>
${(App.lookups.types || []).length > 0 ? `
<div class="filter-group">
<span class="filter-label">Tipo</span>
<select class="filter-select" id="batch-type">
<option value=""></option>
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
</select>
</div>
` : ''}
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: var(--accent-secondary); border-color: var(--accent-secondary); margin-left: 8px;">Unisci Selezionati</button>
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
</div>
@@ -123,30 +146,32 @@ const TicketListView = {
</thead>
<tbody>
${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 `
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" style="display:inline-flex; align-items:center;">
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
${data.otrsBaseUrl ? `
<a href="${data.otrsBaseUrl}index.pl?Action=AgentTicketZoom;TicketID=${t.id}" target="_blank" title="Apri in OTRS" onclick="event.stopPropagation()" style="display:inline-flex; align-items:center; text-decoration:none;">
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; vertical-align:middle; line-height:16px;">O</span>
</a>
` : ''}
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
</span>
</td>
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
<td class="ticket-title-cell"><span class="copy-ticket-btn" data-tn="${t.title}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia titolo ticket">📋</span><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
<td class="queue-cell"><span class="badge badge-queue">${App.escapeHtml(shortQueue)}</span><div class="queue-tooltip">${App.escapeHtml(t.queue_name)}</div></td>
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
<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="8">
<div class="empty-state">
@@ -168,6 +193,19 @@ const TicketListView = {
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}">
@@ -175,6 +213,7 @@ const TicketListView = {
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)}
@@ -187,7 +226,9 @@ const TicketListView = {
return `
<div class="pagination" style="${marginStyle}">
<div class="pagination-info">${total} ticket totali</div>
<div></div>
<div class="pagination-controls">
${limitSelectHtml}
</div>
</div>
`;
}
@@ -270,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) {
@@ -321,10 +442,16 @@ const TicketListView = {
if (batchCustomerId) batchCustomerId.value = '';
const batchState = document.getElementById('batch-state');
if (batchState) batchState.value = '';
const batchQueueSearch = document.getElementById('batch-queue-search');
if (batchQueueSearch) batchQueueSearch.value = '';
const batchQueue = document.getElementById('batch-queue');
if (batchQueue) batchQueue.value = '';
const batchOwner = document.getElementById('batch-owner');
if (batchOwner) batchOwner.value = '';
const batchResponsible = document.getElementById('batch-responsible');
if (batchResponsible) batchResponsible.value = '';
const batchType = document.getElementById('batch-type');
if (batchType) batchType.value = '';
this.updateBatchBar();
});
@@ -384,11 +511,61 @@ const TicketListView = {
});
}
// Close suggestions on click outside
// 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
@@ -400,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() {
@@ -408,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) {
@@ -417,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() {
@@ -426,6 +638,8 @@ const TicketListView = {
const batchState = document.getElementById('batch-state')?.value;
const batchQueue = document.getElementById('batch-queue')?.value;
const batchOwner = document.getElementById('batch-owner')?.value;
const batchResponsible = document.getElementById('batch-responsible')?.value;
const batchType = document.getElementById('batch-type')?.value;
const batchCustomerSearch = document.getElementById('batch-customer-search')?.value.trim();
let batchCustomerUserId = document.getElementById('batch-customer-user-id')?.value;
let batchCustomerId = document.getElementById('batch-customer-id')?.value;
@@ -439,7 +653,13 @@ const TicketListView = {
if (batchState) updates.ticket_state_id = parseInt(batchState);
if (batchQueue) updates.queue_id = parseInt(batchQueue);
if (batchType) updates.type_id = parseInt(batchType);
if (batchOwner) updates.user_id = parseInt(batchOwner);
if (batchResponsible) {
updates.responsible_user_id = parseInt(batchResponsible);
} else if (batchOwner) {
updates.responsible_user_id = parseInt(batchOwner);
}
if (batchCustomerUserId) updates.customer_user_id = batchCustomerUserId;
if (batchCustomerId) updates.customer_id = batchCustomerId;
@@ -486,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 {
@@ -507,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);
}
},
};
+471 -3
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(
@@ -95,7 +177,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')
AND t.user_id = $1`,
[activeAgentId]
[activeAgentId]
),
]);
@@ -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;
+429 -31
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
});
});
@@ -169,41 +206,276 @@ router.get('/lock-types', async (req, res) => {
router.get('/customer-companies/search', async (req, res) => {
try {
const { q = '' } = req.query;
let result;
if (!q) {
result = await pool.query(
`SELECT customer_id, name
FROM customer_company
WHERE valid_id = 1
ORDER BY name
LIMIT 20`
);
} else {
const searchTerm = `%${q}%`;
result = await pool.query(
`SELECT customer_id, name
FROM customer_company
WHERE valid_id = 1 AND (
customer_id ILIKE $1 OR
name ILIKE $1
)
ORDER BY name
LIMIT 20`,
[searchTerm]
);
// 1. Fetch from local SQLite LDAP cache
let localRows = [];
try {
if (!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 != ''
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);
}
res.json(result.rows);
// 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
FROM customer_company
WHERE valid_id = 1 AND (
customer_id ILIKE $1 OR
name ILIKE $1
)
ORDER BY name
LIMIT 500`,
[searchTerm]
);
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;
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) {
@@ -310,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;
+721 -51
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);
+10 -2
View File
@@ -1,12 +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;
@@ -23,6 +28,9 @@ 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) => {
+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 };