feat: contatore tempo allocato, pagina miei ticket, unione ticket (da db)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
glob:
|
||||||
|
description:
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
+54
-19
@@ -1,31 +1,39 @@
|
|||||||
<!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>
|
||||||
<a href="#/dashboard" class="nav-link active" data-view="dashboard" id="nav-dashboard">
|
<a href="#/dashboard" class="nav-link active" data-view="dashboard" id="nav-dashboard">
|
||||||
<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">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -33,19 +41,29 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="#/tickets" class="nav-link" data-view="tickets" id="nav-tickets">
|
<a href="#/tickets" class="nav-link" data-view="tickets" id="nav-tickets">
|
||||||
<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">
|
||||||
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
|
<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"/>
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
<path d="M9 12h6M9 16h4"/>
|
<path d="M9 12h6M9 16h4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Ticket</span>
|
<span>Ticket</span>
|
||||||
<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">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10" />
|
||||||
<path d="M12 8v8M8 12h8"/>
|
<path d="M12 8v8M8 12h8" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Nuovo Ticket</span>
|
<span>Nuovo Ticket</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -78,25 +96,40 @@
|
|||||||
<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">
|
||||||
<div class="search-bar" id="global-search-container">
|
<div class="search-bar" id="global-search-container">
|
||||||
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<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;">
|
||||||
<path d="M12 5v14M5 12h14"/>
|
<path d="M12 5v14M5 12h14" />
|
||||||
</svg>
|
</svg>
|
||||||
Nuovo
|
Nuovo
|
||||||
</button>
|
</button>
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -141,14 +149,14 @@ const TicketDetailView = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="articles-timeline">
|
<div class="articles-timeline">
|
||||||
${articles.length > 0 ? articles.map(a => {
|
${articles.length > 0 ? articles.map(a => {
|
||||||
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
|
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
|
||||||
const displayBody = this.htmlMode && hasHtml
|
const displayBody = this.htmlMode && hasHtml
|
||||||
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
||||||
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
||||||
|
|
||||||
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
|
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
||||||
<div class="article-header">
|
<div class="article-header">
|
||||||
<div class="article-sender">
|
<div class="article-sender">
|
||||||
@@ -189,7 +197,7 @@ const TicketDetailView = {
|
|||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('') : `
|
}).join('') : `
|
||||||
<div class="empty-state" style="padding:var(--space-lg);">
|
<div class="empty-state" style="padding:var(--space-lg);">
|
||||||
<div class="empty-state-icon">💬</div>
|
<div class="empty-state-icon">💬</div>
|
||||||
<div class="empty-state-text">Nessun articolo</div>
|
<div class="empty-state-text">Nessun articolo</div>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
this.selectedIds.add(id);
|
if (!this.selectedIds.has(id)) {
|
||||||
row.classList.add('selected');
|
this.selectedIds.add(id);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
|||||||
+51
-69
@@ -17,16 +17,31 @@ async function otrsRequest(method, path, bodyData = {}) {
|
|||||||
Password: OTRS_API_PASSWORD,
|
Password: OTRS_API_PASSWORD,
|
||||||
...bodyData
|
...bodyData
|
||||||
};
|
};
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
const controller = new AbortController();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
try {
|
||||||
if (!response.ok) {
|
const response = await fetch(url, {
|
||||||
const errorText = await response.text();
|
method,
|
||||||
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
return await response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
+178
-21
@@ -17,16 +17,31 @@ async function otrsRequest(method, path, bodyData = {}) {
|
|||||||
Password: OTRS_API_PASSWORD,
|
Password: OTRS_API_PASSWORD,
|
||||||
...bodyData
|
...bodyData
|
||||||
};
|
};
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
const controller = new AbortController();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
try {
|
||||||
if (!response.ok) {
|
const response = await fetch(url, {
|
||||||
const errorText = await response.text();
|
method,
|
||||||
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OTRS REST API error (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
return await response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user