Compare commits
38
Commits
0f012816ea
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35888400ed | ||
|
|
922a23c062 | ||
|
|
57175922c2 | ||
|
|
e2482159bc | ||
|
|
99c4f450f4 | ||
|
|
0cbd3ccdfc | ||
|
|
5ef7e66be5 | ||
|
|
38b2645f6f | ||
|
|
7243b3da49 | ||
|
|
9527998b7a | ||
|
|
f36788510e | ||
|
|
b484f93640 | ||
|
|
b90d2b2732 | ||
|
|
1bbe561673 | ||
|
|
47d11c311e | ||
|
|
964241d8ec | ||
|
|
a7fb2c22f0 | ||
|
|
7020112e8a | ||
|
|
afe6e8d652 | ||
|
|
df203d8de3 | ||
|
|
748f777a31 | ||
|
|
e764c37d46 | ||
|
|
191f9a407b | ||
|
|
725bdaeae4 | ||
|
|
a89605b888 | ||
|
|
5b914eb198 | ||
|
|
9a91c3d9f5 | ||
|
|
3b06609fcb | ||
|
|
fd78b7e283 | ||
|
|
f9cbfc63b9 | ||
|
|
d39c9895b4 | ||
|
|
e6c3a6a43f | ||
|
|
882a1949a1 | ||
|
|
78a26fe970 | ||
|
|
bf8b4c387e | ||
|
|
3f6d2cc3a8 | ||
|
|
7b0b29e6c6 | ||
|
|
b3570d6757 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
---
|
||||||
|
|
||||||
|
Non usare le notifiche native dei browser.
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
trigger: always_on
|
trigger: always_on
|
||||||
glob:
|
|
||||||
description:
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Per affrontare una issue di git tieni in considerazione anche i commenti interni.
|
||||||
@@ -24,3 +24,43 @@ DAILY_TARGET_TIME=480
|
|||||||
#Variabile per la numerazione dei ticket
|
#Variabile per la numerazione dei ticket
|
||||||
OTRS_SYSTEM_ID=10
|
OTRS_SYSTEM_ID=10
|
||||||
OTRS_COUNTER_PADDING=6
|
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,2 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
|
internal.db
|
||||||
|
internal.db-shm
|
||||||
|
internal.db-wal
|
||||||
|
dist
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* activityDb.js
|
||||||
|
* Local SQLite activity log database.
|
||||||
|
* Opens (or creates) internal.db and provides logAttivita() for logging
|
||||||
|
* every significant action sent to OTRS.
|
||||||
|
*/
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const baseDir = process.pkg ? path.dirname(process.execPath) : __dirname;
|
||||||
|
const DB_PATH = path.join(baseDir, 'internal.db');
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
// Ensure WAL mode for better concurrent access
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
// Create table if it does not exist
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS attivita (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
agente_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
agente_nome TEXT NOT NULL DEFAULT '',
|
||||||
|
titolo_azione TEXT NOT NULL,
|
||||||
|
azione TEXT NOT NULL DEFAULT '{}',
|
||||||
|
esito TEXT NOT NULL DEFAULT 'successo',
|
||||||
|
creato_il DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_settings (
|
||||||
|
agent_id INTEGER PRIMARY KEY,
|
||||||
|
preview_limit INTEGER NOT NULL DEFAULT 10,
|
||||||
|
tickets_per_page INTEGER NOT NULL DEFAULT 50
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_signatures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
body_html TEXT NOT NULL DEFAULT '',
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
nome TEXT NOT NULL,
|
||||||
|
descrizione TEXT,
|
||||||
|
master_ticket_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_group_members (
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, ticket_id),
|
||||||
|
FOREIGN KEY (group_id) REFERENCES ticket_groups (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS filter_presets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
page_mode TEXT NOT NULL,
|
||||||
|
filters_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_address_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
emails TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboard_chart_lines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
statuses TEXT NOT NULL,
|
||||||
|
types TEXT NOT NULL,
|
||||||
|
queues TEXT NOT NULL,
|
||||||
|
owners TEXT NOT NULL,
|
||||||
|
responsibles TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
is_visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bypass_state_filter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN color TEXT`);
|
||||||
|
} catch (e) {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN is_visible INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dashboard_chart_lines ADD COLUMN bypass_state_filter INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (e) {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const countRow = db.prepare("SELECT COUNT(*) AS count FROM dashboard_chart_lines").get();
|
||||||
|
if (countRow && countRow.count === 0) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default, bypass_state_filter)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 1)
|
||||||
|
`).run('Ticket aperti', JSON.stringify([1, 4, 6, 7, 8]), '[]', '[]', '[]', '[]', '#4f46e5');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO dashboard_chart_lines (name, statuses, types, queues, owners, responsibles, color, is_visible, is_default, bypass_state_filter)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 0)
|
||||||
|
`).run('Ticket chiusi', JSON.stringify([2, 3, 10]), '[]', '[]', '[]', '[]', '#10b981');
|
||||||
|
} else {
|
||||||
|
// Update default ones color if not set yet
|
||||||
|
db.prepare(`UPDATE dashboard_chart_lines SET color = '#4f46e5' WHERE name = 'Ticket aperti' AND color IS NULL`).run();
|
||||||
|
db.prepare(`UPDATE dashboard_chart_lines SET color = '#10b981' WHERE name = 'Ticket chiusi' AND color IS NULL`).run();
|
||||||
|
|
||||||
|
// Set default Ticket aperti to bypass state filter
|
||||||
|
db.prepare(`UPDATE dashboard_chart_lines SET bypass_state_filter = 1 WHERE name = 'Ticket aperti'`).run();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error seeding default chart lines:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a UUID v7 (time-ordered).
|
||||||
|
*/
|
||||||
|
function uuidV7() {
|
||||||
|
const tsMs = BigInt(Date.now());
|
||||||
|
const tsMsHex = tsMs.toString(16).padStart(12, '0');
|
||||||
|
const rand = crypto.randomBytes(10).toString('hex');
|
||||||
|
const p1 = tsMsHex.slice(0, 8);
|
||||||
|
const p2 = tsMsHex.slice(8, 12);
|
||||||
|
const p3 = '7' + rand.slice(0, 3);
|
||||||
|
const p4 = ((parseInt(rand.slice(3, 4), 16) & 0x3) | 0x8).toString(16) + rand.slice(4, 7);
|
||||||
|
const p5 = rand.slice(7, 19);
|
||||||
|
return `${p1}-${p2}-${p3}-${p4}-${p5}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertStmt = db.prepare(`
|
||||||
|
INSERT INTO attivita (id, agente_id, agente_nome, titolo_azione, azione, esito)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an activity record.
|
||||||
|
* @param {Object} params
|
||||||
|
*/
|
||||||
|
function logAttivita({ agente_id = 0, agente_nome = '', titolo_azione, azione = {}, esito = 'successo' }) {
|
||||||
|
try {
|
||||||
|
const id = uuidV7();
|
||||||
|
const azioneStr = typeof azione === 'string' ? azione : JSON.stringify(azione, null, 2);
|
||||||
|
insertStmt.run(id, agente_id, agente_nome, titolo_azione, azioneStr, esito);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[activityDb] Failed to log activity:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { db, logAttivita };
|
||||||
@@ -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
|
||||||
@@ -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();
|
const dbType = (process.env.DB_TYPE || 'postgres').toLowerCase();
|
||||||
|
|
||||||
@@ -112,6 +124,16 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
|||||||
connectionLimit: 20,
|
connectionLimit: 20,
|
||||||
idleTimeout: 30000,
|
idleTimeout: 30000,
|
||||||
connectTimeout: 5000,
|
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,
|
max: 20,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000,
|
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) => {
|
pool.on('error', (err) => {
|
||||||
|
|||||||
Generated
+1755
-4
File diff suppressed because it is too large
Load Diff
+24
-3
@@ -3,21 +3,42 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Modern fast interface for OTRS ticket management - direct database access",
|
"description": "Modern fast interface for OTRS ticket management - direct database access",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
|
"bin": "server.js",
|
||||||
|
"pkg": {
|
||||||
|
"scripts": [
|
||||||
|
"routes/*.js",
|
||||||
|
"utils/*.js",
|
||||||
|
"*.js"
|
||||||
|
],
|
||||||
|
"assets": [
|
||||||
|
"public/**/*"
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
"node18-win-x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
"pg": "^8.13.0"
|
"nodemailer": "^9.0.3",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"otrs",
|
"otrs",
|
||||||
"ticket",
|
"ticket",
|
||||||
"helpdesk"
|
"helpdesk"
|
||||||
],
|
],
|
||||||
"license": "AGPL-3.0"
|
"license": "AGPL-3.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"pkg": "^5.8.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+310
-13
@@ -354,6 +354,25 @@ body {
|
|||||||
letter-spacing: -0.02em;
|
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 {
|
.nav-menu {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
@@ -487,6 +506,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Topbar ---- */
|
/* ---- Topbar ---- */
|
||||||
@@ -568,6 +588,7 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--space-xl);
|
padding: var(--space-xl);
|
||||||
animation: fadeIn var(--transition-base);
|
animation: fadeIn var(--transition-base);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@@ -903,6 +924,36 @@ body {
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Queue Custom Tooltip */
|
||||||
|
.ticket-table td.queue-cell {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
.queue-tooltip {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 100%;
|
||||||
|
transform: translate(-50%, -6px);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 1000;
|
||||||
|
transition: opacity 0.05s ease, visibility 0.05s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.queue-cell:hover .queue-tooltip {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.ticket-table td {
|
.ticket-table td {
|
||||||
padding: 10px var(--space-md);
|
padding: 10px var(--space-md);
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
@@ -1067,6 +1118,12 @@ body {
|
|||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-multiselect-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group {
|
.filter-group {
|
||||||
@@ -1119,13 +1176,19 @@ body {
|
|||||||
.batch-bar {
|
.batch-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
margin-bottom: 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: 1px solid var(--border-accent);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
animation: slideDown 0.2s ease-out;
|
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 {
|
@keyframes slideDown {
|
||||||
@@ -1793,21 +1856,22 @@ body {
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: #1f2937;
|
background: var(--bg-card);
|
||||||
border: 1px solid #4b5563;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-lg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-suggestion-item {
|
.autocomplete-suggestion-item {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid rgba(156, 163, 175, 0.15);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #f3f4f6;
|
color: var(--text-primary);
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1817,7 +1881,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-suggestion-item:hover span {
|
.autocomplete-suggestion-item:hover span {
|
||||||
color: #e5e7eb !important;
|
color: #ffffff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-suggestion-item:last-child {
|
.autocomplete-suggestion-item:last-child {
|
||||||
@@ -1943,8 +2007,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bulk-select option {
|
.bulk-select option {
|
||||||
background-color: #111827;
|
background-color: var(--bg-secondary);
|
||||||
/* Sfondo scuro per gli stati e i tipi */
|
/* Sfondo per gli stati e i tipi */
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1955,23 +2019,24 @@ body {
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
background: #1f2937;
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
max-height: 380px;
|
max-height: 380px;
|
||||||
/* Incrementata l'altezza per contenere più elementi */
|
/* Incrementata l'altezza per contenere più elementi */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.6);
|
box-shadow: var(--shadow-lg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-suggestions-item {
|
.bulk-suggestions-item {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
/* Compatto */
|
/* Compatto */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid rgba(156, 163, 175, 0.1);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #e5e7eb;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-suggestions-item:hover {
|
.bulk-suggestions-item:hover {
|
||||||
@@ -1979,6 +2044,10 @@ body {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bulk-suggestions-item:hover span {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
.bulk-suggestions-item:last-child {
|
.bulk-suggestions-item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
@@ -2352,3 +2421,231 @@ body {
|
|||||||
transform: translateY(-50%) translateX(0);
|
transform: translateY(-50%) translateX(0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Overperformance Glow ---- */
|
||||||
|
@keyframes overperformance-glow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), inset 0 0 30px rgba(239, 68, 68, 0.4);
|
||||||
|
border-color: rgba(239, 68, 68, 1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand.glow {
|
||||||
|
animation: overperformance-glow 2s infinite;
|
||||||
|
background: rgba(239, 68, 68, 0.1) !important;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.6) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--space-sm);
|
||||||
|
padding: 12px !important;
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes autotime-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
||||||
|
border-color: rgba(16, 112, 202, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 15px rgba(16, 112, 202, 0.8), inset 0 0 5px rgba(16, 112, 202, 0.3);
|
||||||
|
border-color: rgba(16, 112, 202, 0.9);
|
||||||
|
background: rgba(16, 112, 202, 0.15);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
||||||
|
border-color: rgba(16, 112, 202, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand.clickable-auto-time {
|
||||||
|
cursor: pointer;
|
||||||
|
animation: autotime-pulse 2.5s infinite ease-in-out;
|
||||||
|
border: 1px dashed var(--accent-primary) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--space-xs) var(--space-sm);
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand.clickable-auto-time:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-timer.clickable-auto-time {
|
||||||
|
cursor: pointer;
|
||||||
|
animation: autotime-pulse 2.5s infinite ease-in-out;
|
||||||
|
border: 1px dashed var(--accent-primary) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--space-xs) var(--space-sm);
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-timer.clickable-auto-time:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overperformance-alarm-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 15px rgba(239, 68, 68, 0.8), inset 0 0 5px rgba(239, 68, 68, 0.3);
|
||||||
|
border-color: rgba(239, 68, 68, 0.9);
|
||||||
|
background: rgba(239, 68, 68, 0.15) !important;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-timer.overperformance-alarm {
|
||||||
|
animation: overperformance-alarm-pulse 1.5s infinite ease-in-out;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.6) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--space-xs) var(--space-sm);
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Ticket Tab Bar System ---- */
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap; /* Wraps to new line if too many tabs */
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 0 !important; /* Square corners */
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.tab-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.tab-item.active {
|
||||||
|
background: var(--bg-primary); /* Blend with main content background */
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border-bottom: 2px solid var(--accent-primary);
|
||||||
|
}
|
||||||
|
.tab-item .tab-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.tab-item .tab-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Full Bleed Ticket View Area ---- */
|
||||||
|
#view-container.ticket-view-active {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
#view-container.ticket-view-active .back-link {
|
||||||
|
margin: var(--space-md) var(--space-xl) var(--space-xs);
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
#view-container.ticket-view-active .ticket-detail {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
#view-container.ticket-view-active .ticket-detail .card {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Advanced Dashboard Ticket Chart Custom Styles
|
||||||
|
============================================================ */
|
||||||
|
.multiselect-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.multiselect-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.multiselect-item:hover {
|
||||||
|
background-color: var(--bg-hover, rgba(0,0,0,0.05));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.multiselect-item.selected {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.theme-dark .multiselect-item:hover,
|
||||||
|
.theme-rosso .multiselect-item:hover,
|
||||||
|
.theme-naturale .multiselect-item:hover,
|
||||||
|
.theme-ice .multiselect-item:hover,
|
||||||
|
.theme-autunno .multiselect-item:hover,
|
||||||
|
.theme-fairytale .multiselect-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.line-config-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.line-config-row:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
.line-config-row .line-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.line-config-row .line-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
+62
-6
@@ -18,11 +18,22 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Sidebar Navigation -->
|
<!-- Sidebar Navigation -->
|
||||||
<nav class="sidebar" id="sidebar">
|
<nav class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px;">
|
<div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px; display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-md);">
|
||||||
<div class="brand-icon">⚡</div>
|
<div class="brand-icon">⚡</div>
|
||||||
<span class="brand-text">OTRS Turbo</span>
|
<span class="brand-text">OTRS Turbo</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-timer" id="daily-timer" style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
|
<button id="btn-close-end-of-day" class="btn-brand-action" title="Chiudi i ticket inseriti nel gruppo CHIUDI A FINE GIORNATA">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px; height:18px;">
|
||||||
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
<path d="M9 12h6M9 16h4" />
|
||||||
|
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-timer" id="daily-timer"
|
||||||
|
style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
|
||||||
<span class="timer-display">0 / 480 | 480</span>
|
<span class="timer-display">0 / 480 | 480</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,6 +70,17 @@
|
|||||||
<span class="nav-badge" id="my-ticket-count"></span>
|
<span class="nav-badge" id="my-ticket-count"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<a href="#/tickets/new" class="nav-link" data-view="new-ticket" id="nav-new-ticket">
|
<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">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -79,6 +101,25 @@
|
|||||||
<span>Apertura Massiva</span>
|
<span>Apertura Massiva</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<hr>
|
||||||
|
<li>
|
||||||
|
<a href="#/mail-management" class="nav-link" data-view="mail-management" id="nav-mail-management">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||||
|
<polyline points="22,6 12,13 2,6" />
|
||||||
|
</svg>
|
||||||
|
<span>Gestione Mail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/activity" class="nav-link" data-view="activity" id="nav-activity">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span>Storico Attività Turbo</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -92,7 +133,8 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="main-content" id="main-content">
|
<main class="main-content" id="main-content">
|
||||||
<!-- Top Bar -->
|
<!-- Top Bar -->
|
||||||
<header class="topbar" id="topbar">
|
<header class="topbar" id="topbar" style="height: auto; padding: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0;">
|
||||||
|
<div class="topbar-main" style="display: flex; align-items: center; justify-content: space-between; width: 100%; height: var(--topbar-height); padding: 0 var(--space-xl);">
|
||||||
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
|
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
|
||||||
<h1 class="page-title" id="page-title">Dashboard</h1>
|
<h1 class="page-title" id="page-title">Dashboard</h1>
|
||||||
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
||||||
@@ -122,9 +164,15 @@
|
|||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="M21 21l-4.35-4.35" />
|
<path d="M21 21l-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" class="search-input" id="global-search" placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
|
<input type="text" class="search-input" id="global-search"
|
||||||
<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">
|
placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
|
||||||
<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 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
|
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
|
||||||
@@ -134,6 +182,9 @@
|
|||||||
Nuovo
|
Nuovo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tabs Bar -->
|
||||||
|
<div id="tabs-bar" class="tabs-bar" style="display:none; border-top: 1px solid var(--border-subtle);"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- View Container -->
|
<!-- View Container -->
|
||||||
@@ -151,6 +202,7 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js"></script>
|
<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/toast.js"></script>
|
||||||
<script src="/js/components/filters.js"></script>
|
<script src="/js/components/filters.js"></script>
|
||||||
<script src="/js/views/dashboard.js"></script>
|
<script src="/js/views/dashboard.js"></script>
|
||||||
@@ -158,6 +210,10 @@
|
|||||||
<script src="/js/views/ticketDetail.js"></script>
|
<script src="/js/views/ticketDetail.js"></script>
|
||||||
<script src="/js/views/ticketCreate.js"></script>
|
<script src="/js/views/ticketCreate.js"></script>
|
||||||
<script src="/js/views/ticketBulk.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>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+531
-27
@@ -13,12 +13,134 @@ const App = {
|
|||||||
lookupsLoaded: false,
|
lookupsLoaded: false,
|
||||||
demotivationalPhrases: [],
|
demotivationalPhrases: [],
|
||||||
motivationalPhrases: [],
|
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 */
|
/** Initialize the application */
|
||||||
init() {
|
init() {
|
||||||
this.initTheme();
|
this.initTheme();
|
||||||
this.loadDemotivationalPhrases();
|
this.loadDemotivationalPhrases();
|
||||||
this.loadMotivationalPhrases();
|
this.loadMotivationalPhrases();
|
||||||
|
this.loadTabs();
|
||||||
Toast.init();
|
Toast.init();
|
||||||
|
|
||||||
// Hash-based SPA router
|
// Hash-based SPA router
|
||||||
@@ -34,20 +156,8 @@ const App = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let timeout;
|
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
toggleClearBtn();
|
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) => {
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
@@ -94,7 +204,38 @@ const App = {
|
|||||||
refreshBtn.addEventListener('click', () => this.refreshLookups());
|
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
|
// Initial route
|
||||||
|
window.addEventListener('resize', () => this.updateHeaderHeight());
|
||||||
|
setTimeout(() => this.updateHeaderHeight(), 100);
|
||||||
|
|
||||||
if (!window.location.hash || window.location.hash === '#/') {
|
if (!window.location.hash || window.location.hash === '#/') {
|
||||||
window.location.hash = '#/dashboard';
|
window.location.hash = '#/dashboard';
|
||||||
} else {
|
} else {
|
||||||
@@ -108,6 +249,32 @@ const App = {
|
|||||||
const hash = fullHash.split('?')[0];
|
const hash = fullHash.split('?')[0];
|
||||||
const titleEl = document.getElementById('page-title');
|
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
|
// Update active nav link
|
||||||
document.querySelectorAll('.nav-link').forEach(link => {
|
document.querySelectorAll('.nav-link').forEach(link => {
|
||||||
link.classList.remove('active');
|
link.classList.remove('active');
|
||||||
@@ -138,6 +305,21 @@ const App = {
|
|||||||
titleEl.textContent = 'Apertura Massiva Ticket';
|
titleEl.textContent = 'Apertura Massiva Ticket';
|
||||||
TicketBulkView.render();
|
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+)$/)) {
|
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||||
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
||||||
document.getElementById('nav-tickets')?.classList.add('active');
|
document.getElementById('nav-tickets')?.classList.add('active');
|
||||||
@@ -181,25 +363,29 @@ const App = {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
this.lookups = JSON.parse(cached);
|
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;
|
this.lookupsLoaded = true;
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to parse cached lookups, reloading...', e);
|
console.warn('Failed to parse cached lookups, reloading...', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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/queues'),
|
||||||
this.api('/api/states'),
|
this.api('/api/states'),
|
||||||
this.api('/api/priorities'),
|
this.api('/api/priorities'),
|
||||||
this.api('/api/users'),
|
this.api('/api/users'),
|
||||||
this.api('/api/types'),
|
this.api('/api/types'),
|
||||||
this.api('/api/config').catch(() => ({ defaultAgentLogin: '' })),
|
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));
|
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
|
||||||
this.lookupsLoaded = true;
|
this.lookupsLoaded = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -218,6 +404,9 @@ const App = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Sync LDAP customer users to local DB cache
|
||||||
|
await this.api('/api/customer-users/sync', { method: 'POST' });
|
||||||
|
|
||||||
await this.ensureLookups(true);
|
await this.ensureLookups(true);
|
||||||
await this.initAgentSelector();
|
await this.initAgentSelector();
|
||||||
Toast.success('Dati locali (code, utenti, ecc.) aggiornati con successo!');
|
Toast.success('Dati locali (code, utenti, ecc.) aggiornati con successo!');
|
||||||
@@ -320,11 +509,21 @@ const App = {
|
|||||||
localStorage.setItem('activeAgentId', select.value);
|
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
|
// Handle dropdown change event
|
||||||
select.addEventListener('change', () => {
|
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}`);
|
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
|
||||||
this.updateDailyTimer();
|
this.updateDailyTimer();
|
||||||
|
this.updateSidebarBadges();
|
||||||
this.route();
|
this.route();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -379,26 +578,24 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Load demotivational phrases from txt file */
|
/** Load demotivational phrases from SQLite cache */
|
||||||
async loadDemotivationalPhrases() {
|
async loadDemotivationalPhrases() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/demotivational.txt');
|
const res = await fetch('/api/dashboard/phrases?tipo=demotivational');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const text = await res.text();
|
this.demotivationalPhrases = await res.json();
|
||||||
this.demotivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load demotivational phrases:', e);
|
console.warn('Failed to load demotivational phrases:', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Load motivational phrases from txt file */
|
/** Load motivational phrases from SQLite cache */
|
||||||
async loadMotivationalPhrases() {
|
async loadMotivationalPhrases() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/motivational.txt');
|
const res = await fetch('/api/dashboard/phrases?tipo=motivational');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const text = await res.text();
|
this.motivationalPhrases = await res.json();
|
||||||
this.motivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load motivational phrases:', e);
|
console.warn('Failed to load motivational phrases:', e);
|
||||||
@@ -417,10 +614,93 @@ const App = {
|
|||||||
const data = await this.api('/api/users/time-today');
|
const data = await this.api('/api/users/time-today');
|
||||||
const todayTime = typeof data.totalToday === 'number' ? data.totalToday : 0;
|
const todayTime = typeof data.totalToday === 'number' ? data.totalToday : 0;
|
||||||
const remaining = Math.max(0, targetTime - todayTime);
|
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 = "";
|
let phrase = "";
|
||||||
const isDemotivational = percentage >= 70;
|
const phraseThreshold = this.lookups.config?.phraseThreshold || 70;
|
||||||
|
const isDemotivational = percentage >= phraseThreshold;
|
||||||
|
|
||||||
if (isDemotivational) {
|
if (isDemotivational) {
|
||||||
if (this.demotivationalPhrases.length > 0) {
|
if (this.demotivationalPhrases.length > 0) {
|
||||||
@@ -450,11 +730,16 @@ const App = {
|
|||||||
<div class="timer-tooltip-border"></div>
|
<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;">
|
<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>
|
<span>Progresso Giornaliero</span>
|
||||||
<strong>${percentage}%</strong>
|
<strong>${mathematicalPercentage}%</strong>
|
||||||
</div>
|
</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="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 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>
|
</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;">
|
<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}"
|
"${phrase}"
|
||||||
</div>
|
</div>
|
||||||
@@ -485,6 +770,225 @@ const App = {
|
|||||||
console.warn('Failed to update sidebar badges:', e);
|
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
|
// Start the app when DOM is ready
|
||||||
|
|||||||
+684
-44
@@ -3,21 +3,66 @@
|
|||||||
* Manages ticket list filter state and renders filter dropdowns.
|
* Manages ticket list filter state and renders filter dropdowns.
|
||||||
*/
|
*/
|
||||||
const Filters = {
|
const Filters = {
|
||||||
state: {
|
currentMode: 'general', // 'general' or 'my'
|
||||||
|
|
||||||
|
allStates: {
|
||||||
|
general: {
|
||||||
queue_id: '',
|
queue_id: '',
|
||||||
state_id: '',
|
state_id: '',
|
||||||
priority_id: '',
|
priority_id: '',
|
||||||
user_id: '',
|
user_id: '',
|
||||||
|
customer_user_id: '',
|
||||||
date_from: '',
|
date_from: '',
|
||||||
date_to: '',
|
date_to: '',
|
||||||
},
|
},
|
||||||
|
my: {
|
||||||
|
queue_id: '',
|
||||||
|
state_id: '',
|
||||||
|
priority_id: '',
|
||||||
|
user_id: '',
|
||||||
|
customer_user_id: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
customerCache: {},
|
||||||
|
presets: [],
|
||||||
|
selectedPresetId: null,
|
||||||
|
|
||||||
|
saveCustomerCache() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('otrs_turbo_customer_cache', JSON.stringify(this.customerCache));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dynamic state getter based on active mode
|
||||||
|
get state() {
|
||||||
|
return this.allStates[this.currentMode];
|
||||||
|
},
|
||||||
|
|
||||||
|
set state(val) {
|
||||||
|
this.allStates[this.currentMode] = val;
|
||||||
|
},
|
||||||
|
|
||||||
/** Load saved filters from localStorage */
|
/** Load saved filters from localStorage */
|
||||||
load() {
|
load() {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('otrs_turbo_filters');
|
const savedGeneral = localStorage.getItem('otrs_turbo_filters_general');
|
||||||
if (saved) {
|
if (savedGeneral) {
|
||||||
Object.assign(this.state, JSON.parse(saved));
|
Object.assign(this.allStates.general, JSON.parse(savedGeneral));
|
||||||
|
}
|
||||||
|
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
|
||||||
|
if (savedMy) {
|
||||||
|
Object.assign(this.allStates.my, JSON.parse(savedMy));
|
||||||
|
}
|
||||||
|
// Always sync mode 'my' user_id to activeAgentId
|
||||||
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
this.allStates.my.user_id = activeAgentId;
|
||||||
|
|
||||||
|
const savedCache = localStorage.getItem('otrs_turbo_customer_cache');
|
||||||
|
if (savedCache) {
|
||||||
|
this.customerCache = JSON.parse(savedCache);
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
},
|
},
|
||||||
@@ -25,13 +70,17 @@ const Filters = {
|
|||||||
/** Save filters to localStorage */
|
/** Save filters to localStorage */
|
||||||
save() {
|
save() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('otrs_turbo_filters', JSON.stringify(this.state));
|
localStorage.setItem(`otrs_turbo_filters_${this.currentMode}`, JSON.stringify(this.state));
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset all filters */
|
/** Reset all filters */
|
||||||
reset() {
|
reset() {
|
||||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
|
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', customer_user_id: '', date_from: '', date_to: '' };
|
||||||
|
if (this.currentMode === 'my') {
|
||||||
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
this.state.user_id = activeAgentId;
|
||||||
|
}
|
||||||
this.save();
|
this.save();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -44,101 +93,692 @@ const Filters = {
|
|||||||
return params;
|
return params;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Helper to compute trigger button label text for multiselect dropdowns */
|
||||||
|
getMultiselectLabel(selectedVal, itemsList, labelField = 'name', idField = 'id') {
|
||||||
|
const selectedList = Array.isArray(selectedVal)
|
||||||
|
? selectedVal
|
||||||
|
: (typeof selectedVal === 'string' && selectedVal ? selectedVal.split(',') : []);
|
||||||
|
|
||||||
|
if (selectedList.length === 0) {
|
||||||
|
return 'Tutti';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedNames = (itemsList || [])
|
||||||
|
.filter(item => selectedList.includes(String(item[idField])))
|
||||||
|
.map(item => typeof labelField === 'function' ? labelField(item) : item[labelField]);
|
||||||
|
|
||||||
|
if (selectedNames.length === 0) {
|
||||||
|
return 'Tutti';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNames.length <= 2) {
|
||||||
|
return selectedNames.join(', ');
|
||||||
|
} else {
|
||||||
|
return `${selectedNames.length} selezionati`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomerUserMultiselectLabel(lookups) {
|
||||||
|
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||||
|
? this.state.customer_user_id
|
||||||
|
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||||
|
|
||||||
|
if (selectedList.length === 0) {
|
||||||
|
return 'Tutti';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedNames = selectedList.map(login => {
|
||||||
|
if (this.customerCache && this.customerCache[login]) {
|
||||||
|
return this.customerCache[login];
|
||||||
|
}
|
||||||
|
const found = (lookups.customerUsers || []).find(u => String(u.login) === String(login));
|
||||||
|
if (found) {
|
||||||
|
const fullName = `${found.last_name} ${found.first_name}`.trim();
|
||||||
|
this.customerCache[login] = fullName;
|
||||||
|
this.saveCustomerCache();
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
return login;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedNames.length <= 2) {
|
||||||
|
return selectedNames.join(', ');
|
||||||
|
} else {
|
||||||
|
return `${selectedNames.length} selezionati`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update customer user label DOM element dynamically */
|
||||||
|
updateCustomerUserMultiselectLabel(lookups) {
|
||||||
|
const labelEl = document.getElementById('customer-user-multiselect-label');
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = this.getCustomerUserMultiselectLabel(lookups);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render filter bar HTML.
|
* Render filter bar HTML.
|
||||||
* @param {Object} lookups - { queues, states, priorities, users }
|
* @param {Object} lookups - { queues, states, priorities, users }
|
||||||
* @returns {string} HTML string
|
* @returns {string} HTML string
|
||||||
*/
|
*/
|
||||||
renderBar(lookups) {
|
renderBar(lookups) {
|
||||||
const makeOptions = (items, valueKey, labelKey, selectedVal) => {
|
const stateLabel = this.getMultiselectLabel(this.state.state_id, lookups.states);
|
||||||
return items.map(item => {
|
const queueLabel = this.getMultiselectLabel(this.state.queue_id, lookups.queues);
|
||||||
const val = item[valueKey];
|
const priorityLabel = this.getMultiselectLabel(this.state.priority_id, lookups.priorities);
|
||||||
const label = typeof labelKey === 'function' ? labelKey(item) : item[labelKey];
|
const ownerLabel = this.getMultiselectLabel(this.state.user_id, lookups.users, u => `${u.first_name} ${u.last_name}`);
|
||||||
const sel = String(val) === String(selectedVal) ? 'selected' : '';
|
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups);
|
||||||
return `<option value="${val}" ${sel}>${label}</option>`;
|
|
||||||
|
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('');
|
}).join('');
|
||||||
};
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="filters-bar" id="filters-bar">
|
<div class="filters-bar" id="filters-bar">
|
||||||
<div class="filter-group">
|
<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>
|
<span class="filter-label">Stato</span>
|
||||||
<select class="filter-select" data-filter="state_id" id="filter-state">
|
<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;">
|
||||||
<option value="">Tutti</option>
|
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(stateLabel)}</span>
|
||||||
${makeOptions(lookups.states || [], 'id', 'name', this.state.state_id)}
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
|
<input type="text" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
|
${(lookups.states || []).map(s => {
|
||||||
|
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
|
||||||
|
const isSelected = selectedList.includes(String(s.id));
|
||||||
|
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||||
|
return `
|
||||||
|
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||||
|
${App.escapeHtml(s.name)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coda -->
|
||||||
|
<div class="filter-group" style="position:relative;">
|
||||||
<span class="filter-label">Coda</span>
|
<span class="filter-label">Coda</span>
|
||||||
<select class="filter-select" data-filter="queue_id" id="filter-queue">
|
<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;">
|
||||||
<option value="">Tutte</option>
|
<span class="multiselect-label" id="queue-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(queueLabel)}</span>
|
||||||
${makeOptions(lookups.queues || [], 'id', 'name', this.state.queue_id)}
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
|
<input type="text" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
|
${(lookups.queues || []).map(q => {
|
||||||
|
const selectedList = String(this.state.queue_id || '').split(',').filter(Boolean);
|
||||||
|
const isSelected = selectedList.includes(String(q.id));
|
||||||
|
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||||
|
return `
|
||||||
|
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${q.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||||
|
${App.escapeHtml(q.name)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priorità -->
|
||||||
|
<div class="filter-group" style="position:relative;">
|
||||||
<span class="filter-label">Priorità</span>
|
<span class="filter-label">Priorità</span>
|
||||||
<select class="filter-select" data-filter="priority_id" id="filter-priority">
|
<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;">
|
||||||
<option value="">Tutte</option>
|
<span class="multiselect-label" id="priority-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(priorityLabel)}</span>
|
||||||
${makeOptions(lookups.priorities || [], 'id', 'name', this.state.priority_id)}
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="multiselect-popover" id="priority-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 200px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
|
<input type="text" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
|
${(lookups.priorities || []).map(p => {
|
||||||
|
const selectedList = String(this.state.priority_id || '').split(',').filter(Boolean);
|
||||||
|
const isSelected = selectedList.includes(String(p.id));
|
||||||
|
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||||
|
return `
|
||||||
|
<div class="ms-item ${isSelected ? 'active' : ''}" data-value="${p.id}" style="padding: 4px 8px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||||
|
${App.escapeHtml(p.name)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs ms-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-xs ms-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Owner -->
|
||||||
|
<div class="filter-group" style="position:relative;">
|
||||||
<span class="filter-label">Owner</span>
|
<span class="filter-label">Owner</span>
|
||||||
<select class="filter-select" data-filter="user_id" id="filter-owner">
|
<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;">
|
||||||
<option value="">Tutti</option>
|
<span class="multiselect-label" id="owner-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${App.escapeHtml(ownerLabel)}</span>
|
||||||
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</select>
|
</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>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Da Data/Ora</span>
|
<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>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">A Data/Ora</span>
|
<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>
|
||||||
<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>
|
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Bind change events to filter selects */
|
|
||||||
bindEvents(onFilterChange) {
|
bindEvents(onFilterChange) {
|
||||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||||
const selects = document.querySelectorAll('.filter-select[data-filter]');
|
const selects = document.querySelectorAll('.filter-select[data-filter]');
|
||||||
selects.forEach(sel => {
|
selects.forEach(sel => {
|
||||||
if (isMyTickets && sel.dataset.filter === 'user_id') {
|
|
||||||
sel.disabled = true;
|
|
||||||
} else {
|
|
||||||
sel.disabled = false;
|
sel.disabled = false;
|
||||||
}
|
|
||||||
|
|
||||||
sel.addEventListener('change', (e) => {
|
sel.addEventListener('change', (e) => {
|
||||||
|
this.selectedPresetId = null;
|
||||||
this.state[e.target.dataset.filter] = e.target.value;
|
this.state[e.target.dataset.filter] = e.target.value;
|
||||||
this.save();
|
this.save();
|
||||||
if (onFilterChange) onFilterChange();
|
if (onFilterChange) onFilterChange();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to bind standard multiselect popover events
|
||||||
|
const bindStandardMultiselect = (filterKey, dropdownId, popoverId, labelElId, itemsList, labelField, idField = 'id') => {
|
||||||
|
const dropdown = document.getElementById(dropdownId);
|
||||||
|
const popover = document.getElementById(popoverId);
|
||||||
|
if (!dropdown || !popover) return;
|
||||||
|
|
||||||
|
const searchInput = popover.querySelector('.ms-search');
|
||||||
|
const itemsContainer = popover.querySelector('.ms-items-container');
|
||||||
|
const okBtn = popover.querySelector('.ms-ok');
|
||||||
|
const clearBtn = popover.querySelector('.ms-clear');
|
||||||
|
|
||||||
|
// Toggle popover visibility
|
||||||
|
dropdown.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Close all other popovers
|
||||||
|
document.querySelectorAll('.multiselect-popover').forEach(p => {
|
||||||
|
if (p !== popover) p.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOpen = popover.style.display === 'block';
|
||||||
|
popover.style.display = isOpen ? 'none' : 'block';
|
||||||
|
if (!isOpen && searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
searchInput.dispatchEvent(new Event('input'));
|
||||||
|
setTimeout(() => searchInput.focus(), 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
// Search matching items
|
||||||
|
if (searchInput && itemsContainer) {
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
const q = searchInput.value.toLowerCase().trim();
|
||||||
|
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
|
||||||
|
const text = item.textContent.toLowerCase();
|
||||||
|
item.style.display = text.includes(q) ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind selection clicks
|
||||||
|
if (itemsContainer) {
|
||||||
|
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isActive = item.classList.toggle('active');
|
||||||
|
if (isActive) {
|
||||||
|
item.style.background = 'var(--accent-primary)';
|
||||||
|
item.style.color = '#fff';
|
||||||
|
} else {
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply selection (OK click)
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.addEventListener('click', () => {
|
||||||
|
const activeItems = itemsContainer.querySelectorAll('.ms-item.active');
|
||||||
|
const ids = Array.from(activeItems).map(item => item.dataset.value);
|
||||||
|
this.selectedPresetId = null;
|
||||||
|
this.state[filterKey] = ids.join(',');
|
||||||
|
this.save();
|
||||||
|
|
||||||
|
const labelEl = document.getElementById(labelElId);
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = this.getMultiselectLabel(this.state[filterKey], itemsList, labelField, idField);
|
||||||
|
}
|
||||||
|
popover.style.display = 'none';
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset selection
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
itemsContainer.querySelectorAll('.ms-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
});
|
||||||
|
this.selectedPresetId = null;
|
||||||
|
this.state[filterKey] = '';
|
||||||
|
this.save();
|
||||||
|
|
||||||
|
const labelEl = document.getElementById(labelElId);
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = 'Tutti';
|
||||||
|
}
|
||||||
|
popover.style.display = 'none';
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bind standard multiselects
|
||||||
|
bindStandardMultiselect('state_id', 'state-multiselect-dropdown', 'state-multiselect-popover', 'state-multiselect-label', App.lookups.states, 'name');
|
||||||
|
bindStandardMultiselect('queue_id', 'queue-multiselect-dropdown', 'queue-multiselect-popover', 'queue-multiselect-label', App.lookups.queues, 'name');
|
||||||
|
bindStandardMultiselect('priority_id', 'priority-multiselect-dropdown', 'priority-multiselect-popover', 'priority-multiselect-label', App.lookups.priorities, 'name');
|
||||||
|
bindStandardMultiselect('user_id', 'owner-multiselect-dropdown', 'owner-multiselect-popover', 'owner-multiselect-label', App.lookups.users, u => `${u.first_name} ${u.last_name}`);
|
||||||
|
|
||||||
|
// Customer User Multiselect Popover binding
|
||||||
|
const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
|
||||||
|
const cuPopover = document.getElementById('customer-user-multiselect-popover');
|
||||||
|
const cuSearchInput = document.getElementById('customer-user-search-input');
|
||||||
|
const cuItemsContainer = document.getElementById('customer-user-items-container');
|
||||||
|
const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
|
||||||
|
const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
|
||||||
|
|
||||||
|
let activeSearchController = null;
|
||||||
|
|
||||||
|
const renderCustomerUserItems = (searchResults = null) => {
|
||||||
|
if (!cuItemsContainer) return;
|
||||||
|
|
||||||
|
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||||
|
? this.state.customer_user_id
|
||||||
|
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||||
|
|
||||||
|
const uniqueSelectedLogins = Array.from(new Set(selectedList)).filter(Boolean);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// 1. Show selected items at the top
|
||||||
|
if (uniqueSelectedLogins.length > 0) {
|
||||||
|
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--accent-primary); text-transform:uppercase; padding: 2px var(--space-sm); border-bottom:1px solid var(--border-subtle); margin-bottom:4px;">Selezionati</div>`;
|
||||||
|
uniqueSelectedLogins.forEach(login => {
|
||||||
|
const displayName = this.customerCache[login] || login;
|
||||||
|
html += `
|
||||||
|
<div class="customer-user-multiselect-item active" data-value="${login}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; background: var(--accent-primary); color: #fff; margin-bottom: 2px;">
|
||||||
|
${App.escapeHtml(displayName)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(login)})</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Show search results below
|
||||||
|
let listToRender = searchResults || App.lookups.customerUsers || [];
|
||||||
|
listToRender = listToRender.filter(u => !uniqueSelectedLogins.includes(String(u.login)));
|
||||||
|
|
||||||
|
if (listToRender.length > 0) {
|
||||||
|
if (uniqueSelectedLogins.length > 0) {
|
||||||
|
html += `<div style="font-size:0.72rem; font-weight:700; color:var(--text-secondary); text-transform:uppercase; padding: 4px var(--space-sm) 2px; border-bottom:1px solid var(--border-subtle); margin-top:6px; margin-bottom:4px;">Risultati</div>`;
|
||||||
|
}
|
||||||
|
listToRender.forEach(u => {
|
||||||
|
const displayName = `${u.last_name} ${u.first_name}`.trim() || u.login;
|
||||||
|
html += `
|
||||||
|
<div class="customer-user-multiselect-item" data-value="${u.login}" data-display-name="${displayName}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; margin-bottom: 2px;">
|
||||||
|
${App.escapeHtml(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} else if (uniqueSelectedLogins.length === 0) {
|
||||||
|
html = `<div style="text-align:center; padding:var(--space-md); color:var(--text-muted); font-size:0.8rem;">Cerca digitando sopra...</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cuItemsContainer.innerHTML = html;
|
||||||
|
|
||||||
|
// Bind clicks to items
|
||||||
|
cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const login = item.dataset.value;
|
||||||
|
const displayName = item.dataset.displayName || this.customerCache[login] || login;
|
||||||
|
|
||||||
|
const currentSelected = Array.isArray(this.state.customer_user_id)
|
||||||
|
? [...this.state.customer_user_id]
|
||||||
|
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||||
|
|
||||||
|
const idx = currentSelected.indexOf(login);
|
||||||
|
if (idx > -1) {
|
||||||
|
currentSelected.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
currentSelected.push(login);
|
||||||
|
this.customerCache[login] = displayName;
|
||||||
|
this.saveCustomerCache();
|
||||||
|
}
|
||||||
|
this.state.customer_user_id = currentSelected.join(',');
|
||||||
|
renderCustomerUserItems(searchResults);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cuDropdown && cuPopover) {
|
||||||
|
cuDropdown.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Close other popovers
|
||||||
|
document.querySelectorAll('.multiselect-popover').forEach(p => {
|
||||||
|
if (p !== cuPopover) p.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOpen = cuPopover.style.display === 'block';
|
||||||
|
cuPopover.style.display = isOpen ? 'none' : 'block';
|
||||||
|
if (!isOpen) {
|
||||||
|
if (cuSearchInput) {
|
||||||
|
cuSearchInput.value = '';
|
||||||
|
}
|
||||||
|
renderCustomerUserItems();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (cuSearchInput) cuSearchInput.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cuPopover.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
let cuSearchDebounce;
|
||||||
|
if (cuSearchInput) {
|
||||||
|
cuSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(cuSearchDebounce);
|
||||||
|
const q = cuSearchInput.value.trim();
|
||||||
|
|
||||||
|
cuSearchDebounce = setTimeout(async () => {
|
||||||
|
if (activeSearchController) activeSearchController.abort();
|
||||||
|
activeSearchController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
cuItemsContainer.innerHTML = '<div style="display:flex; justify-content:center; padding:12px;"><div class="spinner" style="width:18px;height:18px;border-width:2px;"></div></div>';
|
||||||
|
|
||||||
|
const users = await fetch(`/api/customer-users/search?q=${encodeURIComponent(q)}`, {
|
||||||
|
signal: activeSearchController.signal
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
renderCustomerUserItems(users);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Search failed:', err);
|
||||||
|
renderCustomerUserItems([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cuOkBtn) {
|
||||||
|
cuOkBtn.addEventListener('click', () => {
|
||||||
|
this.selectedPresetId = null;
|
||||||
|
this.save();
|
||||||
|
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||||
|
if (cuPopover) cuPopover.style.display = 'none';
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cuClearBtn) {
|
||||||
|
cuClearBtn.addEventListener('click', () => {
|
||||||
|
this.selectedPresetId = null;
|
||||||
|
this.state.customer_user_id = '';
|
||||||
|
this.save();
|
||||||
|
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||||
|
if (cuSearchInput) cuSearchInput.value = '';
|
||||||
|
renderCustomerUserItems();
|
||||||
|
if (cuPopover) cuPopover.style.display = 'none';
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all popovers on backdrop click and confirm selection
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.multiselect-popover').forEach(p => {
|
||||||
|
if (p.style.display === 'block') {
|
||||||
|
const okBtn = p.querySelector('.ms-ok, #customer-user-multiselect-ok');
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.click();
|
||||||
|
} else {
|
||||||
|
p.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshBtn = document.getElementById('filter-refresh');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const resetBtn = document.getElementById('filter-reset');
|
const resetBtn = document.getElementById('filter-reset');
|
||||||
if (resetBtn) {
|
if (resetBtn) {
|
||||||
resetBtn.addEventListener('click', () => {
|
resetBtn.addEventListener('click', () => {
|
||||||
|
this.selectedPresetId = null;
|
||||||
this.reset();
|
this.reset();
|
||||||
if (isMyTickets) {
|
if (isMyTickets) {
|
||||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
this.state.user_id = activeAgentId;
|
this.state.user_id = activeAgentId;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
selects.forEach(s => {
|
|
||||||
if (isMyTickets && s.dataset.filter === 'user_id') {
|
// Reset highlight states in popovers
|
||||||
s.value = localStorage.getItem('activeAgentId') || '1';
|
document.querySelectorAll('.ms-items-container .ms-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also clear customer user multiselect items and label
|
||||||
|
const cuPopover = document.getElementById('customer-user-multiselect-popover');
|
||||||
|
if (cuPopover) {
|
||||||
|
const searchInput = cuPopover.querySelector('#customer-user-search-input');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
cuPopover.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute labels
|
||||||
|
document.getElementById('state-multiselect-label').textContent = 'Tutti';
|
||||||
|
document.getElementById('queue-multiselect-label').textContent = 'Tutti';
|
||||||
|
document.getElementById('priority-multiselect-label').textContent = 'Tutti';
|
||||||
|
document.getElementById('owner-multiselect-label').textContent = isMyTickets ? this.getMultiselectLabel(this.state.user_id, App.lookups.users, u => `${u.first_name} ${u.last_name}`) : 'Tutti';
|
||||||
|
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||||
|
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and bind presets
|
||||||
|
const loadPresets = async () => {
|
||||||
|
try {
|
||||||
|
const agentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
this.presets = await App.api(`/api/presets?page_mode=${this.currentMode}`);
|
||||||
|
const select = document.getElementById('filter-presets-select');
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = '<option value="">-- Nessuno --</option>' + this.presets.map(p => {
|
||||||
|
const sel = String(p.id) === String(this.selectedPresetId) ? 'selected' : '';
|
||||||
|
return `<option value="${p.id}" ${sel}>${App.escapeHtml(p.name)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load presets:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPresets();
|
||||||
|
|
||||||
|
const selectPresets = document.getElementById('filter-presets-select');
|
||||||
|
if (selectPresets) {
|
||||||
|
selectPresets.addEventListener('change', (e) => {
|
||||||
|
const presetId = e.target.value;
|
||||||
|
if (!presetId) {
|
||||||
|
this.selectedPresetId = null;
|
||||||
|
const resetBtn = document.getElementById('filter-reset');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.click();
|
||||||
} else {
|
} else {
|
||||||
s.value = '';
|
this.reset();
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = this.presets.find(p => String(p.id) === String(presetId));
|
||||||
|
if (preset) {
|
||||||
|
try {
|
||||||
|
const filters = JSON.parse(preset.filters_json);
|
||||||
|
this.state = Object.assign({
|
||||||
|
queue_id: '',
|
||||||
|
state_id: '',
|
||||||
|
priority_id: '',
|
||||||
|
user_id: '',
|
||||||
|
customer_user_id: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: ''
|
||||||
|
}, filters);
|
||||||
|
this.selectedPresetId = preset.id;
|
||||||
|
this.save();
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nel caricamento del preset: ' + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnSavePreset = document.getElementById('btn-save-preset');
|
||||||
|
if (btnSavePreset) {
|
||||||
|
btnSavePreset.addEventListener('click', async () => {
|
||||||
|
const name = await App.prompt('Nuovo Preset', 'Inserisci il nome per questo preset di filtri:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
btnSavePreset.disabled = true;
|
||||||
|
const newPreset = await App.api('/api/presets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
page_mode: this.currentMode,
|
||||||
|
filters: this.state
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Toast.success('Preset salvato con successo!');
|
||||||
|
this.selectedPresetId = newPreset.id;
|
||||||
if (onFilterChange) onFilterChange();
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* ActivityLogView — Storico Attività
|
||||||
|
* Displays the local SQLite activity log with filters, dual pagination,
|
||||||
|
* and an expandable JSON detail panel.
|
||||||
|
*/
|
||||||
|
const ActivityLogView = {
|
||||||
|
currentPage: 1,
|
||||||
|
perPage: 50,
|
||||||
|
filters: { esito: '', agente_id: '', da: '', a: '' },
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento storico...</p></div>';
|
||||||
|
await this._draw();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _draw() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: this.currentPage,
|
||||||
|
per_page: this.perPage,
|
||||||
|
});
|
||||||
|
if (this.filters.esito) params.set('esito', this.filters.esito);
|
||||||
|
if (this.filters.agente_id) params.set('agente_id', this.filters.agente_id);
|
||||||
|
if (this.filters.da) params.set('da', this.filters.da);
|
||||||
|
if (this.filters.a) params.set('a', this.filters.a);
|
||||||
|
|
||||||
|
const data = await App.api(`/api/attivita?${params}`);
|
||||||
|
const { rows, total, page, per_page, total_pages } = data;
|
||||||
|
|
||||||
|
container.innerHTML = this._buildHtml(rows, total, page, per_page, total_pages);
|
||||||
|
this._bind();
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="empty-state"><p style="color:var(--danger)">Errore caricamento: ${App.escapeHtml(err.message)}</p></div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildHtml(rows, total, page, per_page, total_pages) {
|
||||||
|
const filtersHtml = this._buildFilters();
|
||||||
|
const paginationHtml = this._buildPagination(total, page, per_page, total_pages);
|
||||||
|
const tableHtml = rows.length === 0
|
||||||
|
? `<div class="empty-state" style="padding:var(--space-2xl);text-align:center;color:var(--text-muted);">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px;height:48px;margin:0 auto var(--space-md);display:block;opacity:0.4;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
<p>Nessuna attività registrata.</p>
|
||||||
|
</div>`
|
||||||
|
: `<div class="table-wrapper" style="overflow-x:auto;">
|
||||||
|
<table class="tickets-table" id="activity-table" style="width:100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:160px;">Data/Ora</th>
|
||||||
|
<th style="width:160px;">Agente</th>
|
||||||
|
<th style="width:180px;">Azione</th>
|
||||||
|
<th style="min-width:60px;text-align:center;">Esito</th>
|
||||||
|
<th style="width:48px;text-align:center;">≡</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows.map(r => this._buildRow(r)).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="view-header" style="padding:var(--space-md) var(--space-xl);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--space-sm);border-bottom:1px solid var(--border-subtle);">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-sm);">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px;height:20px;color:var(--primary);"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
<span style="font-weight:600;font-size:1rem;">Storico Attività</span>
|
||||||
|
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);font-size:0.75rem;padding:2px 8px;border-radius:12px;">${total} record</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${filtersHtml}
|
||||||
|
${paginationHtml}
|
||||||
|
${tableHtml}
|
||||||
|
${rows.length > 0 ? paginationHtml.replace(/id="pagination-top"/g,'id="pagination-bottom"') : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildRow(r) {
|
||||||
|
const dt = r.creato_il ? new Date(r.creato_il).toLocaleString('it-IT') : '—';
|
||||||
|
const esitoBadge = r.esito === 'successo'
|
||||||
|
? `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(34,197,94,0.15);color:#16a34a;font-size:0.75rem;font-weight:600;">✓ successo</span>`
|
||||||
|
: `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(239,68,68,0.15);color:#dc2626;font-size:0.75rem;font-weight:600;">✕ errore</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr id="row-${r.id}" data-id="${r.id}">
|
||||||
|
<td style="font-size:0.8rem;color:var(--text-secondary);white-space:nowrap;">${dt}</td>
|
||||||
|
<td style="font-size:0.85rem;">${App.escapeHtml(r.agente_nome || '—')}</td>
|
||||||
|
<td style="font-size:0.85rem;font-weight:500;">${App.escapeHtml(r.titolo_azione)}</td>
|
||||||
|
<td style="text-align:center;">${esitoBadge}</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<button class="btn btn-ghost btn-sm act-detail-btn" data-id="${r.id}" title="Mostra dettaglio" style="padding:4px 8px;font-size:0.85rem;">≡</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="detail-${r.id}" class="act-detail-row" style="display:none;">
|
||||||
|
<td colspan="5" style="padding:0 var(--space-md) var(--space-md);background:var(--bg-secondary);">
|
||||||
|
<pre style="margin:0;padding:var(--space-md);background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:0.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-primary);border:1px solid var(--border-subtle);">${App.escapeHtml(this._prettyJson(r.azione))}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_prettyJson(str) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(str), null, 2);
|
||||||
|
} catch (_) {
|
||||||
|
return str || '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildFilters() {
|
||||||
|
return `
|
||||||
|
<div id="activity-filters" style="display:flex;flex-wrap:wrap;gap:var(--space-sm);padding:var(--space-md) var(--space-xl);border-bottom:1px solid var(--border-subtle);background:var(--bg-secondary);align-items:flex-end;">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">Esito</label>
|
||||||
|
<select id="filter-esito" class="form-select" style="min-width:120px;height:36px;font-size:0.85rem;padding:6px 28px 6px 10px;">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
<option value="successo" ${this.filters.esito === 'successo' ? 'selected' : ''}>✓ Successo</option>
|
||||||
|
<option value="errore" ${this.filters.esito === 'errore' ? 'selected' : ''}>✕ Errore</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">Da data</label>
|
||||||
|
<input type="datetime-local" id="filter-da" class="form-input" value="${this.filters.da}" style="height:36px;font-size:0.85rem;padding:6px 10px;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">A data</label>
|
||||||
|
<input type="datetime-local" id="filter-a" class="form-input" value="${this.filters.a}" style="height:36px;font-size:0.85rem;padding:6px 10px;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:var(--space-xs);align-self:flex-end;">
|
||||||
|
<button id="btn-apply-filters" class="btn btn-primary btn-sm" style="height:36px;">Applica</button>
|
||||||
|
<button id="btn-reset-filters" class="btn btn-ghost btn-sm" style="height:36px;">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildPagination(total, page, per_page, total_pages) {
|
||||||
|
if (total_pages <= 1) return '';
|
||||||
|
const from = (page - 1) * per_page + 1;
|
||||||
|
const to = Math.min(page * per_page, total);
|
||||||
|
|
||||||
|
const pageBtn = (p, label, disabled, active) => {
|
||||||
|
const isDisabled = disabled || p === page;
|
||||||
|
return `<button class="btn btn-ghost btn-sm page-btn" data-page="${p}"
|
||||||
|
style="min-width:36px;height:32px;${active ? 'background:var(--primary);color:#fff;' : ''}${isDisabled ? 'opacity:0.4;pointer-events:none;' : ''}"
|
||||||
|
${disabled || active ? 'disabled' : ''}>${label}</button>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
pages.push(pageBtn(1, '«', page === 1, false));
|
||||||
|
pages.push(pageBtn(page - 1, '‹', page === 1, false));
|
||||||
|
|
||||||
|
const rangeStart = Math.max(1, page - 2);
|
||||||
|
const rangeEnd = Math.min(total_pages, page + 2);
|
||||||
|
for (let p = rangeStart; p <= rangeEnd; p++) {
|
||||||
|
pages.push(pageBtn(p, p, false, p === page));
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(pageBtn(page + 1, '›', page === total_pages, false));
|
||||||
|
pages.push(pageBtn(total_pages, '»', page === total_pages, false));
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div id="pagination-top" style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-sm) var(--space-xl);flex-wrap:wrap;gap:var(--space-sm);">
|
||||||
|
<span style="font-size:0.8rem;color:var(--text-muted);">Record ${from}–${to} di ${total}</span>
|
||||||
|
<div style="display:flex;gap:4px;flex-wrap:wrap;">${pages.join('')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_bind() {
|
||||||
|
// Filter apply
|
||||||
|
document.getElementById('btn-apply-filters')?.addEventListener('click', () => {
|
||||||
|
this.filters.esito = document.getElementById('filter-esito')?.value || '';
|
||||||
|
this.filters.da = document.getElementById('filter-da')?.value || '';
|
||||||
|
this.filters.a = document.getElementById('filter-a')?.value || '';
|
||||||
|
this.currentPage = 1;
|
||||||
|
this._draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter reset
|
||||||
|
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
|
||||||
|
this.filters = { esito: '', agente_id: '', da: '', a: '' };
|
||||||
|
this.currentPage = 1;
|
||||||
|
this._draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detail expand/collapse
|
||||||
|
document.querySelectorAll('.act-detail-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const rowId = btn.dataset.id;
|
||||||
|
const detailRow = document.getElementById(`detail-${rowId}`);
|
||||||
|
if (!detailRow) return;
|
||||||
|
const isOpen = detailRow.style.display !== 'none';
|
||||||
|
detailRow.style.display = isOpen ? 'none' : 'table-row';
|
||||||
|
btn.textContent = isOpen ? '≡' : '✕';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination buttons (top and bottom share same .page-btn class)
|
||||||
|
document.querySelectorAll('.page-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const p = parseInt(btn.dataset.page, 10);
|
||||||
|
if (!isNaN(p)) {
|
||||||
|
this.currentPage = p;
|
||||||
|
this._draw();
|
||||||
|
document.getElementById('view-container')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,20 +1,136 @@
|
|||||||
/**
|
/**
|
||||||
* Dashboard View
|
* 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 = {
|
const DashboardView = {
|
||||||
|
chartInstance: null,
|
||||||
|
activeLines: [],
|
||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure lookup tables are loaded in App.lookups
|
||||||
|
await App.ensureLookups();
|
||||||
|
|
||||||
const stats = await App.api('/api/dashboard/stats');
|
const stats = await App.api('/api/dashboard/stats');
|
||||||
|
|
||||||
const maxByState = Math.max(...(stats.by_state || []).map(s => parseInt(s.count)), 1);
|
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 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);
|
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 = `
|
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 -->
|
<!-- Stats Cards -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card accent">
|
<div class="stat-card accent">
|
||||||
@@ -88,7 +204,19 @@ const DashboardView = {
|
|||||||
|
|
||||||
<!-- Recent Tickets -->
|
<!-- Recent Tickets -->
|
||||||
<div class="card">
|
<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 ? `
|
${(stats.recent_tickets || []).length > 0 ? `
|
||||||
<table class="recent-tickets-table">
|
<table class="recent-tickets-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -121,8 +249,211 @@ const DashboardView = {
|
|||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</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
|
// Animate bars after render
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.querySelectorAll('.dist-bar-fill').forEach(bar => {
|
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
|
// Update open ticket count in sidebar badge
|
||||||
const badge = document.getElementById('open-ticket-count');
|
const badge = document.getElementById('open-ticket-count');
|
||||||
if (badge) {
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -665,9 +665,17 @@ const TicketBulkView = {
|
|||||||
const type_id = document.getElementById(`bulk-type-${id}`).value;
|
const type_id = document.getElementById(`bulk-type-${id}`).value;
|
||||||
const ownerId = document.getElementById(`bulk-owner-${id}`).value;
|
const ownerId = document.getElementById(`bulk-owner-${id}`).value;
|
||||||
const responsibleId = document.getElementById(`bulk-responsible-${id}`).value;
|
const responsibleId = document.getElementById(`bulk-responsible-${id}`).value;
|
||||||
const customerId = document.getElementById(`bulk-customer-${id}`).value;
|
let customerId = document.getElementById(`bulk-customer-${id}`).value;
|
||||||
const customerUserId = document.getElementById(`bulk-customer-user-id-${id}`).value;
|
let customerUserId = document.getElementById(`bulk-customer-user-id-${id}`).value;
|
||||||
const customerSearch = document.getElementById(`bulk-customer-search-${id}`).value;
|
const customerSearch = document.getElementById(`bulk-customer-search-${id}`).value.trim();
|
||||||
|
|
||||||
|
if (!customerUserId && customerSearch) {
|
||||||
|
customerUserId = customerSearch;
|
||||||
|
if (!customerId) {
|
||||||
|
customerId = customerSearch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const subject = document.getElementById(`bulk-subject-${id}`).value.trim();
|
const subject = document.getElementById(`bulk-subject-${id}`).value.trim();
|
||||||
const body = document.getElementById(`bulk-body-${id}`).value.trim();
|
const body = document.getElementById(`bulk-body-${id}`).value.trim();
|
||||||
const priority_id = document.getElementById(`bulk-priority-${id}`).value;
|
const priority_id = document.getElementById(`bulk-priority-${id}`).value;
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const TicketCreateView = {
|
|||||||
<label class="form-label">Coda <span class="required">*</span></label>
|
<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="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" />
|
||||||
<input type="hidden" id="create-queue" />
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -235,7 +235,17 @@ const TicketCreateView = {
|
|||||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
['clean']
|
['clean']
|
||||||
]
|
],
|
||||||
|
keyboard: {
|
||||||
|
bindings: {
|
||||||
|
tab: {
|
||||||
|
key: 'Tab',
|
||||||
|
handler: function() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,6 +253,14 @@ const TicketCreateView = {
|
|||||||
if (this.savedState && this.savedState.body) {
|
if (this.savedState && this.savedState.body) {
|
||||||
this.quill.root.innerHTML = this.savedState.body;
|
this.quill.root.innerHTML = this.savedState.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent tab navigation on toolbar items
|
||||||
|
const toolbar = container.querySelector('.ql-toolbar');
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.querySelectorAll('button, select, span[role="button"], input').forEach(el => {
|
||||||
|
el.setAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.quill = null;
|
this.quill = null;
|
||||||
}
|
}
|
||||||
@@ -306,7 +324,6 @@ const TicketCreateView = {
|
|||||||
document.getElementById('create-company-search').value = this.savedState.company_search;
|
document.getElementById('create-company-search').value = this.savedState.company_search;
|
||||||
document.getElementById('create-priority').value = this.savedState.priority_id;
|
document.getElementById('create-priority').value = this.savedState.priority_id;
|
||||||
document.getElementById('create-subject').value = this.savedState.subject;
|
document.getElementById('create-subject').value = this.savedState.subject;
|
||||||
document.getElementById('create-body').value = this.savedState.body;
|
|
||||||
|
|
||||||
if (this.savedState.isAdvancedVisible) {
|
if (this.savedState.isAdvancedVisible) {
|
||||||
advancedOptions.style.display = 'grid';
|
advancedOptions.style.display = 'grid';
|
||||||
@@ -314,7 +331,7 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// 1. Owner & Responsible pre-population with active agent
|
// Active agent pre-population for Owner & Responsible
|
||||||
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||||
if (activeAgent) {
|
if (activeAgent) {
|
||||||
@@ -327,41 +344,6 @@ const TicketCreateView = {
|
|||||||
responsibleIdInput.value = activeAgent.id;
|
responsibleIdInput.value = activeAgent.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Customer User pre-population with first match
|
|
||||||
try {
|
|
||||||
const companies = await App.api('/api/customer-companies/search?q=cliente');
|
|
||||||
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
|
||||||
const defaultCompany = companies[0];
|
|
||||||
customerIdInput.value = defaultCompany.customer_id;
|
|
||||||
companySearchInput.value = defaultCompany.customer_id;
|
|
||||||
|
|
||||||
// Search users for this company
|
|
||||||
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
|
|
||||||
if (users.length > 0 && userSearchInput && customerUserIdInput) {
|
|
||||||
const defaultUser = users[0];
|
|
||||||
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
|
|
||||||
customerUserIdInput.value = defaultUser.login;
|
|
||||||
} else {
|
|
||||||
// Fallback: use company name as customer user ID
|
|
||||||
userSearchInput.value = defaultCompany.name;
|
|
||||||
customerUserIdInput.value = defaultCompany.customer_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error pre-populating defaults:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Queue pre-population
|
|
||||||
try {
|
|
||||||
const queues = await App.api('/api/queues/search?q=');
|
|
||||||
if (queues.length > 0 && queueSearchInput && queueIdInput) {
|
|
||||||
queueSearchInput.value = queues[0].name;
|
|
||||||
queueIdInput.value = queues[0].id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error pre-populating queues:', err);
|
|
||||||
}
|
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,15 +362,16 @@ const TicketCreateView = {
|
|||||||
userSearchInput.addEventListener('input', () => {
|
userSearchInput.addEventListener('input', () => {
|
||||||
clearTimeout(userDebounce);
|
clearTimeout(userDebounce);
|
||||||
const q = userSearchInput.value.trim();
|
const q = userSearchInput.value.trim();
|
||||||
if (q.length < 2) {
|
|
||||||
userSuggestionsDiv.style.display = 'none';
|
|
||||||
customerUserIdInput.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
userDebounce = setTimeout(async () => {
|
userDebounce = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
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) {
|
if (users.length === 0) {
|
||||||
userSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
|
userSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
|
||||||
userSuggestionsDiv.style.display = 'block';
|
userSuggestionsDiv.style.display = 'block';
|
||||||
@@ -396,17 +379,18 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userSuggestionsDiv.innerHTML = users.map(u => `
|
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>
|
<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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
userSuggestionsDiv.style.display = 'block';
|
userSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
// Bind click
|
// Use mousedown instead of click to fire before blur event
|
||||||
userSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
userSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
if (item.dataset.login) {
|
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;
|
userSearchInput.value = item.dataset.name;
|
||||||
customerUserIdInput.value = item.dataset.login;
|
customerUserIdInput.value = item.dataset.login;
|
||||||
userSuggestionsDiv.style.display = 'none';
|
userSuggestionsDiv.style.display = 'none';
|
||||||
@@ -424,7 +408,18 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
userSearchInput.addEventListener('focus', () => {
|
||||||
|
if (!customerUserIdInput.value) {
|
||||||
|
// Only search if no user is selected yet
|
||||||
|
userSearchInput.dispatchEvent(new Event('input'));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
userSearchInput.addEventListener('blur', () => {
|
||||||
|
// Small delay to allow mousedown on item to fire first
|
||||||
|
setTimeout(() => { userSuggestionsDiv.style.display = 'none'; }, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Owner Autocomplete (dynamic backend search)
|
// Owner Autocomplete (dynamic backend search)
|
||||||
let ownerDebounce;
|
let ownerDebounce;
|
||||||
@@ -537,11 +532,14 @@ const TicketCreateView = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
queueSuggestionsDiv.innerHTML = queues.map(q => `
|
queueSuggestionsDiv.innerHTML = queues.map(q => {
|
||||||
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}">
|
const displayName = q.name.replace(/::/g, ' › ');
|
||||||
<strong>${App.escapeHtml(q.name)}</strong>
|
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>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
queueSuggestionsDiv.style.display = 'block';
|
queueSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
// Bind click
|
// Bind click
|
||||||
@@ -589,9 +587,17 @@ const TicketCreateView = {
|
|||||||
const priority_id = document.getElementById('create-priority').value;
|
const priority_id = document.getElementById('create-priority').value;
|
||||||
const type_id = document.getElementById('create-type')?.value;
|
const type_id = document.getElementById('create-type')?.value;
|
||||||
|
|
||||||
const customerId = customerIdInput.value;
|
const userSearchVal = userSearchInput.value.trim();
|
||||||
|
let customerId = customerIdInput.value;
|
||||||
let customerUserId = customerUserIdInput.value;
|
let customerUserId = customerUserIdInput.value;
|
||||||
|
|
||||||
|
if (!customerUserId && userSearchVal) {
|
||||||
|
customerUserId = userSearchVal;
|
||||||
|
if (!customerId) {
|
||||||
|
customerId = userSearchVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ownerId = ownerIdInput.value;
|
const ownerId = ownerIdInput.value;
|
||||||
const responsibleId = responsibleIdInput.value;
|
const responsibleId = responsibleIdInput.value;
|
||||||
|
|
||||||
|
|||||||
+827
-40
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
|||||||
|
/**
|
||||||
|
* ticketGroups.js
|
||||||
|
* SPA View for managing Ticket Groups (mimics master/slave relationships).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TicketGroupsView = {
|
||||||
|
groups: [],
|
||||||
|
selectedGroupId: null,
|
||||||
|
selectedGroupData: null,
|
||||||
|
selectedTicketIds: new Set(), // For bulk actions (complying with no-checkboxes rule)
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="ticket-groups-container" style="display: flex; gap: var(--space-lg); min-height: calc(100vh - 120px); padding: var(--space-lg) 0;">
|
||||||
|
<!-- Left Sidebar: Groups List -->
|
||||||
|
<div class="groups-sidebar card" style="flex: 0 0 320px; display: flex; flex-direction: column; padding: var(--space-md); max-height: calc(100vh - 120px); overflow-y: auto;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 700; color: var(--text-primary);">🗂️ Gruppi Ticket</h3>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-create-group" style="padding: 4px 10px; font-size: 0.8rem;">+ Nuovo</button>
|
||||||
|
</div>
|
||||||
|
<div id="groups-list-container" style="display: flex; flex-direction: column; gap: var(--space-xs); flex: 1;">
|
||||||
|
<div class="spinner-container" style="display: flex; justify-content: center; padding: var(--space-lg);">
|
||||||
|
<div class="spinner" style="width: 24px; height: 24px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Content: Group Details -->
|
||||||
|
<div class="group-details-pane card" id="group-details-pane" style="flex: 1; padding: var(--space-lg); display: flex; flex-direction: column; max-height: calc(100vh - 120px); overflow-y: auto;">
|
||||||
|
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
|
||||||
|
<h4>Nessun gruppo selezionato</h4>
|
||||||
|
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Group Modal -->
|
||||||
|
<div id="group-modal" style="display: none; position: fixed; inset: 0; z-index: 8000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); align-items: center; justify-content: center;">
|
||||||
|
<div class="card" style="width: min(480px, 94vw); max-height: 90vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); padding: 0;">
|
||||||
|
<div style="padding: 16px 20px; border-bottom: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h4 id="group-modal-title" style="margin: 0; font-size: 1.1rem; font-weight: 600;">Nuovo Gruppo</h4>
|
||||||
|
<button id="group-modal-close" style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--text-muted);">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px; display: flex; flex-direction: column; gap: var(--space-md); overflow-y: auto;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Nome Gruppo</label>
|
||||||
|
<input type="text" id="group-name-input" class="form-input" placeholder="es. Disservizio Mail Server" style="margin-top: 4px;" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">Descrizione</label>
|
||||||
|
<textarea id="group-desc-input" class="form-input" placeholder="Breve descrizione o scopo di questo gruppo..." style="margin-top: 4px; min-height: 80px; resize: vertical;"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary);">ID Ticket Master (Opzionale)</label>
|
||||||
|
<input type="text" id="group-master-input" class="form-input" placeholder="ID o Numero ticket (es. 12345)" style="margin-top: 4px;" />
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-muted);">Il ticket master funge da riferimento principale.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 12px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: var(--space-sm);">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-group-modal-cancel">Annulla</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-group-modal-save">Salva</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bind event listeners
|
||||||
|
document.getElementById('btn-create-group').addEventListener('click', () => this.openGroupModal());
|
||||||
|
document.getElementById('group-modal-close').addEventListener('click', () => this.closeGroupModal());
|
||||||
|
document.getElementById('btn-group-modal-cancel').addEventListener('click', () => this.closeGroupModal());
|
||||||
|
document.getElementById('btn-group-modal-save').addEventListener('click', () => this.saveGroup());
|
||||||
|
|
||||||
|
// Restore state from localStorage if available
|
||||||
|
const savedGroupId = localStorage.getItem('otrs_selected_group_id');
|
||||||
|
if (savedGroupId) {
|
||||||
|
this.selectedGroupId = parseInt(savedGroupId, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadGroups();
|
||||||
|
|
||||||
|
const prefillMasterTn = sessionStorage.getItem('otrs_create_group_with_master_tn');
|
||||||
|
if (prefillMasterTn) {
|
||||||
|
sessionStorage.removeItem('otrs_create_group_with_master_tn');
|
||||||
|
this.openGroupModal();
|
||||||
|
const masterInput = document.getElementById('group-master-input');
|
||||||
|
if (masterInput) {
|
||||||
|
masterInput.value = prefillMasterTn;
|
||||||
|
}
|
||||||
|
} else if (this.selectedGroupId) {
|
||||||
|
this.selectGroup(this.selectedGroupId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadGroups() {
|
||||||
|
try {
|
||||||
|
this.groups = await App.api('/api/groups');
|
||||||
|
this.renderGroupsList();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nel caricamento dei gruppi: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderGroupsList() {
|
||||||
|
const listContainer = document.getElementById('groups-list-container');
|
||||||
|
if (!listContainer) return;
|
||||||
|
|
||||||
|
if (this.groups.length === 0) {
|
||||||
|
listContainer.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: var(--space-lg); color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Nessun gruppo presente
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listContainer.innerHTML = this.groups.map(group => {
|
||||||
|
const isActive = group.id === this.selectedGroupId;
|
||||||
|
return `
|
||||||
|
<div class="group-item" data-id="${group.id}" style="
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: ${isActive ? 'var(--bg-tertiary)' : 'transparent'};
|
||||||
|
border: 1px solid ${isActive ? 'var(--accent-primary)' : 'transparent'};
|
||||||
|
">
|
||||||
|
<div style="font-weight: 600; font-size: 0.9rem; color: var(--text-primary); margin-bottom: 2px;">
|
||||||
|
${App.escapeHtml(group.nome)}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin-bottom: 4px;">
|
||||||
|
${App.escapeHtml(group.descrizione || 'Nessuna descrizione')}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; color: var(--text-muted);">
|
||||||
|
<span>Master: ${group.master_ticket_id ? '#' + group.master_ticket_id : 'Nessuno'}</span>
|
||||||
|
<span style="background: var(--bg-secondary); padding: 1px 6px; border-radius: 10px;">${group.member_count} ticket</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Bind click events to items
|
||||||
|
listContainer.querySelectorAll('.group-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const id = parseInt(item.dataset.id, 10);
|
||||||
|
this.selectGroup(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectGroup(id) {
|
||||||
|
this.selectedGroupId = id;
|
||||||
|
localStorage.setItem('otrs_selected_group_id', id);
|
||||||
|
this.selectedTicketIds.clear();
|
||||||
|
this.renderGroupsList();
|
||||||
|
|
||||||
|
const detailPane = document.getElementById('group-details-pane');
|
||||||
|
detailPane.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: center; align-items: center; flex: 1;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.selectedGroupData = await App.api(`/api/groups/${id}`);
|
||||||
|
this.renderGroupDetails();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nel caricamento del dettaglio gruppo: ' + err.message);
|
||||||
|
detailPane.innerHTML = `
|
||||||
|
<div style="color: var(--danger); text-align: center; padding: var(--space-xl);">
|
||||||
|
Errore nel caricamento dei dati: ${err.message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderGroupDetails() {
|
||||||
|
const detailPane = document.getElementById('group-details-pane');
|
||||||
|
if (!detailPane || !this.selectedGroupData) return;
|
||||||
|
|
||||||
|
const { group, masterTicket, memberTickets } = this.selectedGroupData;
|
||||||
|
|
||||||
|
let masterTicketHtml = '';
|
||||||
|
if (masterTicket) {
|
||||||
|
masterTicketHtml = `
|
||||||
|
<div class="card" style="border: 1px solid var(--accent-primary); background: var(--bg-secondary); padding: var(--space-md);">
|
||||||
|
<div style="display: flex; gap: var(--space-md); align-items: flex-start;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-weight: 700; font-size: 1rem; margin-bottom: var(--space-xs); display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span class="copy-ticket-btn" data-tn="${masterTicket.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" title="Copia numero ticket">📋</span>
|
||||||
|
<a href="#/tickets/${masterTicket.id}" style="color: var(--text-primary); text-decoration: none; border-bottom: 1px dashed var(--text-muted);">
|
||||||
|
#${masterTicket.tn} — ${App.escapeHtml(masterTicket.title)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: var(--space-xs);">
|
||||||
|
<span class="badge badge-state" data-state-type="${(masterTicket.state_type || '').toLowerCase()}">${masterTicket.state_name}</span>
|
||||||
|
<span class="badge" style="background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem;">Coda: ${masterTicket.queue_name}</span>
|
||||||
|
<span style="font-size: 0.78rem; color: var(--text-muted);">Proprietario: ${masterTicket.owner_first ? `${masterTicket.owner_first} ${masterTicket.owner_last}` : masterTicket.owner_login}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-xs); flex-shrink: 0;">
|
||||||
|
<span style="font-size: 0.7rem; font-weight: 700; color: var(--accent-primary); text-transform: uppercase; border: 1px solid var(--accent-primary); padding: 2px 6px; border-radius: var(--radius-sm); white-space: nowrap;">👑 Master Ticket</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-unlink-master" style="color: var(--danger); font-size: 0.8rem; height: 32px; padding: 4px 8px;" title="Rimuovi ruolo master">Scollega Master</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
masterTicketHtml = `
|
||||||
|
<div style="border: 1px dashed var(--border-subtle); padding: var(--space-md); border-radius: var(--radius-md); text-align: center; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Nessun master ticket assegnato. Imposta un ticket come master usando il tasto Modifica Gruppo o associa un ID/Numero valido.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let membersListHtml = '';
|
||||||
|
if (memberTickets.length === 0) {
|
||||||
|
membersListHtml = `
|
||||||
|
<div style="text-align: center; padding: var(--space-xl); border: 1px dashed var(--border-subtle); border-radius: var(--radius-md); color: var(--text-muted); font-size: 0.88rem;">
|
||||||
|
Nessun ticket slave/membro associato a questo gruppo.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
membersListHtml = `
|
||||||
|
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
||||||
|
${memberTickets.map(t => {
|
||||||
|
const isSelected = this.selectedTicketIds.has(t.id);
|
||||||
|
return `
|
||||||
|
<div class="member-ticket-card" data-ticket-id="${t.id}" style="
|
||||||
|
border: 1px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-subtle)'};
|
||||||
|
background: ${isSelected ? 'var(--bg-tertiary)' : 'var(--bg-card)'};
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
">
|
||||||
|
<div style="flex: 1;" onclick="event.stopPropagation();">
|
||||||
|
<div style="font-weight: 600; font-size: 0.9rem; margin-bottom: 2px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.85rem; display: inline-flex; align-items: center;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
|
||||||
|
<a href="#/tickets/${t.id}" style="color: var(--text-primary); text-decoration: none;">
|
||||||
|
#${t.tn} — ${App.escapeHtml(t.title)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--space-sm); align-items: center; flex-wrap: wrap; margin-top: 4px; font-size: 0.75rem;">
|
||||||
|
<span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}" style="font-size: 0.7rem; padding: 2px 6px;">${t.state_name}</span>
|
||||||
|
<span style="color: var(--text-muted);">Coda: ${t.queue_name}</span>
|
||||||
|
<span style="color: var(--text-muted);">Proprietario: ${t.owner_first ? `${t.owner_first} ${t.owner_last}` : t.owner_login}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-remove-member" data-ticket-id="${t.id}" style="color: var(--danger); padding: 4px 8px; font-size: 0.8rem;" onclick="event.stopPropagation();">Rimuovi</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailPane.innerHTML = `
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border-subtle); padding-bottom: var(--space-md); margin-bottom: var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin: 0; font-size: 1.3rem; font-weight: 700; color: var(--text-primary);">${App.escapeHtml(group.nome)}</h2>
|
||||||
|
<p style="margin: var(--space-xs) 0 0; font-size: 0.88rem; color: var(--text-secondary);">${App.escapeHtml(group.descrizione || 'Nessuna descrizione per questo gruppo.')}</p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--space-sm);">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-edit-group" style="padding: 6px 12px; font-size: 0.82rem;">Modifica</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-delete-group" style="padding: 6px 12px; font-size: 0.82rem; color: var(--danger);">Elimina Gruppo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Master Ticket Section -->
|
||||||
|
<div style="margin-bottom: var(--space-lg);">
|
||||||
|
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Master</h4>
|
||||||
|
${masterTicketHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Add Section -->
|
||||||
|
<div class="card" style="background: var(--bg-tertiary); padding: var(--space-md); margin-bottom: var(--space-lg);">
|
||||||
|
<h4 style="margin: 0 0 var(--space-xs); font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Associa Nuovo Ticket Slave</h4>
|
||||||
|
<div style="display: flex; gap: var(--space-sm);">
|
||||||
|
<input type="text" id="quick-add-ticket-input" class="form-input" placeholder="Inserisci ID ticket o Numero ticket (es. 202310... o 12345)" style="margin: 0; font-size: 0.88rem; flex: 1;" />
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-quick-add-ticket">Associa Ticket</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slave / Members Section -->
|
||||||
|
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs);">
|
||||||
|
<h4 style="margin: 0; font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase;">Ticket Membri (${memberTickets.length})</h4>
|
||||||
|
${this.selectedTicketIds.size > 0 ? `
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-bulk-copy" style="font-size: 0.8rem; padding: 4px 8px;">Copia selezionati (${this.selectedTicketIds.size})</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-bulk-remove" style="color: var(--danger); font-size: 0.8rem; padding: 4px 8px;">Rimuovi selezionati (${this.selectedTicketIds.size})</button>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-muted);">Clicca sulle schede per selezionare in blocco</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; overflow-y: auto;">
|
||||||
|
${membersListHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bind actions
|
||||||
|
document.getElementById('btn-edit-group').addEventListener('click', () => this.openGroupModal(group));
|
||||||
|
document.getElementById('btn-delete-group').addEventListener('click', () => this.deleteGroup(group.id));
|
||||||
|
|
||||||
|
if (masterTicket) {
|
||||||
|
document.getElementById('btn-unlink-master').addEventListener('click', () => this.unlinkMaster());
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-quick-add-ticket').addEventListener('click', () => this.addTicketToGroup());
|
||||||
|
document.getElementById('quick-add-ticket-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') this.addTicketToGroup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Individual removal buttons
|
||||||
|
detailPane.querySelectorAll('.btn-remove-member').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const ticketId = parseInt(btn.dataset.ticketId, 10);
|
||||||
|
this.removeTicketFromGroup(ticketId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multi-select cards logic (No-checkboxes rule)
|
||||||
|
detailPane.querySelectorAll('.member-ticket-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const ticketId = parseInt(card.dataset.ticketId, 10);
|
||||||
|
if (this.selectedTicketIds.has(ticketId)) {
|
||||||
|
this.selectedTicketIds.delete(ticketId);
|
||||||
|
} else {
|
||||||
|
this.selectedTicketIds.add(ticketId);
|
||||||
|
}
|
||||||
|
this.renderGroupDetails();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk actions
|
||||||
|
const btnBulkRemove = document.getElementById('btn-bulk-remove');
|
||||||
|
if (btnBulkRemove) {
|
||||||
|
btnBulkRemove.addEventListener('click', () => this.bulkRemoveTickets());
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnBulkCopy = document.getElementById('btn-bulk-copy');
|
||||||
|
if (btnBulkCopy) {
|
||||||
|
btnBulkCopy.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const selectedTns = [];
|
||||||
|
this.selectedTicketIds.forEach(id => {
|
||||||
|
const ticket = memberTickets.find(t => t.id === id);
|
||||||
|
if (ticket && ticket.tn) {
|
||||||
|
selectedTns.push(ticket.tn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (selectedTns.length > 0) {
|
||||||
|
const textToCopy = selectedTns.join('\n');
|
||||||
|
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||||
|
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
|
||||||
|
}).catch(err => {
|
||||||
|
Toast.error('Errore durante la copia: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual copy buttons
|
||||||
|
detailPane.querySelectorAll('.copy-ticket-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const tn = btn.dataset.tn;
|
||||||
|
if (tn) {
|
||||||
|
navigator.clipboard.writeText(tn).then(() => {
|
||||||
|
Toast.success(`Numero ticket ${tn} copiato!`);
|
||||||
|
}).catch(err => {
|
||||||
|
Toast.error('Errore durante la copia: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openGroupModal(group = null) {
|
||||||
|
const modal = document.getElementById('group-modal');
|
||||||
|
const title = document.getElementById('group-modal-title');
|
||||||
|
const nameInput = document.getElementById('group-name-input');
|
||||||
|
const descInput = document.getElementById('group-desc-input');
|
||||||
|
const masterInput = document.getElementById('group-master-input');
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
title.textContent = 'Modifica Gruppo';
|
||||||
|
nameInput.value = group.nome || '';
|
||||||
|
descInput.value = group.descrizione || '';
|
||||||
|
masterInput.value = group.master_ticket_id || '';
|
||||||
|
modal.dataset.editId = group.id;
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Nuovo Gruppo';
|
||||||
|
nameInput.value = '';
|
||||||
|
descInput.value = '';
|
||||||
|
masterInput.value = '';
|
||||||
|
delete modal.dataset.editId;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
nameInput.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
closeGroupModal() {
|
||||||
|
document.getElementById('group-modal').style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveGroup() {
|
||||||
|
const modal = document.getElementById('group-modal');
|
||||||
|
const editId = modal.dataset.editId;
|
||||||
|
|
||||||
|
const nome = document.getElementById('group-name-input').value.trim();
|
||||||
|
const descrizione = document.getElementById('group-desc-input').value.trim();
|
||||||
|
const masterVal = document.getElementById('group-master-input').value.trim();
|
||||||
|
|
||||||
|
if (!nome) {
|
||||||
|
Toast.error('Il nome del gruppo è richiesto.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let masterTicketId = null;
|
||||||
|
if (masterVal) {
|
||||||
|
// Validate master ticket input (ID or Number)
|
||||||
|
try {
|
||||||
|
const res = await App.api(`/api/tickets?search=${masterVal}&per_page=1`);
|
||||||
|
if (res && res.tickets && res.tickets.length > 0) {
|
||||||
|
masterTicketId = res.tickets[0].id;
|
||||||
|
} else {
|
||||||
|
// Check if it's a direct database ID by querying search directly
|
||||||
|
if (/^\d+$/.test(masterVal)) {
|
||||||
|
masterTicketId = parseInt(masterVal, 10);
|
||||||
|
} else {
|
||||||
|
Toast.error('Ticket master non trovato.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (/^\d+$/.test(masterVal)) {
|
||||||
|
masterTicketId = parseInt(masterVal, 10);
|
||||||
|
} else {
|
||||||
|
Toast.error('Errore nella verifica del ticket master: ' + err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { nome, descrizione, master_ticket_id: masterTicketId };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await App.api(`/api/groups/${editId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
Toast.success('Gruppo aggiornato con successo');
|
||||||
|
} else {
|
||||||
|
const newGroup = await App.api('/api/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
this.selectedGroupId = newGroup.id;
|
||||||
|
Toast.success('Gruppo creato con successo');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeGroupModal();
|
||||||
|
await this.loadGroups();
|
||||||
|
if (this.selectedGroupId) {
|
||||||
|
await this.selectGroup(this.selectedGroupId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nel salvataggio: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGroup(id) {
|
||||||
|
const ok = await App.confirm('Elimina Gruppo', 'Sei sicuro di voler eliminare questo gruppo? Le associazioni dei ticket verranno rimosse, ma i ticket non saranno modificati.');
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.api(`/api/groups/${id}`, { method: 'DELETE' });
|
||||||
|
Toast.success('Gruppo eliminato con successo');
|
||||||
|
this.selectedGroupId = null;
|
||||||
|
this.selectedGroupData = null;
|
||||||
|
localStorage.removeItem('otrs_selected_group_id');
|
||||||
|
await this.loadGroups();
|
||||||
|
document.getElementById('group-details-pane').innerHTML = `
|
||||||
|
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
|
||||||
|
<h4>Nessun gruppo selezionato</h4>
|
||||||
|
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nella cancellazione: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async unlinkMaster() {
|
||||||
|
if (!this.selectedGroupData) return;
|
||||||
|
const { group } = this.selectedGroupData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.api(`/api/groups/${group.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
nome: group.nome,
|
||||||
|
descrizione: group.descrizione,
|
||||||
|
master_ticket_id: null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Toast.success('Ticket master scollegato con successo');
|
||||||
|
await this.selectGroup(group.id);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nello scollegamento: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addTicketToGroup() {
|
||||||
|
const input = document.getElementById('quick-add-ticket-input');
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (!val) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.api(`/api/groups/${this.selectedGroupId}/tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ticket_identifier: val })
|
||||||
|
});
|
||||||
|
Toast.success('Ticket associato correttamente');
|
||||||
|
input.value = '';
|
||||||
|
await this.selectGroup(this.selectedGroupId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nell\'associazione: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeTicketFromGroup(ticketId) {
|
||||||
|
try {
|
||||||
|
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
Toast.success('Ticket rimosso dal gruppo');
|
||||||
|
await this.selectGroup(this.selectedGroupId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore nella rimozione: ' + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkRemoveTickets() {
|
||||||
|
if (this.selectedTicketIds.size === 0) return;
|
||||||
|
const ok = await App.confirm('Rimuovi Ticket', `Vuoi rimuovere i ${this.selectedTicketIds.size} ticket selezionati da questo gruppo?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const ticketId of this.selectedTicketIds) {
|
||||||
|
try {
|
||||||
|
await App.api(`/api/groups/${this.selectedGroupId}/tickets/${ticketId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
} catch (err) {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
Toast.success(`${successCount} ticket rimossi con successo.`);
|
||||||
|
}
|
||||||
|
if (failCount > 0) {
|
||||||
|
Toast.error(`Impossibile rimuovere ${failCount} ticket.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedTicketIds.clear();
|
||||||
|
await this.selectGroup(this.selectedGroupId);
|
||||||
|
}
|
||||||
|
};
|
||||||
+450
-32
@@ -13,17 +13,33 @@ const TicketListView = {
|
|||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
|
if (!container.querySelector('.ticket-table-wrapper') && !container.querySelector('.ticket-table')) {
|
||||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch lookups for filter dropdowns
|
// Fetch lookups for filter dropdowns
|
||||||
await App.ensureLookups();
|
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
|
// Build query params
|
||||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||||
|
Filters.currentMode = isMyTickets ? 'my' : 'general';
|
||||||
|
Filters.load(); // Load state for current mode
|
||||||
|
|
||||||
if (isMyTickets) {
|
if (isMyTickets) {
|
||||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
Filters.state.user_id = activeAgentId;
|
Filters.state.user_id = activeAgentId;
|
||||||
|
Filters.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = Filters.toQueryParams();
|
const params = Filters.toQueryParams();
|
||||||
@@ -63,6 +79,7 @@ const TicketListView = {
|
|||||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
<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-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>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Stato</span>
|
<span class="filter-label">Stato</span>
|
||||||
@@ -71,12 +88,11 @@ const TicketListView = {
|
|||||||
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group" style="position:relative;">
|
||||||
<span class="filter-label">Coda</span>
|
<span class="filter-label">Coda</span>
|
||||||
<select class="filter-select" id="batch-queue">
|
<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;" />
|
||||||
<option value="">—</option>
|
<input type="hidden" id="batch-queue" />
|
||||||
${(App.lookups.queues || []).map(q => `<option value="${q.id}">${q.name}</option>`).join('')}
|
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; width: 280px; z-index: 1001;"></div>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Owner</span>
|
<span class="filter-label">Owner</span>
|
||||||
@@ -85,43 +101,79 @@ const TicketListView = {
|
|||||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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-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>
|
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${Filters.renderBar(App.lookups)}
|
${Filters.renderBar(App.lookups)}
|
||||||
|
|
||||||
|
<!-- Pagination Top -->
|
||||||
|
${this.renderPagination(page, per_page, total, total_pages, true)}
|
||||||
|
|
||||||
<!-- Ticket Table -->
|
<!-- Ticket Table -->
|
||||||
<div class="ticket-table-wrapper">
|
<div class="ticket-table-wrapper">
|
||||||
<table class="ticket-table" id="ticket-table">
|
<table class="ticket-table" id="ticket-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
<th style="width: 75px;" class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
||||||
|
<th style="width: 125px;" class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||||||
|
<th style="width: 90px;" class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
||||||
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
||||||
<th class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
<th style="width: 130px;" class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
||||||
<th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
|
<th style="width: 120px;">Owner</th>
|
||||||
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
<th style="width: 140px;">Cliente</th>
|
||||||
<th>Owner</th>
|
<th style="width: 80px;" class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
|
||||||
<th>Cliente</th>
|
|
||||||
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${tickets.length > 0 ? tickets.map(t => `
|
${tickets.length > 0 ? tickets.map(t => {
|
||||||
|
const displayQueue = t.queue_name.includes('::') ? t.queue_name.split('::').pop() : t.queue_name;
|
||||||
|
const shortQueue = displayQueue.length > 15 ? displayQueue.substring(0, 12) + '...' : displayQueue;
|
||||||
|
return `
|
||||||
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
|
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
|
||||||
<td><span class="ticket-tn"><a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a></span></td>
|
<td>
|
||||||
<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>
|
<span class="ticket-tn" style="display:inline-flex; align-items:center;">
|
||||||
|
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
|
||||||
|
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
||||||
|
${data.otrsBaseUrl ? `
|
||||||
|
<a href="${data.otrsBaseUrl}index.pl?Action=AgentTicketZoom;TicketID=${t.id}" target="_blank" title="Apri in OTRS" onclick="event.stopPropagation()" style="display:inline-flex; align-items:center; text-decoration:none;">
|
||||||
|
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; vertical-align:middle; line-height:16px;">O</span>
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
||||||
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
||||||
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
|
<td 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><span class="badge badge-queue">${t.queue_name}</span></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.owner_first || ''} ${t.owner_last || ''}</td>
|
||||||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
||||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('') : `
|
`;
|
||||||
|
}).join('') : `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7">
|
<td colspan="8">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📭</div>
|
<div class="empty-state-icon">📭</div>
|
||||||
<div class="empty-state-text">Nessun ticket trovato</div>
|
<div class="empty-state-text">Nessun ticket trovato</div>
|
||||||
@@ -134,13 +186,34 @@ const TicketListView = {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination Bottom -->
|
||||||
${total_pages > 1 ? `
|
${this.renderPagination(page, per_page, total, total_pages, false)}
|
||||||
<div class="pagination">
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPagination(page, per_page, total, total_pages, isTop) {
|
||||||
|
const marginStyle = isTop ? 'margin-bottom: var(--space-md); margin-top: 0;' : 'margin-top: var(--space-md); margin-bottom: 0;';
|
||||||
|
const limitSelectHtml = `
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary); margin-right:var(--space-md);">
|
||||||
|
<span>Righe:</span>
|
||||||
|
<select class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
|
||||||
|
<option value="10" ${per_page === 10 ? 'selected' : ''}>10</option>
|
||||||
|
<option value="20" ${per_page === 20 ? 'selected' : ''}>20</option>
|
||||||
|
<option value="50" ${per_page === 50 ? 'selected' : ''}>50</option>
|
||||||
|
<option value="100" ${per_page === 100 ? 'selected' : ''}>100</option>
|
||||||
|
<option value="200" ${per_page === 200 ? 'selected' : ''}>200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (total_pages > 1) {
|
||||||
|
return `
|
||||||
|
<div class="pagination" style="${marginStyle}">
|
||||||
<div class="pagination-info">
|
<div class="pagination-info">
|
||||||
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination-controls">
|
<div class="pagination-controls">
|
||||||
|
${limitSelectHtml}
|
||||||
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
|
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
|
||||||
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>‹</button>
|
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>‹</button>
|
||||||
${this.renderPageButtons(page, total_pages)}
|
${this.renderPageButtons(page, total_pages)}
|
||||||
@@ -148,13 +221,17 @@ const TicketListView = {
|
|||||||
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
|
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : `
|
|
||||||
<div class="pagination">
|
|
||||||
<div class="pagination-info">${total} ticket totali</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
} else {
|
||||||
|
return `
|
||||||
|
<div class="pagination" style="${marginStyle}">
|
||||||
|
<div class="pagination-info">${total} ticket totali</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
${limitSelectHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
renderPageButtons(current, total) {
|
renderPageButtons(current, total) {
|
||||||
@@ -234,12 +311,92 @@ const TicketListView = {
|
|||||||
batchApply.addEventListener('click', () => this.applyBatch());
|
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)
|
// Batch merge (Issue #7)
|
||||||
const batchMerge = document.getElementById('batch-merge');
|
const batchMerge = document.getElementById('batch-merge');
|
||||||
if (batchMerge) {
|
if (batchMerge) {
|
||||||
batchMerge.addEventListener('click', () => this.mergeBatch());
|
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
|
// Batch select all visible
|
||||||
const batchSelectAll = document.getElementById('batch-select-all');
|
const batchSelectAll = document.getElementById('batch-select-all');
|
||||||
if (batchSelectAll) {
|
if (batchSelectAll) {
|
||||||
@@ -277,10 +434,140 @@ const TicketListView = {
|
|||||||
tr.classList.remove('selected');
|
tr.classList.remove('selected');
|
||||||
tr.classList.remove('first-selected');
|
tr.classList.remove('first-selected');
|
||||||
});
|
});
|
||||||
|
const batchCustomerSearch = document.getElementById('batch-customer-search');
|
||||||
|
const batchCustomerUserId = document.getElementById('batch-customer-user-id');
|
||||||
|
const batchCustomerId = document.getElementById('batch-customer-id');
|
||||||
|
if (batchCustomerSearch) batchCustomerSearch.value = '';
|
||||||
|
if (batchCustomerUserId) batchCustomerUserId.value = '';
|
||||||
|
if (batchCustomerId) batchCustomerId.value = '';
|
||||||
|
const batchState = document.getElementById('batch-state');
|
||||||
|
if (batchState) batchState.value = '';
|
||||||
|
const batchQueueSearch = document.getElementById('batch-queue-search');
|
||||||
|
if (batchQueueSearch) batchQueueSearch.value = '';
|
||||||
|
const batchQueue = document.getElementById('batch-queue');
|
||||||
|
if (batchQueue) batchQueue.value = '';
|
||||||
|
const batchOwner = document.getElementById('batch-owner');
|
||||||
|
if (batchOwner) batchOwner.value = '';
|
||||||
|
const batchResponsible = document.getElementById('batch-responsible');
|
||||||
|
if (batchResponsible) batchResponsible.value = '';
|
||||||
|
const batchType = document.getElementById('batch-type');
|
||||||
|
if (batchType) batchType.value = '';
|
||||||
|
|
||||||
this.updateBatchBar();
|
this.updateBatchBar();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch Customer User Autocomplete
|
||||||
|
const batchCustomerSearchInput = document.getElementById('batch-customer-search');
|
||||||
|
const batchCustomerSuggestionsDiv = document.getElementById('batch-customer-suggestions');
|
||||||
|
const batchCustomerUserIdInput = document.getElementById('batch-customer-user-id');
|
||||||
|
const batchCustomerIdInput = document.getElementById('batch-customer-id');
|
||||||
|
|
||||||
|
let batchCustomerDebounce;
|
||||||
|
if (batchCustomerSearchInput) {
|
||||||
|
batchCustomerSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(batchCustomerDebounce);
|
||||||
|
const q = batchCustomerSearchInput.value.trim();
|
||||||
|
// Do not block empty query to allow all results on focus
|
||||||
|
|
||||||
|
batchCustomerDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (users.length === 0) {
|
||||||
|
batchCustomerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
|
||||||
|
batchCustomerSuggestionsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
batchCustomerSuggestionsDiv.innerHTML = users.map(u => `
|
||||||
|
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login)}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
|
||||||
|
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
batchCustomerSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Bind click
|
||||||
|
batchCustomerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
if (item.dataset.login) {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
batchCustomerSearchInput.value = item.dataset.name;
|
||||||
|
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = item.dataset.login;
|
||||||
|
if (batchCustomerIdInput) batchCustomerIdInput.value = item.dataset.customerId || '';
|
||||||
|
batchCustomerSuggestionsDiv.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
batchCustomerSearchInput.addEventListener('focus', () => {
|
||||||
|
batchCustomerSearchInput.value = '';
|
||||||
|
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = '';
|
||||||
|
if (batchCustomerIdInput) batchCustomerIdInput.value = '';
|
||||||
|
batchCustomerSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch Queue Autocomplete
|
||||||
|
const batchQueueSearchInput = document.getElementById('batch-queue-search');
|
||||||
|
const batchQueueSuggestionsDiv = document.getElementById('batch-queue-suggestions');
|
||||||
|
const batchQueueIdInput = document.getElementById('batch-queue');
|
||||||
|
|
||||||
|
let batchQueueDebounce;
|
||||||
|
if (batchQueueSearchInput) {
|
||||||
|
batchQueueSearchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(batchQueueDebounce);
|
||||||
|
const q = batchQueueSearchInput.value.trim();
|
||||||
|
|
||||||
|
batchQueueDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (queues.length === 0) {
|
||||||
|
batchQueueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
batchQueueSuggestionsDiv.innerHTML = queues.map(queue => `
|
||||||
|
<div class="autocomplete-suggestion-item" data-id="${queue.id}" data-name="${App.escapeHtml(queue.name)}">
|
||||||
|
<strong>${App.escapeHtml(queue.name)}</strong>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Bind click
|
||||||
|
batchQueueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
batchQueueSearchInput.value = item.dataset.name;
|
||||||
|
if (batchQueueIdInput) batchQueueIdInput.value = item.dataset.id;
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
batchQueueSearchInput.addEventListener('focus', () => {
|
||||||
|
batchQueueSearchInput.value = '';
|
||||||
|
if (batchQueueIdInput) batchQueueIdInput.value = '';
|
||||||
|
batchQueueSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close suggestions on click outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (batchCustomerSearchInput && e.target !== batchCustomerSearchInput && e.target !== batchCustomerSuggestionsDiv) {
|
||||||
|
batchCustomerSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (batchQueueSearchInput && e.target !== batchQueueSearchInput && e.target !== batchQueueSuggestionsDiv) {
|
||||||
|
batchQueueSuggestionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
document.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
|
document.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -290,6 +577,25 @@ const TicketListView = {
|
|||||||
this.render();
|
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() {
|
updateBatchBar() {
|
||||||
@@ -298,6 +604,12 @@ const TicketListView = {
|
|||||||
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
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
|
// Enable/disable merge button
|
||||||
const mergeBtn = document.getElementById('batch-merge');
|
const mergeBtn = document.getElementById('batch-merge');
|
||||||
if (mergeBtn) {
|
if (mergeBtn) {
|
||||||
@@ -307,6 +619,16 @@ const TicketListView = {
|
|||||||
mergeBtn.disabled = true;
|
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() {
|
async applyBatch() {
|
||||||
@@ -316,10 +638,30 @@ const TicketListView = {
|
|||||||
const batchState = document.getElementById('batch-state')?.value;
|
const batchState = document.getElementById('batch-state')?.value;
|
||||||
const batchQueue = document.getElementById('batch-queue')?.value;
|
const batchQueue = document.getElementById('batch-queue')?.value;
|
||||||
const batchOwner = document.getElementById('batch-owner')?.value;
|
const batchOwner = document.getElementById('batch-owner')?.value;
|
||||||
|
const batchResponsible = document.getElementById('batch-responsible')?.value;
|
||||||
|
const batchType = document.getElementById('batch-type')?.value;
|
||||||
|
const batchCustomerSearch = document.getElementById('batch-customer-search')?.value.trim();
|
||||||
|
let batchCustomerUserId = document.getElementById('batch-customer-user-id')?.value;
|
||||||
|
let batchCustomerId = document.getElementById('batch-customer-id')?.value;
|
||||||
|
|
||||||
|
if (!batchCustomerUserId && batchCustomerSearch) {
|
||||||
|
batchCustomerUserId = batchCustomerSearch;
|
||||||
|
if (!batchCustomerId) {
|
||||||
|
batchCustomerId = batchCustomerSearch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (batchState) updates.ticket_state_id = parseInt(batchState);
|
if (batchState) updates.ticket_state_id = parseInt(batchState);
|
||||||
if (batchQueue) updates.queue_id = parseInt(batchQueue);
|
if (batchQueue) updates.queue_id = parseInt(batchQueue);
|
||||||
|
if (batchType) updates.type_id = parseInt(batchType);
|
||||||
if (batchOwner) updates.user_id = parseInt(batchOwner);
|
if (batchOwner) updates.user_id = parseInt(batchOwner);
|
||||||
|
if (batchResponsible) {
|
||||||
|
updates.responsible_user_id = parseInt(batchResponsible);
|
||||||
|
} else if (batchOwner) {
|
||||||
|
updates.responsible_user_id = parseInt(batchOwner);
|
||||||
|
}
|
||||||
|
if (batchCustomerUserId) updates.customer_user_id = batchCustomerUserId;
|
||||||
|
if (batchCustomerId) updates.customer_id = batchCustomerId;
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
Toast.warning('Seleziona almeno un campo da modificare');
|
Toast.warning('Seleziona almeno un campo da modificare');
|
||||||
@@ -364,7 +706,7 @@ const TicketListView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmed = confirm(`Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`);
|
const confirmed = await App.confirm('Unione Ticket', `Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -385,10 +727,86 @@ const TicketListView = {
|
|||||||
Toast.success(res.message || 'Ticket uniti con successo');
|
Toast.success(res.message || 'Ticket uniti con successo');
|
||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
this.selectedOrder = [];
|
this.selectedOrder = [];
|
||||||
this.render();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.error('Errore durante l\'unione: ' + err.message);
|
Toast.error('Errore durante l\'unione: ' + err.message);
|
||||||
this.updateBatchBar();
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* routes/activity.js
|
||||||
|
* GET /api/attivita — Paginates and filters the local SQLite activity log.
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { db } = require('../activityDb');
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
per_page = 50,
|
||||||
|
agente_id,
|
||||||
|
esito,
|
||||||
|
da,
|
||||||
|
a,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const pageNum = Math.max(1, parseInt(page, 10));
|
||||||
|
const perPageNum = Math.min(200, Math.max(1, parseInt(per_page, 10)));
|
||||||
|
const offset = (pageNum - 1) * perPageNum;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (agente_id) {
|
||||||
|
conditions.push('agente_id = ?');
|
||||||
|
params.push(parseInt(agente_id, 10));
|
||||||
|
}
|
||||||
|
if (esito) {
|
||||||
|
conditions.push('esito = ?');
|
||||||
|
params.push(esito);
|
||||||
|
}
|
||||||
|
if (da) {
|
||||||
|
conditions.push('creato_il >= ?');
|
||||||
|
params.push(da);
|
||||||
|
}
|
||||||
|
if (a) {
|
||||||
|
conditions.push('creato_il <= ?');
|
||||||
|
params.push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const totalRow = db.prepare(`SELECT COUNT(*) AS cnt FROM attivita ${whereClause}`).get(...params);
|
||||||
|
const total = totalRow ? totalRow.cnt : 0;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(`SELECT * FROM attivita ${whereClause} ORDER BY creato_il DESC LIMIT ? OFFSET ?`)
|
||||||
|
.all(...params, perPageNum, offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page: pageNum,
|
||||||
|
per_page: perPageNum,
|
||||||
|
total_pages: Math.ceil(total / perPageNum),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[activity] Error fetching activities:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+470
-2
@@ -1,10 +1,91 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const pool = require('../db');
|
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
|
// GET /api/dashboard/stats — Dashboard statistics
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
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 {
|
try {
|
||||||
// All queries in parallel for speed
|
// All queries in parallel for speed
|
||||||
const [
|
const [
|
||||||
@@ -69,7 +150,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')`
|
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')`
|
||||||
),
|
),
|
||||||
// 10 most recent tickets
|
// Custom most recent tickets based on agent settings
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT t.id, t.tn, t.title, ts.name AS state_name,
|
`SELECT t.id, t.tn, t.title, ts.name AS state_name,
|
||||||
tp.name AS priority_name, tp.color AS priority_color,
|
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 ticket_priority tp ON t.ticket_priority_id = tp.id
|
||||||
JOIN queue q ON t.queue_id = q.id
|
JOIN queue q ON t.queue_id = q.id
|
||||||
ORDER BY t.create_time DESC
|
ORDER BY t.create_time DESC
|
||||||
LIMIT 10`
|
LIMIT $1`,
|
||||||
|
[previewLimit]
|
||||||
),
|
),
|
||||||
// Escalated tickets
|
// Escalated tickets
|
||||||
pool.query(
|
pool.query(
|
||||||
@@ -109,6 +191,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
recent_tickets: recentTickets.rows,
|
recent_tickets: recentTickets.rows,
|
||||||
escalated: parseInt(escalated.rows[0].count),
|
escalated: parseInt(escalated.rows[0].count),
|
||||||
total_my_open: parseInt(myOpenCount.rows[0].count),
|
total_my_open: parseInt(myOpenCount.rows[0].count),
|
||||||
|
preview_limit: previewLimit,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching dashboard stats:', 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;
|
module.exports = router;
|
||||||
|
|||||||
+424
@@ -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;
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
const { db, logAttivita } = require('../activityDb');
|
||||||
|
|
||||||
|
// Helper to fetch details of a list of tickets from OTRS DB
|
||||||
|
async function fetchTicketsDetails(ticketIds) {
|
||||||
|
if (!ticketIds || ticketIds.length === 0) return [];
|
||||||
|
try {
|
||||||
|
const placeholders = ticketIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
t.id, t.tn, t.title,
|
||||||
|
t.queue_id, q.name AS queue_name,
|
||||||
|
t.ticket_state_id, ts.name AS state_name, tst.name AS state_type,
|
||||||
|
t.ticket_priority_id, tp.name AS priority_name,
|
||||||
|
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last, u.login AS owner_login
|
||||||
|
FROM ticket t
|
||||||
|
JOIN queue q ON t.queue_id = q.id
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
|
||||||
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id IN (${placeholders})
|
||||||
|
`;
|
||||||
|
const res = await pool.query(query, ticketIds);
|
||||||
|
return res.rows;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching ticket details from OTRS DB:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/groups - List all groups with member counts
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
// Ensure default group exists
|
||||||
|
const defaultGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
|
||||||
|
if (!defaultGroup) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO ticket_groups (nome, descrizione)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`).run('CHIUDI A FINE GIORNATA', 'I ticket in questo gruppo verranno chiusi automaticamente alla consuntivazione di fine giornata');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = db.prepare(`
|
||||||
|
SELECT g.*,
|
||||||
|
(SELECT COUNT(*) FROM ticket_group_members WHERE group_id = g.id) AS member_count
|
||||||
|
FROM ticket_groups g
|
||||||
|
ORDER BY CASE WHEN UPPER(g.nome) = 'CHIUDI A FINE GIORNATA' THEN 1 ELSE 0 END ASC, g.nome ASC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json(groups);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nel recupero dei gruppi', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/groups/by-ticket/:ticket_id - Get groups associated to a specific ticket
|
||||||
|
router.get('/by-ticket/:ticket_id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ticket_id } = req.params;
|
||||||
|
const ticketIdNum = parseInt(ticket_id, 10);
|
||||||
|
if (isNaN(ticketIdNum)) {
|
||||||
|
return res.status(400).json({ error: 'ID ticket non valido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asMaster = db.prepare('SELECT id, nome, descrizione FROM ticket_groups WHERE master_ticket_id = ?').all(ticketIdNum);
|
||||||
|
const asMember = db.prepare(`
|
||||||
|
SELECT g.id, g.nome, g.descrizione
|
||||||
|
FROM ticket_groups g
|
||||||
|
JOIN ticket_group_members m ON g.id = m.group_id
|
||||||
|
WHERE m.ticket_id = ?
|
||||||
|
`).all(ticketIdNum);
|
||||||
|
|
||||||
|
res.json({ asMaster, asMember });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nel recupero dei gruppi del ticket', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/groups - Create a new group
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { nome, descrizione, master_ticket_id } = req.body;
|
||||||
|
if (!nome) {
|
||||||
|
return res.status(400).json({ error: 'Il nome del gruppo è obbligatorio' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterId = master_ticket_id ? parseInt(master_ticket_id, 10) : null;
|
||||||
|
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO ticket_groups (nome, descrizione, master_ticket_id)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(nome, descrizione || '', masterId);
|
||||||
|
|
||||||
|
res.json({ id: info.lastInsertRowid, nome, descrizione, master_ticket_id: masterId });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nella creazione del gruppo', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/groups/:id - Detail of a single group
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const group = db.prepare('SELECT * FROM ticket_groups WHERE id = ?').get(id);
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({ error: 'Gruppo non trovato' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get members list
|
||||||
|
const memberRows = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(id);
|
||||||
|
const memberIds = memberRows.map(r => r.ticket_id);
|
||||||
|
|
||||||
|
// Fetch details of master and member tickets from OTRS
|
||||||
|
let masterTicket = null;
|
||||||
|
if (group.master_ticket_id) {
|
||||||
|
const details = await fetchTicketsDetails([group.master_ticket_id]);
|
||||||
|
if (details.length > 0) {
|
||||||
|
masterTicket = details[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let memberTickets = [];
|
||||||
|
if (memberIds.length > 0) {
|
||||||
|
memberTickets = await fetchTicketsDetails(memberIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
group,
|
||||||
|
masterTicket,
|
||||||
|
memberTickets
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nel recupero dei dettagli del gruppo', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/groups/:id - Update group info
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { nome, descrizione, master_ticket_id } = req.body;
|
||||||
|
if (!nome) {
|
||||||
|
return res.status(400).json({ error: 'Il nome del gruppo è obbligatorio' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterId = master_ticket_id ? parseInt(master_ticket_id, 10) : null;
|
||||||
|
|
||||||
|
const info = db.prepare(`
|
||||||
|
UPDATE ticket_groups
|
||||||
|
SET nome = ?, descrizione = ?, master_ticket_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(nome, descrizione || '', masterId, id);
|
||||||
|
|
||||||
|
if (info.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Gruppo non trovato o nessuna modifica' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ id: parseInt(id, 10), nome, descrizione, master_ticket_id: masterId });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nell\'aggiornamento del gruppo', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/groups/:id - Delete group
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const info = db.prepare('DELETE FROM ticket_groups WHERE id = ?').run(id);
|
||||||
|
if (info.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Gruppo non trovato' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'Gruppo eliminato con successo' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nella cancellazione del gruppo', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/groups/:id/tickets - Add ticket(s) to group
|
||||||
|
router.post('/:id/tickets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { ticket_identifier } = req.body; // Can be ticket ID or ticket number (tn)
|
||||||
|
if (!ticket_identifier) {
|
||||||
|
return res.status(400).json({ error: 'Identificativo ticket obbligatorio' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanIdentifier = String(ticket_identifier).trim();
|
||||||
|
let queryTicketSql;
|
||||||
|
let queryParams;
|
||||||
|
|
||||||
|
if (/^\d+$/.test(cleanIdentifier)) {
|
||||||
|
// It's a number - check if it matches id or tn
|
||||||
|
queryTicketSql = 'SELECT id, tn, title FROM ticket WHERE id = $1 OR tn = $2';
|
||||||
|
queryParams = [parseInt(cleanIdentifier, 10), cleanIdentifier];
|
||||||
|
} else {
|
||||||
|
// Check tn
|
||||||
|
queryTicketSql = 'SELECT id, tn, title FROM ticket WHERE tn = $1';
|
||||||
|
queryParams = [cleanIdentifier];
|
||||||
|
}
|
||||||
|
|
||||||
|
const otrsRes = await pool.query(queryTicketSql, queryParams);
|
||||||
|
if (otrsRes.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: `Ticket con identificativo '${cleanIdentifier}' non trovato` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = otrsRes.rows[0];
|
||||||
|
|
||||||
|
// Check if group exists
|
||||||
|
const group = db.prepare('SELECT id FROM ticket_groups WHERE id = ?').get(id);
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({ error: 'Gruppo non trovato' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert to membership
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO ticket_group_members (group_id, ticket_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`).run(id, ticket.id);
|
||||||
|
} catch (dbErr) {
|
||||||
|
if (dbErr.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
|
||||||
|
return res.status(409).json({ error: 'Il ticket appartiene già a questo gruppo' });
|
||||||
|
}
|
||||||
|
throw dbErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, ticket });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nell\'associazione del ticket', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/groups/:id/tickets/:ticket_id - Remove ticket from group
|
||||||
|
router.delete('/:id/tickets/:ticket_id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, ticket_id } = req.params;
|
||||||
|
const info = db.prepare('DELETE FROM ticket_group_members WHERE group_id = ? AND ticket_id = ?').run(id, ticket_id);
|
||||||
|
if (info.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Associazione non trovata' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'Ticket rimosso dal gruppo' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nella rimozione del ticket', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// POST /api/groups/close-end-of-day - Close all tickets in 'CHIUDI A FINE GIORNATA' group
|
||||||
|
router.post('/close-end-of-day', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
|
||||||
|
// Find default group 'CHIUDI A FINE GIORNATA'
|
||||||
|
const closeGroup = db.prepare('SELECT id FROM ticket_groups WHERE UPPER(nome) = ?').get('CHIUDI A FINE GIORNATA');
|
||||||
|
if (!closeGroup) {
|
||||||
|
return res.status(404).json({ error: 'Gruppo "CHIUDI A FINE GIORNATA" non trovato' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMembers = db.prepare('SELECT ticket_id FROM ticket_group_members WHERE group_id = ?').all(closeGroup.id);
|
||||||
|
if (groupMembers.length === 0) {
|
||||||
|
return res.json({ success: true, count: 0, message: 'Nessun ticket presente nel gruppo "CHIUDI A FINE GIORNATA"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupTicketIds = groupMembers.map(m => m.ticket_id);
|
||||||
|
const placeholders = groupTicketIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
|
||||||
|
// Get open tickets in group (not already in a closed state)
|
||||||
|
const openTicketsQuery = `
|
||||||
|
SELECT t.id
|
||||||
|
FROM ticket t
|
||||||
|
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE t.id IN (${placeholders})
|
||||||
|
AND LOWER(tst.name) NOT LIKE '%closed%'
|
||||||
|
`;
|
||||||
|
const openRes = await pool.query(openTicketsQuery, groupTicketIds);
|
||||||
|
const openTicketIds = openRes.rows.map(r => r.id);
|
||||||
|
|
||||||
|
if (openTicketIds.length === 0) {
|
||||||
|
return res.json({ success: true, count: 0, message: 'Tutti i ticket nel gruppo "CHIUDI A FINE GIORNATA" risultano già chiusi' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve closed state ID (closed successful / closed fallback)
|
||||||
|
const closedStateRes = await pool.query(
|
||||||
|
`SELECT id FROM ticket_state WHERE name = 'closed successful' OR name = 'chiuso con successo' LIMIT 1`
|
||||||
|
);
|
||||||
|
let stateId;
|
||||||
|
if (closedStateRes.rows.length > 0) {
|
||||||
|
stateId = closedStateRes.rows[0].id;
|
||||||
|
} else {
|
||||||
|
const fallbackRes = await pool.query(
|
||||||
|
`SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'closed' LIMIT 1`
|
||||||
|
);
|
||||||
|
stateId = fallbackRes.rows[0]?.id || 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlaceholders = openTicketIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE ticket
|
||||||
|
SET ticket_state_id = $${openTicketIds.length + 1},
|
||||||
|
ticket_lock_id = 1,
|
||||||
|
change_time = NOW(),
|
||||||
|
change_by = $${openTicketIds.length + 2}
|
||||||
|
WHERE id IN (${updatePlaceholders})
|
||||||
|
`;
|
||||||
|
await pool.query(updateQuery, [...openTicketIds, stateId, operatorId]);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
titolo_azione: 'Chiusura ticket gruppo CHIUDI A FINE GIORNATA',
|
||||||
|
azione: { closed_count: openTicketIds.length, ticket_ids: openTicketIds },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
count: openTicketIds.length,
|
||||||
|
message: `${openTicketIds.length} ticket del gruppo "CHIUDI A FINE GIORNATA" chius${openTicketIds.length === 1 ? 'o' : 'i'} con successo!`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Errore nella chiusura ticket fine giornata:', err);
|
||||||
|
res.status(500).json({ error: 'Errore nella chiusura ticket', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+417
-11
@@ -1,6 +1,23 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const pool = require('../db');
|
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
|
// Helper for OTRS CE GenericInterface REST API calls
|
||||||
async function otrsRequest(method, path, bodyData = {}) {
|
async function otrsRequest(method, path, bodyData = {}) {
|
||||||
@@ -19,7 +36,7 @@ async function otrsRequest(method, path, bodyData = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -34,11 +51,15 @@ async function otrsRequest(method, path, bodyData = {}) {
|
|||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
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) {
|
} catch (err) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (err.name === 'AbortError') {
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -111,10 +132,26 @@ router.get('/users', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/config — Application config
|
// 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({
|
res.json({
|
||||||
defaultAgentLogin: process.env.OTRS_API_USER || '',
|
defaultAgentLogin: process.env.OTRS_API_USER || '',
|
||||||
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480
|
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480,
|
||||||
|
phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70,
|
||||||
|
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00',
|
||||||
|
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
|
||||||
|
agentEmail: agentEmail
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,10 +205,46 @@ router.get('/lock-types', async (req, res) => {
|
|||||||
// GET /api/customer-companies/search — Search customer companies
|
// GET /api/customer-companies/search — Search customer companies
|
||||||
router.get('/customer-companies/search', async (req, res) => {
|
router.get('/customer-companies/search', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q } = req.query;
|
const { q = '' } = req.query;
|
||||||
|
|
||||||
|
// 1. Fetch from local SQLite LDAP cache
|
||||||
|
let localRows = [];
|
||||||
|
try {
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return res.json([]);
|
localRows = db.prepare(`
|
||||||
|
SELECT DISTINCT customer_id AS customer_id, customer_id AS name
|
||||||
|
FROM customer_user_cache
|
||||||
|
WHERE customer_id IS NOT NULL AND customer_id != ''
|
||||||
|
ORDER BY customer_id
|
||||||
|
LIMIT 500
|
||||||
|
`).all();
|
||||||
|
} else {
|
||||||
|
const searchTerm = `%${q}%`;
|
||||||
|
localRows = db.prepare(`
|
||||||
|
SELECT DISTINCT customer_id AS customer_id, customer_id AS name
|
||||||
|
FROM customer_user_cache
|
||||||
|
WHERE customer_id IS NOT NULL AND customer_id != '' AND customer_id LIKE ?
|
||||||
|
ORDER BY customer_id
|
||||||
|
LIMIT 500
|
||||||
|
`).all(searchTerm);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to query local customer cache:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch from OTRS Postgres DB
|
||||||
|
let dbRows = [];
|
||||||
|
try {
|
||||||
|
if (!q) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT customer_id, name
|
||||||
|
FROM customer_company
|
||||||
|
WHERE valid_id = 1
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 500`
|
||||||
|
);
|
||||||
|
dbRows = result.rows;
|
||||||
|
} else {
|
||||||
const searchTerm = `%${q}%`;
|
const searchTerm = `%${q}%`;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT customer_id, name
|
`SELECT customer_id, name
|
||||||
@@ -181,21 +254,228 @@ router.get('/customer-companies/search', async (req, res) => {
|
|||||||
name ILIKE $1
|
name ILIKE $1
|
||||||
)
|
)
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
LIMIT 20`,
|
LIMIT 500`,
|
||||||
[searchTerm]
|
[searchTerm]
|
||||||
);
|
);
|
||||||
res.json(result.rows);
|
dbRows = result.rows;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to query OTRS customer_company table:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Merge results and remove duplicates by customer_id
|
||||||
|
const seen = new Set();
|
||||||
|
const merged = [];
|
||||||
|
|
||||||
|
// Prioritize OTRS database rows (which might have better names)
|
||||||
|
for (const row of dbRows) {
|
||||||
|
const cid = String(row.customer_id).trim();
|
||||||
|
if (cid && !seen.has(cid.toLowerCase())) {
|
||||||
|
seen.add(cid.toLowerCase());
|
||||||
|
merged.push({ customer_id: cid, name: row.name || cid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add local LDAP rows
|
||||||
|
for (const row of localRows) {
|
||||||
|
const cid = String(row.customer_id).trim();
|
||||||
|
if (cid && !seen.has(cid.toLowerCase())) {
|
||||||
|
seen.add(cid.toLowerCase());
|
||||||
|
merged.push({ customer_id: cid, name: row.name || cid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by name
|
||||||
|
merged.sort((a, b) => a.name.localeCompare(b.name, 'it', { sensitivity: 'base' }));
|
||||||
|
|
||||||
|
res.json(merged.slice(0, 500));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error searching customer companies:', err);
|
console.error('Error searching customer companies:', err);
|
||||||
res.status(500).json({ error: err.message });
|
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) => {
|
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 queryText;
|
||||||
let queryParams;
|
let queryParams;
|
||||||
if (q) {
|
if (q) {
|
||||||
@@ -302,4 +582,130 @@ router.get('/states/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to sync customer users from LDAP (OTRS API) into SQLite cache
|
||||||
|
async function syncCustomerUsers() {
|
||||||
|
if (!process.env.OTRS_API_URL || !process.env.OTRS_API_USER) {
|
||||||
|
console.log('[LDAP Sync] OTRS API not configured, skipping customer user sync.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[LDAP Sync] Starting customer user synchronization...');
|
||||||
|
try {
|
||||||
|
const searchResult = await otrsRequest('POST', '/CustomerUserSearch', { Search: '*', Valid: 1 });
|
||||||
|
const searchData = (searchResult && searchResult.Data) || searchResult;
|
||||||
|
if (!searchData || !searchData.CustomerUserID) {
|
||||||
|
console.log('[LDAP Sync] No customer users found to sync.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let logins = searchData.CustomerUserID;
|
||||||
|
if (!Array.isArray(logins)) {
|
||||||
|
logins = [logins];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[LDAP Sync] Found ${logins.length} customer users. Fetching details...`);
|
||||||
|
|
||||||
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// Prepare SQLite insert statement with upsert logic
|
||||||
|
const upsertStmt = db.prepare(`
|
||||||
|
INSERT INTO customer_user_cache (login, email, first_name, last_name, customer_id, phone)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(login) DO UPDATE SET
|
||||||
|
email=excluded.email,
|
||||||
|
first_name=excluded.first_name,
|
||||||
|
last_name=excluded.last_name,
|
||||||
|
customer_id=excluded.customer_id,
|
||||||
|
phone=excluded.phone
|
||||||
|
`);
|
||||||
|
|
||||||
|
let syncCount = 0;
|
||||||
|
// Fetch details in batches of 3 to avoid overloading the OTRS CGI server
|
||||||
|
const batchSize = 3;
|
||||||
|
for (let i = 0; i < logins.length; i += batchSize) {
|
||||||
|
const batchLogins = logins.slice(i, i + batchSize);
|
||||||
|
await Promise.all(batchLogins.map(async (login) => {
|
||||||
|
let retries = 2;
|
||||||
|
while (retries >= 0) {
|
||||||
|
try {
|
||||||
|
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
|
||||||
|
const getData = (getResult && getResult.Data) || getResult;
|
||||||
|
if (getData && getData.CustomerUser) {
|
||||||
|
const u = getData.CustomerUser;
|
||||||
|
|
||||||
|
// Run insertion in SQLite cache table
|
||||||
|
upsertStmt.run(
|
||||||
|
u.UserLogin,
|
||||||
|
u.UserEmail || '',
|
||||||
|
u.UserFirstname || '',
|
||||||
|
u.UserLastname || '',
|
||||||
|
u.UserCustomerID || '',
|
||||||
|
u.UserPhone || ''
|
||||||
|
);
|
||||||
|
syncCount++;
|
||||||
|
}
|
||||||
|
break; // Success, break retry loop
|
||||||
|
} catch (getErr) {
|
||||||
|
if (retries === 0) {
|
||||||
|
console.warn(`[LDAP Sync] Failed to sync customer user ${login} after retries:`, getErr.message);
|
||||||
|
} else {
|
||||||
|
await delay(200); // Wait 200ms before retrying
|
||||||
|
}
|
||||||
|
retries--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
// Add a small delay between batches
|
||||||
|
await delay(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last sync timestamp in SQLite sync_status table
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO sync_status (key, val)
|
||||||
|
VALUES ('last_ldap_sync_time', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET val=excluded.val
|
||||||
|
`).run(new Date().toISOString());
|
||||||
|
|
||||||
|
console.log(`[LDAP Sync] Completed. Synced ${syncCount} users to SQLite customer_user_cache.`);
|
||||||
|
return syncCount;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LDAP Sync] Error during customer user sync:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/customer-users/sync — Sync customer users from LDAP
|
||||||
|
router.post('/customer-users/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const count = await syncCustomerUsers();
|
||||||
|
res.json({ message: `Sincronizzazione completata! ${count} utenti sincronizzati.`, count });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[LDAP Sync] Sincronizzazione non riuscita:', err.message);
|
||||||
|
res.json({ message: `Sincronizzazione LDAP ignorata o non disponibile: ${err.message}`, count: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run LDAP synchronization on startup if last sync was > LDAP_SYNC_INTERVAL_HOURS ago
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare("SELECT val FROM sync_status WHERE key = 'last_ldap_sync_time'").get();
|
||||||
|
let shouldSync = true;
|
||||||
|
if (row && row.val) {
|
||||||
|
const lastSync = new Date(row.val);
|
||||||
|
const intervalHours = parseFloat(process.env.LDAP_SYNC_INTERVAL_HOURS) || 24;
|
||||||
|
const thresholdTime = new Date(Date.now() - intervalHours * 60 * 60 * 1000);
|
||||||
|
if (lastSync > thresholdTime) {
|
||||||
|
shouldSync = false;
|
||||||
|
console.log(`[LDAP Sync] Last sync was on ${lastSync.toLocaleString()} (Threshold: ${intervalHours}h). Skipping auto-sync at startup.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSync) {
|
||||||
|
syncCustomerUsers().catch(err => console.error('Startup LDAP sync failed:', err));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LDAP Sync] Failed to check LDAP sync status:', err.message);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { db } = require('../activityDb');
|
||||||
|
|
||||||
|
// GET /api/presets - Get all presets for the active agent and page mode
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { page_mode } = req.query; // 'general' or 'my'
|
||||||
|
|
||||||
|
if (!page_mode) {
|
||||||
|
return res.status(400).json({ error: 'Il parametro page_mode è obbligatorio' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = db.prepare(`
|
||||||
|
SELECT * FROM filter_presets
|
||||||
|
WHERE agent_id = ? AND page_mode = ?
|
||||||
|
ORDER BY name ASC
|
||||||
|
`).all(agentId, page_mode);
|
||||||
|
|
||||||
|
res.json(presets);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nel caricamento dei preset', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/presets - Save a new filter preset
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { name, page_mode, filters } = req.body;
|
||||||
|
|
||||||
|
if (!name || !page_mode || !filters) {
|
||||||
|
return res.status(400).json({ error: 'I campi name, page_mode e filters sono obbligatori' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtersJson = typeof filters === 'string' ? filters : JSON.stringify(filters);
|
||||||
|
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO filter_presets (agent_id, name, page_mode, filters_json)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(agentId, name, page_mode, filtersJson);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: info.lastInsertRowid,
|
||||||
|
agent_id: agentId,
|
||||||
|
name,
|
||||||
|
page_mode,
|
||||||
|
filters_json: filtersJson
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nel salvataggio del preset', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/presets/:id - Delete a preset
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const info = db.prepare('DELETE FROM filter_presets WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
if (info.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Preset non trovato' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Preset eliminato con successo' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Errore nella rimozione del preset', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+1178
-77
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
require('dotenv').config();
|
const path = require('path');
|
||||||
|
const baseDir = process.pkg ? path.dirname(process.execPath) : __dirname;
|
||||||
|
require('dotenv').config({ path: path.resolve(baseDir, '.env') });
|
||||||
|
require('dotenv').config({ path: path.resolve(__dirname, '.env') });
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ticketsRouter = require('./routes/tickets');
|
const ticketsRouter = require('./routes/tickets');
|
||||||
const lookupsRouter = require('./routes/lookups');
|
const lookupsRouter = require('./routes/lookups');
|
||||||
const dashboardRouter = require('./routes/dashboard');
|
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 app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -21,6 +27,10 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|||||||
app.use('/api/tickets', ticketsRouter);
|
app.use('/api/tickets', ticketsRouter);
|
||||||
app.use('/api', lookupsRouter);
|
app.use('/api', lookupsRouter);
|
||||||
app.use('/api/dashboard', dashboardRouter);
|
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
|
// SPA fallback — serve index.html for all non-API routes
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
|||||||
Binary file not shown.
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user