feat: contatore tempo allocato, pagina miei ticket, unione ticket (da db)

This commit is contained in:
2026-07-07 00:18:22 +02:00
parent e55cccbaa5
commit 0f012816ea
15 changed files with 1133 additions and 185 deletions
+6
View File
@@ -0,0 +1,6 @@
---
trigger: always_on
glob:
description:
---
+7
View File
@@ -17,3 +17,10 @@ PORT=3000
OTRS_API_USER=root@localhost OTRS_API_USER=root@localhost
OTRS_API_PASSWORD=your_password_here OTRS_API_PASSWORD=your_password_here
OTRS_API_URL=http://your_otrs_host_or_ip/otrs/nph-genericinterface.pl/Webservice/GenericTicketConnectorREST OTRS_API_URL=http://your_otrs_host_or_ip/otrs/nph-genericinterface.pl/Webservice/GenericTicketConnectorREST
#Variabile per il Tempo di consultivazione giornaliero
DAILY_TARGET_TIME=480
#Variabile per la numerazione dei ticket
OTRS_SYSTEM_ID=10
OTRS_COUNTER_PADDING=6
+356 -43
View File
@@ -5,59 +5,59 @@
/* ---- CSS Variables / Design Tokens ---- */ /* ---- CSS Variables / Design Tokens ---- */
:root { :root {
/* Base colors */ /* Base colors - Light Theme Default */
--bg-primary: #0b0e14; --bg-primary: #f8fafc;
--bg-secondary: #111827; --bg-secondary: #ffffff;
--bg-tertiary: #1a2234; --bg-tertiary: #f1f5f9;
--bg-card: rgba(17, 24, 39, 0.7); --bg-card: #ffffff;
--bg-card-hover: rgba(26, 34, 52, 0.85); --bg-card-hover: #f8fafc;
--bg-glass: rgba(17, 24, 39, 0.55); --bg-glass: rgba(255, 255, 255, 0.8);
/* Accent */ /* Accent */
--accent-primary: #6366f1; --accent-primary: #4f46e5;
--accent-primary-hover: #818cf8; --accent-primary-hover: #4338ca;
--accent-primary-glow: rgba(99, 102, 241, 0.3); --accent-primary-glow: rgba(79, 70, 229, 0.15);
--accent-secondary: #8b5cf6; --accent-secondary: #7c3aed;
/* Text */ /* Text */
--text-primary: #f1f5f9; --text-primary: #0f172a;
--text-secondary: #94a3b8; --text-secondary: #475569;
--text-tertiary: #64748b; --text-tertiary: #64748b;
--text-muted: #475569; --text-muted: #94a3b8;
/* Borders */ /* Borders */
--border-subtle: rgba(148, 163, 184, 0.08); --border-subtle: rgba(15, 23, 42, 0.05);
--border-light: rgba(148, 163, 184, 0.15); --border-light: rgba(15, 23, 42, 0.1);
--border-accent: rgba(99, 102, 241, 0.3); --border-accent: rgba(79, 70, 229, 0.2);
/* Priority colors */ /* Priority colors */
--priority-1-bg: rgba(30, 58, 95, 0.5); --priority-1-bg: #eff6ff;
--priority-1-text: #60a5fa; --priority-1-text: #1d4ed8;
--priority-2-bg: rgba(30, 70, 50, 0.5); --priority-2-bg: #f0fdf4;
--priority-2-text: #4ade80; --priority-2-text: #15803d;
--priority-3-bg: rgba(80, 65, 20, 0.5); --priority-3-bg: #fef9c3;
--priority-3-text: #facc15; --priority-3-text: #854d0e;
--priority-4-bg: rgba(100, 50, 15, 0.5); --priority-4-bg: #ffedd5;
--priority-4-text: #fb923c; --priority-4-text: #c2410c;
--priority-5-bg: rgba(100, 20, 20, 0.5); --priority-5-bg: #fef2f2;
--priority-5-text: #f87171; --priority-5-text: #b91c1c;
/* State colors */ /* State colors */
--state-new: #38bdf8; --state-new: #0284c7;
--state-open: #6366f1; --state-open: #4f46e5;
--state-pending: #f59e0b; --state-pending: #d97706;
--state-closed: #64748b; --state-closed: #475569;
--state-removed: #ef4444; --state-removed: #dc2626;
/* Semantic */ /* Semantic */
--success: #22c55e; --success: #16a34a;
--success-bg: rgba(34, 197, 94, 0.12); --success-bg: rgba(22, 163, 74, 0.1);
--warning: #f59e0b; --warning: #d97706;
--warning-bg: rgba(245, 158, 11, 0.12); --warning-bg: rgba(217, 119, 6, 0.1);
--error: #ef4444; --error: #dc2626;
--error-bg: rgba(239, 68, 68, 0.12); --error-bg: rgba(220, 38, 38, 0.1);
--info: #38bdf8; --info: #0284c7;
--info-bg: rgba(56, 189, 248, 0.12); --info-bg: rgba(2, 132, 199, 0.1);
/* Spacing */ /* Spacing */
--space-xs: 4px; --space-xs: 4px;
@@ -75,9 +75,9 @@
--radius-full: 9999px; --radius-full: 9999px;
/* Shadows */ /* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.08);
--shadow-glow: 0 0 20px var(--accent-primary-glow); --shadow-glow: 0 0 20px var(--accent-primary-glow);
/* Layout */ /* Layout */
@@ -90,6 +90,186 @@
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
} }
/* ==========================================
Alternative Dark & Custom Themes
========================================== */
/* Helpers for dark priority variables */
.theme-dark,
.theme-rosso,
.theme-naturale,
.theme-ice,
.theme-autunno,
.theme-fairytale {
--priority-1-bg: rgba(30, 58, 95, 0.5);
--priority-1-text: #60a5fa;
--priority-2-bg: rgba(30, 70, 50, 0.5);
--priority-2-text: #4ade80;
--priority-3-bg: rgba(80, 65, 20, 0.5);
--priority-3-text: #facc15;
--priority-4-bg: rgba(100, 50, 15, 0.5);
--priority-4-text: #fb923c;
--priority-5-bg: rgba(100, 20, 20, 0.5);
--priority-5-text: #f87171;
--state-new: #38bdf8;
--state-open: #6366f1;
--state-pending: #f59e0b;
--state-closed: #64748b;
--state-removed: #ef4444;
--success: #22c55e;
--success-bg: rgba(34, 197, 94, 0.12);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.12);
--error: #ef4444;
--error-bg: rgba(239, 68, 68, 0.12);
--info: #38bdf8;
--info-bg: rgba(56, 189, 248, 0.12);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
}
body.theme-dark {
--bg-primary: #0b0e14;
--bg-secondary: #111827;
--bg-tertiary: #1a2234;
--bg-card: rgba(17, 24, 39, 0.7);
--bg-card-hover: rgba(26, 34, 52, 0.85);
--bg-glass: rgba(17, 24, 39, 0.55);
--accent-primary: #6366f1;
--accent-primary-hover: #818cf8;
--accent-primary-glow: rgba(99, 102, 241, 0.3);
--accent-secondary: #8b5cf6;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--text-muted: #475569;
--border-subtle: rgba(148, 163, 184, 0.08);
--border-light: rgba(148, 163, 184, 0.15);
--border-accent: rgba(99, 102, 241, 0.3);
}
body.theme-rosso {
--bg-primary: #1f0002;
--bg-secondary: #3d0004;
--bg-tertiary: #620007;
--bg-card: rgba(61, 0, 4, 0.7);
--bg-card-hover: rgba(98, 0, 7, 0.85);
--bg-glass: rgba(31, 0, 2, 0.6);
--accent-primary: #fd5d41;
--accent-primary-hover: #ff7e67;
--accent-primary-glow: rgba(253, 93, 65, 0.3);
--accent-secondary: #fec4a1;
--text-primary: #fed8b9;
--text-secondary: #fec4a1;
--text-tertiary: #c48375;
--text-muted: #8d4f45;
--border-subtle: rgba(254, 196, 161, 0.08);
--border-light: rgba(254, 196, 161, 0.15);
--border-accent: rgba(253, 93, 65, 0.3);
}
body.theme-naturale {
--bg-primary: #181007;
--bg-secondary: #271a0b;
--bg-tertiary: #39271b;
--bg-card: rgba(39, 26, 11, 0.7);
--bg-card-hover: rgba(57, 39, 27, 0.85);
--bg-glass: rgba(24, 16, 7, 0.6);
--accent-primary: #a8ae7b;
--accent-primary-hover: #c4ca9a;
--accent-primary-glow: rgba(168, 174, 123, 0.3);
--accent-secondary: #4b57de;
--text-primary: #f3efe9;
--text-secondary: #baa820;
--text-tertiary: #a8ae7b;
--text-muted: #736952;
--border-subtle: rgba(168, 174, 123, 0.08);
--border-light: rgba(168, 174, 123, 0.15);
--border-accent: rgba(168, 174, 123, 0.3);
}
body.theme-ice {
--bg-primary: #0b1822;
--bg-secondary: #162c3d;
--bg-tertiary: #235878;
--bg-card: rgba(22, 44, 61, 0.7);
--bg-card-hover: rgba(35, 88, 120, 0.85);
--bg-glass: rgba(11, 24, 34, 0.6);
--accent-primary: #719ebd;
--accent-primary-hover: #8fb6d2;
--accent-primary-glow: rgba(113, 158, 189, 0.3);
--accent-secondary: #497591;
--text-primary: #f0f6fa;
--text-secondary: #a3c4dc;
--text-tertiary: #80a9c7;
--text-muted: #5e839f;
--border-subtle: rgba(163, 196, 220, 0.08);
--border-light: rgba(163, 196, 220, 0.15);
--border-accent: rgba(113, 158, 189, 0.3);
}
body.theme-autunno {
--bg-primary: #161216;
--bg-secondary: #2d1d0f;
--bg-tertiary: #472e18;
--bg-card: rgba(45, 29, 15, 0.7);
--bg-card-hover: rgba(71, 46, 24, 0.85);
--bg-glass: rgba(22, 18, 22, 0.6);
--accent-primary: #a46a02;
--accent-primary-hover: #c4840b;
--accent-primary-glow: rgba(164, 106, 2, 0.3);
--accent-secondary: #8d5203;
--text-primary: #faf6f0;
--text-secondary: #eed4b3;
--text-tertiary: #cda371;
--text-muted: #a48259;
--border-subtle: rgba(164, 106, 2, 0.15);
--border-light: rgba(164, 106, 2, 0.25);
--border-accent: rgba(164, 106, 2, 0.4);
}
body.theme-fairytale {
--bg-primary: #2a1d28;
--bg-secondary: #443241;
--bg-tertiary: #4f3558;
--bg-card: rgba(68, 50, 65, 0.88);
--bg-card-hover: rgba(85, 62, 81, 0.95);
--bg-glass: rgba(42, 29, 40, 0.75);
--accent-primary: #e67dcf;
--accent-primary-hover: #fcaae7;
--accent-primary-glow: rgba(230, 125, 207, 0.35);
--accent-secondary: #c971ba;
--text-primary: #fdf4fa;
--text-secondary: #f3bae2;
--text-tertiary: #dfa0cf;
--text-muted: #bd8eaf;
--border-subtle: rgba(243, 186, 226, 0.08);
--border-light: rgba(243, 186, 226, 0.15);
--border-accent: rgba(230, 125, 207, 0.3);
}
/* ---- Reset & Base ---- */ /* ---- Reset & Base ---- */
*, *,
*::before, *::before,
@@ -747,6 +927,11 @@ body {
box-shadow: inset 3px 0 0 var(--accent-primary); box-shadow: inset 3px 0 0 var(--accent-primary);
} }
.ticket-table tbody tr.first-selected {
background: rgba(160, 65, 71, 0.32) !important;
box-shadow: inset 6px 0 0 var(--accent-primary) !important;
}
.ticket-table .checkbox-cell { .ticket-table .checkbox-cell {
width: 40px; width: 40px;
text-align: center; text-align: center;
@@ -2039,3 +2224,131 @@ body {
border-radius: 6px; border-radius: 6px;
font-weight: bold; font-weight: bold;
} }
/* ============================================================
Quill Editor Custom Theme Integration
============================================================ */
.ql-toolbar.ql-snow {
background: var(--bg-secondary) !important;
border: 1px solid var(--border-light) !important;
border-bottom: none !important;
border-radius: var(--radius-md) var(--radius-md) 0 0 !important;
}
.ql-container.ql-snow {
background: var(--bg-tertiary) !important;
border: 1px solid var(--border-light) !important;
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
color: var(--text-primary) !important;
font-family: inherit !important;
}
.ql-editor {
min-height: 200px;
font-family: inherit !important;
font-size: 0.95rem !important;
}
.ql-snow .ql-stroke {
stroke: var(--text-secondary) !important;
}
.ql-snow .ql-fill {
fill: var(--text-secondary) !important;
}
.ql-snow .ql-picker {
color: var(--text-secondary) !important;
}
.ql-snow .ql-picker-options {
background-color: var(--bg-secondary) !important;
border-color: var(--border-light) !important;
}
.ql-snow .ql-picker-item {
color: var(--text-secondary) !important;
}
.ql-snow.ql-toolbar button:hover,
.ql-snow .ql-toolbar button:hover,
.ql-snow.ql-toolbar button:focus,
.ql-snow .ql-toolbar button:focus,
.ql-snow.ql-toolbar button.ql-active,
.ql-snow .ql-toolbar button.ql-active,
.ql-snow.ql-toolbar .ql-picker-label:hover,
.ql-snow .ql-toolbar .ql-picker-label:hover,
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
.ql-snow .ql-toolbar .ql-picker-label.ql-active {
color: var(--accent-primary) !important;
}
.ql-snow.ql-toolbar button:hover .ql-stroke,
.ql-snow .ql-toolbar button:hover .ql-stroke,
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
.ql-snow .ql-toolbar button.ql-active .ql-stroke {
stroke: var(--accent-primary) !important;
}
.ql-snow.ql-toolbar button:hover .ql-fill,
.ql-snow .ql-toolbar button:hover .ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-fill,
.ql-snow .ql-toolbar button.ql-active .ql-fill {
fill: var(--accent-primary) !important;
}
/* ============================================================
Daily Timer Tooltip & Progress Bar
============================================================ */
.sidebar-timer {
position: relative;
cursor: pointer;
}
.timer-tooltip {
position: absolute;
left: 105%;
top: 50%;
transform: translateY(-50%) translateX(-8px);
width: 290px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: var(--space-md);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: all var(--transition-base);
z-index: 1000;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.timer-tooltip::after {
content: "";
position: absolute;
top: 50%;
right: 100%;
transform: translateY(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent var(--bg-secondary) transparent transparent;
}
.timer-tooltip-border {
position: absolute;
top: 50%;
right: 100%;
transform: translateY(-50%);
border-width: 7px;
border-style: solid;
border-color: transparent var(--border-light) transparent transparent;
z-index: 999;
}
.sidebar-timer:hover .timer-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
pointer-events: auto;
}
+16
View File
@@ -0,0 +1,16 @@
Lavora duro così il tuo capo potrà comprarsi una macchina nuova l'anno prossimo.
Se pensi che a nessuno importi se sei vivo, prova a saltare un giorno di lavoro.
Nessuno è inutile, puoi sempre essere usato come cattivo esempio.
Il lavoro di squadra è essenziale: ti permette di dare la colpa a qualcun altro.
Arrendersi è molto più facile che provarci e poi fallire ugualmente.
Le cose peggioreranno prima di diventare insostenibili.
Ogni giorno è un'opportunità per fare un altro passo verso la pensione che non avrai.
Ricorda: il lavoro nobilita l'uomo, ma rende ricchi gli altri.
La luce in fondo al tunnel è stata spenta per risparmiare energia.
Non rimandare a domani quello che puoi evitare del tutto.
Fai oggi quello che puoi rimandare a domani: così domani non avrai nulla da fare.
Se l'esperienza fosse così importante, non ti pagherebbero così poco per averla.
I campioni non si arrendono mai, ma tu non sei un campione.
Chi fa da sé fa per tre, chi fa in squadra fa ricorso.
Il successo è l'abilità di andare da un fallimento all'altro senza perdere l'entusiasmo.
L'unico posto in cui il successo viene prima del lavoro è il dizionario.
+41 -6
View File
@@ -1,22 +1,30 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="it"> <html lang="it">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="OTRS Turbo — Interfaccia moderna per gestione rapida ticket. Accesso diretto al database OTRS."> <meta name="description"
content="OTRS Turbo — Interfaccia moderna per gestione rapida ticket. Accesso diretto al database OTRS.">
<title>OTRS Turbo — Gestione Ticket Veloce</title> <title>OTRS Turbo — Gestione Ticket Veloce</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.snow.css" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<!-- Sidebar Navigation --> <!-- Sidebar Navigation -->
<nav class="sidebar" id="sidebar"> <nav class="sidebar" id="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand" style="border-bottom:none; padding-bottom:4px;">
<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;">
<span class="timer-display">0 / 480 | 480</span>
</div>
<ul class="nav-menu"> <ul class="nav-menu">
<li> <li>
@@ -41,6 +49,16 @@
<span class="nav-badge" id="open-ticket-count"></span> <span class="nav-badge" id="open-ticket-count"></span>
</a> </a>
</li> </li>
<li>
<a href="#/tickets/my" class="nav-link" data-view="tickets-my" id="nav-tickets-my">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>A mio carico</span>
<span class="nav-badge" id="my-ticket-count"></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">
@@ -78,12 +96,24 @@
<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);">
<select class="form-select" id="active-agent-select" style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select> <select class="form-select" id="active-agent-select"
<button class="btn btn-ghost btn-sm" id="refresh-lookups-btn" style="height:36px; padding:0 10px; min-width:36px;" title="Aggiorna dati locali (code, utenti)"> style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
<button class="btn btn-ghost btn-sm" id="refresh-lookups-btn"
style="height:36px; padding:0 10px; min-width:36px;" title="Aggiorna dati locali (code, utenti)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" /> <path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
</svg> </svg>
</button> </button>
<select class="form-select" id="theme-select"
style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 130px; margin: 0; background-position: right 10px center; border-color: var(--border-light);">
<option value="light">Tema Chiaro</option>
<option value="dark">Tema Scuro</option>
<option value="rosso">Rosso</option>
<option value="naturale">Naturale</option>
<option value="ice">Ice</option>
<option value="autunno">Autunno</option>
<option value="fairytale">Fairytale</option>
</select>
</div> </div>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
@@ -92,7 +122,10 @@
<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 o titolo)..." /> <input type="text" class="search-input" id="global-search" placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
<button id="global-search-clear" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-muted); display: none; align-items: center; justify-content: center;" title="Cancella ricerca">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;display:block;"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div> </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'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
@@ -117,6 +150,7 @@
<div class="toast-container" id="toast-container"></div> <div class="toast-container" id="toast-container"></div>
<!-- Scripts --> <!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.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>
@@ -126,4 +160,5 @@
<script src="/js/views/ticketBulk.js"></script> <script src="/js/views/ticketBulk.js"></script>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>
+192 -3
View File
@@ -11,9 +11,14 @@ const App = {
types: [], types: [],
}, },
lookupsLoaded: false, lookupsLoaded: false,
demotivationalPhrases: [],
motivationalPhrases: [],
/** Initialize the application */ /** Initialize the application */
init() { init() {
this.initTheme();
this.loadDemotivationalPhrases();
this.loadMotivationalPhrases();
Toast.init(); Toast.init();
// Hash-based SPA router // Hash-based SPA router
@@ -21,9 +26,17 @@ const App = {
// Global search // Global search
const searchInput = document.getElementById('global-search'); const searchInput = document.getElementById('global-search');
const searchClearBtn = document.getElementById('global-search-clear');
if (searchInput) { if (searchInput) {
const toggleClearBtn = () => {
if (searchClearBtn) {
searchClearBtn.style.display = searchInput.value.trim() ? 'flex' : 'none';
}
};
let timeout; let timeout;
searchInput.addEventListener('input', () => { searchInput.addEventListener('input', () => {
toggleClearBtn();
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => { timeout = setTimeout(() => {
const hash = window.location.hash; const hash = window.location.hash;
@@ -49,6 +62,24 @@ const App = {
} }
} }
}); });
if (searchClearBtn) {
searchClearBtn.addEventListener('click', () => {
searchInput.value = '';
searchClearBtn.style.display = 'none';
searchInput.focus();
const hash = window.location.hash;
if (hash.startsWith('#/tickets') && !hash.includes('/new') && !hash.match(/#\/tickets\/\d+/)) {
TicketListView.currentPage = 1;
TicketListView.render();
} else {
window.location.hash = '#/tickets';
}
});
}
// Initial state of clear button
toggleClearBtn();
} }
// Check DB connection // Check DB connection
@@ -92,6 +123,11 @@ const App = {
titleEl.textContent = 'Ticket'; titleEl.textContent = 'Ticket';
TicketListView.render(); TicketListView.render();
} else if (hash === '#/tickets/my') {
document.getElementById('nav-tickets-my')?.classList.add('active');
titleEl.textContent = 'Ticket a mio carico';
TicketListView.render();
} else if (hash === '#/tickets/new') { } else if (hash === '#/tickets/new') {
document.getElementById('nav-new-ticket')?.classList.add('active'); document.getElementById('nav-new-ticket')?.classList.add('active');
titleEl.textContent = 'Nuovo Ticket'; titleEl.textContent = 'Nuovo Ticket';
@@ -154,15 +190,16 @@ const App = {
} }
try { try {
const [queues, states, priorities, users, types] = await Promise.all([ const [queues, states, priorities, users, types, config] = 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.lookups = { queues, states, priorities, users, types }; this.lookups = { queues, states, priorities, users, types, config };
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) {
@@ -266,10 +303,18 @@ const App = {
`<option value="${u.id}">${u.first_name} ${u.last_name} (${u.login})</option>` `<option value="${u.id}">${u.first_name} ${u.last_name} (${u.login})</option>`
).join(''); ).join('');
// Load saved agent ID or default to the first available // Load saved agent ID or default to the config-specified default agent, or first available
const savedAgentId = localStorage.getItem('activeAgentId'); const savedAgentId = localStorage.getItem('activeAgentId');
const defaultAgentLogin = this.lookups.config ? this.lookups.config.defaultAgentLogin : null;
const defaultAgent = defaultAgentLogin
? (this.lookups.users || []).find(u => u.login === defaultAgentLogin)
: null;
if (savedAgentId && (this.lookups.users || []).some(u => String(u.id) === String(savedAgentId))) { if (savedAgentId && (this.lookups.users || []).some(u => String(u.id) === String(savedAgentId))) {
select.value = savedAgentId; select.value = savedAgentId;
} else if (defaultAgent) {
select.value = defaultAgent.id;
localStorage.setItem('activeAgentId', select.value);
} else if ((this.lookups.users || []).length > 0) { } else if ((this.lookups.users || []).length > 0) {
select.value = this.lookups.users[0].id; select.value = this.lookups.users[0].id;
localStorage.setItem('activeAgentId', select.value); localStorage.setItem('activeAgentId', select.value);
@@ -279,7 +324,12 @@ const App = {
select.addEventListener('change', () => { select.addEventListener('change', () => {
localStorage.setItem('activeAgentId', select.value); localStorage.setItem('activeAgentId', select.value);
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`); Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
this.updateDailyTimer();
this.route();
}); });
// Initial update
this.updateDailyTimer();
} catch (err) { } catch (err) {
console.error('Failed to init agent selector:', err); console.error('Failed to init agent selector:', err);
} }
@@ -296,6 +346,145 @@ const App = {
if (lower.includes('very high') || lower.includes('5')) return 5; if (lower.includes('very high') || lower.includes('5')) return 5;
return 3; return 3;
}, },
/** Initialize and handle theme selection */
initTheme() {
const themeSelect = document.getElementById('theme-select');
const savedTheme = localStorage.getItem('app-theme') || 'light';
// Apply the saved theme class to body
this.applyThemeClass(savedTheme);
if (themeSelect) {
themeSelect.value = savedTheme;
themeSelect.addEventListener('change', () => {
const selectedTheme = themeSelect.value;
this.applyThemeClass(selectedTheme);
localStorage.setItem('app-theme', selectedTheme);
Toast.success(`Tema cambiato in: ${themeSelect.options[themeSelect.selectedIndex].text}`);
});
}
},
/** Helper to apply theme classes to document.body */
applyThemeClass(theme) {
// Remove any existing theme- classes
document.body.className = document.body.className
.split(' ')
.filter(c => !c.startsWith('theme-'))
.join(' ');
if (theme !== 'light') {
document.body.classList.add(`theme-${theme}`);
}
},
/** Load demotivational phrases from txt file */
async loadDemotivationalPhrases() {
try {
const res = await fetch('/demotivational.txt');
if (res.ok) {
const text = await res.text();
this.demotivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
}
} catch (e) {
console.warn('Failed to load demotivational phrases:', e);
}
},
/** Load motivational phrases from txt file */
async loadMotivationalPhrases() {
try {
const res = await fetch('/motivational.txt');
if (res.ok) {
const text = await res.text();
this.motivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
}
} catch (e) {
console.warn('Failed to load motivational phrases:', e);
}
},
/** Fetch daily time accounting and update sidebar display */
async updateDailyTimer() {
const timerEl = document.getElementById('daily-timer');
if (!timerEl) return;
try {
await this.ensureLookups();
const targetTime = this.lookups.config?.dailyTargetTime || 480;
const data = await this.api('/api/users/time-today');
const todayTime = typeof data.totalToday === 'number' ? data.totalToday : 0;
const remaining = Math.max(0, targetTime - todayTime);
const percentage = Math.min(100, Math.round((todayTime / targetTime) * 100));
let phrase = "";
const isDemotivational = percentage >= 70;
if (isDemotivational) {
if (this.demotivationalPhrases.length > 0) {
const daySeed = new Date().getDate() + todayTime;
const idx = Math.floor(Math.abs(Math.sin(daySeed) * this.demotivationalPhrases.length));
phrase = this.demotivationalPhrases[idx % this.demotivationalPhrases.length];
} else {
phrase = "Hai fatto fin troppo lavoro per oggi. Smetti.";
}
} else {
if (this.motivationalPhrases.length > 0) {
const daySeed = new Date().getDate() + todayTime;
const idx = Math.floor(Math.abs(Math.sin(daySeed) * this.motivationalPhrases.length));
phrase = this.motivationalPhrases[idx % this.motivationalPhrases.length];
} else {
phrase = "Continua così! Stai andando alla grande.";
}
}
timerEl.innerHTML = `
<div class="timer-display" style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; font-weight:500;">
<span>⏱ ${todayTime} / ${targetTime} m</span>
<span style="color:var(--text-muted);">|</span>
<span>rimanenti: ${remaining} m</span>
</div>
<div class="timer-tooltip">
<div class="timer-tooltip-border"></div>
<div style="font-size:0.75rem; font-weight:600; color:var(--text-primary); margin-bottom:4px; display:flex; justify-content:space-between;">
<span>Progresso Giornaliero</span>
<strong>${percentage}%</strong>
</div>
<div style="background:var(--border-light); border-radius:var(--radius-full); height:10px; width:100%; overflow:hidden; border:1px solid var(--border-subtle);">
<div style="width:${percentage}%; background:linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); height:100%; border-radius:inherit; transition: width 0.3s ease;"></div>
</div>
<div style="font-size:0.72rem; color:var(--text-secondary); line-height:1.35; font-style:italic; margin-top:6px; border-top:1px solid var(--border-subtle); padding-top:6px; text-align:center;">
"${phrase}"
</div>
</div>
`;
// Update sidebar counts as well
this.updateSidebarBadges();
} catch (err) {
console.warn('Failed to update daily timer:', err);
}
},
/** Fetch dashboard stats to update sidebar counts */
async updateSidebarBadges() {
try {
const stats = await this.api('/api/dashboard/stats');
const badge = document.getElementById('open-ticket-count');
if (badge) {
badge.textContent = stats.total_open > 0 ? stats.total_open : '';
}
const myBadge = document.getElementById('my-ticket-count');
if (myBadge) {
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
}
} catch (e) {
console.warn('Failed to update sidebar badges:', e);
}
},
}; };
// Start the app when DOM is ready // Start the app when DOM is ready
+19 -1
View File
@@ -106,8 +106,15 @@ const Filters = {
/** Bind change events to filter selects */ /** Bind change events to filter selects */
bindEvents(onFilterChange) { bindEvents(onFilterChange) {
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.addEventListener('change', (e) => { sel.addEventListener('change', (e) => {
this.state[e.target.dataset.filter] = e.target.value; this.state[e.target.dataset.filter] = e.target.value;
this.save(); this.save();
@@ -119,7 +126,18 @@ const Filters = {
if (resetBtn) { if (resetBtn) {
resetBtn.addEventListener('click', () => { resetBtn.addEventListener('click', () => {
this.reset(); this.reset();
selects.forEach(s => s.value = ''); if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
this.state.user_id = activeAgentId;
this.save();
}
selects.forEach(s => {
if (isMyTickets && s.dataset.filter === 'user_id') {
s.value = localStorage.getItem('activeAgentId') || '1';
} else {
s.value = '';
}
});
if (onFilterChange) onFilterChange(); if (onFilterChange) onFilterChange();
}); });
} }
+8 -2
View File
@@ -134,8 +134,14 @@ const DashboardView = {
// 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 && stats.total_open > 0) { if (badge) {
badge.textContent = stats.total_open; badge.textContent = stats.total_open > 0 ? stats.total_open : '';
}
// Update my ticket count in sidebar badge
const myBadge = document.getElementById('my-ticket-count');
if (myBadge) {
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
} }
} catch (err) { } catch (err) {
+32 -3
View File
@@ -45,7 +45,7 @@ const TicketCreateView = {
company_search: document.getElementById('create-company-search').value, company_search: document.getElementById('create-company-search').value,
priority_id: document.getElementById('create-priority').value, priority_id: document.getElementById('create-priority').value,
subject: document.getElementById('create-subject').value, subject: document.getElementById('create-subject').value,
body: document.getElementById('create-body').value, body: this.quill ? this.quill.root.innerHTML : '',
isAdvancedVisible: document.getElementById('advanced-options').style.display !== 'none' isAdvancedVisible: document.getElementById('advanced-options').style.display !== 'none'
}; };
}, },
@@ -192,7 +192,9 @@ const TicketCreateView = {
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label">Messaggio / Nota iniziale</label> <label class="form-label">Messaggio / Nota iniziale</label>
<textarea class="form-textarea" id="create-body" placeholder="Descrivi il problema in dettaglio..."></textarea> <div id="create-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden;">
<div id="create-body-editor" style="min-height: 200px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
</div>
</div> </div>
</div> </div>
@@ -221,6 +223,30 @@ const TicketCreateView = {
</div> </div>
`; `;
// Initialize Quill Editor
if (window.Quill) {
this.quill = new Quill('#create-body-editor', {
theme: 'snow',
placeholder: 'Descrivi il problema in dettaglio...',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'header': [1, 2, 3, false] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link', 'image'],
['clean']
]
}
});
// Set saved body/state if available
if (this.savedState && this.savedState.body) {
this.quill.root.innerHTML = this.savedState.body;
}
} else {
this.quill = null;
}
this.bindEvents(); this.bindEvents();
} catch (err) { } catch (err) {
@@ -607,7 +633,7 @@ const TicketCreateView = {
customer_id: customerId || undefined, customer_id: customerId || undefined,
customer_user_id: customerUserId || undefined, customer_user_id: customerUserId || undefined,
subject: document.getElementById('create-subject').value.trim() || undefined, subject: document.getElementById('create-subject').value.trim() || undefined,
body: document.getElementById('create-body').value.trim() || undefined, body: this.quill ? this.quill.root.innerHTML.trim() : undefined,
attachments: this.attachments.length > 0 ? this.attachments : undefined attachments: this.attachments.length > 0 ? this.attachments : undefined
}; };
@@ -624,6 +650,9 @@ const TicketCreateView = {
this.savedState = null; // Clear cached state on success this.savedState = null; // Clear cached state on success
this.attachments = []; // Clear attachments array this.attachments = []; // Clear attachments array
// Update daily timer
App.updateDailyTimer();
// Navigate to the new ticket // Navigate to the new ticket
window.location.hash = `#/tickets/${result.id}`; window.location.hash = `#/tickets/${result.id}`;
+60 -8
View File
@@ -28,6 +28,12 @@ const TicketDetailView = {
return sum + (isNaN(val) ? 0 : val); return sum + (isNaN(val) ? 0 : val);
}, 0); }, 0);
// Display total time in the topbar
const titleEl = document.getElementById('page-title');
if (titleEl) {
titleEl.innerHTML = `Ticket #${id} <span style="font-size:0.85rem; font-weight:normal; color:var(--text-secondary); margin-left:var(--space-md); background:var(--bg-tertiary); padding:4px 10px; border-radius:var(--radius-sm); border:1px solid var(--border-light); display:inline-flex; align-items:center; gap:4px;">⏱ Tempo Consultivato: <strong>${totalTime} min</strong></span>`;
}
this.originalValues = { this.originalValues = {
ticket_state_id: ticket.ticket_state_id, ticket_state_id: ticket.ticket_state_id,
ticket_priority_id: ticket.ticket_priority_id, ticket_priority_id: ticket.ticket_priority_id,
@@ -119,11 +125,13 @@ const TicketDetailView = {
<div class="card-title">Aggiungi Nota</div> <div class="card-title">Aggiungi Nota</div>
<div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);"> <div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);">
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" /> <input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0;" />
</div> </div>
<textarea class="note-textarea" id="note-body" placeholder="Scrivi una nota interna..."></textarea> <div id="note-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-md);">
<div style="display:flex;gap:var(--space-sm);justify-content:flex-end;"> <div id="note-body-editor" style="min-height: 180px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
<button class="btn btn-primary btn-sm" id="note-send"> </div>
<div style="display:flex; gap:var(--space-md); justify-content:flex-end; align-items:center; margin-top:var(--space-md);">
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
Invia Nota Invia Nota
</button> </button>
@@ -207,6 +215,10 @@ const TicketDetailView = {
<span class="meta-label">Numero</span> <span class="meta-label">Numero</span>
<span class="meta-value" style="font-family:monospace;">${ticket.tn}</span> <span class="meta-value" style="font-family:monospace;">${ticket.tn}</span>
</div> </div>
<div class="meta-row">
<span class="meta-label">Stato</span>
<span class="meta-value"><span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span></span>
</div>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">Creato</span> <span class="meta-label">Creato</span>
<span class="meta-value"> <span class="meta-value">
@@ -294,6 +306,24 @@ const TicketDetailView = {
</div> </div>
`; `;
// Initialize Quill Editor for Note
if (window.Quill) {
this.noteQuill = new Quill('#note-body-editor', {
theme: 'snow',
placeholder: 'Scrivi una nota interna...',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link', 'image'],
['clean']
]
}
});
} else {
this.noteQuill = null;
}
this.bindEvents(); this.bindEvents();
} catch (err) { } catch (err) {
@@ -371,12 +401,33 @@ const TicketDetailView = {
// Send note // Send note
const noteSendBtn = document.getElementById('note-send'); const noteSendBtn = document.getElementById('note-send');
noteSendBtn.addEventListener('click', async () => { noteSendBtn.addEventListener('click', async () => {
const body = document.getElementById('note-body').value.trim(); let body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
const subject = document.getElementById('note-subject').value.trim(); if (body === '<p><br></p>') {
body = '';
}
let subject = document.getElementById('note-subject').value.trim();
const time_unit = document.getElementById('note-time-units').value.trim(); const time_unit = document.getElementById('note-time-units').value.trim();
if (!body) { // If body is empty but subject is provided, fill body with subject text
Toast.warning('Scrivi qualcosa prima di inviare'); if (!body && subject) {
body = `<p>${App.escapeHtml(subject)}</p>`;
if (this.noteQuill) {
this.noteQuill.root.innerHTML = body;
}
}
// If subject is empty but body is provided, fill subject with truncated plain text of body
if (body && !subject) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = body;
const plainText = tempDiv.textContent || tempDiv.innerText || '';
subject = plainText.trim().substring(0, 50) || 'Nota interna';
document.getElementById('note-subject').value = subject;
}
// If both are still empty, show warning
if (!body && !subject) {
Toast.warning('Inserisci un oggetto o il corpo della nota prima di inviare');
return; return;
} }
@@ -390,6 +441,7 @@ const TicketDetailView = {
}); });
Toast.success(res.message || 'Nota aggiunta!'); Toast.success(res.message || 'Nota aggiunta!');
App.updateDailyTimer();
this.render(this.ticketId); this.render(this.ticketId);
} catch (err) { } catch (err) {
Toast.error('Errore: ' + err.message); Toast.error('Errore: ' + err.message);
+114 -4
View File
@@ -8,6 +8,7 @@ const TicketListView = {
sortBy: 'create_time', sortBy: 'create_time',
sortDir: 'DESC', sortDir: 'DESC',
selectedIds: new Set(), selectedIds: new Set(),
selectedOrder: [],
searchTimeout: null, searchTimeout: null,
async render() { async render() {
@@ -19,6 +20,12 @@ const TicketListView = {
await App.ensureLookups(); await App.ensureLookups();
// Build query params // Build query params
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
if (isMyTickets) {
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
Filters.state.user_id = activeAgentId;
}
const params = Filters.toQueryParams(); const params = Filters.toQueryParams();
params.set('page', this.currentPage); params.set('page', this.currentPage);
params.set('per_page', this.perPage); params.set('per_page', this.perPage);
@@ -79,6 +86,7 @@ const TicketListView = {
</select> </select>
</div> </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-ghost btn-xs" id="batch-cancel">Annulla</button> <button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
</div> </div>
@@ -95,18 +103,20 @@ const TicketListView = {
<th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th> <th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th> <th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
<th>Owner</th> <th>Owner</th>
<th>Cliente</th>
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th> <th 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 => `
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? '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><span class="ticket-tn"><a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a></span></td>
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td> <td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
<td><span class="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><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
<td><span class="badge badge-queue">${t.queue_name}</span></td> <td><span class="badge badge-queue">${t.queue_name}</span></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-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td> <td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
</tr> </tr>
`).join('') : ` `).join('') : `
@@ -163,6 +173,7 @@ const TicketListView = {
Filters.bindEvents(() => { Filters.bindEvents(() => {
this.currentPage = 1; this.currentPage = 1;
this.selectedIds.clear(); this.selectedIds.clear();
this.selectedOrder = [];
this.render(); this.render();
}); });
@@ -192,11 +203,27 @@ const TicketListView = {
const id = String(row.dataset.ticketId); const id = String(row.dataset.ticketId);
if (this.selectedIds.has(id)) { if (this.selectedIds.has(id)) {
this.selectedIds.delete(id); this.selectedIds.delete(id);
row.classList.remove('selected'); this.selectedOrder = this.selectedOrder.filter(x => x !== id);
} else { } else {
this.selectedIds.add(id); this.selectedIds.add(id);
row.classList.add('selected'); this.selectedOrder.push(id);
} }
// Update classes on all rows
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(tr => {
const trId = String(tr.dataset.ticketId);
if (this.selectedIds.has(trId)) {
tr.classList.add('selected');
} else {
tr.classList.remove('selected');
}
if (this.selectedOrder[0] === trId) {
tr.classList.add('first-selected');
} else {
tr.classList.remove('first-selected');
}
});
this.updateBatchBar(); this.updateBatchBar();
}); });
}); });
@@ -207,15 +234,35 @@ const TicketListView = {
batchApply.addEventListener('click', () => this.applyBatch()); batchApply.addEventListener('click', () => this.applyBatch());
} }
// Batch merge (Issue #7)
const batchMerge = document.getElementById('batch-merge');
if (batchMerge) {
batchMerge.addEventListener('click', () => this.mergeBatch());
}
// 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) {
batchSelectAll.addEventListener('click', () => { batchSelectAll.addEventListener('click', () => {
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => { document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
const id = String(row.dataset.ticketId); const id = String(row.dataset.ticketId);
if (!this.selectedIds.has(id)) {
this.selectedIds.add(id); this.selectedIds.add(id);
row.classList.add('selected'); this.selectedOrder.push(id);
}
}); });
// Update classes
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(tr => {
const trId = String(tr.dataset.ticketId);
tr.classList.add('selected');
if (this.selectedOrder[0] === trId) {
tr.classList.add('first-selected');
} else {
tr.classList.remove('first-selected');
}
});
this.updateBatchBar(); this.updateBatchBar();
}); });
} }
@@ -225,8 +272,10 @@ const TicketListView = {
if (batchCancel) { if (batchCancel) {
batchCancel.addEventListener('click', () => { batchCancel.addEventListener('click', () => {
this.selectedIds.clear(); this.selectedIds.clear();
this.selectedOrder = [];
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => { document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
tr.classList.remove('selected'); tr.classList.remove('selected');
tr.classList.remove('first-selected');
}); });
this.updateBatchBar(); this.updateBatchBar();
}); });
@@ -237,6 +286,7 @@ const TicketListView = {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
this.currentPage = parseInt(btn.dataset.page); this.currentPage = parseInt(btn.dataset.page);
this.selectedIds.clear(); this.selectedIds.clear();
this.selectedOrder = [];
this.render(); this.render();
}); });
}); });
@@ -247,6 +297,16 @@ const TicketListView = {
if (count) { if (count) {
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 merge button
const mergeBtn = document.getElementById('batch-merge');
if (mergeBtn) {
if (this.selectedIds.size >= 2) {
mergeBtn.disabled = false;
} else {
mergeBtn.disabled = true;
}
}
}, },
async applyBatch() { async applyBatch() {
@@ -276,9 +336,59 @@ const TicketListView = {
}); });
Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`); Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`);
this.selectedIds.clear(); this.selectedIds.clear();
this.selectedOrder = [];
this.render(); this.render();
} catch (err) { } catch (err) {
Toast.error('Errore aggiornamento batch: ' + err.message); Toast.error('Errore aggiornamento batch: ' + err.message);
} }
}, },
async mergeBatch() {
if (this.selectedOrder.length < 2) return;
const targetId = this.selectedOrder[0];
const sourceIds = this.selectedOrder.slice(1);
// Get target ticket number
const targetRow = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${targetId}"]`);
const targetTn = targetRow ? targetRow.querySelector('.ticket-tn-link').textContent.trim() : targetId;
// Collect source ticket numbers
const sourceTns = [];
sourceIds.forEach(id => {
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
if (row) {
sourceTns.push(row.querySelector('.ticket-tn-link').textContent.trim());
} else {
sourceTns.push(`#${id}`);
}
});
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.`);
if (!confirmed) return;
try {
const mergeBtn = document.getElementById('batch-merge');
if (mergeBtn) {
mergeBtn.disabled = true;
mergeBtn.textContent = 'Unione in corso...';
}
const res = await App.api('/api/tickets/merge', {
method: 'POST',
body: JSON.stringify({
targetId: parseInt(targetId),
sourceIds: sourceIds.map(x => parseInt(x))
})
});
Toast.success(res.message || 'Ticket uniti con successo');
this.selectedIds.clear();
this.selectedOrder = [];
this.render();
} catch (err) {
Toast.error('Errore durante l\'unione: ' + err.message);
this.updateBatchBar();
}
},
}; };
+15
View File
@@ -0,0 +1,15 @@
Ogni piccolo passo ti avvicina al traguardo!
Stai facendo un ottimo lavoro, continua così!
La costanza premia sempre, un minuto alla volta.
Il successo è la somma di piccoli sforzi ripetuti giorno dopo giorno.
Focalizzati sull'obiettivo e non mollare.
Il futuro appartiene a chi crede nella bellezza dei propri sogni.
Ottimo ritmo! Il traguardo della giornata si avvicina.
La tua dedizione fa la differenza oggi.
Credi in te stesso e in quello che puoi fare.
Sii orgoglioso di ogni minuto dedicato al tuo lavoro.
Concentrati sui tuoi progressi, stai andando alla grande!
Ogni minuto speso con impegno costruisce il tuo successo.
La fatica di oggi sarà la forza di domani.
Manca poco alla meta, continua a spingere!
Sei una risorsa preziosa, non dimenticarlo.
+13
View File
@@ -4,6 +4,7 @@ const pool = require('../db');
// 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;
try { try {
// All queries in parallel for speed // All queries in parallel for speed
const [ const [
@@ -15,6 +16,7 @@ router.get('/stats', async (req, res) => {
totalOpen, totalOpen,
recentTickets, recentTickets,
escalated, escalated,
myOpenCount,
] = await Promise.all([ ] = await Promise.all([
// Tickets by state (only open-ish states) // Tickets by state (only open-ish states)
pool.query( pool.query(
@@ -85,6 +87,16 @@ router.get('/stats', async (req, res) => {
WHERE escalation_time > 0 WHERE escalation_time > 0
AND escalation_time < EXTRACT(EPOCH FROM NOW())` AND escalation_time < EXTRACT(EPOCH FROM NOW())`
), ),
// My open tickets count
pool.query(
`SELECT COUNT(*) AS count
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 tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
AND t.user_id = $1`,
[activeAgentId]
),
]); ]);
res.json({ res.json({
@@ -96,6 +108,7 @@ router.get('/stats', async (req, res) => {
total_open: parseInt(totalOpen.rows[0].count), total_open: parseInt(totalOpen.rows[0].count),
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),
}); });
} catch (err) { } catch (err) {
console.error('Error fetching dashboard stats:', err); console.error('Error fetching dashboard stats:', err);
+43 -61
View File
@@ -17,16 +17,31 @@ async function otrsRequest(method, path, bodyData = {}) {
Password: OTRS_API_PASSWORD, Password: OTRS_API_PASSWORD,
...bodyData ...bodyData
}; };
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
try {
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload),
signal: controller.signal
}); });
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
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(); return await response.json();
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('OTRS REST API request timed out (6s limit exceeded)');
}
throw err;
}
} }
@@ -95,6 +110,32 @@ router.get('/users', async (req, res) => {
} }
}); });
// GET /api/config — Application config
router.get('/config', (req, res) => {
res.json({
defaultAgentLogin: process.env.OTRS_API_USER || '',
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480
});
});
// GET /api/users/time-today — Get sum of today's time units for active agent
router.get('/users/time-today', async (req, res) => {
try {
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const result = await pool.query(
`SELECT COALESCE(SUM(time_unit), 0) AS total_today
FROM time_accounting
WHERE create_by = $1 AND DATE(create_time) = CURRENT_DATE`,
[operatorId]
);
res.json({ totalToday: parseFloat(result.rows[0].total_today) });
} catch (err) {
console.error('Error fetching today\'s time units:', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/types — Ticket types // GET /api/types — Ticket types
router.get('/types', async (req, res) => { router.get('/types', async (req, res) => {
try { try {
@@ -150,70 +191,11 @@ router.get('/customer-companies/search', async (req, res) => {
} }
}); });
// GET /api/customer-users/search — Search customer users // GET /api/customer-users/search — Search customer users (always from local DB)
router.get('/customer-users/search', async (req, res) => { router.get('/customer-users/search', async (req, res) => {
try { try {
const { q = '', customer_company_id } = req.query; const { q = '', customer_company_id } = req.query;
// Try OTRS API first if configured
const OTRS_API_URL = process.env.OTRS_API_URL;
const OTRS_API_USER = process.env.OTRS_API_USER;
if (OTRS_API_URL && OTRS_API_USER) {
try {
const searchParams = {
Search: q ? `*${q}*` : '*',
Valid: 1
};
if (customer_company_id) {
searchParams.CustomerID = customer_company_id;
}
const searchRes = await otrsRequest('POST', '/CustomerUserSearch', searchParams);
let logins = [];
if (searchRes) {
if (Array.isArray(searchRes.CustomerUserID)) {
logins = searchRes.CustomerUserID;
} else if (searchRes.Data && Array.isArray(searchRes.Data.CustomerUserID)) {
logins = searchRes.Data.CustomerUserID;
} else if (Array.isArray(searchRes)) {
logins = searchRes;
}
}
if (logins.length > 0) {
// Limit to top 20 logins to avoid rate/performance issues
const limitedLogins = logins.slice(0, 20);
const detailPromises = limitedLogins.map(async (login) => {
try {
const detailRes = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: login });
const userObj = detailRes?.CustomerUser;
if (userObj) {
return {
login: userObj.UserLogin || login,
email: userObj.UserEmail || '',
first_name: userObj.UserFirstname || '',
last_name: userObj.UserLastname || '',
customer_id: userObj.UserCustomerID || ''
};
}
} catch (err) {
console.error(`Error fetching details for user ${login}:`, err.message);
}
return null;
});
const details = await Promise.all(detailPromises);
const validUsers = details.filter(u => u !== null);
if (validUsers.length > 0) {
return res.json(validUsers);
}
}
} catch (apiErr) {
console.warn('OTRS CustomerUserSearch API request failed, falling back to local DB:', apiErr.message);
}
}
// Fallback: local DB query
let queryText; let queryText;
let queryParams; let queryParams;
if (q) { if (q) {
+170 -13
View File
@@ -17,16 +17,31 @@ async function otrsRequest(method, path, bodyData = {}) {
Password: OTRS_API_PASSWORD, Password: OTRS_API_PASSWORD,
...bodyData ...bodyData
}; };
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
try {
const response = await fetch(url, { const response = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload),
signal: controller.signal
}); });
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
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(); return await response.json();
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('OTRS REST API request timed out (6s limit exceeded)');
}
throw err;
}
} }
@@ -75,7 +90,18 @@ router.get('/', async (req, res) => {
params.push(new Date(date_to)); params.push(new Date(date_to));
} }
if (search) { if (search) {
conditions.push(`(t.title ILIKE $${paramIdx} OR t.tn ILIKE $${paramIdx})`); conditions.push(`(
t.title ILIKE $${paramIdx} OR
t.tn ILIKE $${paramIdx} OR
EXISTS (
SELECT 1 FROM article art
LEFT JOIN article_data_mime adm ON art.id = adm.article_id
WHERE art.ticket_id = t.id AND (
adm.a_subject ILIKE $${paramIdx} OR
adm.a_body ILIKE $${paramIdx}
)
)
)`);
params.push(`%${search}%`); params.push(`%${search}%`);
paramIdx++; paramIdx++;
} }
@@ -118,6 +144,7 @@ router.get('/', async (req, res) => {
t.user_id, u.first_name AS owner_first, u.last_name AS owner_last, t.user_id, u.first_name AS owner_first, u.last_name AS owner_last,
t.type_id, tt.name AS type_name, t.type_id, tt.name AS type_name,
t.customer_id, t.customer_user_id, t.customer_id, t.customer_user_id,
cu.first_name AS customer_first, cu.last_name AS customer_last,
t.ticket_lock_id, t.ticket_lock_id,
t.create_time, t.change_time, t.create_time, t.change_time,
t.escalation_time t.escalation_time
@@ -128,6 +155,7 @@ router.get('/', 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
LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN ticket_type tt ON t.type_id = tt.id LEFT JOIN ticket_type tt ON t.type_id = tt.id
LEFT JOIN customer_user cu ON t.customer_user_id = cu.login
${whereClause} ${whereClause}
ORDER BY ${sortColumn} ${sortDirection} ORDER BY ${sortColumn} ${sortDirection}
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
@@ -249,19 +277,29 @@ router.post('/', async (req, res) => {
await client.query('BEGIN'); await client.query('BEGIN');
// Generate ticket number: get next counter value // Generate ticket number: get next counter value for today (daily reset)
const counterTodayResult = await client.query(
`SELECT COALESCE(MAX(counter), 0) AS max_counter
FROM ticket_number_counter
WHERE create_time >= CURRENT_DATE`
);
const nextCounter = parseInt(counterTodayResult.rows[0].max_counter, 10) + 1;
const counterResult = await client.query( const counterResult = await client.query(
`INSERT INTO ticket_number_counter (counter, counter_uid, create_time) `INSERT INTO ticket_number_counter (counter, counter_uid, create_time)
VALUES ( VALUES (
COALESCE((SELECT MAX(counter) FROM ticket_number_counter), 0) + 1, $1,
md5(random()::text || clock_timestamp()::text), md5(random()::text || clock_timestamp()::text),
NOW() NOW()
) )
RETURNING counter` RETURNING counter`,
[nextCounter]
); );
const counter = counterResult.rows[0].counter; const counter = counterResult.rows[0].counter;
const now = new Date(); const now = new Date();
const tn = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${String(counter).padStart(10, '0')}`; const systemId = process.env.OTRS_SYSTEM_ID || '10';
const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6;
const tn = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}${String(counter).padStart(counterPadding, '0')}`;
// Determine lock type (1 = unlock by default) // Determine lock type (1 = unlock by default)
const lockId = 1; const lockId = 1;
@@ -372,6 +410,8 @@ router.post('/', async (req, res) => {
const articleId = articleResult.rows[0].id; const articleId = articleResult.rows[0].id;
const finalBody = body || 'File allegati in creazione'; const finalBody = body || 'File allegati in creazione';
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
await client.query( await client.query(
`INSERT INTO article_data_mime ( `INSERT INTO article_data_mime (
@@ -380,10 +420,10 @@ router.post('/', async (req, res) => {
create_time, create_by, change_time, change_by create_time, create_by, change_time, change_by
) VALUES ( ) VALUES (
$1, $2, '', $3, $4, $1, $2, '', $3, $4,
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5, EXTRACT(EPOCH FROM NOW())::INTEGER,
NOW(), $5, NOW(), $5 NOW(), $6, NOW(), $6
)`, )`,
[articleId, customerFrom, subject || title, finalBody, operatorId] [articleId, customerFrom, subject || title, finalBody, contentType, operatorId]
); );
// Insert attachments if any // Insert attachments if any
@@ -646,6 +686,9 @@ router.post('/:id/articles', async (req, res) => {
const { subject, body, is_visible_for_customer = 0, time_unit } = req.body; const { subject, body, is_visible_for_customer = 0, time_unit } = req.body;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1; const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
// 1. Try to add note via REST API if configured // 1. Try to add note via REST API if configured
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) { if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
try { try {
@@ -656,7 +699,7 @@ router.post('/:id/articles', async (req, res) => {
IsVisibleForCustomer: is_visible_for_customer ? '1' : '0', IsVisibleForCustomer: is_visible_for_customer ? '1' : '0',
Subject: subject || 'Nota interna', Subject: subject || 'Nota interna',
Body: body, Body: body,
ContentType: 'text/plain; charset=utf8', ContentType: isHtml ? 'text/html; charset=utf8' : 'text/plain; charset=utf8',
} }
}; };
@@ -732,10 +775,10 @@ router.post('/:id/articles', async (req, res) => {
) VALUES ( ) VALUES (
$1, $2, '', '', '', '', $3, $4, $1, $2, '', '', '', '', $3, $4,
'', '', '', '', '', '',
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5, $5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
NOW(), $6, NOW(), $6 NOW(), $7, NOW(), $7
)`, )`,
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentPath, operatorId] [articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentType, contentPath, operatorId]
); );
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and raw Buffer) // Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and raw Buffer)
@@ -991,4 +1034,118 @@ router.get('/attachments/:id', async (req, res) => {
} }
}); });
// POST /api/tickets/merge — Merge tickets (Issue #7)
router.post('/merge', async (req, res) => {
const { targetId, sourceIds } = req.body;
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
if (!targetId || !sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) {
return res.status(400).json({ error: 'targetId e array sourceIds sono obbligatori' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// Fetch target ticket details
const targetRes = await client.query('SELECT tn, title FROM ticket WHERE id = $1', [targetId]);
if (targetRes.rows.length === 0) {
throw new Error(`Ticket di destinazione #${targetId} non trovato`);
}
const targetTn = targetRes.rows[0].tn;
const targetTitle = targetRes.rows[0].title;
for (const sourceId of sourceIds) {
// Fetch source ticket details
const sourceRes = await client.query('SELECT tn, title FROM ticket WHERE id = $1', [sourceId]);
if (sourceRes.rows.length === 0) continue;
const sourceTn = sourceRes.rows[0].tn;
const sourceTitle = sourceRes.rows[0].title;
// 1. Move all articles of source to target
await client.query(
`UPDATE article
SET ticket_id = $1, change_time = NOW(), change_by = $2
WHERE ticket_id = $3`,
[targetId, operatorId, sourceId]
);
// 2. Move all time accounting entries
await client.query(
`UPDATE time_accounting
SET ticket_id = $1, change_time = NOW(), change_by = $2
WHERE ticket_id = $3`,
[targetId, operatorId, sourceId]
);
// 3. Mark source ticket as merged (state_id = 9)
await client.query(
`UPDATE ticket
SET ticket_state_id = 9, change_time = NOW(), change_by = $1
WHERE id = $2`,
[operatorId, sourceId]
);
// 4. Create ParentChild relation link (source B is child/merged of target A)
const relCheck = await client.query(
`SELECT 1 FROM link_relation
WHERE source_object_id = 1 AND source_key = $1
AND target_object_id = 1 AND target_key = $2 AND type_id = 2`,
[String(sourceId), String(targetId)]
);
if (relCheck.rows.length === 0) {
await client.query(
`INSERT INTO link_relation (
source_object_id, source_key, target_object_id, target_key,
type_id, state_id, create_time, create_by
) VALUES (1, $1, 1, $2, 2, 1, NOW(), $3)`,
[String(sourceId), String(targetId), operatorId]
);
}
// 5. Create internal system note inside target ticket documenting the merge
const now = new Date();
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
// Insert article
const artResult = await client.query(
`INSERT INTO article (
ticket_id, article_sender_type_id, communication_channel_id,
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES ($1, 1, 1, 0, 1, NOW(), $2, NOW(), $2) RETURNING id`,
[targetId, operatorId]
);
const articleId = artResult.rows[0].id;
const mergeNoteBody = `Il ticket #${sourceTn} ("${sourceTitle}") è stato unito a questo ticket.`;
await client.query(
`INSERT INTO article_data_mime (
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
a_message_id, a_in_reply_to, a_references,
a_content_type, incoming_time, content_path,
create_time, create_by, change_time, change_by
) VALUES (
$1, 'Sistema OTRS Turbo', '', '', '', '', 'Ticket Unito', $2,
'', '', '',
'text/plain; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $3,
NOW(), $4, NOW(), $4
)`,
[articleId, mergeNoteBody, contentPath, operatorId]
);
}
await client.query('COMMIT');
res.json({ message: 'Ticket uniti con successo!' });
} catch (err) {
await client.query('ROLLBACK');
console.error('Error merging tickets:', err);
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
module.exports = router; module.exports = router;