Compare commits
30
Commits
b828d60bb2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6361484cd | ||
|
|
d32522bcbb | ||
|
|
2ca95e062a | ||
|
|
abb72d53c9 | ||
|
|
0b6907969d | ||
|
|
ce904756dd | ||
|
|
107ba10689 | ||
|
|
60259427d3 | ||
|
|
dcc0456070 | ||
|
|
a1b6d9333c | ||
|
|
ae458256d5 | ||
|
|
d4ef03b6f4 | ||
|
|
1bc3d8bf8a | ||
|
|
c33a7a4666 | ||
|
|
44c89aa92f | ||
|
|
a15ab7e89d | ||
|
|
6944b02263 | ||
|
|
c1dc86d6cd | ||
|
|
e94ef9a38c | ||
|
|
56dcca57aa | ||
|
|
b06a0993d1 | ||
|
|
b957272eb8 | ||
|
|
a5d3431008 | ||
|
|
c7b18ac959 | ||
|
|
b1754c3a67 | ||
|
|
719fc09c66 | ||
|
|
ec1fb25d4e | ||
|
|
cf500aabb3 | ||
|
|
9ae0cec0ca | ||
|
|
f21be0abcb |
No files matched your search
@@ -78,6 +78,35 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_signatures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL DEFAULT 1,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
image_data TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE user_signatures ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_shapes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL DEFAULT 1,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
shape_type TEXT NOT NULL DEFAULT 'rectangle',
|
||||||
|
fill_color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||||
|
border_color TEXT NOT NULL DEFAULT '#1e3a8a',
|
||||||
|
border_width INTEGER NOT NULL DEFAULT 3,
|
||||||
|
is_favorite INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS ticket_draft_notes (
|
CREATE TABLE IF NOT EXISTS ticket_draft_notes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
|||||||
// 2. ILIKE -> LIKE (MySQL LIKE is case-insensitive by default)
|
// 2. ILIKE -> LIKE (MySQL LIKE is case-insensitive by default)
|
||||||
translatedSql = translatedSql.replace(/\bILIKE\b/gi, 'LIKE');
|
translatedSql = translatedSql.replace(/\bILIKE\b/gi, 'LIKE');
|
||||||
|
|
||||||
|
// 2b. PostgreSQL regex ~* / ~ -> REGEXP
|
||||||
|
translatedSql = translatedSql.replace(/~\*/g, 'REGEXP');
|
||||||
|
translatedSql = translatedSql.replace(/~/g, 'REGEXP');
|
||||||
|
|
||||||
// 3. PostgreSQL string concatenation '||' -> CONCAT(...) in ticket number generator
|
// 3. PostgreSQL string concatenation '||' -> CONCAT(...) in ticket number generator
|
||||||
translatedSql = translatedSql.replace(/md5\(random\(\)::text\s*\|\|\s*clock_timestamp\(\)::text\)/gi, 'MD5(CONCAT(RAND(), NOW()))');
|
translatedSql = translatedSql.replace(/md5\(random\(\)::text\s*\|\|\s*clock_timestamp\(\)::text\)/gi, 'MD5(CONCAT(RAND(), NOW()))');
|
||||||
|
|
||||||
@@ -182,13 +186,6 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
|||||||
options: '-c timezone=UTC', // Ensure connection timezone is UTC
|
options: '-c timezone=UTC', // Ensure connection timezone is UTC
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure connection timezone is UTC via query fallback
|
|
||||||
pool.on('connect', (client) => {
|
|
||||||
client.query("SET TIME ZONE 'UTC'").catch(err => {
|
|
||||||
console.error('[DB] Errore nell\'impostazione della timezone UTC per Postgres:', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
pool.on('error', (err) => {
|
pool.on('error', (err) => {
|
||||||
console.error('Unexpected error on idle client', err);
|
console.error('Unexpected error on idle client', err);
|
||||||
});
|
});
|
||||||
|
|||||||
+418
-4
@@ -445,14 +445,35 @@ body {
|
|||||||
min-width: 22px;
|
min-width: 22px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
display: none;
|
display: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-badge:not(:empty) {
|
.nav-badge:not(:empty) {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-badge.hidden-badge {
|
||||||
|
display: inline-block !important;
|
||||||
|
opacity: 0.6;
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
min-width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge.hidden-badge::after {
|
||||||
|
content: "🙈";
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: var(--space-md) var(--space-lg);
|
padding: var(--space-md) var(--space-lg);
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
@@ -929,6 +950,7 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-tooltip {
|
.queue-tooltip {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -949,6 +971,7 @@ body {
|
|||||||
transition: opacity 0.05s ease, visibility 0.05s ease;
|
transition: opacity 0.05s ease, visibility 0.05s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-cell:hover .queue-tooltip {
|
.queue-cell:hover .queue-tooltip {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -1209,6 +1232,31 @@ body {
|
|||||||
color: var(--accent-primary-hover);
|
color: var(--accent-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-right .btn svg {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-right .btn:not(:disabled):hover svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Pagination ---- */
|
/* ---- Pagination ---- */
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2456,10 +2504,12 @@ body {
|
|||||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
|
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
|
||||||
border-color: rgba(239, 68, 68, 0.6);
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), inset 0 0 30px rgba(239, 68, 68, 0.4);
|
box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), inset 0 0 30px rgba(239, 68, 68, 0.4);
|
||||||
border-color: rgba(239, 68, 68, 1);
|
border-color: rgba(239, 68, 68, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
|
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4), inset 0 0 15px rgba(239, 68, 68, 0.2);
|
||||||
border-color: rgba(239, 68, 68, 0.6);
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
@@ -2481,11 +2531,13 @@ body {
|
|||||||
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
||||||
border-color: rgba(16, 112, 202, 0.5);
|
border-color: rgba(16, 112, 202, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 15px rgba(16, 112, 202, 0.8), inset 0 0 5px rgba(16, 112, 202, 0.3);
|
box-shadow: 0 0 15px rgba(16, 112, 202, 0.8), inset 0 0 5px rgba(16, 112, 202, 0.3);
|
||||||
border-color: rgba(16, 112, 202, 0.9);
|
border-color: rgba(16, 112, 202, 0.9);
|
||||||
background: rgba(16, 112, 202, 0.15);
|
background: rgba(16, 112, 202, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
||||||
border-color: rgba(16, 112, 202, 0.5);
|
border-color: rgba(16, 112, 202, 0.5);
|
||||||
@@ -2527,11 +2579,13 @@ body {
|
|||||||
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
||||||
border-color: rgba(239, 68, 68, 0.6);
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.8), inset 0 0 5px rgba(239, 68, 68, 0.3);
|
box-shadow: 0 0 15px rgba(239, 68, 68, 0.8), inset 0 0 5px rgba(239, 68, 68, 0.3);
|
||||||
border-color: rgba(239, 68, 68, 0.9);
|
border-color: rgba(239, 68, 68, 0.9);
|
||||||
background: rgba(239, 68, 68, 0.15) !important;
|
background: rgba(239, 68, 68, 0.15) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
||||||
border-color: rgba(239, 68, 68, 0.6);
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
@@ -2550,11 +2604,13 @@ body {
|
|||||||
/* ---- Ticket Tab Bar System ---- */
|
/* ---- Ticket Tab Bar System ---- */
|
||||||
.tabs-bar {
|
.tabs-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap; /* Wraps to new line if too many tabs */
|
flex-wrap: wrap;
|
||||||
|
/* Wraps to new line if too many tabs */
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2563,7 +2619,8 @@ body {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-right: 1px solid var(--border-subtle);
|
border-right: 1px solid var(--border-subtle);
|
||||||
border-radius: 0 !important; /* Square corners */
|
border-radius: 0 !important;
|
||||||
|
/* Square corners */
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -2572,15 +2629,19 @@ body {
|
|||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item:hover {
|
.tab-item:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item.active {
|
.tab-item.active {
|
||||||
background: var(--bg-primary); /* Blend with main content background */
|
background: var(--bg-primary);
|
||||||
|
/* Blend with main content background */
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
border-bottom: 2px solid var(--accent-primary);
|
border-bottom: 2px solid var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item .tab-close {
|
.tab-item .tab-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2592,6 +2653,7 @@ body {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item .tab-close:hover {
|
.tab-item .tab-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
@@ -2601,14 +2663,17 @@ body {
|
|||||||
#view-container.ticket-view-active {
|
#view-container.ticket-view-active {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#view-container.ticket-view-active .back-link {
|
#view-container.ticket-view-active .back-link {
|
||||||
margin: var(--space-md) var(--space-xl) var(--space-xs);
|
margin: var(--space-md) var(--space-xl) var(--space-xs);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
#view-container.ticket-view-active .ticket-detail {
|
#view-container.ticket-view-active .ticket-detail {
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#view-container.ticket-view-active .ticket-detail .card {
|
#view-container.ticket-view-active .ticket-detail .card {
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
border-left: none !important;
|
border-left: none !important;
|
||||||
@@ -2630,6 +2695,7 @@ body {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect-item {
|
.multiselect-item {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -2639,14 +2705,17 @@ body {
|
|||||||
transition: background-color 0.15s, color 0.15s;
|
transition: background-color 0.15s, color 0.15s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect-item:hover {
|
.multiselect-item:hover {
|
||||||
background-color: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
background-color: var(--bg-hover, rgba(0, 0, 0, 0.05));
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect-item.selected {
|
.multiselect-item.selected {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .multiselect-item:hover,
|
.theme-dark .multiselect-item:hover,
|
||||||
.theme-rosso .multiselect-item:hover,
|
.theme-rosso .multiselect-item:hover,
|
||||||
.theme-naturale .multiselect-item:hover,
|
.theme-naturale .multiselect-item:hover,
|
||||||
@@ -2655,6 +2724,7 @@ body {
|
|||||||
.theme-fairytale .multiselect-item:hover {
|
.theme-fairytale .multiselect-item:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-config-row {
|
.line-config-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -2665,15 +2735,359 @@ body {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: border-color var(--transition-fast);
|
transition: border-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-config-row:hover {
|
.line-config-row:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-config-row .line-name {
|
.line-config-row .line-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-config-row .line-actions {
|
.line-config-row .line-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover popover per dettagli articolo (pulsante i) */
|
||||||
|
.article-info-popover-wrapper:hover .article-info-popover,
|
||||||
|
.article-info-popover:hover {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info-popover::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PDF.js Text Layer Styling per selezione testo pulita */
|
||||||
|
.textLayer {
|
||||||
|
position: absolute;
|
||||||
|
text-align: initial;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.0;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer span,
|
||||||
|
.textLayer br {
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
position: absolute;
|
||||||
|
white-space: pre;
|
||||||
|
cursor: text;
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer ::selection,
|
||||||
|
.textLayer ::-moz-selection,
|
||||||
|
.textLayer span::selection,
|
||||||
|
.textLayer span::-moz-selection {
|
||||||
|
background: rgba(59, 130, 246, 0.35) !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Linux Terminal Console ---- */
|
||||||
|
.console-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-md);
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-window {
|
||||||
|
width: 900px;
|
||||||
|
max-width: 95vw;
|
||||||
|
height: 600px;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-header {
|
||||||
|
height: 38px;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-window-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-btn {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-btn.btn-close { background: #ff5f56; border: 1px solid #e0443e; }
|
||||||
|
.console-btn.btn-minimize { background: #ffbd2e; border: 1px solid #dea123; }
|
||||||
|
.console-btn.btn-maximize { background: #27c93f; border: 1px solid #1aab29; }
|
||||||
|
|
||||||
|
.console-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #a6adc8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Themes */
|
||||||
|
.theme-dark .console-window { background: #0f1419; color: #e6edf3; }
|
||||||
|
.theme-dark .console-prompt .user-part { color: #38bdf8; font-weight: bold; }
|
||||||
|
.theme-dark .console-prompt .path-part { color: #818cf8; }
|
||||||
|
.theme-dark .cmd-highlight { color: #38bdf8; font-weight: bold; }
|
||||||
|
.theme-dark .cmd-executed { color: #f43f5e; font-weight: bold; }
|
||||||
|
|
||||||
|
.theme-green .console-window { background: #051a05; color: #4ade80; }
|
||||||
|
.theme-green .console-prompt .user-part { color: #22c55e; font-weight: bold; }
|
||||||
|
.theme-green .console-prompt .path-part { color: #86efac; }
|
||||||
|
.theme-green .cmd-highlight { color: #4ade80; font-weight: bold; }
|
||||||
|
.theme-green .cmd-executed { color: #a7f3d0; font-weight: bold; }
|
||||||
|
.theme-green .console-header { background: #082808; }
|
||||||
|
|
||||||
|
.theme-amber .console-window { background: #1a1202; color: #fbbf24; }
|
||||||
|
.theme-amber .console-prompt .user-part { color: #f59e0b; font-weight: bold; }
|
||||||
|
.theme-amber .console-prompt .path-part { color: #fde68a; }
|
||||||
|
.theme-amber .cmd-highlight { color: #fbbf24; font-weight: bold; }
|
||||||
|
.theme-amber .cmd-executed { color: #fef08a; font-weight: bold; }
|
||||||
|
.theme-amber .console-header { background: #2a1d04; }
|
||||||
|
|
||||||
|
.theme-cyber .console-window { background: #0d0221; color: #00f5d4; }
|
||||||
|
.theme-cyber .console-prompt .user-part { color: #7b2cbf; font-weight: bold; }
|
||||||
|
.theme-cyber .console-prompt .path-part { color: #f72585; }
|
||||||
|
.theme-cyber .cmd-highlight { color: #7209b7; font-weight: bold; }
|
||||||
|
.theme-cyber .cmd-executed { color: #4cc9f0; font-weight: bold; }
|
||||||
|
.theme-cyber .console-header { background: #19053d; }
|
||||||
|
|
||||||
|
.console-banner {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-welcome-text {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-line {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-output-line {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: 0;
|
||||||
|
caret-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success { color: #22c55e; }
|
||||||
|
.text-danger { color: #ef4444; }
|
||||||
|
.text-warning { color: #f59e0b; }
|
||||||
|
.text-info { color: #3b82f6; }
|
||||||
|
.text-muted { color: #64748b; }
|
||||||
|
.text-accent { color: #38bdf8; font-weight: bold; }
|
||||||
|
|
||||||
|
.dir-item { color: #60a5fa; font-weight: bold; }
|
||||||
|
.file-item { color: #e2e8f0; }
|
||||||
|
|
||||||
|
.console-help-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-help-table > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-name {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-desc {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neofetch-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 8px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neofetch-logo {
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neofetch-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-table th {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-table td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-mini {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-stream {
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-train-box {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ricerca Spudorata - Info Modal / Popover & Regex */
|
||||||
|
#spudorata-info-btn:hover {
|
||||||
|
background: var(--accent-primary) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 10px var(--accent-primary-glow);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spudorata-rules-popover {
|
||||||
|
animation: popoverFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popoverFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spudorata-regex-toggle:hover {
|
||||||
|
border-color: var(--accent-primary) !important;
|
||||||
|
background: var(--bg-card-hover) !important;
|
||||||
|
}
|
||||||
+29
-4
@@ -11,7 +11,7 @@
|
|||||||
<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"
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.snow.css" rel="stylesheet">
|
<link href="/vendor/quill.snow.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Note Temporanee</span>
|
<span>Note</span>
|
||||||
<span class="nav-badge" id="temp-notes-count"></span>
|
<span class="nav-badge" id="temp-notes-count"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
<span>Nuovo Ticket</span>
|
<span>Nuovo Ticket</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<!--
|
||||||
<li>
|
<li>
|
||||||
<a href="#/tickets/bulk" class="nav-link" data-view="bulk-tickets" id="nav-bulk-tickets">
|
<a href="#/tickets/bulk" class="nav-link" data-view="bulk-tickets" id="nav-bulk-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">
|
||||||
@@ -123,6 +124,7 @@
|
|||||||
<span>Apertura Massiva</span>
|
<span>Apertura Massiva</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
-->
|
||||||
<hr>
|
<hr>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/mail-management" class="nav-link" data-view="mail-management" id="nav-mail-management">
|
<a href="#/mail-management" class="nav-link" data-view="mail-management" id="nav-mail-management">
|
||||||
@@ -133,6 +135,24 @@
|
|||||||
<span>Gestione Mail</span>
|
<span>Gestione Mail</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/config-user" class="nav-link" data-view="config-user" id="nav-config-user">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Configurazioni Utente</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/shortcuts" class="nav-link" data-view="shortcuts" id="nav-shortcuts">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
||||||
|
</svg>
|
||||||
|
<span>Shortcuts</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/config-env" class="nav-link" data-view="config-env" id="nav-config-env">
|
<a href="#/config-env" class="nav-link" data-view="config-env" id="nav-config-env">
|
||||||
<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">
|
||||||
@@ -238,8 +258,10 @@
|
|||||||
<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="/vendor/quill.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="/vendor/chart.js"></script>
|
||||||
|
<script src="/vendor/pdf.min.js"></script>
|
||||||
|
<script src="/vendor/pdf-lib.min.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>
|
||||||
@@ -249,12 +271,15 @@
|
|||||||
<script src="/js/views/ticketCreate.js"></script>
|
<script src="/js/views/ticketCreate.js"></script>
|
||||||
<script src="/js/views/ticketBulk.js"></script>
|
<script src="/js/views/ticketBulk.js"></script>
|
||||||
<script src="/js/views/activityLog.js"></script>
|
<script src="/js/views/activityLog.js"></script>
|
||||||
|
<script src="/js/views/shortcuts.js"></script>
|
||||||
<script src="/js/views/emailCompose.js"></script>
|
<script src="/js/views/emailCompose.js"></script>
|
||||||
<script src="/js/views/mailManagement.js"></script>
|
<script src="/js/views/mailManagement.js"></script>
|
||||||
<script src="/js/views/timeAccounting.js"></script>
|
<script src="/js/views/timeAccounting.js"></script>
|
||||||
<script src="/js/views/ticketGroups.js"></script>
|
<script src="/js/views/ticketGroups.js"></script>
|
||||||
<script src="/js/views/envConfig.js"></script>
|
<script src="/js/views/envConfig.js"></script>
|
||||||
|
<script src="/js/views/userConfig.js"></script>
|
||||||
<script src="/js/views/noteTemporanee.js"></script>
|
<script src="/js/views/noteTemporanee.js"></script>
|
||||||
|
<script src="/js/views/consoleView.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+258
-6
@@ -53,12 +53,105 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
openTab(id, tn, title) {
|
getShortcut(id) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('otrs_shortcuts');
|
||||||
|
const customMap = stored ? JSON.parse(stored) : {};
|
||||||
|
if (customMap[id]) return customMap[id];
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
create_ticket_open: { ctrl: true, alt: true, shift: false, key: 'n', enabled: true },
|
||||||
|
create_ticket_submit: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||||
|
quick_edit_save: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||||
|
send_email_submit: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||||
|
pdf_save_edited: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||||
|
ricerca_spudorata_open: { ctrl: true, alt: false, shift: true, key: 'f', enabled: true },
|
||||||
|
ticket_list_open: { ctrl: true, alt: false, shift: true, key: 'Backspace', enabled: true },
|
||||||
|
open_terminal_console: { ctrl: true, alt: true, shift: false, key: 't', enabled: true },
|
||||||
|
add_ticket_tab: { ctrl: true, alt: true, shift: false, key: 's', enabled: true },
|
||||||
|
};
|
||||||
|
return defaults[id] || { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
saveCustomShortcut(id, combo) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('otrs_shortcuts');
|
||||||
|
const customMap = stored ? JSON.parse(stored) : {};
|
||||||
|
customMap[id] = combo;
|
||||||
|
localStorage.setItem('otrs_shortcuts', JSON.stringify(customMap));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isShortcutTriggered(e, id) {
|
||||||
|
const sc = this.getShortcut(id);
|
||||||
|
if (!sc || !sc.enabled) return false;
|
||||||
|
|
||||||
|
const isCtrlOrCmd = sc.ctrl ? (e.ctrlKey || e.metaKey) : (!e.ctrlKey && !e.metaKey);
|
||||||
|
const isAlt = sc.alt ? e.altKey : !e.altKey;
|
||||||
|
const isShift = sc.shift ? e.shiftKey : !e.shiftKey;
|
||||||
|
|
||||||
|
let targetKey = sc.key.toLowerCase();
|
||||||
|
let pressedKey = (e.key || '').toLowerCase();
|
||||||
|
let codeKey = (e.code || '').replace(/^Key/, '').toLowerCase();
|
||||||
|
|
||||||
|
if (targetKey === 'space' && (pressedKey === ' ' || pressedKey === 'space' || codeKey === 'space')) {
|
||||||
|
return isCtrlOrCmd && isAlt && isShift;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesKey = (pressedKey === targetKey) || (codeKey === targetKey);
|
||||||
|
return isCtrlOrCmd && isAlt && isShift && matchesKey;
|
||||||
|
},
|
||||||
|
|
||||||
|
formatShortcutLabel(sc) {
|
||||||
|
if (!sc) return '';
|
||||||
|
const parts = [];
|
||||||
|
if (sc.ctrl) parts.push(navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl');
|
||||||
|
if (sc.alt) parts.push('Alt');
|
||||||
|
if (sc.shift) parts.push('Shift');
|
||||||
|
let keyName = sc.key;
|
||||||
|
if (keyName === 'Enter') keyName = 'Invio';
|
||||||
|
else if (keyName === ' ' || keyName === 'Space') keyName = 'Spazio';
|
||||||
|
parts.push(keyName);
|
||||||
|
return parts.join(' + ');
|
||||||
|
},
|
||||||
|
|
||||||
|
linkifyText(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
||||||
|
return text.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-primary); text-decoration:underline;">${url}</a>`);
|
||||||
|
},
|
||||||
|
|
||||||
|
openTab(id, tn, title, notify = false) {
|
||||||
const exists = this.tabs.find(t => String(t.id) === String(id));
|
const exists = this.tabs.find(t => String(t.id) === String(id));
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
this.tabs.push({ id, tn, title });
|
this.tabs.push({ id, tn, title });
|
||||||
this.saveTabs();
|
this.saveTabs();
|
||||||
|
if (notify && typeof Toast !== 'undefined') {
|
||||||
|
Toast.success(`Ticket #${tn || id} aggiunto alle schede!`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (notify && typeof Toast !== 'undefined') {
|
||||||
|
Toast.info(`Ticket #${tn || id} è già nelle schede`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignAndOpenTab(id, tn, title) {
|
||||||
|
const activeAgentId = parseInt(localStorage.getItem('activeAgentId') || '1', 10);
|
||||||
|
try {
|
||||||
|
await this.api(`/api/tickets/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ user_id: activeAgentId }),
|
||||||
|
});
|
||||||
|
Toast.success('Ticket assegnato all\'agente attivo e aperto in scheda!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error assigning ticket:', err);
|
||||||
|
Toast.error('Errore durante l\'assegnazione del ticket: ' + err.message);
|
||||||
|
}
|
||||||
|
this.openTab(id, tn, title);
|
||||||
},
|
},
|
||||||
|
|
||||||
addTabWithoutRedirect(id, tn, title) {
|
addTabWithoutRedirect(id, tn, title) {
|
||||||
@@ -197,6 +290,82 @@ const App = {
|
|||||||
toggleClearBtn();
|
toggleClearBtn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global keyboard shortcuts (e.g. Crea Nuovo Ticket, Ricerca Spudorata, Torna a Elenco Ticket)
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (this.isShortcutTriggered(e, 'create_ticket_open')) {
|
||||||
|
if (window.location.hash !== '#/tickets/new') {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.hash = '#/tickets/new';
|
||||||
|
}
|
||||||
|
} else if (this.isShortcutTriggered(e, 'ricerca_spudorata_open')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!window.location.hash.startsWith('#/ricerca-spudorata')) {
|
||||||
|
window.location.hash = '#/ricerca-spudorata';
|
||||||
|
} else {
|
||||||
|
const searchInput = document.getElementById('spudorata-search-input');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.focus();
|
||||||
|
searchInput.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.isShortcutTriggered(e, 'ticket_list_open')) {
|
||||||
|
if (window.location.hash !== '#/tickets' && window.location.hash !== '') {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.hash = '#/tickets';
|
||||||
|
}
|
||||||
|
} else if (this.isShortcutTriggered(e, 'open_terminal_console')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const consoleObj = window.ConsoleView || (typeof ConsoleView !== 'undefined' ? ConsoleView : null);
|
||||||
|
if (consoleObj) {
|
||||||
|
consoleObj.toggle();
|
||||||
|
}
|
||||||
|
} else if (this.isShortcutTriggered(e, 'add_ticket_tab')) {
|
||||||
|
e.preventDefault();
|
||||||
|
// 1. Check if in ticket detail
|
||||||
|
const detailTabBtn = document.querySelector('.ticket-header .open-tab-btn, .ticket-detail .open-tab-btn');
|
||||||
|
if (detailTabBtn) {
|
||||||
|
detailTabBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if multiple tickets selected in list
|
||||||
|
const selectedRows = document.querySelectorAll('.ticket-table tbody tr.selected, .results-table tbody tr.selected');
|
||||||
|
if (selectedRows.length > 0) {
|
||||||
|
let count = 0;
|
||||||
|
selectedRows.forEach(row => {
|
||||||
|
const tabBtn = row.querySelector('.open-tab-btn');
|
||||||
|
if (tabBtn) {
|
||||||
|
const id = tabBtn.dataset.id;
|
||||||
|
const tn = tabBtn.dataset.tn;
|
||||||
|
const title = tabBtn.dataset.title || '';
|
||||||
|
if (id) {
|
||||||
|
this.openTab(parseInt(id, 10), tn, title);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (count > 0 && typeof Toast !== 'undefined') {
|
||||||
|
Toast.success(`${count} ticket aggiunti alle schede!`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check hovered or focused row in any ticket table/list
|
||||||
|
const hoveredRow = document.querySelector('.ticket-table tbody tr:hover, .results-table tbody tr:hover, .ticket-table tbody tr:focus-within, .results-table tbody tr:focus-within');
|
||||||
|
if (hoveredRow) {
|
||||||
|
const tabBtn = hoveredRow.querySelector('.open-tab-btn');
|
||||||
|
if (tabBtn) {
|
||||||
|
tabBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof Toast !== 'undefined') {
|
||||||
|
Toast.info('Seleziona o apri un ticket per aggiungerlo in scheda');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Check DB connection
|
// Check DB connection
|
||||||
this.checkConnection();
|
this.checkConnection();
|
||||||
|
|
||||||
@@ -209,6 +378,39 @@ const App = {
|
|||||||
refreshBtn.addEventListener('click', () => this.refreshLookups());
|
refreshBtn.addEventListener('click', () => this.refreshLookups());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset old global hide setting if present
|
||||||
|
localStorage.removeItem('hide_sidebar_badges');
|
||||||
|
document.body.classList.remove('badges-hidden');
|
||||||
|
|
||||||
|
// Helper to check if a specific badge is hidden
|
||||||
|
this.isBadgeHidden = (badgeId) => localStorage.getItem(`hide_badge_${badgeId}`) === 'true';
|
||||||
|
|
||||||
|
// Bind click event on nav-badges to toggle individual visibility
|
||||||
|
document.querySelectorAll('.nav-badge').forEach(badge => {
|
||||||
|
if (!badge.id) return;
|
||||||
|
|
||||||
|
badge.title = 'Clicca per nascondere/mostrare questo contatore';
|
||||||
|
badge.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const isCurrentlyHidden = this.isBadgeHidden(badge.id);
|
||||||
|
const newHiddenState = !isCurrentlyHidden;
|
||||||
|
localStorage.setItem(`hide_badge_${badge.id}`, newHiddenState ? 'true' : 'false');
|
||||||
|
|
||||||
|
if (newHiddenState) {
|
||||||
|
badge.classList.add('hidden-badge');
|
||||||
|
Toast.info('Contatore nascosto');
|
||||||
|
} else {
|
||||||
|
badge.classList.remove('hidden-badge');
|
||||||
|
Toast.info('Contatore visibile');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh display text for badges
|
||||||
|
this.updateSidebarBadges();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Bind button close end of day tickets
|
// Bind button close end of day tickets
|
||||||
const btnCloseEod = document.getElementById('btn-close-end-of-day');
|
const btnCloseEod = document.getElementById('btn-close-end-of-day');
|
||||||
if (btnCloseEod) {
|
if (btnCloseEod) {
|
||||||
@@ -313,7 +515,7 @@ const App = {
|
|||||||
|
|
||||||
} else if (hash === '#/note-temporanee') {
|
} else if (hash === '#/note-temporanee') {
|
||||||
document.getElementById('nav-note-temporanee')?.classList.add('active');
|
document.getElementById('nav-note-temporanee')?.classList.add('active');
|
||||||
titleEl.textContent = 'Note Temporanee';
|
titleEl.textContent = 'Note';
|
||||||
NoteTemporaneeView.render();
|
NoteTemporaneeView.render();
|
||||||
|
|
||||||
} else if (hash === '#/tickets/new') {
|
} else if (hash === '#/tickets/new') {
|
||||||
@@ -336,6 +538,11 @@ const App = {
|
|||||||
titleEl.textContent = 'Storico Attività';
|
titleEl.textContent = 'Storico Attività';
|
||||||
ActivityLogView.render();
|
ActivityLogView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/shortcuts') {
|
||||||
|
document.getElementById('nav-shortcuts')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Shortcuts';
|
||||||
|
ShortcutsView.render();
|
||||||
|
|
||||||
} else if (hash === '#/mail-management') {
|
} else if (hash === '#/mail-management') {
|
||||||
document.getElementById('nav-mail-management')?.classList.add('active');
|
document.getElementById('nav-mail-management')?.classList.add('active');
|
||||||
titleEl.textContent = 'Gestione Mail';
|
titleEl.textContent = 'Gestione Mail';
|
||||||
@@ -343,9 +550,14 @@ const App = {
|
|||||||
|
|
||||||
} else if (hash === '#/config-env') {
|
} else if (hash === '#/config-env') {
|
||||||
document.getElementById('nav-config-env')?.classList.add('active');
|
document.getElementById('nav-config-env')?.classList.add('active');
|
||||||
titleEl.textContent = 'Modifica .env';
|
titleEl.textContent = 'Modifica file .env';
|
||||||
EnvConfigView.render();
|
EnvConfigView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/config-user') {
|
||||||
|
document.getElementById('nav-config-user')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Configurazioni Utente';
|
||||||
|
UserConfigView.render();
|
||||||
|
|
||||||
} else if (hash === '#/time-accounting') {
|
} else if (hash === '#/time-accounting') {
|
||||||
document.getElementById('nav-time-accounting')?.classList.add('active');
|
document.getElementById('nav-time-accounting')?.classList.add('active');
|
||||||
titleEl.textContent = 'Storico consuntivazione';
|
titleEl.textContent = 'Storico consuntivazione';
|
||||||
@@ -397,7 +609,13 @@ const App = {
|
|||||||
|
|
||||||
/** Ensure lookup data is loaded (cached) */
|
/** Ensure lookup data is loaded (cached) */
|
||||||
async ensureLookups(forceRefresh = false) {
|
async ensureLookups(forceRefresh = false) {
|
||||||
if (this.lookupsLoaded && !forceRefresh) return;
|
if (this.lookupsLoaded && !forceRefresh) {
|
||||||
|
const freshConfig = await this.api('/api/config').catch(() => null);
|
||||||
|
if (freshConfig && this.lookups) {
|
||||||
|
this.lookups.config = freshConfig;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check localStorage cache first if not forcing refresh
|
// Check localStorage cache first if not forcing refresh
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
@@ -408,6 +626,10 @@ const App = {
|
|||||||
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined || !this.lookups.customerUsers || !this.lookups.customer_users_version_1) {
|
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined || !this.lookups.customerUsers || !this.lookups.customer_users_version_1) {
|
||||||
throw new Error('Outdated config cache (missing autoTimeMinHour or customerUsers)');
|
throw new Error('Outdated config cache (missing autoTimeMinHour or customerUsers)');
|
||||||
}
|
}
|
||||||
|
const freshConfig = await this.api('/api/config').catch(() => null);
|
||||||
|
if (freshConfig) {
|
||||||
|
this.lookups.config = freshConfig;
|
||||||
|
}
|
||||||
this.lookupsLoaded = true;
|
this.lookupsLoaded = true;
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -842,20 +1064,41 @@ const App = {
|
|||||||
|
|
||||||
const badge = document.getElementById('open-ticket-count');
|
const badge = document.getElementById('open-ticket-count');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
|
const isHidden = this.isBadgeHidden ? this.isBadgeHidden('open-ticket-count') : false;
|
||||||
|
if (isHidden) {
|
||||||
|
badge.classList.add('hidden-badge');
|
||||||
|
badge.textContent = '';
|
||||||
|
} else {
|
||||||
|
badge.classList.remove('hidden-badge');
|
||||||
badge.textContent = stats.total_open > 0 ? stats.total_open : '';
|
badge.textContent = stats.total_open > 0 ? stats.total_open : '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const myBadge = document.getElementById('my-ticket-count');
|
const myBadge = document.getElementById('my-ticket-count');
|
||||||
if (myBadge) {
|
if (myBadge) {
|
||||||
|
const isHidden = this.isBadgeHidden ? this.isBadgeHidden('my-ticket-count') : false;
|
||||||
|
if (isHidden) {
|
||||||
|
myBadge.classList.add('hidden-badge');
|
||||||
|
myBadge.textContent = '';
|
||||||
|
} else {
|
||||||
|
myBadge.classList.remove('hidden-badge');
|
||||||
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
|
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const notes = await this.api('/api/notes');
|
const notes = await this.api('/api/notes');
|
||||||
const notesBadge = document.getElementById('temp-notes-count');
|
const notesBadge = document.getElementById('temp-notes-count');
|
||||||
if (notesBadge) {
|
if (notesBadge) {
|
||||||
|
const isHidden = this.isBadgeHidden ? this.isBadgeHidden('temp-notes-count') : false;
|
||||||
|
if (isHidden) {
|
||||||
|
notesBadge.classList.add('hidden-badge');
|
||||||
|
notesBadge.textContent = '';
|
||||||
|
} else {
|
||||||
|
notesBadge.classList.remove('hidden-badge');
|
||||||
notesBadge.textContent = (notes && notes.length > 0) ? notes.length : '';
|
notesBadge.textContent = (notes && notes.length > 0) ? notes.length : '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to update sidebar badges:', e);
|
console.warn('Failed to update sidebar badges:', e);
|
||||||
@@ -897,7 +1140,7 @@ const App = {
|
|||||||
<p style="margin-bottom: var(--space-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
|
<p style="margin-bottom: var(--space-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
|
||||||
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
|
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
|
||||||
<button id="confirm-btn-cancel" class="btn btn-ghost" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px);">${options.cancelText || 'Annulla'}</button>
|
<button id="confirm-btn-cancel" class="btn btn-ghost" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px);">${options.cancelText || 'Annulla'}</button>
|
||||||
<button id="confirm-btn-ok" class="btn btn-danger" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Conferma'}</button>
|
<button id="confirm-btn-ok" class="btn ${options.confirmClass || 'btn-danger'}" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Conferma'}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1136,10 +1379,19 @@ const App = {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(items[currentIndex]);
|
onSelect(items[currentIndex]);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Tab' || e.key === 'Escape') {
|
||||||
|
if (currentIndex >= 0 && items[currentIndex] && e.key === 'Tab') {
|
||||||
|
onSelect(items[currentIndex]);
|
||||||
|
}
|
||||||
suggestionsEl.style.display = 'none';
|
suggestionsEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inputEl.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (suggestionsEl) suggestionsEl.style.display = 'none';
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const Filters = {
|
|||||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
<input type="text" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
<input type="text" id="state-ms-search-input" name="state_search" class="form-input ms-search" placeholder="Cerca..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
${(lookups.states || []).map(s => {
|
${(lookups.states || []).map(s => {
|
||||||
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
|
const selectedList = String(this.state.state_id || '').split(',').filter(Boolean);
|
||||||
@@ -243,7 +243,7 @@ const Filters = {
|
|||||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; min-width: 480px; width: max-content; max-width: 85vw; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
<div class="multiselect-popover" id="queue-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; min-width: 480px; width: max-content; max-width: 85vw; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
<input type="text" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
<input type="text" id="queue-ms-search-input" name="queue_search" class="form-input ms-search" placeholder="Cerca coda..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:300px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
${(lookups.queues || []).map(q => {
|
${(lookups.queues || []).map(q => {
|
||||||
const selectedList = String(this.state.queue_id || '').split(',').filter(Boolean);
|
const selectedList = String(this.state.queue_id || '').split(',').filter(Boolean);
|
||||||
@@ -271,7 +271,7 @@ const Filters = {
|
|||||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="multiselect-popover" id="priority-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 200px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
<div class="multiselect-popover" id="priority-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 200px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
<input type="text" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
<input type="text" id="priority-ms-search-input" name="priority_search" class="form-input ms-search" placeholder="Cerca priorità..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
${(lookups.priorities || []).map(p => {
|
${(lookups.priorities || []).map(p => {
|
||||||
const selectedList = String(this.state.priority_id || '').split(',').filter(Boolean);
|
const selectedList = String(this.state.priority_id || '').split(',').filter(Boolean);
|
||||||
@@ -299,7 +299,7 @@ const Filters = {
|
|||||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="multiselect-popover" id="owner-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 240px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
<div class="multiselect-popover" id="owner-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 240px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
<input type="text" class="form-input ms-search" placeholder="Cerca owner..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
<input type="text" id="owner-ms-search-input" name="owner_search" class="form-input ms-search" placeholder="Cerca owner..." style="width:100%; padding:4px 8px; font-size:0.78rem; margin-bottom:6px; box-sizing:border-box;" autocomplete="off" />
|
||||||
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
<div class="ms-items-container" style="display:flex; flex-direction:column; gap:2px; max-height:180px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
${(lookups.users || []).map(u => {
|
${(lookups.users || []).map(u => {
|
||||||
const selectedList = String(this.state.user_id || '').split(',').filter(Boolean);
|
const selectedList = String(this.state.user_id || '').split(',').filter(Boolean);
|
||||||
@@ -326,7 +326,7 @@ const Filters = {
|
|||||||
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
<input type="text" id="customer-user-search-input" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
|
<input type="text" id="customer-user-search-input" name="customer_user_search" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
|
||||||
<div id="customer-user-items-container" style="display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
<div id="customer-user-items-container" style="display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
<!-- Populated dynamically -->
|
<!-- Populated dynamically -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* ConsoleView — Linux Terminal Simulation for OTRS Turbo
|
||||||
|
* Shortcut: Ctrl + Alt + T
|
||||||
|
*/
|
||||||
|
const ConsoleView = {
|
||||||
|
isOpen: false,
|
||||||
|
history: [],
|
||||||
|
historyIndex: -1,
|
||||||
|
currentTheme: 'dark', // dark, green, amber, cyber
|
||||||
|
activeAnimation: null,
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
open() {
|
||||||
|
let modal = document.getElementById('linux-console-modal');
|
||||||
|
if (!modal) {
|
||||||
|
this.createModal();
|
||||||
|
modal = document.getElementById('linux-console-modal');
|
||||||
|
}
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
this.isOpen = true;
|
||||||
|
const input = document.getElementById('console-input');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
const modal = document.getElementById('linux-console-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
this.stopAnimation();
|
||||||
|
this.isOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
createModal() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'linux-console-modal';
|
||||||
|
overlay.className = 'console-modal-overlay theme-dark';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="console-window">
|
||||||
|
<div class="console-header">
|
||||||
|
<div class="console-window-buttons">
|
||||||
|
<span class="console-btn btn-close" id="console-btn-close" title="Chiudi (Esc)"></span>
|
||||||
|
<span class="console-btn btn-minimize" id="console-btn-min"></span>
|
||||||
|
<span class="console-btn btn-maximize" id="console-btn-max"></span>
|
||||||
|
</div>
|
||||||
|
<div class="console-title">otrs-turbo@linux-server:~</div>
|
||||||
|
<div class="console-header-right">
|
||||||
|
<span class="console-badge">BASH v5.2.15</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="console-body" id="console-body">
|
||||||
|
<div class="console-welcome">
|
||||||
|
<pre class="console-banner">
|
||||||
|
____ _____ ____ ____ _____ _ _ ____ ____ ___
|
||||||
|
/ ___|_ _| _ \\/ ___| |_ _| | | | _ \\/ __ ) / _ \\
|
||||||
|
| | | | | |_) \\___ \\ | | | | | | |_) | _ \\| | | |
|
||||||
|
| |___ | | | _ < ___) | | | | |_| | _ <| |_) | |_| |
|
||||||
|
\\____| |_| |_| \\_\\____/ |_| \\___/|_| \\_\\____/ \\___/
|
||||||
|
</pre>
|
||||||
|
<div class="console-welcome-text">
|
||||||
|
Benvenuto nella console di simulazione Linux per <strong>OTRS Turbo</strong>.<br>
|
||||||
|
Digita <span class="cmd-highlight">help</span> per vedere l'elenco dei comandi disponibili.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="console-output"></div>
|
||||||
|
<div class="console-input-row" id="console-input-row">
|
||||||
|
<span class="console-prompt"><span class="user-part">agent@otrs-turbo</span>:<span class="path-part">~</span>$</span>
|
||||||
|
<input type="text" id="console-input" class="console-input" autocomplete="off" spellcheck="false" autocorrect="off" autocapitalize="none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Event Bindings
|
||||||
|
overlay.querySelector('#console-btn-close').addEventListener('click', () => this.close());
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) this.close();
|
||||||
|
else {
|
||||||
|
const input = document.getElementById('console-input');
|
||||||
|
if (input && document.activeElement !== input) input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = overlay.querySelector('#console-input');
|
||||||
|
input.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
||||||
|
},
|
||||||
|
|
||||||
|
stopAnimation() {
|
||||||
|
if (this.activeAnimation) {
|
||||||
|
clearInterval(this.activeAnimation);
|
||||||
|
this.activeAnimation = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleKeyDown(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const input = e.target;
|
||||||
|
const cmdText = input.value.trim();
|
||||||
|
input.value = '';
|
||||||
|
if (cmdText) {
|
||||||
|
this.history.push(cmdText);
|
||||||
|
this.historyIndex = this.history.length;
|
||||||
|
this.executeCommand(cmdText);
|
||||||
|
} else {
|
||||||
|
this.printPrompt('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.history.length > 0 && this.historyIndex > 0) {
|
||||||
|
this.historyIndex--;
|
||||||
|
e.target.value = this.history[this.historyIndex];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.historyIndex < this.history.length - 1) {
|
||||||
|
this.historyIndex++;
|
||||||
|
e.target.value = this.history[this.historyIndex];
|
||||||
|
} else {
|
||||||
|
this.historyIndex = this.history.length;
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.autocomplete(e.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
|
||||||
|
if (this.activeAnimation) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.stopAnimation();
|
||||||
|
this.printOutput('<span class="text-warning">^C Animation interrupted</span>');
|
||||||
|
this.showInputRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
autocomplete(inputEl) {
|
||||||
|
const val = inputEl.value.trim();
|
||||||
|
const commands = ['help', 'neofetch', 'otrsfetch', 'tickets', 'ls', 'cat', 'matrix', 'sl', 'sudo', 'whoami', 'theme', 'clear', 'history', 'exit'];
|
||||||
|
const matches = commands.filter(c => c.startsWith(val));
|
||||||
|
if (matches.length === 1) {
|
||||||
|
inputEl.value = matches[0] + ' ';
|
||||||
|
} else if (matches.length > 1) {
|
||||||
|
this.printOutput(`<span class="text-muted">${matches.join(' ')}</span>`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
printPrompt(cmdText) {
|
||||||
|
const outputDiv = document.getElementById('console-output');
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'console-line';
|
||||||
|
line.innerHTML = `<span class="console-prompt"><span class="user-part">agent@otrs-turbo</span>:<span class="path-part">~</span>$</span> <span class="cmd-executed">${App.escapeHtml(cmdText)}</span>`;
|
||||||
|
outputDiv.appendChild(line);
|
||||||
|
this.scrollToBottom();
|
||||||
|
},
|
||||||
|
|
||||||
|
printOutput(htmlContent) {
|
||||||
|
const outputDiv = document.getElementById('console-output');
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'console-output-line';
|
||||||
|
line.innerHTML = htmlContent;
|
||||||
|
outputDiv.appendChild(line);
|
||||||
|
this.scrollToBottom();
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
const body = document.getElementById('console-body');
|
||||||
|
if (body) {
|
||||||
|
body.scrollTop = body.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showInputRow() {
|
||||||
|
const row = document.getElementById('console-input-row');
|
||||||
|
if (row) row.style.display = 'flex';
|
||||||
|
const input = document.getElementById('console-input');
|
||||||
|
if (input) input.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
hideInputRow() {
|
||||||
|
const row = document.getElementById('console-input-row');
|
||||||
|
if (row) row.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
async executeCommand(rawCmd) {
|
||||||
|
this.stopAnimation();
|
||||||
|
this.printPrompt(rawCmd);
|
||||||
|
const parts = rawCmd.trim().split(/\s+/);
|
||||||
|
const mainCmd = parts[0].toLowerCase();
|
||||||
|
const args = parts.slice(1);
|
||||||
|
|
||||||
|
switch (mainCmd) {
|
||||||
|
case 'help':
|
||||||
|
this.cmdHelp();
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
document.getElementById('console-output').innerHTML = '';
|
||||||
|
break;
|
||||||
|
case 'whoami':
|
||||||
|
const agent = App.activeAgent || 'gabriele.cimaschi';
|
||||||
|
this.printOutput(`<span class="text-success">${App.escapeHtml(agent)}</span> (OTRS Turbo Operator)`);
|
||||||
|
break;
|
||||||
|
case 'sudo':
|
||||||
|
this.printOutput(`<span class="text-danger">agent is not in the sudoers file. This incident will be reported to OTRS Admins.</span>`);
|
||||||
|
break;
|
||||||
|
case 'ls':
|
||||||
|
this.cmdLs();
|
||||||
|
break;
|
||||||
|
case 'cat':
|
||||||
|
this.cmdCat(args[0]);
|
||||||
|
break;
|
||||||
|
case 'history':
|
||||||
|
this.cmdHistory();
|
||||||
|
break;
|
||||||
|
case 'neofetch':
|
||||||
|
case 'otrsfetch':
|
||||||
|
await this.cmdNeofetch();
|
||||||
|
break;
|
||||||
|
case 'tickets':
|
||||||
|
await this.cmdTickets();
|
||||||
|
break;
|
||||||
|
case 'theme':
|
||||||
|
this.cmdTheme(args[0]);
|
||||||
|
break;
|
||||||
|
case 'matrix':
|
||||||
|
this.cmdMatrix();
|
||||||
|
break;
|
||||||
|
case 'sl':
|
||||||
|
this.cmdSl();
|
||||||
|
break;
|
||||||
|
case 'exit':
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.printOutput(`<span class="text-danger">bash: ${App.escapeHtml(mainCmd)}: command not found.</span> Digita <span class="cmd-highlight">help</span> per la lista dei comandi.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cmdHelp() {
|
||||||
|
const helpHtml = `
|
||||||
|
<div class="console-help-table">
|
||||||
|
<div><span class="cmd-name">help</span><span class="cmd-desc">Mostra questo messaggio di aiuto</span></div>
|
||||||
|
<div><span class="cmd-name">neofetch</span> / <span class="cmd-name">otrsfetch</span><span class="cmd-desc">Visualizza informazioni ed ASCII art di OTRS Turbo</span></div>
|
||||||
|
<div><span class="cmd-name">tickets</span><span class="cmd-desc">Elenca i ticket recenti ed i conteggi direttamente dal DB</span></div>
|
||||||
|
<div><span class="cmd-name">ls</span><span class="cmd-desc">Elenca i file della directory corrente</span></div>
|
||||||
|
<div><span class="cmd-name">cat <file></span><span class="cmd-desc">Legge il contenuto di un file virtuale</span></div>
|
||||||
|
<div><span class="cmd-name">whoami</span><span class="cmd-desc">Mostra l'agente attualmente autenticato</span></div>
|
||||||
|
<div><span class="cmd-name">theme <dark|green|amber|cyber></span><span class="cmd-desc">Cambia il tema cromatico del terminale</span></div>
|
||||||
|
<div><span class="cmd-name">matrix</span><span class="cmd-desc">Avvia l'animazione della pioggia digitale Matrix (Ctrl+C per uscire)</span></div>
|
||||||
|
<div><span class="cmd-name">sl</span><span class="cmd-desc">Fai passare la locomotiva a vapore ASCII nel terminale</span></div>
|
||||||
|
<div><span class="cmd-name">sudo <comando></span><span class="cmd-desc">Esegue un comando con privilegi di amministratore</span></div>
|
||||||
|
<div><span class="cmd-name">history</span><span class="cmd-desc">Mostra lo storico dei comandi usati</span></div>
|
||||||
|
<div><span class="cmd-name">clear</span><span class="cmd-desc">Pulisce lo schermo della console</span></div>
|
||||||
|
<div><span class="cmd-name">exit</span><span class="cmd-desc">Chiude la finestra del terminale</span></div>
|
||||||
|
</div>`;
|
||||||
|
this.printOutput(helpHtml);
|
||||||
|
},
|
||||||
|
|
||||||
|
cmdLs() {
|
||||||
|
this.printOutput(`
|
||||||
|
<span class="dir-item">drwxr-xr-x 2 agent otrs 4096 Sep 16 15:00 .</span>
|
||||||
|
<span class="dir-item">drwxr-xr-x 10 agent otrs 4096 Sep 16 15:00 ..</span>
|
||||||
|
<span class="file-item">-rw-r--r-- 1 agent otrs 2450 Sep 16 15:00 tickets.db</span>
|
||||||
|
<span class="file-item">-rw-r--r-- 1 agent otrs 777 Sep 16 15:00 motivational.txt</span>
|
||||||
|
<span class="file-item">-rw-r--r-- 1 agent otrs 1180 Sep 16 15:00 demotivational.txt</span>
|
||||||
|
<span class="file-item">-rw-r--r-- 1 agent otrs 512 Sep 16 15:00 secret.key</span>
|
||||||
|
<span class="file-item">-rwxr-xr-x 1 agent otrs 8192 Sep 16 15:00 system.log</span>
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
cmdCat(fileName) {
|
||||||
|
if (!fileName) {
|
||||||
|
this.printOutput('<span class="text-danger">cat: specifica un nome file. Es: cat motivational.txt</span>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = fileName.toLowerCase();
|
||||||
|
if (name === 'motivational.txt') {
|
||||||
|
const phrases = App.motivationalPhrases || [
|
||||||
|
"Ogni ticket chiuso è una vittoria verso la fine della giornata!",
|
||||||
|
"La velocità è la nostra virtù con OTRS Turbo!",
|
||||||
|
"Mantieni la calma ed applica le macro."
|
||||||
|
];
|
||||||
|
const random = phrases[Math.floor(Math.random() * phrases.length)];
|
||||||
|
this.printOutput(`<span class="text-info">💡 ${App.escapeHtml(random)}</span>`);
|
||||||
|
} else if (name === 'demotivational.txt') {
|
||||||
|
const phrases = App.demotivationalPhrases || [
|
||||||
|
"Nessuna buona azione resta impunita: ogni ticket chiuso ne genera due nuovi.",
|
||||||
|
"Il weekend si avvicina, ma la coda di ticket non dorme mai.",
|
||||||
|
"Hai davvero controllato se il cavo di rete è collegato?"
|
||||||
|
];
|
||||||
|
const random = phrases[Math.floor(Math.random() * phrases.length)];
|
||||||
|
this.printOutput(`<span class="text-warning">💀 ${App.escapeHtml(random)}</span>`);
|
||||||
|
} else if (name === 'secret.key') {
|
||||||
|
this.printOutput('<span class="text-danger">🔒 Access Denied: 0x8840192A (Encryption key locked by OTRS Turbo core)</span>');
|
||||||
|
} else if (name === 'tickets.db') {
|
||||||
|
this.printOutput('<span class="text-muted">SQLite/PG Binary format header. Usare il comando "tickets" per consultare i dati.</span>');
|
||||||
|
} else if (name === 'system.log') {
|
||||||
|
this.printOutput('<span class="text-muted">[INFO] 2026-09-16 15:10:00 - OTRS Turbo Engine v1.0 running cleanly.\n[INFO] LDAP auto-sync skipped (within 24h window).\n[OK] Database query latency < 4ms.</span>');
|
||||||
|
} else {
|
||||||
|
this.printOutput(`<span class="text-danger">cat: ${App.escapeHtml(fileName)}: No such file or directory</span>`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cmdHistory() {
|
||||||
|
if (this.history.length === 0) {
|
||||||
|
this.printOutput('<span class="text-muted">Nessun comando nello storico.</span>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = this.history.map((c, i) => ` ${i + 1} ${App.escapeHtml(c)}`).join('\n');
|
||||||
|
this.printOutput(`<pre class="text-muted">${lines}</pre>`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cmdNeofetch() {
|
||||||
|
let ticketStats = { total: 0, open: 0 };
|
||||||
|
try {
|
||||||
|
const data = await App.api('/api/tickets/stats');
|
||||||
|
if (data) {
|
||||||
|
ticketStats.total = data.total_tickets || data.total || 0;
|
||||||
|
ticketStats.open = data.open_tickets || data.open || 0;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const agent = App.activeAgent || 'gabriele.cimaschi';
|
||||||
|
const html = `
|
||||||
|
<div class="neofetch-container">
|
||||||
|
<pre class="neofetch-logo">
|
||||||
|
██████╗ ████████╗██████╗ ██████╗
|
||||||
|
██╔═══██╗╚══██╔══╝██╔══██╗██╔════╝
|
||||||
|
██║ ██║ ██║ ██████╔╝╚█████╗
|
||||||
|
██║ ██║ ██║ ██╔══██╗ ╚═══██╗
|
||||||
|
╚██████╔╝ ██║ ██║ ██║██████╔╝
|
||||||
|
╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═════╝
|
||||||
|
⚡ T U R B O E N G I N E
|
||||||
|
</pre>
|
||||||
|
<div class="neofetch-info">
|
||||||
|
<div><strong style="color:var(--accent-primary);">${App.escapeHtml(agent)}</strong>@<strong style="color:var(--accent-primary);">otrs-turbo</strong></div>
|
||||||
|
<div style="color:var(--border-subtle)">----------------------------------</div>
|
||||||
|
<div><strong>OS:</strong> OTRS Turbo Linux Web Console</div>
|
||||||
|
<div><strong>Host:</strong> Enterprise Ticket Engine 1.0</div>
|
||||||
|
<div><strong>Uptime:</strong> 17 hours, 42 mins</div>
|
||||||
|
<div><strong>Shell:</strong> OTRS-Bash 5.2</div>
|
||||||
|
<div><strong>Engine:</strong> Express / PG SQL Node.js 18.x</div>
|
||||||
|
<div><strong>Tickets In DB:</strong> ${ticketStats.total} total (${ticketStats.open} aperti)</div>
|
||||||
|
<div><strong>Theme:</strong> ${this.currentTheme}</div>
|
||||||
|
<div class="color-palette" style="margin-top:6px;">
|
||||||
|
<span style="background:#ef4444; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||||
|
<span style="background:#f59e0b; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||||
|
<span style="background:#10b981; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||||
|
<span style="background:#3b82f6; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||||
|
<span style="background:#8b5cf6; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||||
|
<span style="background:#ec4899; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
this.printOutput(html);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cmdTickets() {
|
||||||
|
this.printOutput('<span class="text-muted">Caricamento ultimi ticket dal database...</span>');
|
||||||
|
try {
|
||||||
|
const res = await App.api('/api/tickets?page=1&per_page=8');
|
||||||
|
const tickets = res.tickets || [];
|
||||||
|
if (tickets.length === 0) {
|
||||||
|
this.printOutput('<span class="text-warning">Nessun ticket trovato.</span>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = `
|
||||||
|
<table class="console-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>TN</th>
|
||||||
|
<th>Titolo</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Coda</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tickets.forEach(t => {
|
||||||
|
rows += `
|
||||||
|
<tr>
|
||||||
|
<td class="text-accent">#${App.escapeHtml(t.tn)}</td>
|
||||||
|
<td>${App.escapeHtml((t.title || '').substring(0, 35))}</td>
|
||||||
|
<td><span class="badge-mini">${App.escapeHtml(t.state_name || '')}</span></td>
|
||||||
|
<td>${App.escapeHtml(t.queue_name || '')}</td>
|
||||||
|
<td>${App.escapeHtml(t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—'))}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows += `</tbody></table>`;
|
||||||
|
this.printOutput(rows);
|
||||||
|
} catch (err) {
|
||||||
|
this.printOutput(`<span class="text-danger">Errore recupero ticket: ${App.escapeHtml(err.message)}</span>`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cmdTheme(themeName) {
|
||||||
|
const valid = ['dark', 'green', 'amber', 'cyber'];
|
||||||
|
if (!themeName || !valid.includes(themeName.toLowerCase())) {
|
||||||
|
this.printOutput('<span class="text-warning">Uso: theme <dark|green|amber|cyber></span>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = themeName.toLowerCase();
|
||||||
|
const overlay = document.getElementById('linux-console-modal');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.className = `console-modal-overlay theme-${t}`;
|
||||||
|
}
|
||||||
|
this.currentTheme = t;
|
||||||
|
this.printOutput(`<span class="text-success">Tema terminale cambiato in: <strong>${t}</strong></span>`);
|
||||||
|
},
|
||||||
|
|
||||||
|
cmdMatrix() {
|
||||||
|
this.printOutput('<span class="text-success">Inizializzazione matrice in corso... premi Esc o Ctrl+C per fermare.</span>');
|
||||||
|
this.hideInputRow();
|
||||||
|
const outputDiv = document.getElementById('console-output');
|
||||||
|
const matrixBox = document.createElement('pre');
|
||||||
|
matrixBox.className = 'matrix-stream';
|
||||||
|
outputDiv.appendChild(matrixBox);
|
||||||
|
|
||||||
|
const chars = '0123456789ABCDEFヲアウエオカキケコサシスセソタツテナニヌネハヒホマミムメモヤユラリワ';
|
||||||
|
const cols = 60;
|
||||||
|
|
||||||
|
this.activeAnimation = setInterval(() => {
|
||||||
|
let line = '';
|
||||||
|
for (let i = 0; i < cols; i++) {
|
||||||
|
const char = chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
line += char + ' ';
|
||||||
|
}
|
||||||
|
matrixBox.textContent += line + '\n';
|
||||||
|
this.scrollToBottom();
|
||||||
|
if (matrixBox.textContent.length > 3000) {
|
||||||
|
matrixBox.textContent = matrixBox.textContent.substring(1500);
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
},
|
||||||
|
|
||||||
|
cmdSl() {
|
||||||
|
this.printOutput('<span class="text-warning">Choo Choo! Locomotiva in transito...</span>');
|
||||||
|
this.hideInputRow();
|
||||||
|
const outputDiv = document.getElementById('console-output');
|
||||||
|
const trainBox = document.createElement('pre');
|
||||||
|
trainBox.className = 'sl-train-box';
|
||||||
|
outputDiv.appendChild(trainBox);
|
||||||
|
|
||||||
|
const trainAscii = [
|
||||||
|
" ==== ________ _____",
|
||||||
|
" _D _| |_______/ \\__I_I_____===__|__ |",
|
||||||
|
" | (_)--- ___/ OTRS \\_____/___======|___|",
|
||||||
|
" /___/____/______TURBO________/________(O)___/",
|
||||||
|
" $(O)(O)(O) (O)(O)(O)"
|
||||||
|
];
|
||||||
|
|
||||||
|
let pos = 80;
|
||||||
|
this.activeAnimation = setInterval(() => {
|
||||||
|
pos -= 3;
|
||||||
|
if (pos < -60) {
|
||||||
|
this.stopAnimation();
|
||||||
|
this.showInputRow();
|
||||||
|
trainBox.remove();
|
||||||
|
this.printOutput('<span class="text-success">La locomotiva ha completato la corsa!</span>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spaces = ' '.repeat(Math.max(0, pos));
|
||||||
|
trainBox.textContent = trainAscii.map(l => spaces + l).join('\n');
|
||||||
|
this.scrollToBottom();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ConsoleView = ConsoleView;
|
||||||
@@ -481,17 +481,8 @@ const DashboardView = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update open ticket count in sidebar badge
|
// Update sidebar counts using centralized method
|
||||||
const badge = document.getElementById('open-ticket-count');
|
App.updateSidebarBadges();
|
||||||
if (badge) {
|
|
||||||
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) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
|
|||||||
@@ -96,14 +96,30 @@ const EmailCompose = (() => {
|
|||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-tertiary);
|
background: #ffffff;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] #email-compose-modal .ec-editor-wrapper,
|
||||||
|
body.dark-mode #email-compose-modal .ec-editor-wrapper {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
#email-compose-modal .ec-editor-wrapper .ql-toolbar {
|
#email-compose-modal .ec-editor-wrapper .ql-toolbar {
|
||||||
border: none; border-bottom: 1px solid var(--border-subtle);
|
border: none; border-bottom: 1px solid var(--border-subtle);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
#email-compose-modal .ec-editor-wrapper .ql-container {
|
#email-compose-modal .ec-editor-wrapper .ql-container {
|
||||||
border: none; min-height: 220px; font-size: 0.9rem;
|
border: none; min-height: 260px; font-size: 0.92rem; font-family: Inter, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
#email-compose-modal .ec-editor-wrapper .ql-editor {
|
||||||
|
min-height: 260px;
|
||||||
|
padding: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
#email-compose-modal .ec-editor-wrapper .ql-editor.ql-blank::before {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
#email-compose-modal .ec-attachments-zone {
|
#email-compose-modal .ec-attachments-zone {
|
||||||
border: 2px dashed var(--border-subtle);
|
border: 2px dashed var(--border-subtle);
|
||||||
@@ -467,11 +483,12 @@ const EmailCompose = (() => {
|
|||||||
placeholder: 'Scrivi il testo della email...',
|
placeholder: 'Scrivi il testo della email...',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
|
||||||
[{ 'header': [1, 2, 3, false] }],
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
['link', 'image'],
|
|
||||||
[{ 'color': [] }, { 'background': [] }],
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image', 'code-block'],
|
||||||
['clean']
|
['clean']
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -542,7 +559,22 @@ const EmailCompose = (() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send
|
// Send
|
||||||
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl));
|
const handleEmailSend = () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl);
|
||||||
|
document.getElementById('ec-send').addEventListener('click', handleEmailSend);
|
||||||
|
|
||||||
|
// Keyboard shortcut handler for email compose modal
|
||||||
|
const handleEmailKeydown = (e) => {
|
||||||
|
if (!document.getElementById('email-compose-overlay')) {
|
||||||
|
document.removeEventListener('keydown', handleEmailKeydown, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (App.isShortcutTriggered(e, 'send_email_submit')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEmailSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEmailKeydown, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processFiles(files) {
|
function processFiles(files) {
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ const EnvConfigView = {
|
|||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:4px;">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:4px;">
|
||||||
<div style="display:flex; align-items:center; gap:6px;">
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
<span class="badge" style="background:var(--bg-secondary); border:1px solid var(--border-subtle); font-weight:700; font-size:0.75rem; color:var(--text-primary);">#${h.id}</span>
|
<span class="badge" style="background:var(--bg-secondary); border:1px solid var(--border-subtle); font-weight:700; font-size:0.75rem; color:var(--text-primary);">#${h.id}</span>
|
||||||
|
<button class="env-history-delete-btn" data-id="${h.id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:2px 5px; display:inline-flex; align-items:center; justify-content:center; color:var(--danger); transition:all 0.15s ease;" title="Elimina questa versione dallo storico">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px; height:13px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||||
|
</button>
|
||||||
${isCurrentVersion ? `<span class="badge" style="background:rgba(16,185,129,0.2); color:#10b981; font-weight:600; font-size:0.7rem;">Attuale</span>` : ''}
|
${isCurrentVersion ? `<span class="badge" style="background:rgba(16,185,129,0.2); color:#10b981; font-weight:600; font-size:0.7rem;">Attuale</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<span style="font-size:0.75rem; color:var(--text-tertiary); font-family:monospace;">${formattedDate}</span>
|
<span style="font-size:0.75rem; color:var(--text-tertiary); font-family:monospace;">${formattedDate}</span>
|
||||||
@@ -176,6 +179,11 @@ const EnvConfigView = {
|
|||||||
this.currentContent = content;
|
this.currentContent = content;
|
||||||
this.history = res.history || [];
|
this.history = res.history || [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.ensureLookups(true);
|
||||||
|
App.updateDailyTimer();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Re-render history panel
|
// Re-render history panel
|
||||||
const historyListDiv = document.getElementById('env-history-list');
|
const historyListDiv = document.getElementById('env-history-list');
|
||||||
if (historyListDiv) {
|
if (historyListDiv) {
|
||||||
@@ -237,6 +245,11 @@ const EnvConfigView = {
|
|||||||
this.currentContent = res.content || record.content;
|
this.currentContent = res.content || record.content;
|
||||||
this.history = res.history || [];
|
this.history = res.history || [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.ensureLookups(true);
|
||||||
|
App.updateDailyTimer();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
const editorTextarea = document.getElementById('env-editor-content');
|
const editorTextarea = document.getElementById('env-editor-content');
|
||||||
if (editorTextarea) {
|
if (editorTextarea) {
|
||||||
editorTextarea.value = this.currentContent;
|
editorTextarea.value = this.currentContent;
|
||||||
@@ -254,6 +267,45 @@ const EnvConfigView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete buttons
|
||||||
|
document.querySelectorAll('.env-history-delete-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = parseInt(btn.dataset.id, 10);
|
||||||
|
const record = this.history.find(h => h.id === id);
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
const confirmed = await App.confirm(
|
||||||
|
`Eliminazione Versione #${record.id}`,
|
||||||
|
`Sei sicuro di voler eliminare permanentemente la versione #${record.id} dallo storico?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
btn.disabled = true;
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await App.api(`/api/config/env/history/${id}`, { method: 'DELETE' });
|
||||||
|
} catch (delErr) {
|
||||||
|
// Fallback to POST endpoint if DELETE route returned 404
|
||||||
|
res = await App.api(`/api/config/env/history/${id}/delete`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.success(res.message || `Versione #${id} eliminata dallo storico!`);
|
||||||
|
this.history = res.history || [];
|
||||||
|
|
||||||
|
const historyListDiv = document.getElementById('env-history-list');
|
||||||
|
if (historyListDiv) {
|
||||||
|
historyListDiv.innerHTML = this.renderHistoryList();
|
||||||
|
this.bindHistoryEvents();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante l\'eliminazione dello storico: ' + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
showPreviewModal(record) {
|
showPreviewModal(record) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const NoteTemporaneeView = {
|
|||||||
<div style="display:flex; align-items:center; gap: 10px;">
|
<div style="display:flex; align-items:center; gap: 10px;">
|
||||||
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2), rgba(16, 185, 129, 0.2)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">📝</div>
|
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2), rgba(16, 185, 129, 0.2)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">📝</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);" id="editor-title-text">Nuova Nota Temporanea</h2>
|
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);" id="editor-title-text">Nuova Nota</h2>
|
||||||
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
|
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
|
||||||
Le note vengono mantenute in locale finché non le elimini con il cestino 🗑️ o le invii a OTRS 🚀.
|
Le note vengono mantenute in locale finché non le elimini con il cestino 🗑️ o le invii a OTRS 🚀.
|
||||||
</p>
|
</p>
|
||||||
@@ -155,10 +155,12 @@ const NoteTemporaneeView = {
|
|||||||
placeholder: 'Scrivi il contenuto dettagliato della nota temporanea...',
|
placeholder: 'Scrivi il contenuto dettagliato della nota temporanea...',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
|
||||||
[{ 'color': [] }, { 'background': [] }],
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
['link', 'image'],
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image', 'code-block'],
|
||||||
['clean']
|
['clean']
|
||||||
],
|
],
|
||||||
keyboard: {
|
keyboard: {
|
||||||
@@ -173,6 +175,14 @@ const NoteTemporaneeView = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prevent tab navigation on Quill toolbar items
|
||||||
|
const toolbar = document.querySelector('#note-body-editor-wrapper .ql-toolbar');
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.querySelectorAll('button, select, span[role="button"], input').forEach(el => {
|
||||||
|
el.setAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -364,9 +374,8 @@ const NoteTemporaneeView = {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; align-items:center; gap:4px;">
|
<div style="display:flex; align-items:center; gap:4px;">
|
||||||
<button class="btn btn-ghost btn-xs btn-edit-note" data-id="${n.id}" title="Carica ed modifica nota" style="padding:2px 5px; font-size:0.75rem;">✏️</button>
|
|
||||||
<button class="btn btn-ghost btn-xs btn-submit-note" data-id="${n.id}" title="Invia direttamente a OTRS" style="padding:2px 5px; font-size:0.75rem; color:var(--accent-primary);">🚀</button>
|
|
||||||
<button class="btn btn-ghost btn-xs btn-delete-note" data-id="${n.id}" title="Elimina definitivamente dal DB locale" style="padding:2px 5px; font-size:0.75rem; color:var(--error);">🗑️</button>
|
<button class="btn btn-ghost btn-xs btn-delete-note" data-id="${n.id}" title="Elimina definitivamente dal DB locale" style="padding:2px 5px; font-size:0.75rem; color:var(--error);">🗑️</button>
|
||||||
|
<button class="btn btn-ghost btn-xs btn-submit-note" data-id="${n.id}" title="Invia direttamente a OTRS" style="padding:2px 5px; font-size:0.75rem; color:var(--accent-primary);">🚀</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -375,10 +384,10 @@ const NoteTemporaneeView = {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Bind item actions
|
// Bind item actions
|
||||||
listContainer.querySelectorAll('.btn-edit-note').forEach(btn => {
|
listContainer.querySelectorAll('.btn-delete-note').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.loadNoteIntoForm(parseInt(btn.dataset.id, 10));
|
this.deleteNote(parseInt(btn.dataset.id, 10));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -389,13 +398,6 @@ const NoteTemporaneeView = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
listContainer.querySelectorAll('.btn-delete-note').forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.deleteNote(parseInt(btn.dataset.id, 10));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
listContainer.querySelectorAll('.saved-note-card').forEach(card => {
|
listContainer.querySelectorAll('.saved-note-card').forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
this.loadNoteIntoForm(parseInt(card.dataset.noteId, 10));
|
this.loadNoteIntoForm(parseInt(card.dataset.noteId, 10));
|
||||||
@@ -447,7 +449,7 @@ const NoteTemporaneeView = {
|
|||||||
const deleteBtn = document.getElementById('note-delete-current-btn');
|
const deleteBtn = document.getElementById('note-delete-current-btn');
|
||||||
const ticketClearBtn = document.getElementById('note-ticket-clear-btn');
|
const ticketClearBtn = document.getElementById('note-ticket-clear-btn');
|
||||||
|
|
||||||
if (editorTitle) editorTitle.textContent = 'Nuova Nota Temporanea';
|
if (editorTitle) editorTitle.textContent = 'Nuova Nota';
|
||||||
if (ticketInput) ticketInput.value = '';
|
if (ticketInput) ticketInput.value = '';
|
||||||
if (ticketClearBtn) ticketClearBtn.style.display = 'none';
|
if (ticketClearBtn) ticketClearBtn.style.display = 'none';
|
||||||
if (subjectInput) subjectInput.value = '';
|
if (subjectInput) subjectInput.value = '';
|
||||||
@@ -487,7 +489,7 @@ const NoteTemporaneeView = {
|
|||||||
try {
|
try {
|
||||||
const res = await App.api('/api/notes', {
|
const res = await App.api('/api/notes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: payload
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Toast.success('Nota temporanea salvata nel database locale!');
|
Toast.success('Nota temporanea salvata nel database locale!');
|
||||||
@@ -518,12 +520,19 @@ const NoteTemporaneeView = {
|
|||||||
const note = this.savedNotes.find(n => n.id === noteId);
|
const note = this.savedNotes.find(n => n.id === noteId);
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
|
|
||||||
if (!note.ticket_tn && !note.ticket_id) {
|
const hasTicket = Boolean(note.ticket_tn || note.ticket_id);
|
||||||
Toast.error('Inserisci un ticket di riferimento (TN o ID) prima di inviare a OTRS');
|
const modalTitle = hasTicket ? 'Invia Nota a OTRS' : 'Crea Nuovo Ticket da Nota';
|
||||||
return;
|
const confirmMsg = hasTicket
|
||||||
}
|
? `Sei sicuro di voler inviare questa nota a OTRS per il ticket #${note.ticket_tn || note.ticket_id}? La nota verrà poi rimossa dal database locale.`
|
||||||
|
: `Non hai specificato un ticket. Vuoi creare un NUOVO ticket a partire da questa nota ed inviarla a OTRS?`;
|
||||||
|
|
||||||
if (!confirm(`Sei sicuro di voler inviare questa nota a OTRS per il ticket #${note.ticket_tn || note.ticket_id}? La nota verrà poi rimossa dal database locale.`)) {
|
const ok = await App.confirm(modalTitle, confirmMsg, {
|
||||||
|
confirmText: hasTicket ? 'Invia a OTRS' : 'Crea Ticket e Invia',
|
||||||
|
confirmClass: 'btn-success',
|
||||||
|
cancelText: 'Annulla'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,7 +558,12 @@ const NoteTemporaneeView = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteNote(noteId) {
|
async deleteNote(noteId) {
|
||||||
if (!confirm('Sei sicuro di voler eliminare questa nota temporanea dal database locale?')) {
|
const ok = await App.confirm(
|
||||||
|
'Elimina Nota Temporanea',
|
||||||
|
'Sei sicuro di voler eliminare questa nota temporanea dal database locale?',
|
||||||
|
{ confirmText: 'Elimina', cancelText: 'Annulla' }
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
const RicercaSpudorataView = {
|
const RicercaSpudorataView = {
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
|
isRegex: false,
|
||||||
cachedTickets: null,
|
cachedTickets: null,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
perPage: 50,
|
perPage: 50,
|
||||||
@@ -29,6 +30,10 @@ const RicercaSpudorataView = {
|
|||||||
|
|
||||||
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
|
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
|
||||||
const qParam = urlParams.get('q');
|
const qParam = urlParams.get('q');
|
||||||
|
const regexParam = urlParams.get('regex');
|
||||||
|
if (regexParam !== null) {
|
||||||
|
this.isRegex = regexParam === '1' || regexParam === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
if (qParam && qParam !== this.searchTerm) {
|
if (qParam && qParam !== this.searchTerm) {
|
||||||
this.renderContainer(container);
|
this.renderContainer(container);
|
||||||
@@ -49,17 +54,67 @@ const RicercaSpudorataView = {
|
|||||||
|
|
||||||
this.renderContainer(container);
|
this.renderContainer(container);
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById('spudorata-search-input');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderContainer(container) {
|
renderContainer(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<!-- Hero Text Search Area -->
|
<!-- Hero Text Search Area -->
|
||||||
<div class="ricerca-spudorata-hero" style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); box-shadow: var(--shadow-md);">
|
<div class="ricerca-spudorata-hero" style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); box-shadow: var(--shadow-md); position: relative;">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-sm); flex-wrap:wrap; gap:10px;">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-sm); flex-wrap:wrap; gap:10px;">
|
||||||
<div style="display:flex; align-items:center; gap: 10px;">
|
<div style="display:flex; align-items:center; gap: 10px;">
|
||||||
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(235, 114, 110, 0.2), rgba(160, 65, 71, 0.3)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">🚀</div>
|
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(235, 114, 110, 0.2), rgba(160, 65, 71, 0.3)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">🚀</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);">Ricerca Spudorata Testuale</h2>
|
<div style="display:flex; align-items:center; gap: 8px;">
|
||||||
|
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);">Ricerca Testuale Spudorata</h2>
|
||||||
|
<div style="position: relative; display: inline-flex; align-items: center;">
|
||||||
|
<button type="button" id="spudorata-info-btn" aria-label="Informazioni sulle regole avanzate e Regex" style="background: rgba(79, 70, 229, 0.1); border: 1px solid rgba(79, 70, 229, 0.25); border-radius: 50%; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: var(--accent-primary); padding: 0; transition: all 0.2s ease;">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Modal / Popover indicazioni regole avanzate -->
|
||||||
|
<div id="spudorata-info-modal" class="spudorata-rules-popover" style="display:none; position:absolute; top:calc(100% + 10px); left:0; width:460px; max-width:90vw; background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-lg); box-shadow:0 12px 32px rgba(0,0,0,0.22); z-index:1100; padding:18px; font-size:0.85rem; color:var(--text-primary); backdrop-filter:blur(12px);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; border-bottom:1px solid var(--border-subtle); padding-bottom:8px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:6px; font-weight:700; font-size:0.95rem; color:var(--accent-primary);">
|
||||||
|
<span>💡</span>
|
||||||
|
<span>Guida Ricerca & Regole Avanzate (Regex)</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); background:var(--bg-tertiary); padding:2px 6px; border-radius:4px;">Regole avanzate</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="line-height:1.45; color:var(--text-secondary); margin-bottom:12px;">
|
||||||
|
Puoi cercare normalmente per parole chiave o attivare l'opzione <strong>Regex</strong> per ricerche con espressioni regolari (POSIX / JavaScript) sia sul database che sui filtri in cache.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:10px 12px; margin-bottom:12px;">
|
||||||
|
<div style="font-weight:600; color:var(--text-primary); margin-bottom:6px; font-size:0.82rem;">Esempi di espressioni regolari:</div>
|
||||||
|
<ul style="margin:0; padding-left:18px; display:flex; flex-direction:column; gap:6px;">
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">vpn|firewall</code> — Trova ticket contenenti <em>vpn</em> <strong>oppure</strong> <em>firewall</em></li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">\b(errore|fatal)\b</code> — Trova parole esatte "errore" o "fatal"</li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">^INC-[0-9]+</code> — Ticket o codici che iniziano per "INC-" seguito da cifre</li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">host(name)?.*down</code> — Trova "host down" o "hostname down" anche con parole in mezzo</li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}</code> — Trova indirizzi IP nel corpo o nell'oggetto</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:0.78rem; color:var(--text-tertiary); display:flex; flex-direction:column; gap:4px;">
|
||||||
|
<div>⚡ <strong>Case-insensitive:</strong> Le ricerche con Regex non distinguono automaticamente tra maiuscole e minuscole.</div>
|
||||||
|
<div>⚡ <strong>Cache locale:</strong> Anche il campo <em>"Cerca in Cache"</em> rispetta la modalità Regex o il formato <code>/pattern/</code>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
|
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
|
||||||
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano <strong>istantaneamente in cache locale</strong> senza fare nuove richieste al server.
|
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano <strong>istantaneamente in cache locale</strong> senza fare nuove richieste al server.
|
||||||
</p>
|
</p>
|
||||||
@@ -70,11 +125,19 @@ const RicercaSpudorataView = {
|
|||||||
|
|
||||||
<div style="display:flex; align-items:center; gap: var(--space-sm); flex-wrap:wrap; margin-top: var(--space-md);">
|
<div style="display:flex; align-items:center; gap: var(--space-sm); flex-wrap:wrap; margin-top: var(--space-md);">
|
||||||
<div style="position:relative; flex:1; min-width:300px;">
|
<div style="position:relative; flex:1; min-width:300px;">
|
||||||
<input type="text" id="spudorata-search-input" class="form-input" placeholder="Cerca nel testo (oggetto, corpo, numero ticket, cliente)..." value="${App.escapeHtml(this.searchTerm)}" style="width:100%; height:42px; font-size:0.95rem; padding-left:14px; padding-right:36px; border-radius:var(--radius-md);" />
|
<input type="text" id="spudorata-search-input" class="form-input" placeholder="${this.isRegex ? 'Cerca con espressione regolare (es. vpn|firewall, ^INC-\\d+)...' : 'Cerca nel testo (oggetto, corpo, numero ticket, cliente)...'}" value="${App.escapeHtml(this.searchTerm)}" style="width:100%; height:42px; font-size:0.95rem; padding-left:14px; padding-right:36px; border-radius:var(--radius-md);" />
|
||||||
<button id="spudorata-input-clear" style="position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-muted); cursor:pointer; display:${this.searchTerm ? 'flex' : 'none'}; align-items:center; justify-content:center;" title="Cancella testo">
|
<button id="spudorata-input-clear" style="position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-muted); cursor:pointer; display:${this.searchTerm ? 'flex' : 'none'}; align-items:center; justify-content:center;" title="Cancella testo">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggle Regex -->
|
||||||
|
<label id="spudorata-regex-toggle" style="display:inline-flex; align-items:center; gap:6px; cursor:pointer; user-select:none; height:42px; padding:0 12px; background:var(--bg-secondary); border:1px solid ${this.isRegex ? 'var(--accent-primary)' : 'var(--border-light)'}; border-radius:var(--radius-md); font-size:0.85rem; font-weight:600; color:${this.isRegex ? 'var(--accent-primary)' : 'var(--text-secondary)'}; transition:all 0.15s ease;" title="Abilita ricerca tramite Regular Expression">
|
||||||
|
<input type="checkbox" id="spudorata-regex-checkbox" ${this.isRegex ? 'checked' : ''} style="cursor:pointer; accent-color:var(--accent-primary);" />
|
||||||
|
<span style="font-family:monospace; font-size:0.95rem; font-weight:700;">.*</span>
|
||||||
|
<span>Regex</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button class="btn btn-primary" id="spudorata-search-btn" style="height:42px; padding:0 var(--space-xl); font-size:0.9rem; font-weight:600; min-width:130px; display:flex; align-items:center; justify-content:center; gap:6px;">
|
<button class="btn btn-primary" id="spudorata-search-btn" style="height:42px; padding:0 var(--space-xl); font-size:0.9rem; font-weight:600; min-width:130px; display:flex; align-items:center; justify-content:center; gap:6px;">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
<span>Cerca</span>
|
<span>Cerca</span>
|
||||||
@@ -88,12 +151,10 @@ const RicercaSpudorataView = {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batch Actions Bar -->
|
<!-- Batch Actions Bar -->
|
||||||
<div class="batch-bar" id="batch-bar">
|
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
|
||||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Stato</span>
|
<span class="filter-label">Stato</span>
|
||||||
<select class="filter-select" id="batch-state">
|
<select class="filter-select" id="batch-state">
|
||||||
@@ -130,11 +191,40 @@ const RicercaSpudorataView = {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonna Destra: Pulsanti delle Azioni -->
|
||||||
|
<div class="batch-bar-right" style="display: flex; flex-direction: column; align-items: flex-end; gap: 6px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
|
||||||
<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-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled title="Aggiungi a gruppo" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-merge" disabled title="Unisci Selezionati" style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<circle cx="18" cy="18" r="3"></circle>
|
||||||
|
<circle cx="6" cy="6" r="3"></circle>
|
||||||
|
<path d="M6 21V9a9 9 0 0 0 9 9"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled title="Apri ticket in schede" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<path d="M2 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9z"></path>
|
||||||
|
<path d="M6 3h12a2 2 0 0 1 2 2v12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-copy-tns">Copia numeri</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-cancel" title="Annulla selezione">Annulla</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
|
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
|
||||||
@@ -159,6 +249,15 @@ const RicercaSpudorataView = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isRegex) {
|
||||||
|
try {
|
||||||
|
new RegExp(query);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('Espressione regolare non valida: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.searchTerm = query;
|
this.searchTerm = query;
|
||||||
this.isFetching = true;
|
this.isFetching = true;
|
||||||
|
|
||||||
@@ -167,13 +266,14 @@ const RicercaSpudorataView = {
|
|||||||
resultsArea.innerHTML = `
|
resultsArea.innerHTML = `
|
||||||
<div class="loading-screen" style="padding: 60px 0;">
|
<div class="loading-screen" style="padding: 60px 0;">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Ricerca testuale nel server in corso per "${App.escapeHtml(query)}"...</p>
|
<p>Ricerca testuale ${this.isRegex ? 'con Regex ' : ''}nel server in corso per "${App.escapeHtml(query)}"...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}&per_page=2000`);
|
const regexParam = this.isRegex ? '&is_regex=1' : '';
|
||||||
|
const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}${regexParam}&per_page=2000`);
|
||||||
this.cachedTickets = data.tickets || [];
|
this.cachedTickets = data.tickets || [];
|
||||||
this.otrsBaseUrl = data.otrsBaseUrl || '';
|
this.otrsBaseUrl = data.otrsBaseUrl || '';
|
||||||
this.hasSearched = true;
|
this.hasSearched = true;
|
||||||
@@ -181,7 +281,7 @@ const RicercaSpudorataView = {
|
|||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
this.selectedOrder = [];
|
this.selectedOrder = [];
|
||||||
|
|
||||||
Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"`);
|
Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"${this.isRegex ? ' (Regex)' : ''}`);
|
||||||
this.renderContainer(document.getElementById('view-container'));
|
this.renderContainer(document.getElementById('view-container'));
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.renderLocalResults();
|
this.renderLocalResults();
|
||||||
@@ -272,7 +372,7 @@ const RicercaSpudorataView = {
|
|||||||
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; vertical-align:middle; line-height:16px;">O</span>
|
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; vertical-align:middle; line-height:16px;">O</span>
|
||||||
</a>
|
</a>
|
||||||
` : ''}
|
` : ''}
|
||||||
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title, true); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
||||||
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -326,8 +426,22 @@ const RicercaSpudorataView = {
|
|||||||
const s = Filters.state;
|
const s = Filters.state;
|
||||||
|
|
||||||
if (s.sub_search) {
|
if (s.sub_search) {
|
||||||
const q = String(s.sub_search).toLowerCase().trim();
|
const rawQ = String(s.sub_search).trim();
|
||||||
if (q) {
|
if (rawQ) {
|
||||||
|
let regexMatcher = null;
|
||||||
|
if (this.isRegex) {
|
||||||
|
try {
|
||||||
|
regexMatcher = new RegExp(rawQ, 'i');
|
||||||
|
} catch (e) {
|
||||||
|
// If invalid while typing, fallback or ignore
|
||||||
|
}
|
||||||
|
} else if (rawQ.startsWith('/') && rawQ.length > 2 && rawQ.endsWith('/')) {
|
||||||
|
try {
|
||||||
|
regexMatcher = new RegExp(rawQ.slice(1, -1), 'i');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qLower = rawQ.toLowerCase();
|
||||||
list = list.filter(t => {
|
list = list.filter(t => {
|
||||||
const fields = [
|
const fields = [
|
||||||
t.tn,
|
t.tn,
|
||||||
@@ -344,7 +458,10 @@ const RicercaSpudorataView = {
|
|||||||
t.customer_id,
|
t.customer_id,
|
||||||
t.customer_user_id
|
t.customer_user_id
|
||||||
];
|
];
|
||||||
return fields.some(f => f && String(f).toLowerCase().includes(q));
|
if (regexMatcher) {
|
||||||
|
return fields.some(f => f && regexMatcher.test(String(f)));
|
||||||
|
}
|
||||||
|
return fields.some(f => f && String(f).toLowerCase().includes(qLower));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,17 +537,34 @@ const RicercaSpudorataView = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderPagination(current, perPage, total, totalPages, isTop = false) {
|
renderPagination(current, perPage, total, totalPages, isTop = false) {
|
||||||
|
const limitSelectHtml = `
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary);">
|
||||||
|
<span>Righe:</span>
|
||||||
|
<select id="spudorata-per-page-select-${isTop ? 'top' : 'bottom'}" name="spudorata_per_page_${isTop ? 'top' : 'bottom'}" class="form-select spudorata-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
|
||||||
|
<option value="10" ${perPage === 10 ? 'selected' : ''}>10</option>
|
||||||
|
<option value="20" ${perPage === 20 ? 'selected' : ''}>20</option>
|
||||||
|
<option value="50" ${perPage === 50 ? 'selected' : ''}>50</option>
|
||||||
|
<option value="100" ${perPage === 100 ? 'selected' : ''}>100</option>
|
||||||
|
<option value="200" ${perPage === 200 ? 'selected' : ''}>200</option>
|
||||||
|
<option value="500" ${perPage === 500 ? 'selected' : ''}>500</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="pagination" style="${isTop ? 'margin-bottom:var(--space-md);' : 'margin-top:var(--space-md);'} align-items:center; justify-content:space-between; display:flex;">
|
<div class="pagination" style="${isTop ? 'margin-bottom:var(--space-md);' : 'margin-top:var(--space-md);'} align-items:center; justify-content:space-between; display:flex;">
|
||||||
<div class="pagination-info" style="font-size:0.82rem; color:var(--text-tertiary);">
|
<div class="pagination-info" style="font-size:0.82rem; color:var(--text-tertiary);">
|
||||||
Mostrati ${Math.min(total, (current - 1) * perPage + 1)}-${Math.min(total, current * perPage)} di ${total} ticket
|
Mostrati ${total > 0 ? Math.min(total, (current - 1) * perPage + 1) : 0}-${Math.min(total, current * perPage)} di ${total} ticket
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pagination-controls" style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
|
${limitSelectHtml}
|
||||||
<div class="pagination-buttons" style="display:flex; gap:4px;">
|
<div class="pagination-buttons" style="display:flex; gap:4px;">
|
||||||
<button class="pagination-btn spudorata-page-btn" data-page="${Math.max(1, current - 1)}" ${current === 1 ? 'disabled' : ''}>«</button>
|
<button class="pagination-btn spudorata-page-btn" data-page="${Math.max(1, current - 1)}" ${current === 1 ? 'disabled' : ''}>«</button>
|
||||||
${this.renderPageNumbers(current, totalPages)}
|
${this.renderPageNumbers(current, totalPages)}
|
||||||
<button class="pagination-btn spudorata-page-btn" data-page="${Math.min(totalPages, current + 1)}" ${current === totalPages ? 'disabled' : ''}>»</button>
|
<button class="pagination-btn spudorata-page-btn" data-page="${Math.min(totalPages, current + 1)}" ${current === totalPages ? 'disabled' : ''}>»</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -477,6 +611,78 @@ const RicercaSpudorataView = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regexCheckbox = document.getElementById('spudorata-regex-checkbox');
|
||||||
|
if (regexCheckbox) {
|
||||||
|
regexCheckbox.addEventListener('change', () => {
|
||||||
|
this.isRegex = regexCheckbox.checked;
|
||||||
|
const toggleLabel = document.getElementById('spudorata-regex-toggle');
|
||||||
|
if (toggleLabel) {
|
||||||
|
toggleLabel.style.borderColor = this.isRegex ? 'var(--accent-primary)' : 'var(--border-light)';
|
||||||
|
toggleLabel.style.color = this.isRegex ? 'var(--accent-primary)' : 'var(--text-secondary)';
|
||||||
|
}
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.placeholder = this.isRegex
|
||||||
|
? 'Cerca con espressione regolare (es. vpn|firewall, ^INC-\\d+)...'
|
||||||
|
: 'Cerca nel testo (oggetto, corpo, numero ticket, cliente)...';
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
// If we already have cached tickets, update the local filtered view immediately
|
||||||
|
if (this.hasSearched && this.cachedTickets) {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.renderLocalResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info button & hover modal
|
||||||
|
const infoBtn = document.getElementById('spudorata-info-btn');
|
||||||
|
const infoModal = document.getElementById('spudorata-info-modal');
|
||||||
|
if (infoBtn && infoModal) {
|
||||||
|
let closeTimeout = null;
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
if (closeTimeout) {
|
||||||
|
clearTimeout(closeTimeout);
|
||||||
|
closeTimeout = null;
|
||||||
|
}
|
||||||
|
infoModal.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModalWithDelay = () => {
|
||||||
|
if (closeTimeout) clearTimeout(closeTimeout);
|
||||||
|
closeTimeout = setTimeout(() => {
|
||||||
|
infoModal.style.display = 'none';
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
infoBtn.addEventListener('mouseenter', openModal);
|
||||||
|
infoBtn.addEventListener('mouseleave', closeModalWithDelay);
|
||||||
|
|
||||||
|
infoModal.addEventListener('mouseenter', () => {
|
||||||
|
if (closeTimeout) {
|
||||||
|
clearTimeout(closeTimeout);
|
||||||
|
closeTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
infoModal.addEventListener('mouseleave', closeModalWithDelay);
|
||||||
|
|
||||||
|
// Also toggle on click for touch/mobile devices or explicit clicks
|
||||||
|
infoBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (infoModal.style.display === 'block') {
|
||||||
|
infoModal.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!infoBtn.contains(e.target) && !infoModal.contains(e.target)) {
|
||||||
|
infoModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (resetCacheBtn) {
|
if (resetCacheBtn) {
|
||||||
resetCacheBtn.addEventListener('click', () => {
|
resetCacheBtn.addEventListener('click', () => {
|
||||||
this.invalidateCache();
|
this.invalidateCache();
|
||||||
@@ -575,6 +781,17 @@ const RicercaSpudorataView = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.spudorata-per-page-select').forEach(sel => {
|
||||||
|
sel.addEventListener('change', (e) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(val) && val > 0) {
|
||||||
|
this.perPage = val;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.renderLocalResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.spudorata-page-btn').forEach(btn => {
|
document.querySelectorAll('.spudorata-page-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const page = parseInt(btn.dataset.page, 10);
|
const page = parseInt(btn.dataset.page, 10);
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* ShortcutsView — Gestione Shortcuts e Scorciatoie da Tastiera
|
||||||
|
* Visualizza e permette la personalizzazione delle scorciatoie da tastiera.
|
||||||
|
*/
|
||||||
|
const ShortcutsView = {
|
||||||
|
editingId: null,
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
const shortcuts = this.getShortcutsList();
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="view-header" style="padding:var(--space-md) var(--space-xl); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-sm); border-bottom:1px solid var(--border-subtle);">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px; height:22px; color:var(--accent-primary);">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
||||||
|
</svg>
|
||||||
|
<span style="font-weight:700; font-size:1.1rem;">Shortcuts e Scorciatoie da Tastiera</span>
|
||||||
|
</div>
|
||||||
|
<button id="sc-reset-all" class="btn btn-ghost btn-sm" style="font-size:0.82rem;">
|
||||||
|
🔄 Ripristina Predefiniti
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:var(--space-lg) var(--space-xl); max-width:960px;">
|
||||||
|
<p style="color:var(--text-secondary); font-size:0.88rem; margin-bottom:var(--space-lg); line-height:1.5;">
|
||||||
|
Personalizza o abilita/disabilita le scorciatoie da tastiera rapide per velocizzare la gestione dei ticket e delle consuntivazioni.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:var(--space-md);">
|
||||||
|
${shortcuts.map(sc => this.buildShortcutRow(sc)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal per la registrazione di una nuova combinazione -->
|
||||||
|
<div id="sc-modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content" style="max-width:440px; text-align:center;">
|
||||||
|
<h3 style="margin-bottom:var(--space-xs); font-size:1.1rem;" id="sc-modal-title">Modifica Shortcut</h3>
|
||||||
|
<p style="font-size:0.85rem; color:var(--text-muted); margin-bottom:var(--space-lg);">
|
||||||
|
Premi la combinazione di tasti desiderata sulla tastiera (es. <kbd style="background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; border:1px solid var(--border-subtle);">Ctrl</kbd> + <kbd style="background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; border:1px solid var(--border-subtle);">Invio</kbd>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="sc-key-recorder" tabindex="0" style="padding:var(--space-lg); background:var(--bg-tertiary); border:2px dashed var(--accent-primary); border-radius:var(--radius-md); font-size:1.2rem; font-weight:700; color:var(--accent-primary); outline:none; cursor:pointer; min-height:70px; display:flex; align-items:center; justify-content:center;">
|
||||||
|
Premi i tasti...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:var(--space-sm); margin-top:var(--space-lg);">
|
||||||
|
<button id="sc-modal-cancel" class="btn btn-ghost btn-sm">Annulla</button>
|
||||||
|
<button id="sc-modal-save" class="btn btn-primary btn-sm" disabled>Conferma</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
getShortcutsList() {
|
||||||
|
const list = [
|
||||||
|
{
|
||||||
|
id: 'create_ticket_open',
|
||||||
|
name: 'Crea Nuovo Ticket',
|
||||||
|
description: 'Apre la pagina di creazione di un nuovo ticket',
|
||||||
|
context: 'Globale',
|
||||||
|
defaultCombo: { ctrl: true, alt: true, shift: false, key: 'n', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ricerca_spudorata_open',
|
||||||
|
name: 'Apri Ricerca Spudorata',
|
||||||
|
description: 'Naviga direttamente alla pagina di Ricerca Spudorata o mette a fuoco il campo di ricerca',
|
||||||
|
context: 'Globale',
|
||||||
|
defaultCombo: { ctrl: true, alt: false, shift: true, key: 'f', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ticket_list_open',
|
||||||
|
name: 'Torna a Elenco Ticket',
|
||||||
|
description: 'Naviga direttamente alla pagina principale dell\'elenco ticket',
|
||||||
|
context: 'Globale',
|
||||||
|
defaultCombo: { ctrl: true, alt: false, shift: true, key: 'Backspace', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_terminal_console',
|
||||||
|
name: 'Apri Terminale Linux',
|
||||||
|
description: 'Apre la finestra di simulazione del terminale Linux in overlay',
|
||||||
|
context: 'Globale',
|
||||||
|
defaultCombo: { ctrl: true, alt: true, shift: false, key: 't', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'add_ticket_tab',
|
||||||
|
name: 'Aggiungi Ticket in Scheda',
|
||||||
|
description: 'Aggiunge il ticket visualizzato (o selezionato nell\'elenco) alle schede aperte in alto',
|
||||||
|
context: 'Globale / Dettaglio / Elenco',
|
||||||
|
defaultCombo: { ctrl: true, alt: true, shift: false, key: 's', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'create_ticket_submit',
|
||||||
|
name: 'Invia Nuovo Ticket',
|
||||||
|
description: 'Invia il modulo e crea un nuovo ticket',
|
||||||
|
context: 'Pagina Nuovo Ticket',
|
||||||
|
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quick_edit_save',
|
||||||
|
name: 'Salva Modifiche / Invia Nota',
|
||||||
|
description: 'Salva le modifiche apportate nei campi di Modifica Rapida o invia la nota interna nel dettaglio ticket',
|
||||||
|
context: 'Pagina Dettaglio Ticket',
|
||||||
|
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'send_email_submit',
|
||||||
|
name: 'Invia Email',
|
||||||
|
description: 'Invia l\'email quando la finestra di composizione email è aperta',
|
||||||
|
context: 'Modal Composizione Email',
|
||||||
|
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pdf_save_edited',
|
||||||
|
name: 'Salva Immagine / Allegato / PDF Modificato',
|
||||||
|
description: 'Scarica l\'immagine o il PDF con le correzioni e annotazioni apportate',
|
||||||
|
context: 'Anteprima Allegato / PDF / Immagine',
|
||||||
|
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return list.map(item => {
|
||||||
|
const current = App.getShortcut(item.id);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
currentCombo: current
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
buildShortcutRow(sc) {
|
||||||
|
const combo = sc.currentCombo;
|
||||||
|
const label = App.formatShortcutLabel(combo);
|
||||||
|
const keysHtml = label.split(' + ').map(k => `
|
||||||
|
<kbd style="background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border-subtle); border-bottom-width:2px; padding:3px 8px; border-radius:4px; font-family:monospace; font-weight:700; font-size:0.85rem; box-shadow:0 1px 2px rgba(0,0,0,0.15);">
|
||||||
|
${App.escapeHtml(k)}
|
||||||
|
</kbd>
|
||||||
|
`).join(' + ');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card" style="display:flex; align-items:center; justify-content:space-between; padding:var(--space-md) var(--space-lg); gap:var(--space-md); border:1px solid var(--border-subtle); flex-wrap:wrap;">
|
||||||
|
<div style="flex:1; min-width:240px;">
|
||||||
|
<div style="font-weight:700; font-size:0.95rem; color:var(--text-primary); display:flex; align-items:center; gap:var(--space-xs);">
|
||||||
|
${App.escapeHtml(sc.name)}
|
||||||
|
<span class="badge" style="background:var(--bg-tertiary); color:var(--text-muted); font-size:0.72rem; font-weight:600; padding:2px 8px; border-radius:10px;">
|
||||||
|
${App.escapeHtml(sc.context)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.82rem; color:var(--text-secondary); margin-top:3px;">
|
||||||
|
${App.escapeHtml(sc.description)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-lg);">
|
||||||
|
<div style="display:flex; align-items:center; gap:4px;">
|
||||||
|
${combo.enabled ? keysHtml : '<span style="color:var(--text-muted); font-size:0.82rem; font-style:italic;">Disabilitato</span>'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
||||||
|
<button class="btn btn-ghost btn-sm sc-edit-btn" data-id="${sc.id}" style="height:32px; padding:4px 10px; font-size:0.82rem;">
|
||||||
|
✏️ Modifica
|
||||||
|
</button>
|
||||||
|
<label class="html-toggle-container" style="margin:0; cursor:pointer;" title="Abilita/Disabilita shortcut">
|
||||||
|
<input type="checkbox" class="sc-toggle-input" data-id="${sc.id}" ${combo.enabled ? 'checked' : ''} />
|
||||||
|
<span style="font-size:0.8rem; color:var(--text-secondary);">${combo.enabled ? 'Attivo' : 'Off'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Reset all
|
||||||
|
document.getElementById('sc-reset-all')?.addEventListener('click', () => {
|
||||||
|
if (confirm('Ripristinare tutte le scorciatoie da tastiera ai valori predefiniti?')) {
|
||||||
|
localStorage.removeItem('otrs_shortcuts');
|
||||||
|
Toast.success('Shortcuts ripristinati ai valori predefiniti');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggles
|
||||||
|
document.querySelectorAll('.sc-toggle-input').forEach(toggle => {
|
||||||
|
toggle.addEventListener('change', (e) => {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const current = App.getShortcut(id);
|
||||||
|
current.enabled = e.target.checked;
|
||||||
|
App.saveCustomShortcut(id, current);
|
||||||
|
Toast.success(`Shortcut ${current.enabled ? 'abilitato' : 'disabilitato'}`);
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit button
|
||||||
|
let recordedCombo = null;
|
||||||
|
const modal = document.getElementById('sc-modal');
|
||||||
|
const recorder = document.getElementById('sc-key-recorder');
|
||||||
|
const saveBtn = document.getElementById('sc-modal-save');
|
||||||
|
const cancelBtn = document.getElementById('sc-modal-cancel');
|
||||||
|
|
||||||
|
document.querySelectorAll('.sc-edit-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.editingId = btn.dataset.id;
|
||||||
|
const item = this.getShortcutsList().find(x => x.id === this.editingId);
|
||||||
|
document.getElementById('sc-modal-title').textContent = `Modifica: ${item ? item.name : ''}`;
|
||||||
|
recordedCombo = null;
|
||||||
|
recorder.innerHTML = 'Premi i tasti sulla tastiera...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
recorder.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn?.addEventListener('click', () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder?.addEventListener('keydown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Ignore lone modifier presses
|
||||||
|
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctrl = e.ctrlKey || e.metaKey;
|
||||||
|
const alt = e.altKey;
|
||||||
|
const shift = e.shiftKey;
|
||||||
|
let key = e.key;
|
||||||
|
|
||||||
|
if (key === ' ') key = 'Space';
|
||||||
|
|
||||||
|
recordedCombo = { ctrl, alt, shift, key, enabled: true };
|
||||||
|
const label = App.formatShortcutLabel(recordedCombo);
|
||||||
|
recorder.innerHTML = label.split(' + ').map(k => `
|
||||||
|
<kbd style="background:var(--bg-secondary); color:var(--accent-primary); border:1px solid var(--accent-primary); padding:4px 10px; border-radius:6px; font-family:monospace; margin:0 2px;">
|
||||||
|
${App.escapeHtml(k)}
|
||||||
|
</kbd>
|
||||||
|
`).join(' + ');
|
||||||
|
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn?.addEventListener('click', () => {
|
||||||
|
if (this.editingId && recordedCombo) {
|
||||||
|
App.saveCustomShortcut(this.editingId, recordedCombo);
|
||||||
|
Toast.success('Combinazione salvata!');
|
||||||
|
modal.style.display = 'none';
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -128,7 +128,7 @@ const TicketCreateView = {
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="form-label">Titolo <span class="required">*</span></label>
|
<label class="form-label">Titolo <span class="required">*</span></label>
|
||||||
<input type="text" class="form-input" id="create-title" placeholder="Descrizione breve del problema" autofocus />
|
<input type="text" class="form-input" id="create-title" placeholder="Descrizione breve del problema" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="position:relative;">
|
<div class="form-group" style="position:relative;">
|
||||||
@@ -230,10 +230,12 @@ const TicketCreateView = {
|
|||||||
placeholder: 'Descrivi il problema in dettaglio...',
|
placeholder: 'Descrivi il problema in dettaglio...',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
|
||||||
[{ 'header': [1, 2, 3, false] }],
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
['link', 'image'],
|
[{ 'align': [] }],
|
||||||
|
['link', 'image', 'code-block'],
|
||||||
['clean']
|
['clean']
|
||||||
],
|
],
|
||||||
keyboard: {
|
keyboard: {
|
||||||
@@ -462,10 +464,11 @@ const TicketCreateView = {
|
|||||||
`).join('');
|
`).join('');
|
||||||
ownerSuggestionsDiv.style.display = 'block';
|
ownerSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
// Bind click
|
// Bind click/mousedown
|
||||||
ownerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
ownerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
if (item.dataset.id) {
|
if (item.dataset.id) {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
ownerSearchInput.value = item.dataset.name;
|
ownerSearchInput.value = item.dataset.name;
|
||||||
ownerIdInput.value = item.dataset.id;
|
ownerIdInput.value = item.dataset.id;
|
||||||
ownerSuggestionsDiv.style.display = 'none';
|
ownerSuggestionsDiv.style.display = 'none';
|
||||||
@@ -485,8 +488,6 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ownerSearchInput.addEventListener('focus', () => {
|
ownerSearchInput.addEventListener('focus', () => {
|
||||||
ownerSearchInput.value = '';
|
|
||||||
ownerIdInput.value = '';
|
|
||||||
ownerSearchInput.dispatchEvent(new Event('input'));
|
ownerSearchInput.dispatchEvent(new Event('input'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -517,10 +518,11 @@ const TicketCreateView = {
|
|||||||
`).join('');
|
`).join('');
|
||||||
responsibleSuggestionsDiv.style.display = 'block';
|
responsibleSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
// Bind click
|
// Bind click/mousedown
|
||||||
responsibleSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
responsibleSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
if (item.dataset.id) {
|
if (item.dataset.id) {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
responsibleSearchInput.value = item.dataset.name;
|
responsibleSearchInput.value = item.dataset.name;
|
||||||
responsibleIdInput.value = item.dataset.id;
|
responsibleIdInput.value = item.dataset.id;
|
||||||
responsibleSuggestionsDiv.style.display = 'none';
|
responsibleSuggestionsDiv.style.display = 'none';
|
||||||
@@ -540,8 +542,6 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
responsibleSearchInput.addEventListener('focus', () => {
|
responsibleSearchInput.addEventListener('focus', () => {
|
||||||
responsibleSearchInput.value = '';
|
|
||||||
responsibleIdInput.value = '';
|
|
||||||
responsibleSearchInput.dispatchEvent(new Event('input'));
|
responsibleSearchInput.dispatchEvent(new Event('input'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -571,10 +571,11 @@ const TicketCreateView = {
|
|||||||
}).join('');
|
}).join('');
|
||||||
queueSuggestionsDiv.style.display = 'block';
|
queueSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
// Bind click
|
// Bind click/mousedown
|
||||||
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
if (item.dataset.id) {
|
if (item.dataset.id) {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
queueSearchInput.value = item.dataset.name;
|
queueSearchInput.value = item.dataset.name;
|
||||||
queueIdInput.value = item.dataset.id;
|
queueIdInput.value = item.dataset.id;
|
||||||
queueSuggestionsDiv.style.display = 'none';
|
queueSuggestionsDiv.style.display = 'none';
|
||||||
@@ -594,8 +595,6 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
queueSearchInput.addEventListener('focus', () => {
|
queueSearchInput.addEventListener('focus', () => {
|
||||||
queueSearchInput.value = '';
|
|
||||||
queueIdInput.value = '';
|
|
||||||
queueSearchInput.dispatchEvent(new Event('input'));
|
queueSearchInput.dispatchEvent(new Event('input'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -616,6 +615,11 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set focus on State dropdown (#create-state) on page load
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('create-state')?.focus();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
submitBtn.addEventListener('click', async () => {
|
submitBtn.addEventListener('click', async () => {
|
||||||
const title = document.getElementById('create-title').value.trim();
|
const title = document.getElementById('create-title').value.trim();
|
||||||
const queue_id = queueIdInput.value;
|
const queue_id = queueIdInput.value;
|
||||||
@@ -744,5 +748,22 @@ const TicketCreateView = {
|
|||||||
el.addEventListener('input', () => this.saveState());
|
el.addEventListener('input', () => this.saveState());
|
||||||
el.addEventListener('change', () => this.saveState());
|
el.addEventListener('change', () => this.saveState());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shortcut to submit ticket (global keydown listener for ticket creation view)
|
||||||
|
const handleTicketCreateShortcut = (e) => {
|
||||||
|
// Check if we are still on ticket creation view
|
||||||
|
if (!document.getElementById('create-submit')) {
|
||||||
|
document.removeEventListener('keydown', handleTicketCreateShortcut, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (App.isShortcutTriggered(e, 'create_ticket_submit')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (submitBtn && !submitBtn.disabled) {
|
||||||
|
submitBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleTicketCreateShortcut, true);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+3947
-347
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,16 @@ const TicketGroupsView = {
|
|||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="ticket-groups-container" style="display: flex; gap: var(--space-lg); min-height: calc(100vh - 120px); padding: var(--space-lg) 0;">
|
<div class="ticket-groups-container" style="display: flex; gap: var(--space-lg); min-height: calc(100vh - 120px); padding: var(--space-lg) 0;">
|
||||||
<!-- Left Sidebar: Groups List -->
|
<!-- Left Content: Group Details -->
|
||||||
|
<div class="group-details-pane card" id="group-details-pane" style="flex: 1; padding: var(--space-lg); display: flex; flex-direction: column; max-height: calc(100vh - 120px); overflow-y: auto;">
|
||||||
|
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
|
||||||
|
<h4>Nessun gruppo selezionato</h4>
|
||||||
|
<p style="font-size: 0.85rem;">Seleziona un gruppo dall'elenco o creane uno nuovo per iniziare a gestire le relazioni master/slave.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Sidebar: Groups List -->
|
||||||
<div class="groups-sidebar card" style="flex: 0 0 320px; display: flex; flex-direction: column; padding: var(--space-md); max-height: calc(100vh - 120px); overflow-y: auto;">
|
<div class="groups-sidebar card" style="flex: 0 0 320px; display: flex; flex-direction: column; padding: var(--space-md); max-height: calc(100vh - 120px); overflow-y: auto;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
|
||||||
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 700; color: var(--text-primary);">🗂️ Gruppi Ticket</h3>
|
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 700; color: var(--text-primary);">🗂️ Gruppi Ticket</h3>
|
||||||
@@ -25,15 +34,6 @@ const TicketGroupsView = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Content: Group Details -->
|
|
||||||
<div class="group-details-pane card" id="group-details-pane" style="flex: 1; padding: var(--space-lg); display: flex; flex-direction: column; max-height: calc(100vh - 120px); overflow-y: auto;">
|
|
||||||
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
|
|
||||||
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
|
|
||||||
<h4>Nessun gruppo selezionato</h4>
|
|
||||||
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Group Modal -->
|
<!-- Create/Edit Group Modal -->
|
||||||
@@ -89,8 +89,29 @@ const TicketGroupsView = {
|
|||||||
masterInput.value = prefillMasterTn;
|
masterInput.value = prefillMasterTn;
|
||||||
}
|
}
|
||||||
} else if (this.selectedGroupId) {
|
} else if (this.selectedGroupId) {
|
||||||
|
const exists = this.groups.some(g => g.id === this.selectedGroupId);
|
||||||
|
if (exists) {
|
||||||
this.selectGroup(this.selectedGroupId);
|
this.selectGroup(this.selectedGroupId);
|
||||||
|
} else {
|
||||||
|
this.selectedGroupId = null;
|
||||||
|
localStorage.removeItem('otrs_selected_group_id');
|
||||||
|
this.renderEmptyDetailsPane();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.renderEmptyDetailsPane();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderEmptyDetailsPane() {
|
||||||
|
const detailPane = document.getElementById('group-details-pane');
|
||||||
|
if (!detailPane) return;
|
||||||
|
detailPane.innerHTML = `
|
||||||
|
<div class="empty-state" style="margin: auto; text-align: center; color: var(--text-muted);">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: var(--space-sm);">📂</div>
|
||||||
|
<h4>Nessun gruppo selezionato</h4>
|
||||||
|
<p style="font-size: 0.85rem;">Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadGroups() {
|
async loadGroups() {
|
||||||
@@ -166,12 +187,11 @@ const TicketGroupsView = {
|
|||||||
this.selectedGroupData = await App.api(`/api/groups/${id}`);
|
this.selectedGroupData = await App.api(`/api/groups/${id}`);
|
||||||
this.renderGroupDetails();
|
this.renderGroupDetails();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.error('Errore nel caricamento del dettaglio gruppo: ' + err.message);
|
console.warn(`[TicketGroups] Failed to load group ${id}:`, err.message);
|
||||||
detailPane.innerHTML = `
|
this.selectedGroupId = null;
|
||||||
<div style="color: var(--danger); text-align: center; padding: var(--space-xl);">
|
localStorage.removeItem('otrs_selected_group_id');
|
||||||
Errore nel caricamento dei dati: ${err.message}
|
this.renderGroupsList();
|
||||||
</div>
|
this.renderEmptyDetailsPane();
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -250,12 +250,11 @@ const TicketListView = {
|
|||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<!-- Batch Actions Bar -->
|
<!-- Batch Actions Bar -->
|
||||||
<div class="batch-bar" id="batch-bar">
|
<!-- Batch Actions Bar -->
|
||||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
|
||||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Stato</span>
|
<span class="filter-label">Stato</span>
|
||||||
<select class="filter-select" id="batch-state">
|
<select class="filter-select" id="batch-state">
|
||||||
@@ -292,11 +291,40 @@ const TicketListView = {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonna Destra: Pulsanti delle Azioni -->
|
||||||
|
<div class="batch-bar-right" style="display: flex; flex-direction: column; align-items: flex-end; gap: 6px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
|
||||||
<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-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled title="Aggiungi a gruppo" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-merge" disabled title="Unisci Selezionati" style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<circle cx="18" cy="18" r="3"></circle>
|
||||||
|
<circle cx="6" cy="6" r="3"></circle>
|
||||||
|
<path d="M6 21V9a9 9 0 0 0 9 9"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled title="Apri ticket in schede" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<path d="M2 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9z"></path>
|
||||||
|
<path d="M6 3h12a2 2 0 0 1 2 2v12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-copy-tns">Copia numeri</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-cancel" title="Annulla selezione">Annulla</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${Filters.renderBar(App.lookups)}
|
${Filters.renderBar(App.lookups)}
|
||||||
@@ -333,7 +361,7 @@ const TicketListView = {
|
|||||||
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; vertical-align:middle; line-height:16px;">O</span>
|
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; vertical-align:middle; line-height:16px;">O</span>
|
||||||
</a>
|
</a>
|
||||||
` : ''}
|
` : ''}
|
||||||
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title, true); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
||||||
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -371,7 +399,7 @@ const TicketListView = {
|
|||||||
const limitSelectHtml = `
|
const limitSelectHtml = `
|
||||||
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary); margin-right:var(--space-md);">
|
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary); margin-right:var(--space-md);">
|
||||||
<span>Righe:</span>
|
<span>Righe:</span>
|
||||||
<select class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
|
<select id="ticket-per-page-select-${isTop ? 'top' : 'bottom'}" name="per_page_${isTop ? 'top' : 'bottom'}" class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
|
||||||
<option value="10" ${per_page === 10 ? 'selected' : ''}>10</option>
|
<option value="10" ${per_page === 10 ? 'selected' : ''}>10</option>
|
||||||
<option value="20" ${per_page === 20 ? 'selected' : ''}>20</option>
|
<option value="20" ${per_page === 20 ? 'selected' : ''}>20</option>
|
||||||
<option value="50" ${per_page === 50 ? 'selected' : ''}>50</option>
|
<option value="50" ${per_page === 50 ? 'selected' : ''}>50</option>
|
||||||
|
|||||||
@@ -9,7 +9,42 @@ const TimeAccountingView = {
|
|||||||
selectedDate: null,
|
selectedDate: null,
|
||||||
selectedArticleId: null,
|
selectedArticleId: null,
|
||||||
summaryData: null,
|
summaryData: null,
|
||||||
|
viewMode: 'list', // 'list' | 'calendar'
|
||||||
|
showAllTime: false,
|
||||||
showNonWorkingDays: localStorage.getItem('otrs_ta_show_non_working_days') !== 'false',
|
showNonWorkingDays: localStorage.getItem('otrs_ta_show_non_working_days') !== 'false',
|
||||||
|
groupByTicket: localStorage.getItem('otrs_ta_group_by_ticket') !== 'false',
|
||||||
|
|
||||||
|
groupArticles(articles) {
|
||||||
|
if (!articles || articles.length === 0) return [];
|
||||||
|
if (!this.groupByTicket) {
|
||||||
|
return articles.map(a => ({ type: 'single', article: a }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = [];
|
||||||
|
let currentGroup = null;
|
||||||
|
|
||||||
|
for (const a of articles) {
|
||||||
|
const ticketId = a.ticket_id;
|
||||||
|
if (currentGroup && currentGroup.ticket_id === ticketId) {
|
||||||
|
currentGroup.articles.push(a);
|
||||||
|
} else {
|
||||||
|
if (currentGroup) {
|
||||||
|
groups.push(currentGroup);
|
||||||
|
}
|
||||||
|
currentGroup = {
|
||||||
|
type: 'group',
|
||||||
|
ticket_id: ticketId,
|
||||||
|
ticket_tn: a.ticket_tn,
|
||||||
|
ticket_title: a.ticket_title,
|
||||||
|
articles: [a]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentGroup) {
|
||||||
|
groups.push(currentGroup);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
|
||||||
formatLocalISO(d) {
|
formatLocalISO(d) {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
@@ -45,8 +80,31 @@ const TimeAccountingView = {
|
|||||||
agent_id: this.selectedAgentId,
|
agent_id: this.selectedAgentId,
|
||||||
date: this.selectedDate,
|
date: this.selectedDate,
|
||||||
});
|
});
|
||||||
|
if (this.showAllTime) {
|
||||||
|
params.set('range', 'all');
|
||||||
|
} else if (this.viewMode === 'calendar') {
|
||||||
|
params.set('range', 'month');
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
|
||||||
|
|
||||||
|
// If non-working days are hidden, ensure selectedDate is a valid working day
|
||||||
|
if (!this.showNonWorkingDays) {
|
||||||
|
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
|
||||||
|
const parts = this.selectedDate.split('-').map(Number);
|
||||||
|
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
|
let shifted = false;
|
||||||
|
while (workDays.length > 0 && !workDays.includes(d.getDay())) {
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
shifted = true;
|
||||||
|
}
|
||||||
|
if (shifted) {
|
||||||
|
this.selectedDate = this.formatLocalISO(d);
|
||||||
|
params.set('date', this.selectedDate);
|
||||||
|
data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
|
|
||||||
this.summaryData = data;
|
this.summaryData = data;
|
||||||
|
|
||||||
// Auto-select first article if current selection is invalid
|
// Auto-select first article if current selection is invalid
|
||||||
@@ -55,9 +113,14 @@ const TimeAccountingView = {
|
|||||||
this.selectedArticleId = articles.length > 0 ? articles[0].article_id : null;
|
this.selectedArticleId = articles.length > 0 ? articles[0].article_id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentScrollX = window.scrollX;
|
||||||
|
const currentScrollY = window.scrollY;
|
||||||
|
|
||||||
container.innerHTML = this.buildHtml(data);
|
container.innerHTML = this.buildHtml(data);
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
|
window.scrollTo(currentScrollX, currentScrollY);
|
||||||
|
|
||||||
// Smooth scroll active day pill into center view
|
// Smooth scroll active day pill into center view
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const activePill = container.querySelector('.ta-day-pill.active');
|
const activePill = container.querySelector('.ta-day-pill.active');
|
||||||
@@ -114,10 +177,13 @@ const TimeAccountingView = {
|
|||||||
return `
|
return `
|
||||||
<div class="view-header" style="padding:var(--space-md) var(--space-xl); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-sm); border-bottom:1px solid var(--border-subtle);">
|
<div class="view-header" style="padding:var(--space-md) var(--space-xl); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-sm); border-bottom:1px solid var(--border-subtle);">
|
||||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px; height:20px; color:var(--accent-primary);">
|
<button id="ta-toggle-view-mode" class="btn btn-ghost btn-sm" style="padding:4px 8px; display:inline-flex; align-items:center; justify-content:center; border-radius:6px; background:var(--bg-tertiary); color:var(--accent-primary); border:1px solid var(--border-subtle); cursor:pointer;" title="${this.viewMode === 'list' ? 'Passa alla vista Calendario Mese' : 'Passa alla vista Dettaglio Elenco'}">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px; height:20px;">
|
||||||
<polyline points="12 6 12 12 16 14" />
|
${this.viewMode === 'calendar'
|
||||||
|
? '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>'
|
||||||
|
: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>'}
|
||||||
</svg>
|
</svg>
|
||||||
|
</button>
|
||||||
<span style="font-weight:600; font-size:1rem;">Storico consuntivazione</span>
|
<span style="font-weight:600; font-size:1rem;">Storico consuntivazione</span>
|
||||||
<span class="badge" style="background:${headerBadgeBg}; color:#fff; font-size:0.78rem; padding:3px 10px; border-radius:12px;">
|
<span class="badge" style="background:${headerBadgeBg}; color:#fff; font-size:0.78rem; padding:3px 10px; border-radius:12px;">
|
||||||
${headerBadgeText}
|
${headerBadgeText}
|
||||||
@@ -141,6 +207,14 @@ const TimeAccountingView = {
|
|||||||
|
|
||||||
<div style="display:flex; gap:var(--space-xs); align-self:flex-end;">
|
<div style="display:flex; gap:var(--space-xs); align-self:flex-end;">
|
||||||
<button id="ta-btn-today" class="btn btn-ghost btn-sm" style="height:36px;">Oggi</button>
|
<button id="ta-btn-today" class="btn btn-ghost btn-sm" style="height:36px;">Oggi</button>
|
||||||
|
<button id="ta-btn-toggle-groupby" class="btn btn-ghost btn-sm" style="height:36px; display:flex; align-items:center; gap:6px; background:${this.groupByTicket ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; color:${this.groupByTicket ? '#ffffff' : 'var(--text-secondary)'}; border:1px solid var(--border-subtle);" title="Raggruppa le note adiacenti appartenenti allo stesso ticket">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px; height:15px;">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
<span>${this.groupByTicket ? 'Raggruppa per Ticket (Attivo)' : 'Raggruppa per Ticket'}</span>
|
||||||
|
</button>
|
||||||
<button id="ta-btn-toggle-nonwork" class="btn btn-ghost btn-sm" style="height:36px; display:flex; align-items:center; gap:6px;">
|
<button id="ta-btn-toggle-nonwork" class="btn btn-ghost btn-sm" style="height:36px; display:flex; align-items:center; gap:6px;">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px; height:15px;">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px; height:15px;">
|
||||||
${this.showNonWorkingDays
|
${this.showNonWorkingDays
|
||||||
@@ -152,14 +226,15 @@ const TimeAccountingView = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Interactive Day Pills Strip with Target-based Color Coding -->
|
${this.viewMode === 'calendar' ? this.buildCalendarGrid(data) : `
|
||||||
|
<!-- Interactive Day Pills Strip -->
|
||||||
${this.buildDayStrip(data)}
|
${this.buildDayStrip(data)}
|
||||||
|
|
||||||
<!-- Two-column Master/Detail Layout -->
|
<!-- Two-column Master/Detail Layout -->
|
||||||
<div style="display:flex; gap:var(--space-md); padding:var(--space-md) var(--space-xl); align-items:flex-start; min-height:calc(100vh - 280px);">
|
<div style="display:flex; gap:var(--space-md); padding:var(--space-md) var(--space-xl); align-items:flex-start; min-height:calc(100vh - 280px);">
|
||||||
|
|
||||||
<!-- Left Side: List of Articles -->
|
<!-- Left Side: List of Articles -->
|
||||||
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-sm);">
|
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-md);">
|
||||||
${articles.length === 0 ? `
|
${articles.length === 0 ? `
|
||||||
<div class="empty-state" style="padding:var(--space-2xl); text-align:center; color:var(--text-muted); background:var(--bg-card); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
<div class="empty-state" style="padding:var(--space-2xl); text-align:center; color:var(--text-muted); background:var(--bg-card); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px; height:48px; margin:0 auto var(--space-md); display:block; opacity:0.4;">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px; height:48px; margin:0 auto var(--space-md); display:block; opacity:0.4;">
|
||||||
@@ -167,7 +242,7 @@ const TimeAccountingView = {
|
|||||||
</svg>
|
</svg>
|
||||||
<p style="font-size:0.9rem; font-weight:500;">Nessuna nota o email trovata in questa data per l'agente selezionato.</p>
|
<p style="font-size:0.9rem; font-weight:500;">Nessuna nota o email trovata in questa data per l'agente selezionato.</p>
|
||||||
</div>
|
</div>
|
||||||
` : articles.map(a => this.buildArticleCard(a)).join('')}
|
` : this.groupArticles(articles).map(g => g.type === 'single' ? this.buildArticleCard(g.article) : this.buildGroupCard(g)).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side: Details & Actions Panel -->
|
<!-- Right Side: Details & Actions Panel -->
|
||||||
@@ -179,6 +254,7 @@ const TimeAccountingView = {
|
|||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -192,22 +268,43 @@ const TimeAccountingView = {
|
|||||||
const targetMins = (data && data.target_minutes) || 480;
|
const targetMins = (data && data.target_minutes) || 480;
|
||||||
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
|
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
// Generate 7 days before and 7 days after selected date (15 days total)
|
// Generate 2 full weeks of days (15 calendar days if showing all, or 10-11 working days if hiding non-working days)
|
||||||
const days = [];
|
const dateList = [];
|
||||||
|
if (this.showNonWorkingDays) {
|
||||||
for (let offset = -7; offset <= 7; offset++) {
|
for (let offset = -7; offset <= 7; offset++) {
|
||||||
const d = new Date(selected);
|
const d = new Date(selected);
|
||||||
d.setDate(d.getDate() + offset);
|
d.setDate(d.getDate() + offset);
|
||||||
|
dateList.push(d);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const before = [];
|
||||||
|
let dPrev = new Date(selected);
|
||||||
|
while (before.length < 5) {
|
||||||
|
dPrev.setDate(dPrev.getDate() - 1);
|
||||||
|
if (workDays.includes(dPrev.getDay())) {
|
||||||
|
before.unshift(new Date(dPrev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = new Date(selected);
|
||||||
|
const after = [];
|
||||||
|
let dNext = new Date(selected);
|
||||||
|
while (after.length < 5) {
|
||||||
|
dNext.setDate(dNext.getDate() + 1);
|
||||||
|
if (workDays.includes(dNext.getDay())) {
|
||||||
|
after.push(new Date(dNext));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dateList.push(...before, current, ...after);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
for (const d of dateList) {
|
||||||
const isoStr = this.formatLocalISO(d);
|
const isoStr = this.formatLocalISO(d);
|
||||||
const dayOfWeek = d.getDay(); // 0 = Sun, 1 = Mon ... 6 = Sat
|
const dayOfWeek = d.getDay(); // 0 = Sun, 1 = Mon ... 6 = Sat
|
||||||
const isWorkDay = workDays.includes(dayOfWeek);
|
const isWorkDay = workDays.includes(dayOfWeek);
|
||||||
const isSelected = isoStr === this.selectedDate;
|
const isSelected = isoStr === this.selectedDate;
|
||||||
const isToday = isoStr === todayStr;
|
const isToday = isoStr === todayStr;
|
||||||
|
|
||||||
// Filter out non-working days if toggle is off, unless it's currently selected
|
|
||||||
if (!isWorkDay && !this.showNonWorkingDays && !isSelected) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayNum = d.getDate();
|
const dayNum = d.getDate();
|
||||||
const weekdayName = weekdays[dayOfWeek];
|
const weekdayName = weekdays[dayOfWeek];
|
||||||
const monthName = months[d.getMonth()];
|
const monthName = months[d.getMonth()];
|
||||||
@@ -236,8 +333,21 @@ const TimeAccountingView = {
|
|||||||
borderStyle = '1px solid var(--border-subtle)';
|
borderStyle = '1px solid var(--border-subtle)';
|
||||||
textColor = 'var(--accent-primary)';
|
textColor = 'var(--accent-primary)';
|
||||||
}
|
}
|
||||||
|
} else if (dayMins === 0) {
|
||||||
|
// Giorno lavorativo senza consuntivazioni: neutro / bianco
|
||||||
|
if (isSelected) {
|
||||||
|
bgStyle = 'var(--accent-primary)';
|
||||||
|
borderStyle = '1px solid var(--accent-primary)';
|
||||||
|
textColor = '#ffffff';
|
||||||
|
badgeBg = 'rgba(255,255,255,0.25)';
|
||||||
|
badgeColor = '#ffffff';
|
||||||
|
} else if (isToday) {
|
||||||
|
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||||
|
borderStyle = '1px solid var(--border-subtle)';
|
||||||
|
textColor = 'var(--accent-primary)';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Working days (configured in WORK_DAYS)
|
// Working days con consuntivazione (> 0 min)
|
||||||
if (isTargetReached) {
|
if (isTargetReached) {
|
||||||
// Verdino (green)
|
// Verdino (green)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -297,6 +407,205 @@ const TimeAccountingView = {
|
|||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
buildCalendarGrid(data) {
|
||||||
|
const todayStr = this.formatLocalISO(new Date());
|
||||||
|
const dailyTotals = (data && data.daily_totals) || {};
|
||||||
|
const targetMins = (data && data.target_minutes) || 480;
|
||||||
|
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
|
||||||
|
const monthNames = ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'];
|
||||||
|
const headerDays = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
|
||||||
|
|
||||||
|
// If showAllTime is true, render all months present in dailyTotals or at least current month down to earliest month
|
||||||
|
let monthsToRender = [];
|
||||||
|
|
||||||
|
if (this.showAllTime) {
|
||||||
|
const dates = Object.keys(dailyTotals).sort();
|
||||||
|
let startYear, startMonth;
|
||||||
|
if (dates.length > 0) {
|
||||||
|
const [y, m] = dates[0].split('-').map(Number);
|
||||||
|
startYear = y;
|
||||||
|
startMonth = m - 1;
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
startYear = now.getFullYear();
|
||||||
|
startMonth = now.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const endYear = now.getFullYear();
|
||||||
|
const endMonth = now.getMonth();
|
||||||
|
|
||||||
|
// Loop month by month from earliest to current/selected
|
||||||
|
let curr = new Date(startYear, startMonth, 1);
|
||||||
|
const end = new Date(endYear, endMonth, 1);
|
||||||
|
while (curr <= end) {
|
||||||
|
monthsToRender.push({ year: curr.getFullYear(), month: curr.getMonth() });
|
||||||
|
curr.setMonth(curr.getMonth() + 1);
|
||||||
|
}
|
||||||
|
// Reverse so newest months appear first
|
||||||
|
monthsToRender.reverse();
|
||||||
|
} else {
|
||||||
|
const selectedParts = this.selectedDate.split('-').map(Number);
|
||||||
|
monthsToRender.push({ year: selectedParts[0], month: selectedParts[1] - 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSingleMonthGrid = (mYear, mMonth) => {
|
||||||
|
const firstDay = new Date(mYear, mMonth, 1);
|
||||||
|
const lastDay = new Date(mYear, mMonth + 1, 0);
|
||||||
|
|
||||||
|
let startDayOfWeek = firstDay.getDay() - 1;
|
||||||
|
if (startDayOfWeek === -1) startDayOfWeek = 6;
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < startDayOfWeek; i++) {
|
||||||
|
cells.push(`<div style="background:var(--bg-tertiary); opacity:0.3; border-radius:var(--radius-md); min-height:85px; border:1px dashed var(--border-subtle);"></div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysInMonth = lastDay.getDate();
|
||||||
|
for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) {
|
||||||
|
const d = new Date(mYear, mMonth, dayNum);
|
||||||
|
const isoStr = this.formatLocalISO(d);
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
const isWorkDay = workDays.includes(dayOfWeek);
|
||||||
|
const isSelected = isoStr === this.selectedDate;
|
||||||
|
const isToday = isoStr === todayStr;
|
||||||
|
|
||||||
|
const dayMins = Math.round(dailyTotals[isoStr] || 0);
|
||||||
|
const formattedTime = this.formatPillTime(dayMins);
|
||||||
|
const isTargetReached = dayMins >= targetMins;
|
||||||
|
|
||||||
|
let bgStyle = 'var(--bg-card)';
|
||||||
|
let borderStyle = '1px solid var(--border-subtle)';
|
||||||
|
let textColor = 'var(--text-primary)';
|
||||||
|
let badgeBg = 'var(--bg-tertiary)';
|
||||||
|
let badgeColor = 'var(--text-muted)';
|
||||||
|
|
||||||
|
if (!isWorkDay) {
|
||||||
|
if (isSelected) {
|
||||||
|
bgStyle = 'var(--accent-primary)';
|
||||||
|
borderStyle = '2px solid var(--accent-primary)';
|
||||||
|
textColor = '#ffffff';
|
||||||
|
badgeBg = 'rgba(255,255,255,0.25)';
|
||||||
|
badgeColor = '#ffffff';
|
||||||
|
} else if (isToday) {
|
||||||
|
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||||
|
textColor = 'var(--accent-primary)';
|
||||||
|
}
|
||||||
|
} else if (dayMins === 0) {
|
||||||
|
// Giorno lavorativo senza ore consuntivate: neutro / non evidenziato in rosso
|
||||||
|
if (isSelected) {
|
||||||
|
bgStyle = 'var(--accent-primary)';
|
||||||
|
borderStyle = '2px solid var(--accent-primary)';
|
||||||
|
textColor = '#ffffff';
|
||||||
|
badgeBg = 'rgba(255,255,255,0.25)';
|
||||||
|
badgeColor = '#ffffff';
|
||||||
|
} else if (isToday) {
|
||||||
|
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||||
|
textColor = 'var(--accent-primary)';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isTargetReached) {
|
||||||
|
if (isSelected) {
|
||||||
|
bgStyle = '#15803d';
|
||||||
|
borderStyle = '2px solid #166534';
|
||||||
|
textColor = '#ffffff';
|
||||||
|
badgeBg = 'rgba(255,255,255,0.28)';
|
||||||
|
badgeColor = '#ffffff';
|
||||||
|
} else {
|
||||||
|
bgStyle = 'rgba(34, 197, 94, 0.16)';
|
||||||
|
borderStyle = '1px solid rgba(34, 197, 94, 0.5)';
|
||||||
|
textColor = '#15803d';
|
||||||
|
badgeBg = 'rgba(34, 197, 94, 0.25)';
|
||||||
|
badgeColor = '#15803d';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isSelected) {
|
||||||
|
bgStyle = '#b91c1c';
|
||||||
|
borderStyle = '2px solid #991b1b';
|
||||||
|
textColor = '#ffffff';
|
||||||
|
badgeBg = 'rgba(255,255,255,0.28)';
|
||||||
|
badgeColor = '#ffffff';
|
||||||
|
} else {
|
||||||
|
bgStyle = 'rgba(239, 68, 68, 0.14)';
|
||||||
|
borderStyle = '1px solid rgba(239, 68, 68, 0.4)';
|
||||||
|
textColor = '#b91c1c';
|
||||||
|
badgeBg = 'rgba(239, 68, 68, 0.22)';
|
||||||
|
badgeColor = '#b91c1c';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdays = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
|
||||||
|
const weekdayName = weekdays[dayOfWeek];
|
||||||
|
|
||||||
|
cells.push(`
|
||||||
|
<button type="button" class="ta-cal-day-cell ta-day-pill ${isSelected ? 'active' : ''}" data-date="${isoStr}"
|
||||||
|
style="display:flex; flex-direction:column; justify-content:space-between; align-items:flex-start; padding:8px 10px; min-height:85px; border-radius:var(--radius-md); border:${borderStyle}; background:${bgStyle}; color:${textColor}; cursor:pointer; transition:all 0.15s ease; width:100%; font-family:inherit; text-align:left;">
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; width:100%; align-items:center;">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:4px;">
|
||||||
|
<span style="font-size:1.1rem; font-weight:800; color:inherit;">${dayNum}</span>
|
||||||
|
<span style="font-size:0.7rem; font-weight:600; text-transform:uppercase; opacity:0.8; color:inherit;">${weekdayName}</span>
|
||||||
|
</div>
|
||||||
|
${isToday ? `<span style="font-size:0.62rem; font-weight:700; background:var(--accent-primary); color:#fff; padding:1px 5px; border-radius:4px;">OGGI</span>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:auto; width:100%; display:flex; flex-direction:column; gap:2px;">
|
||||||
|
<span class="badge" style="font-size:0.75rem; font-weight:800; padding:3px 6px; border-radius:6px; background:${badgeBg}; color:${badgeColor}; display:block; text-align:center; width:100%;">
|
||||||
|
⏱️ ${formattedTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthTitle = `${monthNames[mMonth]} ${mYear}`;
|
||||||
|
return `
|
||||||
|
<div class="ta-month-section" style="margin-bottom:var(--space-xl);">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-md); margin-bottom:var(--space-md);">
|
||||||
|
<h3 style="margin:0; font-size:1.15rem; font-weight:800; color:var(--text-primary); text-transform:capitalize; white-space:nowrap;">
|
||||||
|
📅 ${monthTitle}
|
||||||
|
</h3>
|
||||||
|
<div style="flex:1; height:2px; background:linear-gradient(to right, var(--border-subtle), transparent);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:8px; margin-bottom:8px; text-align:center; font-weight:700; font-size:0.8rem; color:var(--text-muted);">
|
||||||
|
${headerDays.map(h => `<div>${h}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:8px;">
|
||||||
|
${cells.join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedParts = this.selectedDate.split('-').map(Number);
|
||||||
|
const selectedYear = selectedParts[0];
|
||||||
|
const selectedMonth = selectedParts[1] - 1;
|
||||||
|
const currentMonthName = `${monthNames[selectedMonth]} ${selectedYear}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="padding:var(--space-md) var(--space-xl);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-md); background:var(--bg-secondary); padding:var(--space-sm) var(--space-md); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
||||||
|
<button id="ta-cal-toggle-all-time" class="btn btn-ghost btn-sm" style="font-size:0.82rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:${this.showAllTime ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; color:${this.showAllTime ? '#ffffff' : 'var(--text-secondary)'}; border:1px solid var(--border-subtle);" title="Carica l'intero storico delle attività fin dalla prima consuntivazione registrata">
|
||||||
|
🚀 ${this.showAllTime ? 'Tutto lo Storico (Attivo)' : 'Carica Tutto lo Storico'}
|
||||||
|
</button>
|
||||||
|
<button id="ta-cal-show-chart" class="btn btn-ghost btn-sm" style="font-size:0.82rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border-subtle);" title="Apri grafico con l'andamento della consuntivazione">
|
||||||
|
📊 Mostra Grafico
|
||||||
|
</button>
|
||||||
|
<button id="ta-cal-month-prev" class="btn btn-ghost btn-sm" style="font-size:0.9rem; font-weight:600;" ${this.showAllTime ? 'disabled style="opacity:0.4; cursor:not-allowed;"' : ''}>‹ Mese Precedente</button>
|
||||||
|
</div>
|
||||||
|
<span style="font-weight:700; font-size:1.1rem; color:var(--text-primary);">${this.showAllTime ? 'Tutto lo Storico Consuntivato' : currentMonthName}</span>
|
||||||
|
<button id="ta-cal-month-next" class="btn btn-ghost btn-sm" style="font-size:0.9rem; font-weight:600;" ${this.showAllTime ? 'disabled style="opacity:0.4; cursor:not-allowed;"' : ''}>Mese Successivo ›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${monthsToRender.map(m => renderSingleMonthGrid(m.year, m.month)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
buildArticleCard(a) {
|
buildArticleCard(a) {
|
||||||
const isSelected = String(a.article_id) === String(this.selectedArticleId);
|
const isSelected = String(a.article_id) === String(this.selectedArticleId);
|
||||||
const dt = a.create_time ? new Date(a.create_time).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : '—';
|
const dt = a.create_time ? new Date(a.create_time).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : '—';
|
||||||
@@ -331,6 +640,40 @@ const TimeAccountingView = {
|
|||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
buildGroupCard(group) {
|
||||||
|
const totalGroupMins = Math.round(group.articles.reduce((acc, a) => acc + (parseFloat(a.time_unit) || 0), 0));
|
||||||
|
const formattedGroupTime = this.formatPillTime(totalGroupMins);
|
||||||
|
const hasSelectedArticle = group.articles.some(a => String(a.article_id) === String(this.selectedArticleId));
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card ta-group-card" style="border:1px solid ${hasSelectedArticle ? 'var(--accent-primary)' : 'var(--border-subtle)'}; background:var(--bg-card); border-radius:var(--radius-md); overflow:hidden;">
|
||||||
|
<!-- Header del Gruppo Ticket -->
|
||||||
|
<div style="padding:10px 14px; background:var(--bg-secondary); border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; gap:10px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||||
|
<span style="font-size:0.9rem;">🎫</span>
|
||||||
|
<a href="#/tickets/${group.ticket_id}" class="ticket-tn-link" onclick="event.stopPropagation();" style="font-weight:700; font-size:0.9rem;">#${App.escapeHtml(group.ticket_tn || group.ticket_id)}</a>
|
||||||
|
<span style="font-size:0.85rem; font-weight:600; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${App.escapeHtml(group.ticket_title || '')}">
|
||||||
|
${App.escapeHtml(group.ticket_title || '(senza titolo)')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
|
||||||
|
<span class="badge" style="background:var(--bg-tertiary); color:var(--text-secondary); font-size:0.75rem; font-weight:600;">
|
||||||
|
${group.articles.length} ${group.articles.length === 1 ? 'nota' : 'note'}
|
||||||
|
</span>
|
||||||
|
<span class="badge" style="background:${totalGroupMins > 0 ? 'rgba(34,197,94,0.18)' : 'var(--bg-tertiary)'}; color:${totalGroupMins > 0 ? '#15803d' : 'var(--text-muted)'}; font-weight:700; font-size:0.78rem;">
|
||||||
|
⏱️ Totale: ${formattedGroupTime} (${totalGroupMins} m)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note del Gruppo -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap:6px; padding:10px;">
|
||||||
|
${group.articles.map(a => this.buildArticleCard(a)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
buildDetailPanel(a) {
|
buildDetailPanel(a) {
|
||||||
const timeMins = parseFloat(a.time_unit || 0);
|
const timeMins = parseFloat(a.time_unit || 0);
|
||||||
|
|
||||||
@@ -391,6 +734,34 @@ const TimeAccountingView = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
|
// Toggle View Mode (List <-> Calendar Grid)
|
||||||
|
document.getElementById('ta-toggle-view-mode')?.addEventListener('click', () => {
|
||||||
|
this.viewMode = this.viewMode === 'list' ? 'calendar' : 'list';
|
||||||
|
this.loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle Show All Time history
|
||||||
|
document.getElementById('ta-cal-toggle-all-time')?.addEventListener('click', () => {
|
||||||
|
this.showAllTime = !this.showAllTime;
|
||||||
|
Toast.info(this.showAllTime ? 'Caricamento dell\'intero storico in corso...' : 'Filtro intervallo standard ripristinato');
|
||||||
|
this.loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calendar month navigation
|
||||||
|
document.getElementById('ta-cal-month-prev')?.addEventListener('click', () => {
|
||||||
|
const parts = this.selectedDate.split('-').map(Number);
|
||||||
|
const d = new Date(parts[0], parts[1] - 2, 1);
|
||||||
|
this.selectedDate = this.formatLocalISO(d);
|
||||||
|
this.loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('ta-cal-month-next')?.addEventListener('click', () => {
|
||||||
|
const parts = this.selectedDate.split('-').map(Number);
|
||||||
|
const d = new Date(parts[0], parts[1], 1);
|
||||||
|
this.selectedDate = this.formatLocalISO(d);
|
||||||
|
this.loadData();
|
||||||
|
});
|
||||||
|
|
||||||
// Agent change
|
// Agent change
|
||||||
document.getElementById('ta-agent-select')?.addEventListener('change', (e) => {
|
document.getElementById('ta-agent-select')?.addEventListener('change', (e) => {
|
||||||
this.selectedAgentId = e.target.value;
|
this.selectedAgentId = e.target.value;
|
||||||
@@ -413,33 +784,50 @@ const TimeAccountingView = {
|
|||||||
document.getElementById('ta-btn-toggle-nonwork')?.addEventListener('click', () => {
|
document.getElementById('ta-btn-toggle-nonwork')?.addEventListener('click', () => {
|
||||||
this.showNonWorkingDays = !this.showNonWorkingDays;
|
this.showNonWorkingDays = !this.showNonWorkingDays;
|
||||||
localStorage.setItem('otrs_ta_show_non_working_days', this.showNonWorkingDays);
|
localStorage.setItem('otrs_ta_show_non_working_days', this.showNonWorkingDays);
|
||||||
const container = document.getElementById('view-container');
|
this.loadData();
|
||||||
container.innerHTML = this.buildHtml(this.summaryData);
|
|
||||||
this.bindEvents();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Day pill clicks
|
// Toggle group by ticket button
|
||||||
|
document.getElementById('ta-btn-toggle-groupby')?.addEventListener('click', () => {
|
||||||
|
this.groupByTicket = !this.groupByTicket;
|
||||||
|
localStorage.setItem('otrs_ta_group_by_ticket', this.groupByTicket);
|
||||||
|
this.loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Day pill clicks (also handles calendar cell clicks)
|
||||||
document.querySelectorAll('.ta-day-pill[data-date]').forEach(pill => {
|
document.querySelectorAll('.ta-day-pill[data-date]').forEach(pill => {
|
||||||
pill.addEventListener('click', () => {
|
pill.addEventListener('click', () => {
|
||||||
this.selectedDate = pill.dataset.date;
|
const dateClicked = pill.dataset.date;
|
||||||
|
if (this.viewMode === 'calendar') {
|
||||||
|
this.selectedDate = dateClicked;
|
||||||
|
this.viewMode = 'list';
|
||||||
|
} else {
|
||||||
|
this.selectedDate = dateClicked;
|
||||||
|
}
|
||||||
this.loadData();
|
this.loadData();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Previous day button
|
// Previous day button
|
||||||
document.getElementById('ta-day-prev')?.addEventListener('click', () => {
|
document.getElementById('ta-day-prev')?.addEventListener('click', () => {
|
||||||
|
const workDays = (this.summaryData && this.summaryData.work_days) || [1, 2, 3, 4, 5];
|
||||||
const parts = this.selectedDate.split('-').map(Number);
|
const parts = this.selectedDate.split('-').map(Number);
|
||||||
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
|
do {
|
||||||
d.setDate(d.getDate() - 1);
|
d.setDate(d.getDate() - 1);
|
||||||
|
} while (!this.showNonWorkingDays && workDays.length > 0 && !workDays.includes(d.getDay()));
|
||||||
this.selectedDate = this.formatLocalISO(d);
|
this.selectedDate = this.formatLocalISO(d);
|
||||||
this.loadData();
|
this.loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Next day button
|
// Next day button
|
||||||
document.getElementById('ta-day-next')?.addEventListener('click', () => {
|
document.getElementById('ta-day-next')?.addEventListener('click', () => {
|
||||||
|
const workDays = (this.summaryData && this.summaryData.work_days) || [1, 2, 3, 4, 5];
|
||||||
const parts = this.selectedDate.split('-').map(Number);
|
const parts = this.selectedDate.split('-').map(Number);
|
||||||
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
|
do {
|
||||||
d.setDate(d.getDate() + 1);
|
d.setDate(d.getDate() + 1);
|
||||||
|
} while (!this.showNonWorkingDays && workDays.length > 0 && !workDays.includes(d.getDay()));
|
||||||
this.selectedDate = this.formatLocalISO(d);
|
this.selectedDate = this.formatLocalISO(d);
|
||||||
this.loadData();
|
this.loadData();
|
||||||
});
|
});
|
||||||
@@ -448,9 +836,12 @@ const TimeAccountingView = {
|
|||||||
document.querySelectorAll('.ta-article-card').forEach(card => {
|
document.querySelectorAll('.ta-article-card').forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
this.selectedArticleId = card.dataset.articleId;
|
this.selectedArticleId = card.dataset.articleId;
|
||||||
|
const currentScrollX = window.scrollX;
|
||||||
|
const currentScrollY = window.scrollY;
|
||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
container.innerHTML = this.buildHtml(this.summaryData);
|
container.innerHTML = this.buildHtml(this.summaryData);
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
window.scrollTo(currentScrollX, currentScrollY);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,5 +896,422 @@ const TimeAccountingView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show Chart Modal action
|
||||||
|
document.getElementById('ta-cal-show-chart')?.addEventListener('click', () => {
|
||||||
|
this.openChartModal();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async openChartModal() {
|
||||||
|
const existingModal = document.getElementById('ta-chart-modal');
|
||||||
|
if (existingModal) existingModal.remove();
|
||||||
|
|
||||||
|
const users = App.lookups.users || [];
|
||||||
|
|
||||||
|
// Saved banned agent IDs (semitrasparenti e bloccati)
|
||||||
|
let bannedAgentIds = [];
|
||||||
|
try {
|
||||||
|
const savedBanned = localStorage.getItem('otrs_ta_chart_banned_agents');
|
||||||
|
if (savedBanned) bannedAgentIds = JSON.parse(savedBanned);
|
||||||
|
} catch (_) {}
|
||||||
|
if (!Array.isArray(bannedAgentIds)) bannedAgentIds = [];
|
||||||
|
|
||||||
|
// Saved selected agent IDs or default to current agent
|
||||||
|
let activeAgentIds = [];
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('otrs_ta_chart_agents');
|
||||||
|
if (saved) activeAgentIds = JSON.parse(saved);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
if (!Array.isArray(activeAgentIds) || activeAgentIds.length === 0) {
|
||||||
|
activeAgentIds = [String(this.selectedAgentId || App.currentAgentId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter activeAgentIds to remove any banned agents
|
||||||
|
activeAgentIds = activeAgentIds.filter(id => !bannedAgentIds.includes(id));
|
||||||
|
|
||||||
|
// Saved range or default to 'all'
|
||||||
|
let selectedRange = localStorage.getItem('otrs_ta_chart_range') || 'all';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'ta-chart-modal';
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.style.cssText = 'display:flex; position:fixed; z-index:10000; inset:0; background:rgba(0,0,0,0.65); align-items:center; justify-content:center; padding:20px; backdrop-filter:blur(4px);';
|
||||||
|
|
||||||
|
const isDarkMode = document.body.classList.contains('theme-dark');
|
||||||
|
const textColor = isDarkMode ? '#cbd5e1' : '#334155';
|
||||||
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)';
|
||||||
|
|
||||||
|
// Paletta di colori vivaci per gli agenti nel grafico
|
||||||
|
const colorPalette = [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899',
|
||||||
|
'#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#a855f7'
|
||||||
|
];
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width:1100px; width:94vw; max-height:90vh; background:var(--bg-card); border-radius:var(--radius-lg); border:1px solid var(--border-subtle); padding:var(--space-xl); box-shadow:0 20px 50px rgba(0,0,0,0.5); display:flex; flex-direction:column; gap:var(--space-md); overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); padding-bottom:var(--space-md); flex-wrap:wrap; gap:12px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
|
<span style="font-size:1.4rem;">📊</span>
|
||||||
|
<div>
|
||||||
|
<span style="font-weight:700; font-size:1.15rem; color:var(--text-primary); display:block;">Andamento Consuntivazione Tempo</span>
|
||||||
|
<span style="font-size:0.78rem; color:var(--text-muted);">Confronto e analisi temporale delle consuntivazioni agenti</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Select Period Filter -->
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Periodo:</label>
|
||||||
|
<select id="ta-chart-range-select" class="form-select" style="height:34px; font-size:0.82rem; padding:0 28px 0 10px; background:var(--bg-secondary);">
|
||||||
|
<option value="all" ${selectedRange === 'all' ? 'selected' : ''}>🚀 Tutto lo Storico</option>
|
||||||
|
<option value="7" ${selectedRange === '7' ? 'selected' : ''}>Ultimi 7 Giorni</option>
|
||||||
|
<option value="14" ${selectedRange === '14' ? 'selected' : ''}>Ultimi 14 Giorni</option>
|
||||||
|
<option value="30" ${selectedRange === '30' ? 'selected' : ''}>Ultimi 30 Giorni</option>
|
||||||
|
<option value="90" ${selectedRange === '90' ? 'selected' : ''}>Ultimi 3 Mesi (90 gg)</option>
|
||||||
|
<option value="180" ${selectedRange === '180' ? 'selected' : ''}>Ultimi 6 Mesi (180 gg)</option>
|
||||||
|
<option value="365" ${selectedRange === '365' ? 'selected' : ''}>Ultimo Anno (365 gg)</option>
|
||||||
|
</select>
|
||||||
|
<button id="ta-chart-modal-close" class="btn btn-ghost btn-sm" style="font-size:1.2rem; line-height:1; padding:4px 10px; cursor:pointer;">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Body (Chart + Agents Sidebar) -->
|
||||||
|
<div style="display:flex; gap:var(--space-md); flex:1; min-height:0; overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Main Canvas Area -->
|
||||||
|
<div style="flex:1; position:relative; min-height:420px; background:var(--bg-secondary); border-radius:var(--radius-md); padding:16px; border:1px solid var(--border-subtle); display:flex; flex-direction:column;">
|
||||||
|
<div id="ta-chart-loading" style="display:none; position:absolute; inset:0; background:rgba(0,0,0,0.3); backdrop-filter:blur(2px); z-index:10; align-items:center; justify-content:center; color:#fff; font-weight:600; font-size:0.9rem; border-radius:var(--radius-md);">
|
||||||
|
<div class="spinner" style="margin-right:10px;"></div> Caricamento dati...
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; position:relative; width:100%; height:100%;">
|
||||||
|
<canvas id="ta-chart-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Sidebar: Agents Selection Toggle List -->
|
||||||
|
<div style="width:270px; flex-shrink:0; background:var(--bg-secondary); border-radius:var(--radius-md); border:1px solid var(--border-subtle); padding:12px; display:flex; flex-direction:column; gap:10px;">
|
||||||
|
<div style="font-size:0.8rem; font-weight:700; color:var(--text-primary); border-bottom:1px solid var(--border-subtle); padding-bottom:6px; display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span>👥 Agenti (${users.length})</span>
|
||||||
|
<button id="ta-chart-select-all" class="btn btn-ghost btn-sm" style="font-size:0.7rem; padding:2px 6px;">Tutti</button>
|
||||||
|
</div>
|
||||||
|
<div id="ta-chart-agents-list" style="flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:6px; padding-right:4px;">
|
||||||
|
${users.map((u, idx) => {
|
||||||
|
const uId = String(u.id);
|
||||||
|
const isBanned = bannedAgentIds.includes(uId);
|
||||||
|
const isChecked = !isBanned && activeAgentIds.includes(uId);
|
||||||
|
const color = colorPalette[idx % colorPalette.length];
|
||||||
|
return `
|
||||||
|
<div class="ta-agent-toggle-item ${isBanned ? 'banned' : ''}" data-id="${uId}" style="display:flex; align-items:center; gap:6px; padding:5px 8px; border-radius:6px; background:${isChecked ? 'var(--bg-tertiary)' : 'transparent'}; border:1px solid ${isChecked ? 'var(--border-subtle)' : 'transparent'}; font-size:0.8rem; user-select:none; transition:all 0.15s ease; ${isBanned ? 'opacity:0.4; pointer-events:none; cursor:not-allowed;' : ''}">
|
||||||
|
<input type="checkbox" class="ta-agent-checkbox" value="${uId}" ${isChecked ? 'checked' : ''} ${isBanned ? 'disabled' : ''} style="cursor:pointer;" />
|
||||||
|
<span style="width:10px; height:10px; border-radius:50%; background:${color}; flex-shrink:0;"></span>
|
||||||
|
<span style="font-weight:${isChecked ? '600' : 'normal'}; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;" title="${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}">
|
||||||
|
${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}
|
||||||
|
</span>
|
||||||
|
<span class="ta-agent-total-badge" style="font-size:0.72rem; font-weight:700; font-family:monospace; color:var(--text-muted); background:var(--bg-card); border:1px solid var(--border-subtle); padding:1px 5px; border-radius:10px; flex-shrink:0;">
|
||||||
|
0h
|
||||||
|
</span>
|
||||||
|
<button type="button" class="ta-agent-ban-btn" data-id="${uId}" style="pointer-events:auto; background:none; border:none; cursor:pointer; font-size:0.85rem; padding:0 2px; line-height:1; opacity:${isBanned ? '1' : '0.5'}; transition:opacity 0.15s ease;" title="${isBanned ? 'Sblocca agente' : 'Blocca agente (Escludi dal grafico e dal pulsante Tutti)'}">
|
||||||
|
🚫
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Stats -->
|
||||||
|
<div id="ta-chart-footer-info" style="display:flex; justify-content:space-between; align-items:center; font-size:0.8rem; color:var(--text-muted); border-top:1px solid var(--border-subtle); padding-top:var(--space-sm);">
|
||||||
|
<span>Agenti selezionati: <strong id="ta-chart-agents-count">${activeAgentIds.length}</strong></span>
|
||||||
|
<span>Target Giornaliero: <strong>8.0h (480 min)</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (this.chartInstance) {
|
||||||
|
this.chartInstance.destroy();
|
||||||
|
this.chartInstance = null;
|
||||||
|
}
|
||||||
|
modal.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelector('#ta-chart-modal-close').onclick = closeModal;
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to fetch data and refresh chart instance
|
||||||
|
const refreshChartData = async () => {
|
||||||
|
const loadingEl = modal.querySelector('#ta-chart-loading');
|
||||||
|
if (loadingEl) loadingEl.style.display = 'flex';
|
||||||
|
|
||||||
|
const checkboxes = modal.querySelectorAll('.ta-agent-checkbox:checked');
|
||||||
|
const selectedIds = Array.from(checkboxes).map(c => c.value);
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
localStorage.setItem('otrs_ta_chart_agents', JSON.stringify(selectedIds));
|
||||||
|
localStorage.setItem('otrs_ta_chart_range', selectedRange);
|
||||||
|
|
||||||
|
const countEl = modal.querySelector('#ta-chart-agents-count');
|
||||||
|
if (countEl) countEl.textContent = selectedIds.length;
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
if (this.chartInstance) {
|
||||||
|
this.chartInstance.destroy();
|
||||||
|
this.chartInstance = null;
|
||||||
|
}
|
||||||
|
if (loadingEl) loadingEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
agent_ids: selectedIds.join(','),
|
||||||
|
date: this.selectedDate,
|
||||||
|
range: selectedRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
|
||||||
|
const dailyTotalsByAgent = data.daily_totals_by_agent || {};
|
||||||
|
const targetMins = data.target_minutes || 480;
|
||||||
|
const targetHoursVal = +(targetMins / 60).toFixed(2);
|
||||||
|
|
||||||
|
// Compute set of all dates across selected agents
|
||||||
|
let allDates = [];
|
||||||
|
Object.values(dailyTotalsByAgent).forEach(agentTotals => {
|
||||||
|
Object.keys(agentTotals).forEach(d => {
|
||||||
|
if (!allDates.includes(d)) allDates.push(d);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
allDates.sort();
|
||||||
|
|
||||||
|
// If numeric range (e.g. last 30, 90, 180, 365 days), slice dates
|
||||||
|
if (selectedRange !== 'all' && !isNaN(parseInt(selectedRange, 10))) {
|
||||||
|
const limitDays = parseInt(selectedRange, 10);
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - limitDays);
|
||||||
|
const cutoffIso = this.formatLocalISO(cutoff);
|
||||||
|
allDates = allDates.filter(d => d >= cutoffIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = allDates.map(d => {
|
||||||
|
const parts = d.split('-');
|
||||||
|
return `${parts[2]}/${parts[1]}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update total hours badges for all agents in the sidebar list
|
||||||
|
users.forEach((u) => {
|
||||||
|
const uId = String(u.id);
|
||||||
|
const agentTotals = dailyTotalsByAgent[uId] || {};
|
||||||
|
let totalMinsForAgent = 0;
|
||||||
|
allDates.forEach(d => {
|
||||||
|
totalMinsForAgent += (agentTotals[d] || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalHoursVal = (totalMinsForAgent / 60).toFixed(1);
|
||||||
|
const badgeEl = modal.querySelector(`.ta-agent-toggle-item[data-id="${uId}"] .ta-agent-total-badge`);
|
||||||
|
if (badgeEl) {
|
||||||
|
badgeEl.textContent = `${totalHoursVal}h`;
|
||||||
|
if (totalMinsForAgent > 0) {
|
||||||
|
badgeEl.style.color = 'var(--accent-primary)';
|
||||||
|
badgeEl.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
||||||
|
} else {
|
||||||
|
badgeEl.style.color = 'var(--text-muted)';
|
||||||
|
badgeEl.style.borderColor = 'var(--border-subtle)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build dataset per agent
|
||||||
|
const datasets = selectedIds.map((agId) => {
|
||||||
|
const userObj = users.find(u => String(u.id) === String(agId));
|
||||||
|
const idx = users.findIndex(u => String(u.id) === String(agId));
|
||||||
|
const color = colorPalette[idx >= 0 ? idx % colorPalette.length : 0];
|
||||||
|
const agentTotals = dailyTotalsByAgent[agId] || {};
|
||||||
|
|
||||||
|
const hoursData = allDates.map(d => {
|
||||||
|
const mins = agentTotals[d] || 0;
|
||||||
|
return +(mins / 60).toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: userObj ? `${userObj.first_name} ${userObj.last_name}` : `Agente #${agId}`,
|
||||||
|
data: hoursData,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color + '22',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.25,
|
||||||
|
pointBackgroundColor: color,
|
||||||
|
pointRadius: 1.5,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Target reference line
|
||||||
|
datasets.push({
|
||||||
|
label: 'Soglia Target (Ore)',
|
||||||
|
data: allDates.map(() => targetHoursVal),
|
||||||
|
borderColor: '#ef4444',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.chartInstance) {
|
||||||
|
this.chartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.Chart) {
|
||||||
|
const ctx = modal.querySelector('#ta-chart-canvas').getContext('2d');
|
||||||
|
this.chartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: datasets,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const val = context.raw || 0;
|
||||||
|
const mins = Math.round(val * 60);
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
const timeFormatted = h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
return `${context.dataset.label}: ${timeFormatted} (${val}h)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: gridColor },
|
||||||
|
ticks: { color: textColor, font: { family: 'Inter', size: 10 } }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: gridColor },
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
color: textColor,
|
||||||
|
font: { family: 'Inter', size: 10 },
|
||||||
|
callback: function(value) {
|
||||||
|
return value + 'h';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading chart data:', err);
|
||||||
|
} finally {
|
||||||
|
if (loadingEl) loadingEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Range selector change event
|
||||||
|
const rangeSelect = modal.querySelector('#ta-chart-range-select');
|
||||||
|
if (rangeSelect) {
|
||||||
|
rangeSelect.onchange = (e) => {
|
||||||
|
selectedRange = e.target.value;
|
||||||
|
refreshChartData();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent checkboxes toggle events
|
||||||
|
modal.querySelectorAll('.ta-agent-checkbox').forEach(chk => {
|
||||||
|
chk.onchange = (e) => {
|
||||||
|
const itemLabel = e.target.closest('.ta-agent-toggle-item');
|
||||||
|
if (itemLabel) {
|
||||||
|
itemLabel.style.background = e.target.checked ? 'var(--bg-tertiary)' : 'transparent';
|
||||||
|
itemLabel.style.borderColor = e.target.checked ? 'var(--border-subtle)' : 'transparent';
|
||||||
|
}
|
||||||
|
refreshChartData();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agent Ban/Unban toggle button events 🚫
|
||||||
|
modal.querySelectorAll('.ta-agent-ban-btn').forEach(btn => {
|
||||||
|
btn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const uId = String(btn.dataset.id);
|
||||||
|
const itemLabel = btn.closest('.ta-agent-toggle-item');
|
||||||
|
const chk = itemLabel ? itemLabel.querySelector('.ta-agent-checkbox') : null;
|
||||||
|
|
||||||
|
const isCurrentlyBanned = bannedAgentIds.includes(uId);
|
||||||
|
if (isCurrentlyBanned) {
|
||||||
|
bannedAgentIds = bannedAgentIds.filter(id => id !== uId);
|
||||||
|
if (itemLabel) {
|
||||||
|
itemLabel.classList.remove('banned');
|
||||||
|
itemLabel.style.opacity = '1';
|
||||||
|
itemLabel.style.pointerEvents = 'auto';
|
||||||
|
}
|
||||||
|
if (chk) {
|
||||||
|
chk.disabled = false;
|
||||||
|
}
|
||||||
|
btn.title = 'Blocca agente (Escludi dal grafico e dal pulsante Tutti)';
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
} else {
|
||||||
|
bannedAgentIds.push(uId);
|
||||||
|
if (itemLabel) {
|
||||||
|
itemLabel.classList.add('banned');
|
||||||
|
itemLabel.style.opacity = '0.4';
|
||||||
|
itemLabel.style.pointerEvents = 'none';
|
||||||
|
itemLabel.style.background = 'transparent';
|
||||||
|
itemLabel.style.borderColor = 'transparent';
|
||||||
|
}
|
||||||
|
if (chk) {
|
||||||
|
chk.checked = false;
|
||||||
|
chk.disabled = true;
|
||||||
|
}
|
||||||
|
btn.title = 'Sblocca agente';
|
||||||
|
btn.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('otrs_ta_chart_banned_agents', JSON.stringify(bannedAgentIds));
|
||||||
|
refreshChartData();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select All agents button (ignores banned agents)
|
||||||
|
const selectAllBtn = modal.querySelector('#ta-chart-select-all');
|
||||||
|
if (selectAllBtn) {
|
||||||
|
selectAllBtn.onclick = () => {
|
||||||
|
// Only select/unselect checkboxes that are not disabled/banned
|
||||||
|
const activeCheckboxes = modal.querySelectorAll('.ta-agent-checkbox:not(:disabled)');
|
||||||
|
const allChecked = Array.from(activeCheckboxes).every(c => c.checked);
|
||||||
|
activeCheckboxes.forEach(chk => {
|
||||||
|
chk.checked = !allChecked;
|
||||||
|
const itemLabel = chk.closest('.ta-agent-toggle-item');
|
||||||
|
if (itemLabel) {
|
||||||
|
itemLabel.style.background = chk.checked ? 'var(--bg-tertiary)' : 'transparent';
|
||||||
|
itemLabel.style.borderColor = chk.checked ? 'var(--border-subtle)' : 'transparent';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
refreshChartData();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
refreshChartData();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* userConfig.js
|
||||||
|
* User Configurations View: Upload, preview and manage custom signature images stored in SQLite.
|
||||||
|
*/
|
||||||
|
const UserConfigView = {
|
||||||
|
async render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento configurazioni utente...</p></div>';
|
||||||
|
|
||||||
|
const titleEl = document.getElementById('page-title');
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = 'Configurazioni Utente';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [signatures, shapes] = await Promise.all([
|
||||||
|
App.api('/api/config/user-signatures'),
|
||||||
|
App.api('/api/config/user-shapes')
|
||||||
|
]);
|
||||||
|
this.renderUI(container, signatures, shapes);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Errore caricamento configurazioni utente</div>
|
||||||
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderUI(container, signatures = [], shapes = []) {
|
||||||
|
const activeAgentId = App.currentAgentId;
|
||||||
|
const activeUser = (App.lookups.users || []).find(u => String(u.id) === String(activeAgentId));
|
||||||
|
const agentName = activeUser ? `${activeUser.first_name} ${activeUser.last_name}`.trim() : '';
|
||||||
|
const placeholderText = agentName ? `Es. Firma ${agentName}, Timbro Aziendale...` : 'Es. Firma Utente, Timbro Aziendale...';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="user-config-page">
|
||||||
|
<!-- Section 1: Custom Shapes -->
|
||||||
|
<div class="card" style="margin-bottom: var(--space-lg);">
|
||||||
|
<div class="card-title" style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||||
|
<span>📐 Forme Personalizzate & Preferiti</span>
|
||||||
|
<span style="font-size:0.8rem; font-weight:normal; color:var(--text-muted);">Configura forme salvate per l'editor PDF</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; margin-bottom: 20px;">
|
||||||
|
<h4 style="margin-top:0; margin-bottom:12px; font-size:0.95rem; color:var(--text-primary);">Crea Nuova Forma Personalizzata</h4>
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap:12px; align-items:flex-end;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Nome Identificativo</label>
|
||||||
|
<input type="text" id="user-shape-name" class="form-input" placeholder="Es. Rettangolo Blu Evidenziatore" style="height:34px; font-size:0.85rem;" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Tipo Forma</label>
|
||||||
|
<select id="user-shape-type" class="form-select" style="height:34px; font-size:0.85rem;">
|
||||||
|
<option value="rect">Rettangolo</option>
|
||||||
|
<option value="circle">Cerchio / Ellisse</option>
|
||||||
|
<option value="line">Linea Rettilinea</option>
|
||||||
|
<option value="arrow">Freccia di Indicazione</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Colore Riempimento</label>
|
||||||
|
<input type="color" id="user-shape-fill" value="#3b82f6" style="width:100%; height:34px; border:1px solid var(--border-subtle); border-radius:4px; cursor:pointer; background:none;" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Colore Bordo</label>
|
||||||
|
<input type="color" id="user-shape-border" value="#1e3a8a" style="width:100%; height:34px; border:1px solid var(--border-subtle); border-radius:4px; cursor:pointer; background:none;" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Spessore Bordo (px)</label>
|
||||||
|
<input type="number" id="user-shape-width" min="0" max="30" value="3" class="form-input" style="height:34px; font-size:0.85rem;" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
||||||
|
<input type="checkbox" id="user-shape-fav" style="cursor:pointer;" />
|
||||||
|
<label for="user-shape-fav" style="font-size:0.8rem; cursor:pointer; color:var(--text-primary); font-weight:600;">⭐ Preferito</label>
|
||||||
|
</div>
|
||||||
|
<button id="btn-save-user-shape" class="btn btn-primary btn-sm" style="height:34px; padding:0 16px; font-weight:600; display:inline-flex; align-items:center; gap:6px;">
|
||||||
|
➕ Salva Forma
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shapes Gallery Grid -->
|
||||||
|
<div id="user-shapes-list" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap:12px;">
|
||||||
|
${shapes.length > 0 ? shapes.map(sh => `
|
||||||
|
<div class="user-shape-card" style="background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:12px; display:flex; flex-direction:column; position:relative; box-shadow:var(--shadow-sm);">
|
||||||
|
<div style="position:absolute; top:8px; right:8px; display:flex; gap:4px;">
|
||||||
|
<button class="btn-fav-shape" data-id="${sh.id}" style="background:none; border:none; cursor:pointer; font-size:16px;" title="Aggiungi/Rimuovi Preferito">${sh.is_favorite ? '⭐' : '☆'}</button>
|
||||||
|
<button class="btn-delete-shape" data-id="${sh.id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); color:var(--danger); border-radius:50%; width:24px; height:24px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:11px;" title="Elimina Forma">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="width:100%; height:70px; display:flex; align-items:center; justify-content:center; background:var(--bg-tertiary); border-radius:var(--radius-sm); margin-bottom:8px; border:1px dashed var(--border-subtle); padding:4px;">
|
||||||
|
${(() => {
|
||||||
|
const st = sh.shape_type;
|
||||||
|
const fill = sh.fill_color === 'transparent' ? 'none' : sh.fill_color;
|
||||||
|
const stroke = sh.border_color || '#3b82f6';
|
||||||
|
const sw = sh.border_width || 3;
|
||||||
|
if (st === 'circle') {
|
||||||
|
return `<svg width="60" height="50" viewBox="0 0 60 50"><ellipse cx="30" cy="25" rx="22" ry="18" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" /></svg>`;
|
||||||
|
} else if (st === 'line') {
|
||||||
|
return `<svg width="60" height="50" viewBox="0 0 60 50"><line x1="8" y1="42" x2="52" y2="8" stroke="${stroke}" stroke-width="${Math.max(sw, 2)}" stroke-linecap="round" /></svg>`;
|
||||||
|
} else if (st === 'arrow') {
|
||||||
|
return `<svg width="60" height="50" viewBox="0 0 60 50">
|
||||||
|
<defs>
|
||||||
|
<marker id="arr-${sh.id}" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="${stroke}" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<line x1="8" y1="40" x2="48" y2="12" stroke="${stroke}" stroke-width="${Math.max(sw, 2)}" stroke-linecap="round" marker-end="url(#arr-${sh.id})" />
|
||||||
|
</svg>`;
|
||||||
|
} else {
|
||||||
|
return `<svg width="60" height="50" viewBox="0 0 60 50"><rect x="8" y="10" width="44" height="30" rx="3" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" /></svg>`;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-weight:600; font-size:0.85rem; color:var(--text-primary); margin-bottom:4px;">
|
||||||
|
${App.escapeHtml(sh.name)}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.74rem; color:var(--text-muted);">
|
||||||
|
${sh.shape_type === 'circle' ? 'Cerchio' : sh.shape_type === 'line' ? 'Linea' : sh.shape_type === 'arrow' ? 'Freccia' : 'Rettangolo'} • Bordo ${sh.border_width}px
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') : `
|
||||||
|
<div style="grid-column: 1 / -1; padding:20px; text-align:center; color:var(--text-muted); font-style:italic;">
|
||||||
|
Nessuna forma personalizzata salvata.
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 2: Signatures -->
|
||||||
|
<div class="card" style="margin-bottom: var(--space-lg);">
|
||||||
|
<div class="card-title" style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||||
|
<span>✍️ Immagini e Firme Utente</span>
|
||||||
|
<span style="font-size:0.8rem; font-weight:normal; color:var(--text-muted);">Saranno disponibili per l'apposizione rapida sui PDF</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; margin-bottom: 20px;">
|
||||||
|
<h4 style="margin-top:0; margin-bottom:12px; font-size:0.95rem; color:var(--text-primary);">Aggiungi nuova immagine / firma</h4>
|
||||||
|
<div style="display:flex; gap:12px; align-items:flex-end; flex-wrap:wrap;">
|
||||||
|
<div style="flex:1; min-width:200px;">
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Nome Firma / Immagine</label>
|
||||||
|
<input type="text" id="user-sig-name" class="form-input" placeholder="${App.escapeHtml(placeholderText)}" style="height:34px; font-size:0.85rem;" />
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:200px;">
|
||||||
|
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Seleziona File Immagine (PNG, JPG, SVG)</label>
|
||||||
|
<input type="file" id="user-sig-file" accept="image/*" class="form-input" style="height:34px; padding:3px 8px; font-size:0.8rem;" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
||||||
|
<input type="checkbox" id="user-sig-default" style="cursor:pointer;" />
|
||||||
|
<label for="user-sig-default" style="font-size:0.8rem; cursor:pointer; color:var(--text-primary); font-weight:600;">⭐ Firma Predefinita</label>
|
||||||
|
</div>
|
||||||
|
<button id="btn-save-user-sig" class="btn btn-primary btn-sm" style="height:34px; padding:0 16px; font-weight:600; display:inline-flex; align-items:center; gap:6px;">
|
||||||
|
➕ Salva in SQLite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signatures Gallery Grid -->
|
||||||
|
<div id="user-sig-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:16px;">
|
||||||
|
${signatures.length > 0 ? signatures.map(sig => `
|
||||||
|
<div class="user-sig-card" style="background:var(--bg-card); border:${sig.is_default ? '2px solid var(--accent-primary)' : '1px solid var(--border-subtle)'}; border-radius:var(--radius-md); padding:12px; display:flex; flex-direction:column; align-items:center; position:relative; box-shadow:var(--shadow-sm);">
|
||||||
|
<div style="position:absolute; top:8px; left:8px; display:flex; gap:4px; align-items:center;">
|
||||||
|
<button class="btn-default-sig" data-id="${sig.id}" style="background:${sig.is_default ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; border:1px solid var(--border-subtle); color:${sig.is_default ? '#fff' : 'var(--text-secondary)'}; border-radius:12px; padding:2px 8px; cursor:pointer; font-size:0.7rem; font-weight:600;" title="${sig.is_default ? 'Firma Predefinita attuale' : 'Imposta come predefinita'}">
|
||||||
|
${sig.is_default ? '⭐ Predefinita' : '☆ Imposta Predefinita'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-delete-sig" data-id="${sig.id}" style="position:absolute; top:8px; right:8px; background:var(--bg-tertiary); border:1px solid var(--border-subtle); color:var(--danger); border-radius:50%; width:26px; height:26px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:12px;" title="Elimina Firma">✕</button>
|
||||||
|
<div style="width:100%; height:110px; display:flex; align-items:center; justify-content:center; background:#ffffff; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); padding:8px; margin-bottom:8px; margin-top:24px;">
|
||||||
|
<img src="${sig.image_data}" alt="${App.escapeHtml(sig.name)}" style="max-width:100%; max-height:100%; object-fit:contain;" />
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:600; font-size:0.85rem; color:var(--text-primary); text-align:center; word-break:break-word;">
|
||||||
|
${App.escapeHtml(sig.name)}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:2px;">
|
||||||
|
${App.formatDateTime(sig.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') : `
|
||||||
|
<div style="grid-column: 1 / -1; padding:32px; text-align:center; color:var(--text-muted); font-style:italic;">
|
||||||
|
Nessuna firma salvata. Carica la prima immagine con il modulo qui sopra.
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindEvents(container);
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents(container) {
|
||||||
|
// Shapes handlers
|
||||||
|
const btnSaveShape = container.querySelector('#btn-save-user-shape');
|
||||||
|
if (btnSaveShape) {
|
||||||
|
btnSaveShape.addEventListener('click', async () => {
|
||||||
|
const name = container.querySelector('#user-shape-name').value.trim();
|
||||||
|
const shapeType = container.querySelector('#user-shape-type').value;
|
||||||
|
const fillColor = container.querySelector('#user-shape-fill').value;
|
||||||
|
const borderColor = container.querySelector('#user-shape-border').value;
|
||||||
|
const borderWidth = parseInt(container.querySelector('#user-shape-width').value || 3, 10);
|
||||||
|
const isFavorite = container.querySelector('#user-shape-fav').checked;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
Toast.warning('Inserisci un nome per la forma.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
btnSaveShape.disabled = true;
|
||||||
|
await App.api('/api/config/user-shapes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
shape_type: shapeType,
|
||||||
|
fill_color: fillColor,
|
||||||
|
border_color: borderColor,
|
||||||
|
border_width: borderWidth,
|
||||||
|
is_favorite: isFavorite ? 1 : 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Toast.success('Forma salvata!');
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore salvataggio forma: ' + err.message);
|
||||||
|
btnSaveShape.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-fav-shape').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await App.api(`/api/config/user-shapes/${btn.dataset.id}/toggle-favorite`, { method: 'PUT' });
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore aggiornamento preferiti: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-delete-shape').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ok = await App.confirm('Elimina Forma', 'Eliminare questa forma personalizzata?', { confirmText: 'Elimina', cancelText: 'Annulla' });
|
||||||
|
if (ok) {
|
||||||
|
try {
|
||||||
|
await App.api(`/api/config/user-shapes/${btn.dataset.id}`, { method: 'DELETE' });
|
||||||
|
Toast.success('Forma eliminata!');
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore eliminazione: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signatures handlers
|
||||||
|
const btnSave = container.querySelector('#btn-save-user-sig');
|
||||||
|
const inputName = container.querySelector('#user-sig-name');
|
||||||
|
const inputFile = container.querySelector('#user-sig-file');
|
||||||
|
const checkDefault = container.querySelector('#user-sig-default');
|
||||||
|
|
||||||
|
if (btnSave && inputFile) {
|
||||||
|
btnSave.addEventListener('click', async () => {
|
||||||
|
const name = inputName.value.trim();
|
||||||
|
const file = inputFile.files[0];
|
||||||
|
const isDefault = checkDefault ? checkDefault.checked : false;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
Toast.warning('Inserisci un nome descrittivo per la firma/immagine.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
Toast.warning('Seleziona un file immagine.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
const base64Data = reader.result;
|
||||||
|
try {
|
||||||
|
btnSave.disabled = true;
|
||||||
|
btnSave.textContent = 'Salvataggio...';
|
||||||
|
|
||||||
|
const res = await App.api('/api/config/user-signatures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
image_data: base64Data,
|
||||||
|
is_default: isDefault ? 1 : 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(res.message || 'Firma salvata!');
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante il salvataggio: ' + err.message);
|
||||||
|
btnSave.disabled = false;
|
||||||
|
btnSave.textContent = '➕ Salva in SQLite';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-default-sig').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await App.api(`/api/config/user-signatures/${btn.dataset.id}/set-default`, { method: 'PUT' });
|
||||||
|
Toast.success('Firma predefinita aggiornata!');
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore aggiornamento firma predefinita: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-delete-sig').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const ok = await App.confirm('Elimina Firma', 'Sei sicuro di voler eliminare questa firma dal database?', { confirmText: 'Elimina', cancelText: 'Annulla' });
|
||||||
|
if (ok) {
|
||||||
|
try {
|
||||||
|
await App.api(`/api/config/user-signatures/${id}`, { method: 'DELETE' });
|
||||||
|
Toast.success('Firma eliminata!');
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore eliminazione: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/*!
|
||||||
|
* Chart.js v4.5.1
|
||||||
|
* https://www.chartjs.org
|
||||||
|
* (c) 2025 Chart.js Contributors
|
||||||
|
* Released under the MIT License
|
||||||
|
*/
|
||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?x(n,a,s):e[t]=g(a)}function x(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function b(t,e){return x(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?b(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function J(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return Z(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...eLine truncated
|
||||||
|
/*!
|
||||||
|
* @kurkle/color v0.3.2
|
||||||
|
* https://github.com/kurkle/color#readme
|
||||||
|
* (c) 2023 Jukka Kurkela
|
||||||
|
* Released under the MIT License
|
||||||
|
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
|
||||||
|
//# sourceMappingURL=chart.umd.min.js.map
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).PDFLib={})}(this,(function(t){"use strict";
|
||||||
|
/*! *****************************************************************************
|
||||||
|
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||||
|
this file except in compliance with the License. You may obtain a copy of the
|
||||||
|
License at http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
|
||||||
|
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
|
||||||
|
MERCHANTABLITY OR NON-INFRINGEMENT.
|
||||||
|
|
||||||
|
See the Apache Version 2.0 License for specific language governing permissions
|
||||||
|
and limitations under the License.
|
||||||
|
***************************************************************************** */var e=function(t,n){return(e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,n)};function n(t,n){function r(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(r.prototype=n.prototype,new r)}var r=function(){return(r=Object.assign||function(t){for(var e,n=1,r=arguments.length;n<r;n++)for(var i in e=arguments[n])Object.prototype.hasOwnProperty.call(e,i)&&(t[i]=e[i]);return t}).apply(this,arguments)};function i(t,e,n,r){return new(n||(n=Promise))((function(i,o){function a(t){try{l(r.next(t))}catch(t){o(t)}}function s(t){try{l(r.throw(t))}catch(t){o(t)}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(a,s)}l((r=r.apply(t,e||[])).next())}))}function o(t,e){var n,r,i,o,a={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:s(0),throw:s(1),return:s(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function s(o){return function(s){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;a;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return a.label++,{value:o[1],done:!1};case 5:a.label++,r=o[1],o=[0];continue;case 7:o=a.ops.pop(),a.trys.pop();continue;default:if(!(i=a.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){a=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]<i[3])){a.label=o[1];break}if(6===o[0]&&a.label<i[1]){a.label=i[1],i=o;break}if(i&&a.label<i[2]){a.label=i[2],a.ops.push(o);break}i[2]&&a.ops.pop(),a.trys.pop();continue}o=e.call(t,a)}catch(t){o=[6,t],r=0}finally{n=i=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,s])}}}function a(){for(var t=0,e=0,n=arguments.length;e<n;e++)t+=arguments[e].length;var r=Array(t),i=0;for(e=0;e<n;e++)for(var o=arguments[e],a=0,s=o.length;a<s;a++,i++)r[i]=o[a];return r}for(var s="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",l=new Uint8Array(256),h=0;h<s.length;h++)l[s.charCodeAt(h)]=h;var u,c=function(t){for(var e="",n=t.length,r=0;r<n;r+=3)e+=s[t[r]>>2],e+=s[(3&t[r])<<4|t[r+1]>>4],e+=s[(15&t[r+1])<<2|t[r+2]>>6],e+=s[63&t[r+2]];return n%3==2?e=e.substring(0,e.length-1)+"=":n%3==1&&(e=e.substring(0,e.length-2)+"=="),e},d=function(t){var e,n,r,i,o,a=.75*t.length,s=t.length,h=0;"="===t[t.length-1]&&(a--,"="===t[t.length-2]&&a--);var u=new Uint8Array(a);for(e=0;e<s;e+=4)n=l[t.charCodeAt(e)],r=l[t.charCodeAt(e+1)],i=l[t.charCodeAt(e+2)],o=l[t.charCodeAt(e+3)],u[h++]=n<<2|r>>4,u[h++]=(15&r)<<4|i>>2,u[h++]=(3&i)<<6|63&o;return u},f=/^(data)?:?([\w\/\+]+)?;?(charset=[\w-]+|base64)?.*,/i,p=function(t){var e=t.trim(),n=e.substring(0,100).match(f);if(!n)return d(e);var r=n[0],i=e.substring(r.length);return d(i)},g=function(t){return t.charCodeAt(0)},v=function(t){return t.codePointAt(0)},y=function(t,e){return x(t.toString(16),e,"0").toUpperCase()},m=function(t){return y(t,2)},b=function(t){return String.fromCharCode(t)},w=function(t){return b(parseInt(t,16))},x=function(t,e,n){for(var r="",i=0,o=e-t.length;i<o;i++)r+=n;return r+t},k=function(t,e,n){for(var r=t.length,i=0;i<r;i++)e[n++]=t.charCodeAt(i);return r},F=function(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")},S=function(t){return t.replace(/\t|\u0085|\u2028|\u2029/g," ").replace(/[\b\v]/g,"")},C=["\\n","\\f","\\r","\\u000B"],A=function(t){return/^[\n\f\r\u000B]$/.test(t)},T=function(t){return t.split(/[\n\f\r\u000B]/)},P=function(t){return t.replace(/[\n\f\r\u000B]/g," ")},z=function(t,e){var n,r=t.charCodeAt(e),i=e+1,o=1;return r>=55296&&r<=56319&&t.length>i&&(n=t.charCodeAt(i))>=56320&&n<=57343&&(o=2),[t.slice(e,e+o),o]},R=function(t){for(var e=[],n=0,r=t.length;n<r;){var i=z(t,n),o=i[0],a=i[1];e.push(o),n+=a}return e},O=function(t,e,n,r){for(var i=function(t){for(var e=C.join("|"),n=["$"],r=0,i=t.length;r<i;r++){var o=t[r];if(A(o))throw new TypeError("`wordBreak` must not include "+e);n.push(""===o?".":F(o))}var a=n.join("|");return new RegExp("("+e+")|((.*?)("+a+"))","gm")}(e),o=S(t).match(i),a="",s=0,l=[],h=function(){""!==a&&l.push(a),a="",s=0},u=0,c=o.length;u<c;u++){var d=o[u];if(A(d))h();else{var f=r(d);s+f>n&&h(),a+=d,s+=f}}return h(),l},D=/^D:(\d\d\d\d)(\d\d)?(\d\d)?(\d\d)?(\d\d)?(\d\d)?([+\-Z])?(\d\d)?'?(\d\d)?'?$/,B=function(t){var e=t.match(D);if(e){var n=e[1],r=e[2],i=void 0===r?"01":r,o=e[3],a=void 0===o?"01":o,s=e[4],l=void 0===s?"00":s,h=e[5],u=void 0===h?"00":h,c=e[6],d=void 0===c?"00":c,f=e[7],p=void 0===f?"Z":f,g=e[8],v=void 0===g?"00":g,y=e[9];return new Date(n+"-"+i+"-"+a+"T"+l+":"+u+":"+d+("Z"===p?"Z":""+p+v+":"+(void 0===y?"00":y)))}},N=function(t,e){for(var n,r,i=0;i<t.length;){var o=t.substring(i).match(e);if(!o)return{match:r,pos:i};r=o,i+=(null!==(n=o.index)&&void 0Line truncated
|
||||||
|
//# sourceMappingURL=pdf-lib.min.js.map
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @licstart The following is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*
|
||||||
|
* Copyright 2023 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* @licend The above is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*/
|
||||||
|
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=t.pdfjsLib=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],(()=>t.pdfjsLib=e())):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=t.pdfjsLib=e():t["pdfjs-dist/build/pdf"]=t.pdfjsLib=e()}(globalThis,(()=>(()=>{"use strict";var __webpack_modules__=[,(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.VerbosityLevel=e.Util=e.UnknownErrorException=e.UnexpectedResponseException=e.TextRenderingMode=e.RenderingIntentFlag=e.PromiseCapability=e.PermissionFlag=e.PasswordResponses=e.PasswordException=e.PageActionEventType=e.OPS=e.MissingPDFException=e.MAX_IMAGE_SIZE_TO_CACHE=e.LINE_FACTOR=e.LINE_DESCENT_FACTOR=e.InvalidPDFException=e.ImageKind=e.IDENTITY_MATRIX=e.FormatError=e.FeatureTest=e.FONT_IDENTITY_MATRIX=e.DocumentActionEventType=e.CMapCompressionType=e.BaseException=e.BASELINE_FACTOR=e.AnnotationType=e.AnnotationReplyType=e.AnnotationPrefix=e.AnnotationMode=e.AnnotationFlag=e.AnnotationFieldFlag=e.AnnotationEditorType=e.AnnotationEditorPrefix=e.AnnotationEditorParamsType=e.AnnotationBorderStyleType=e.AnnotationActionEventType=e.AbortException=void 0;e.assert=function assert(t,e){t||unreachable(e)};e.bytesToString=bytesToString;e.createValidAbsoluteUrl=function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=stringToUTF8String(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(s))return s}catch{}return null};e.getModificationDate=function getModificationDate(t=new Date){return[t.getUTCFullYear().toString(),(t.getUTCMonth()+1).toString().padStart(2,"0"),t.getUTCDate().toString().padStart(2,"0"),t.getUTCHours().toString().padStart(2,"0"),t.getUTCMinutes().toString().padStart(2,"0"),t.getUTCSeconds().toString().padStart(2,"0")].join("")};e.getUuid=function getUuid(){if("undefined"!=typeof crypto&&"function"==typeof crypto?.randomUUID)return crypto.randomUUID();const t=new Uint8Array(32);if("undefined"!=typeof crypto&&"function"==typeof crypto?.getRandomValues)crypto.getRandomValues(t);else for(let e=0;e<32;e++)t[e]=Math.floor(255*Math.random());return bytesToString(t)};e.getVerbosityLevel=function getVerbosityLevel(){return n};e.info=function info(t){n>=s.INFOS&&console.log(`Info: ${t}`)};e.isArrayBuffer=function isArrayBuffer(t){return"object"==typeof t&&void 0!==t?.byteLength};e.isArrayEqual=function isArrayEqual(t,e){if(t.length!==e.length)return!1;for(let i=0,s=t.length;i<s;i++)if(t[i]!==e[i])return!1;return!0};e.isNodeJS=void 0;e.normalizeUnicode=function normalizeUnicode(t){if(!l){l=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;h=new Map([["ſt","ſt"]])}return t.replaceAll(l,((t,e,i)=>e?e.normalize("NFKC"):h.get(i)))};e.objectFromMap=function objectFromMap(t){const e=Object.create(null);for(const[i,s]of t)e[i]=s;return e};e.objectSize=function objectSize(t){return Object.keys(t).length};e.setVerbosityLevel=function setVerbosityLevel(t){Number.isInteger(t)&&(n=t)};e.shadow=shadow;e.string32=function string32(t){return String.fromCharCode(t>>24&255,t>>16&255,t>>8&255,255&t)};e.stringToBytes=stringToBytes;e.stringToPDFString=function stringToPDFString(t){if(t[0]>="ï"){let e;"þ"===t[0]&&"ÿ"===t[1]?e="utf-16be":"ÿ"===t[0]&&"þ"===t[1]?e="utf-16le":"ï"===t[0]&&"»"===t[1]&&"¿"===t[2]&&(e="utf-8");if(e)try{const i=new TextDecoder(e,{fatal:!0}),s=stringToBytes(t);return i.decode(s)}catch(t){warn(`stringToPDFString: "${t}".`)}}const e=[];for(let i=0,s=t.length;i<s;i++){const s=o[t.charCodeAt(i)];e.push(s?String.fromCharCode(s):t.charAt(i))}return e.join("")};e.stringToUTF8String=stringToUTF8String;e.unreachable=unreachable;e.utf8StringToString=function utf8StringToString(t){return unescape(encodeURIComponent(t))};e.warn=warn;const i=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type);e.isNodeJS=i;e.IDENTITY_MATRIX=[1,0,0,1,0,0];e.FONT_IDENTITY_MATRIX=[.001,0,0,.001,0,0];e.MAX_IMAGE_SIZE_TO_CACHE=1e7;e.LINE_FACTOR=1.35;e.LINE_DESCENT_FACTOR=.35;e.BASELINE_FACTOR=.25925925925925924;e.RenderingIntentFlag={ANY:1,DISPLAY:2,PRINT:4,SAVE:8,ANNOTATIONS_FORMS:16,ANNOTATIONS_STORAGE:32,ANNOTATIONS_DISABLE:64,OPLIST:256};e.AnnotationMode={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3};e.AnnotationEditorPrefix="pdfjs_internal_editor_";e.AnnotationEditorType={DISABLE:-1,NONE:0,FREETEXT:3,STLine truncated
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/*! For license information please see quill.js.LICENSE.txt */
|
||||||
|
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Quill=e():t.Quill=e()}(self,(function(){return function(){var t={9698:function(t,e,n){"use strict";n.d(e,{Ay:function(){return c},Ji:function(){return d},mG:function(){return h},zo:function(){return u}});var r=n(6003),i=n(5232),s=n.n(i),o=n(3036),l=n(4850),a=n(5508);class c extends r.BlockBlot{cache={};delta(){return null==this.cache.delta&&(this.cache.delta=h(this)),this.cache.delta}deleteAt(t,e){super.deleteAt(t,e),this.cache={}}formatAt(t,e,n,i){e<=0||(this.scroll.query(n,r.Scope.BLOCK)?t+e===this.length()&&this.format(n,i):super.formatAt(t,Math.min(e,this.length()-t-1),n,i),this.cache={})}insertAt(t,e,n){if(null!=n)return super.insertAt(t,e,n),void(this.cache={});if(0===e.length)return;const r=e.split("\n"),i=r.shift();i.length>0&&(t<this.length()-1||null==this.children.tail?super.insertAt(Math.min(t,this.length()-1),i):this.children.tail.insertAt(this.children.tail.length(),i),this.cache={});let s=this;r.reduce(((t,e)=>(s=s.split(t,!0),s.insertAt(0,e),e.length)),t+i.length)}insertBefore(t,e){const{head:n}=this.children;super.insertBefore(t,e),n instanceof o.A&&n.remove(),this.cache={}}length(){return null==this.cache.length&&(this.cache.length=super.length()+1),this.cache.length}moveChildren(t,e){super.moveChildren(t,e),this.cache={}}optimize(t){super.optimize(t),this.cache={}}path(t){return super.path(t,!0)}removeChild(t){super.removeChild(t),this.cache={}}split(t){let e=arguments.length>1&&void 0!==arguments[1]&&arguments[1];if(e&&(0===t||t>=this.length()-1)){const e=this.clone();return 0===t?(this.parent.insertBefore(e,this),this):(this.parent.insertBefore(e,this.next),e)}const n=super.split(t,e);return this.cache={},n}}c.blotName="block",c.tagName="P",c.defaultChild=o.A,c.allowedChildren=[o.A,l.A,r.EmbedBlot,a.A];class u extends r.EmbedBlot{attach(){super.attach(),this.attributes=new r.AttributorStore(this.domNode)}delta(){return(new(s())).insert(this.value(),{...this.formats(),...this.attributes.values()})}format(t,e){const n=this.scroll.query(t,r.Scope.BLOCK_ATTRIBUTE);null!=n&&this.attributes.attribute(n,e)}formatAt(t,e,n,r){this.format(n,r)}insertAt(t,e,n){if(null!=n)return void super.insertAt(t,e,n);const r=e.split("\n"),i=r.pop(),s=r.map((t=>{const e=this.scroll.create(c.blotName);return e.insertAt(0,t),e})),o=this.split(t);s.forEach((t=>{this.parent.insertBefore(t,o)})),i&&this.parent.insertBefore(this.scroll.create("text",i),o)}}function h(t){let e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return t.descendants(r.LeafBlot).reduce(((t,n)=>0===n.length()?t:t.insert(n.value(),d(n,{},e))),new(s())).insert("\n",d(t))}function d(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];return null==t?e:("formats"in t&&"function"==typeof t.formats&&(e={...e,...t.formats()},n&&delete e["code-token"]),null==t.parent||"scroll"===t.parent.statics.blotName||t.parent.statics.scope!==t.statics.scope?e:d(t.parent,e,n))}u.scope=r.Scope.BLOCK_BLOT},3036:function(t,e,n){"use strict";var r=n(6003);class i extends r.EmbedBlot{static value(){}optimize(){(this.prev||this.next)&&this.remove()}length(){return 0}value(){return""}}i.blotName="break",i.tagName="BR",e.A=i},580:function(t,e,n){"use strict";var r=n(6003);class i extends r.ContainerBlot{}e.A=i},4541:function(t,e,n){"use strict";var r=n(6003),i=n(5508);class s extends r.EmbedBlot{static blotName="cursor";static className="ql-cursor";static tagName="span";static CONTENTS="\ufeff";static value(){}constructor(t,e,n){super(t,e),this.selection=n,this.textNode=document.createTextNode(s.CONTENTS),this.domNode.appendChild(this.textNode),this.savedLength=0}detach(){null!=this.parent&&this.parent.removeChild(this)}format(t,e){if(0!==this.savedLength)return void super.format(t,e);let n=this,i=0;for(;null!=n&&n.statics.scope!==r.Scope.BLOCK_BLOT;)i+=n.offset(n.parent),n=n.parent;null!=n&&(this.savedLength=s.CONTENTS.length,n.optimize(),n.formatAt(i,s.CONTENTS.length,t,e),this.savedLength=0)}index(t,e){return t===this.textNode?0:super.index(t,e)}length(){return this.savedLength}position(){return[this.textNode,this.textNode.data.length]}remove(){super.remove(),this.parent=null}restore(){if(this.selection.composing||null==this.parent)return null;const t=this.selection.getNativeRange();for(;null!=this.domNode.lastChild&&this.domNode.lastChild!==this.textNode;)this.domNode.parentNode.insertBefore(this.domNode.lastChild,this.domNode);const e=this.prev instanceof i.A?this.prev:null,n=e?e.length():0,r=this.next instanceof i.A?this.next:null,o=r?r.text:"",{textNode:l}=this,a=l.data.split(s.CONTENTS).join("");let c;if(l.data=s.CONTENTS,e)c=e,(a||r)&&(e.insertAt(e.length(),a+o),r&&r.remove());else if(r)c=r,r.insertAt(0,a);else{const t=document.createTextNode(a);c=this.scroll.create(t),this.parent.insertBefore(c,this)}if(this.reLine truncated
|
||||||
|
//# sourceMappingURL=quill.js.map
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/*!
|
||||||
|
* Quill Editor v2.0.2
|
||||||
|
* https://quilljs.com
|
||||||
|
* Copyright (c) 2017-2024, Slab
|
||||||
|
* Copyright (c) 2014, Jason Chen
|
||||||
|
* Copyright (c) 2013, salesforce.com
|
||||||
|
*/
|
||||||
|
.ql-container{box-sizing:border-box;font-family:Helvetica,Arial,sans-serif;font-size:13px;height:100%;margin:0;position:relative}.ql-container.ql-disabled .ql-tooltip{visibility:hidden}.ql-container:not(.ql-disabled) li[data-list=checked] > .ql-ui,.ql-container:not(.ql-disabled) li[data-list=unchecked] > .ql-ui{cursor:pointer}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-clipboard p{margin:0;padding:0}.ql-editor{box-sizing:border-box;counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;line-height:1.42;height:100%;outline:none;overflow-y:auto;padding:12px 15px;tab-size:4;-moz-tab-size:4;text-align:left;white-space:pre-wrap;word-wrap:break-word}.ql-editor > *{cursor:text}.ql-editor p,.ql-editor ol,.ql-editor pre,.ql-editor blockquote,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{margin:0;padding:0}@supports (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-set:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor p,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6{counter-reset:list-0 list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor table{border-collapse:collapse}.ql-editor td{border:1px solid #000;padding:2px 5px}.ql-editor ol{padding-left:1.5em}.ql-editor li{list-style-type:none;padding-left:1.5em;position:relative}.ql-editor li > .ql-ui:before{display:inline-block;margin-left:-1.5em;margin-right:.3em;text-align:right;white-space:nowrap;width:1.2em}.ql-editor li[data-list=checked] > .ql-ui,.ql-editor li[data-list=unchecked] > .ql-ui{color:#777}.ql-editor li[data-list=bullet] > .ql-ui:before{content:'\2022'}.ql-editor li[data-list=checked] > .ql-ui:before{content:'\2611'}.ql-editor li[data-list=unchecked] > .ql-ui:before{content:'\2610'}@supports (counter-set:none){.ql-editor li[data-list]{counter-set:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list]{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered]{counter-increment:list-0}.ql-editor li[data-list=ordered] > .ql-ui:before{content:counter(list-0, decimal) '. '}.ql-editor li[data-list=ordered].ql-indent-1{counter-increment:list-1}.ql-editor li[data-list=ordered].ql-indent-1 > .ql-ui:before{content:counter(list-1, lower-alpha) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-set:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-1{counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-2{counter-increment:list-2}.ql-editor li[data-list=ordered].ql-indent-2 > .ql-ui:before{content:counter(list-2, lower-roman) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-set:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-2{counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-3{counter-increment:list-3}.ql-editor li[data-list=ordered].ql-indent-3 > .ql-ui:before{content:counter(list-3, decimal) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-set:list-4 list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-3{counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-4{counter-increment:list-4}.ql-editor li[data-list=ordered].ql-indent-4 > .ql-ui:before{content:counter(list-4, lower-alpha) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-set:list-5 list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-4{counter-reset:list-5 list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-5{counter-increment:list-5}.ql-editor li[data-list=ordered].ql-indent-5 > .ql-ui:before{content:counter(list-5, lower-roman) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-set:list-6 list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-5{counter-reset:list-6 list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-6{counter-increment:list-6}.ql-editor li[data-list=ordered].ql-indent-6 > .ql-ui:before{content:counter(list-6, decimal) '. '}@supports (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-set:list-7 list-8 list-9}}@supports not (counter-set:none){.ql-editor li[data-list].ql-indent-6{counter-reset:list-7 list-8 list-9}}.ql-editor li[data-list=ordered].ql-indent-7{counter-increment:list-7}.ql-editor li[data-list=ordered].ql-indent-7 > .ql-ui:before{content:coLine truncated
|
||||||
|
|
||||||
|
/*# sourceMappingURL=quill.snow.css.map*/
|
||||||
@@ -67,6 +67,7 @@ router.post('/env', async (req, res) => {
|
|||||||
|
|
||||||
// Write updated content to disk
|
// Write updated content to disk
|
||||||
fs.writeFileSync(envPath, content, 'utf8');
|
fs.writeFileSync(envPath, content, 'utf8');
|
||||||
|
try { require('dotenv').config({ path: envPath, override: true }); } catch (_) {}
|
||||||
|
|
||||||
// Save snapshot in history table
|
// Save snapshot in history table
|
||||||
const info = db.prepare(`
|
const info = db.prepare(`
|
||||||
@@ -112,6 +113,7 @@ router.post('/env/restore/:history_id', async (req, res) => {
|
|||||||
|
|
||||||
// Write snapshot content to disk
|
// Write snapshot content to disk
|
||||||
fs.writeFileSync(envPath, record.content, 'utf8');
|
fs.writeFileSync(envPath, record.content, 'utf8');
|
||||||
|
try { require('dotenv').config({ path: envPath, override: true }); } catch (_) {}
|
||||||
|
|
||||||
// Insert new history record documenting the restore operation
|
// Insert new history record documenting the restore operation
|
||||||
const restoreNote = `Ripristinato alla versione #${record.id} del ${record.created_at}`;
|
const restoreNote = `Ripristinato alla versione #${record.id} del ${record.created_at}`;
|
||||||
@@ -144,4 +146,246 @@ router.post('/env/restore/:history_id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper handler for deleting a specific .env history entry
|
||||||
|
async function handleDeleteHistory(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const agentName = await resolveAgentName(operatorId);
|
||||||
|
|
||||||
|
const record = db.prepare('SELECT * FROM env_history WHERE id = ?').get(id);
|
||||||
|
if (!record) {
|
||||||
|
return res.status(404).json({ error: 'Versione di storico non trovata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM env_history WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome: agentName,
|
||||||
|
titolo_azione: 'Eliminazione Storico .env',
|
||||||
|
azione: { deleted_history_id: id },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
|
||||||
|
const history = db.prepare(`
|
||||||
|
SELECT * FROM env_history ORDER BY id DESC LIMIT 100
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Versione #${id} dello storico eliminata con successo!`,
|
||||||
|
history,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting .env history entry:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante l\'eliminazione della versione dello storico', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE & POST /api/config/env/history/:id — Delete a specific .env history entry
|
||||||
|
router.delete('/env/history/:id', handleDeleteHistory);
|
||||||
|
router.post('/env/history/:id/delete', handleDeleteHistory);
|
||||||
|
|
||||||
|
// GET /api/config/user-signatures — Fetch all user signature images
|
||||||
|
router.get('/user-signatures', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const signatures = db.prepare(`
|
||||||
|
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
res.json(signatures);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching user signatures:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il recupero delle firme utente', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/config/user-signatures — Add new user signature image
|
||||||
|
router.post('/user-signatures', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { name, image_data, is_default } = req.body;
|
||||||
|
|
||||||
|
if (!name || !image_data) {
|
||||||
|
return res.status(400).json({ error: 'Nome e dati immagine sono obbligatori' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultVal = is_default ? 1 : 0;
|
||||||
|
if (defaultVal === 1) {
|
||||||
|
db.prepare(`UPDATE user_signatures SET is_default = 0 WHERE agent_id = ?`).run(operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO user_signatures (agent_id, name, image_data, is_default)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(operatorId, name.trim(), image_data, defaultVal);
|
||||||
|
|
||||||
|
const signatures = db.prepare(`
|
||||||
|
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Immagine/Firma salvata nel database con successo!',
|
||||||
|
id: info.lastInsertRowid,
|
||||||
|
signatures
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving user signature:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il salvataggio della firma', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/config/user-signatures/:id/set-default — Set signature as default
|
||||||
|
router.put('/user-signatures/:id/set-default', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.prepare(`UPDATE user_signatures SET is_default = 0 WHERE agent_id = ?`).run(operatorId);
|
||||||
|
db.prepare(`UPDATE user_signatures SET is_default = 1 WHERE id = ? AND agent_id = ?`).run(id, operatorId);
|
||||||
|
|
||||||
|
const signatures = db.prepare(`
|
||||||
|
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Firma predefinita aggiornata con successo!',
|
||||||
|
signatures
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error setting default signature:', err);
|
||||||
|
res.status(500).json({ error: 'Errore aggiornamento firma predefinita', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/config/user-signatures/:id — Delete user signature image
|
||||||
|
router.delete('/user-signatures/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.prepare(`DELETE FROM user_signatures WHERE id = ? AND agent_id = ?`).run(id, operatorId);
|
||||||
|
|
||||||
|
const signatures = db.prepare(`
|
||||||
|
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Firma eliminata con successo!',
|
||||||
|
signatures
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting user signature:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante l\'eliminazione della firma', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/config/user-shapes — List custom user shapes
|
||||||
|
router.get('/user-shapes', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const shapes = db.prepare(`
|
||||||
|
SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
res.json(shapes);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching user shapes:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il recupero delle forme utente', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/config/user-shapes — Create a new custom shape
|
||||||
|
router.post('/user-shapes', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { name, shape_type, fill_color, border_color, border_width, is_favorite } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'Il nome della forma è obbligatorio' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO user_shapes (agent_id, name, shape_type, fill_color, border_color, border_width, is_favorite)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
operatorId,
|
||||||
|
name.trim(),
|
||||||
|
shape_type || 'rectangle',
|
||||||
|
fill_color || '#3b82f6',
|
||||||
|
border_color || '#1e3a8a',
|
||||||
|
parseInt(border_width || 3, 10),
|
||||||
|
is_favorite !== undefined ? (is_favorite ? 1 : 0) : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const shapes = db.prepare(`
|
||||||
|
SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Forma salvata nei preferiti con successo!',
|
||||||
|
id: info.lastInsertRowid,
|
||||||
|
shapes
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving user shape:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante il salvataggio della forma', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/config/user-shapes/:id/toggle-favorite — Toggle favorite status
|
||||||
|
router.put('/user-shapes/:id/toggle-favorite', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const shape = db.prepare(`SELECT * FROM user_shapes WHERE id = ? AND agent_id = ?`).get(id, operatorId);
|
||||||
|
if (!shape) {
|
||||||
|
return res.status(404).json({ error: 'Forma non trovata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFav = shape.is_favorite ? 0 : 1;
|
||||||
|
db.prepare(`UPDATE user_shapes SET is_favorite = ? WHERE id = ? AND agent_id = ?`).run(newFav, id, operatorId);
|
||||||
|
|
||||||
|
const shapes = db.prepare(`
|
||||||
|
SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
shapes
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling shape favorite status:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante l\'aggiornamento dei preferiti', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/config/user-shapes/:id — Delete a custom shape
|
||||||
|
router.delete('/user-shapes/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.prepare(`DELETE FROM user_shapes WHERE id = ? AND agent_id = ?`).run(id, operatorId);
|
||||||
|
|
||||||
|
const shapes = db.prepare(`
|
||||||
|
SELECT * FROM user_shapes WHERE agent_id = ? ORDER BY id DESC
|
||||||
|
`).all(operatorId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Forma eliminata con successo!',
|
||||||
|
shapes
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting user shape:', err);
|
||||||
|
res.status(500).json({ error: 'Errore durante l\'eliminazione della forma', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
+7
-1
@@ -147,7 +147,7 @@ router.get('/config', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
defaultAgentLogin: process.env.OTRS_API_USER || '',
|
defaultAgentLogin: process.env.OTRS_API_USER || '',
|
||||||
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480,
|
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME || process.env.daily_target_time || process.env.Daily_Target_Time, 10) || 480,
|
||||||
phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70,
|
phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70,
|
||||||
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00',
|
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00',
|
||||||
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
|
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
|
||||||
@@ -296,6 +296,12 @@ router.get('/customer-companies/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Alias /api/customers/search -> /api/customer-users/search for backwards compatibility
|
||||||
|
router.get('/customers/search', (req, res, next) => {
|
||||||
|
req.url = '/customer-users/search' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
|
||||||
|
router.handle(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/customer-users/search — Search customer users (supports LDAP via REST, fallbacks to DB)
|
// GET /api/customer-users/search — Search customer users (supports LDAP via REST, fallbacks to DB)
|
||||||
router.get('/customer-users/search', async (req, res) => {
|
router.get('/customer-users/search', async (req, res) => {
|
||||||
const { q = '', customer_company_id } = req.query;
|
const { q = '', customer_company_id } = req.query;
|
||||||
|
|||||||
+86
-12
@@ -135,14 +135,13 @@ router.post('/:id/submit', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Nota temporanea non trovata' });
|
return res.status(404).json({ error: 'Nota temporanea non trovata' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!note.ticket_id && !note.ticket_tn) {
|
let resolved = null;
|
||||||
return res.status(400).json({ error: 'Impossibile inviare a OTRS: nessun ticket (TN/ID) associato a questa nota' });
|
if (note.ticket_id || note.ticket_tn) {
|
||||||
}
|
resolved = await resolveTicketInfo(note.ticket_id, note.ticket_tn);
|
||||||
|
|
||||||
const resolved = await resolveTicketInfo(note.ticket_id, note.ticket_tn);
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return res.status(400).json({ error: `Ticket non trovato nel sistema OTRS (TN: ${note.ticket_tn || ''}, ID: ${note.ticket_id || ''})` });
|
return res.status(400).json({ error: `Ticket non trovato nel sistema OTRS (TN: ${note.ticket_tn || ''}, ID: ${note.ticket_id || ''})` });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get agent login user
|
// Get agent login user
|
||||||
let userLogin = process.env.OTRS_API_USER || 'root@localhost';
|
let userLogin = process.env.OTRS_API_USER || 'root@localhost';
|
||||||
@@ -156,6 +155,81 @@ router.post('/:id/submit', async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
// If no ticket is associated, create a new ticket from this note
|
||||||
|
if (!resolved) {
|
||||||
|
// Default queue (first queue or 1)
|
||||||
|
let queueId = 1;
|
||||||
|
const qRes = await pool.query('SELECT id FROM queue ORDER BY id ASC LIMIT 1');
|
||||||
|
if (qRes.rows.length > 0) queueId = qRes.rows[0].id;
|
||||||
|
|
||||||
|
// Default state ('new' / 1)
|
||||||
|
let stateId = 1;
|
||||||
|
const sRes = await pool.query(`SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'new' LIMIT 1`);
|
||||||
|
if (sRes.rows.length > 0) stateId = sRes.rows[0].id;
|
||||||
|
|
||||||
|
const ticketTitle = note.subject || 'Nuova Nota Temporanea';
|
||||||
|
|
||||||
|
// Generate TN
|
||||||
|
const now = new Date();
|
||||||
|
const systemId = process.env.OTRS_SYSTEM_ID || String(agentId);
|
||||||
|
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
|
||||||
|
|
||||||
|
const maxTicketResult = await pool.query(
|
||||||
|
`SELECT tn FROM ticket WHERE tn LIKE $1`,
|
||||||
|
[`${datePrefix}%`]
|
||||||
|
);
|
||||||
|
let maxCounter = 0;
|
||||||
|
for (const row of maxTicketResult.rows) {
|
||||||
|
const counterPart = row.tn.substring(datePrefix.length);
|
||||||
|
const parsed = parseInt(counterPart, 10);
|
||||||
|
if (!isNaN(parsed) && parsed > maxCounter) {
|
||||||
|
maxCounter = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6;
|
||||||
|
const newTn = `${datePrefix}${String(maxCounter + 1).padStart(counterPadding, '0')}`;
|
||||||
|
|
||||||
|
// Insert new ticket into DB
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const nowTs = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||||
|
|
||||||
|
const newTicketRes = await client.query(
|
||||||
|
`INSERT INTO ticket (
|
||||||
|
tn, title, queue_id, ticket_lock_id, type_id,
|
||||||
|
user_id, responsible_user_id, ticket_priority_id, ticket_state_id,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, 1, 1,
|
||||||
|
$4, $4, 3, $5,
|
||||||
|
$6, $4, $6, $4
|
||||||
|
) RETURNING id, tn`,
|
||||||
|
[newTn, ticketTitle, queueId, agentId, stateId, nowTs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTicketId = newTicketRes.rows[0].id;
|
||||||
|
resolved = { id: newTicketId, tn: newTn, title: ticketTitle };
|
||||||
|
|
||||||
|
// Insert NewTicket history
|
||||||
|
const htRes = await client.query(`SELECT id FROM ticket_history_type WHERE name = 'NewTicket'`);
|
||||||
|
const htId = htRes.rows.length > 0 ? htRes.rows[0].id : 1;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, type_id, queue_id, owner_id, priority_id, state_id, create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ('%%', $1, $2, 1, $3, $4, 3, $5, $6, $4, $6, $4)`,
|
||||||
|
[htId, newTicketId, queueId, agentId, stateId, nowTs]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (createErr) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw createErr;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare Article for OTRS REST API
|
// Prepare Article for OTRS REST API
|
||||||
const isInternal = note.note_type === 'note-internal';
|
const isInternal = note.note_type === 'note-internal';
|
||||||
const isEmail = note.note_type === 'email-external';
|
const isEmail = note.note_type === 'email-external';
|
||||||
@@ -195,13 +269,13 @@ router.post('/:id/submit', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log action
|
// Log action
|
||||||
logAttivita(
|
logAttivita({
|
||||||
agentId,
|
agente_id: agentId,
|
||||||
userLogin,
|
agente_nome: userLogin,
|
||||||
`Aggiunta nota a Ticket #${resolved.tn}`,
|
titolo_azione: `Aggiunta nota a Ticket #${resolved.tn}`,
|
||||||
{ ticket_id: resolved.id, ticket_tn: resolved.tn, subject: note.subject, time_units: note.time_units },
|
azione: { ticket_id: resolved.id, ticket_tn: resolved.tn, subject: note.subject, time_units: note.time_units },
|
||||||
'successo'
|
esito: 'successo'
|
||||||
);
|
});
|
||||||
|
|
||||||
// Delete note from local SQLite DB upon successful OTRS upload
|
// Delete note from local SQLite DB upon successful OTRS upload
|
||||||
db.prepare('DELETE FROM ticket_draft_notes WHERE id = ?').run(note.id);
|
db.prepare('DELETE FROM ticket_draft_notes WHERE id = ?').run(note.id);
|
||||||
|
|||||||
+549
-50
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const pool = require('../db');
|
const pool = require('../db');
|
||||||
const { db, logAttivita } = require('../activityDb');
|
const { db, logAttivita } = require('../activityDb');
|
||||||
|
const { cleanAndDecodeText } = require('../utils/textDecoder');
|
||||||
|
|
||||||
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
|
// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
|
||||||
function getLocalTimestamp() {
|
function getLocalTimestamp() {
|
||||||
@@ -211,6 +212,30 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const searchTerm = search || sub_search;
|
const searchTerm = search || sub_search;
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
|
const isRegex = req.query.is_regex === 'true' || req.query.is_regex === '1';
|
||||||
|
if (isRegex) {
|
||||||
|
// Validate regex pattern to prevent DB syntax error
|
||||||
|
try {
|
||||||
|
new RegExp(searchTerm);
|
||||||
|
} catch (regexErr) {
|
||||||
|
return res.status(400).json({ error: `Espressione regolare non valida: ${regexErr.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(`(
|
||||||
|
t.title ~* $${paramIdx} OR
|
||||||
|
t.tn ~* $${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 ~* $${paramIdx} OR
|
||||||
|
adm.a_body ~* $${paramIdx}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`);
|
||||||
|
params.push(searchTerm);
|
||||||
|
paramIdx++;
|
||||||
|
} else {
|
||||||
conditions.push(`(
|
conditions.push(`(
|
||||||
t.title ILIKE $${paramIdx} OR
|
t.title ILIKE $${paramIdx} OR
|
||||||
t.tn ILIKE $${paramIdx} OR
|
t.tn ILIKE $${paramIdx} OR
|
||||||
@@ -226,6 +251,7 @@ router.get('/', async (req, res) => {
|
|||||||
params.push(`%${searchTerm}%`);
|
params.push(`%${searchTerm}%`);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0
|
const whereClause = conditions.length > 0
|
||||||
? 'WHERE ' + conditions.join(' AND ')
|
? 'WHERE ' + conditions.join(' AND ')
|
||||||
@@ -292,6 +318,12 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.rows.forEach(t => {
|
||||||
|
if (t.title) t.title = cleanAndDecodeText(t.title);
|
||||||
|
if (t.customer_first) t.customer_first = cleanAndDecodeText(t.customer_first);
|
||||||
|
if (t.customer_last) t.customer_last = cleanAndDecodeText(t.customer_last);
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
tickets: result.rows,
|
tickets: result.rows,
|
||||||
total,
|
total,
|
||||||
@@ -306,12 +338,37 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/tickets/stats — Ticket statistics overview
|
||||||
|
// ============================================================
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totalRes = await pool.query('SELECT COUNT(*) as total FROM ticket');
|
||||||
|
const openRes = await pool.query(
|
||||||
|
`SELECT COUNT(*) as total 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 auto', 'pending reminder')`
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
total_tickets: parseInt(totalRes.rows[0]?.total || 0),
|
||||||
|
open_tickets: parseInt(openRes.rows[0]?.total || 0),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching ticket stats:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GET /api/tickets/:id — Single ticket detail
|
// GET /api/tickets/:id — Single ticket detail
|
||||||
// ============================================================
|
// ============================================================
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({ error: 'ID ticket non valido' });
|
||||||
|
}
|
||||||
|
|
||||||
const ticketResult = await pool.query(
|
const ticketResult = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -355,7 +412,11 @@ router.get('/:id', async (req, res) => {
|
|||||||
// 1. Try to read from local SQLite cache in internal.db
|
// 1. Try to read from local SQLite cache in internal.db
|
||||||
let cachedUser = null;
|
let cachedUser = null;
|
||||||
try {
|
try {
|
||||||
cachedUser = db.prepare('SELECT email, first_name, last_name, phone FROM customer_user_cache WHERE login = ?').get(ticket.customer_user_id);
|
cachedUser = db.prepare(`
|
||||||
|
SELECT login, email, first_name, last_name, phone
|
||||||
|
FROM customer_user_cache
|
||||||
|
WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE
|
||||||
|
`).get(ticket.customer_user_id, ticket.customer_user_id);
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
console.warn('[LDAP Cache Detail] SQLite cache read failed:', dbErr.message);
|
console.warn('[LDAP Cache Detail] SQLite cache read failed:', dbErr.message);
|
||||||
}
|
}
|
||||||
@@ -366,8 +427,20 @@ router.get('/:id', async (req, res) => {
|
|||||||
ticket.customer_email = cachedUser.email || '';
|
ticket.customer_email = cachedUser.email || '';
|
||||||
ticket.customer_phone = cachedUser.phone || '';
|
ticket.customer_phone = cachedUser.phone || '';
|
||||||
} else {
|
} else {
|
||||||
// 2. Fallback to live API fetch
|
// 2. Fallback to live API fetch (only if login doesn't contain spaces)
|
||||||
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: ticket.customer_user_id });
|
let targetLogin = ticket.customer_user_id;
|
||||||
|
if (targetLogin.includes(' ')) {
|
||||||
|
try {
|
||||||
|
const sRes = await otrsRequest('POST', '/CustomerUserSearch', { Search: `*${targetLogin}*`, Valid: 1 });
|
||||||
|
const sData = (sRes && sRes.Data) || sRes;
|
||||||
|
if (sData && sData.CustomerUserID) {
|
||||||
|
targetLogin = Array.isArray(sData.CustomerUserID) ? sData.CustomerUserID[0] : sData.CustomerUserID;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLogin && !targetLogin.includes(' ')) {
|
||||||
|
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: targetLogin });
|
||||||
const getData = (getResult && getResult.Data) || getResult;
|
const getData = (getResult && getResult.Data) || getResult;
|
||||||
if (getData && getData.CustomerUser) {
|
if (getData && getData.CustomerUser) {
|
||||||
const u = getData.CustomerUser;
|
const u = getData.CustomerUser;
|
||||||
@@ -400,6 +473,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (apiErr) {
|
} catch (apiErr) {
|
||||||
console.warn(`Failed to fetch LDAP customer details for detail view:`, apiErr.message);
|
console.warn(`Failed to fetch LDAP customer details for detail view:`, apiErr.message);
|
||||||
}
|
}
|
||||||
@@ -443,6 +517,134 @@ router.get('/:id', async (req, res) => {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch HTML body attachment content for articles if present (e.g. file-1, file-2, text/html, html attachments)
|
||||||
|
const htmlAttachmentsRes = await pool.query(
|
||||||
|
`SELECT id, article_id, filename, content_type, content
|
||||||
|
FROM article_data_mime_attachment
|
||||||
|
WHERE article_id IN (SELECT id FROM article WHERE ticket_id = $1)
|
||||||
|
AND (
|
||||||
|
filename LIKE 'file-%'
|
||||||
|
OR filename LIKE '%.html'
|
||||||
|
OR filename LIKE '%.htm'
|
||||||
|
OR content_type LIKE '%html%'
|
||||||
|
OR content_type LIKE '%text%'
|
||||||
|
OR disposition = 'inline'
|
||||||
|
)
|
||||||
|
ORDER BY id ASC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconv = require('iconv-lite');
|
||||||
|
|
||||||
|
const decodeTextContent = (rawContent, contentTypeStr = '') => {
|
||||||
|
if (!rawContent) return '';
|
||||||
|
let rawBuf = null;
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(rawContent)) {
|
||||||
|
rawBuf = rawContent;
|
||||||
|
} else if (typeof rawContent === 'string') {
|
||||||
|
const cleaned = rawContent.replace(/\s+/g, '');
|
||||||
|
if (cleaned.length > 20 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
|
||||||
|
try {
|
||||||
|
rawBuf = Buffer.from(cleaned, 'base64');
|
||||||
|
} catch (_) {
|
||||||
|
rawBuf = Buffer.from(rawContent, 'utf-8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawBuf = Buffer.from(rawContent, 'utf-8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return String(rawContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect charset from content-type header
|
||||||
|
let charset = 'utf-8';
|
||||||
|
const charsetMatch = (contentTypeStr || '').match(/charset=["']?([^"';\s]+)/i);
|
||||||
|
if (charsetMatch && charsetMatch[1]) {
|
||||||
|
charset = charsetMatch[1].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
if (iconv.encodingExists(charset)) {
|
||||||
|
text = iconv.decode(rawBuf, charset);
|
||||||
|
} else {
|
||||||
|
text = rawBuf.toString('utf-8');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
text = rawBuf.toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If UTF-8 decoding contains replacement character U+FFFD (), retry decoding as win1252/latin1
|
||||||
|
if (text.includes('\uFFFD') || text.includes('')) {
|
||||||
|
try {
|
||||||
|
const latin1Text = iconv.decode(rawBuf, 'windows-1252');
|
||||||
|
if (latin1Text && !latin1Text.includes('\uFFFD')) {
|
||||||
|
text = latin1Text;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair Double-UTF-8 / Mojibake (e.g., ’ -> ’, ù -> ù, è -> è, à -> à, à -> È)
|
||||||
|
if (/(?:Ã[\s\xa0¡-ÿ]|â€[™œ]|–)/.test(text)) {
|
||||||
|
try {
|
||||||
|
const reEncoded = iconv.encode(text, 'win1252');
|
||||||
|
const repaired = iconv.decode(reEncoded, 'utf-8');
|
||||||
|
if (repaired && !repaired.includes('\uFFFD')) {
|
||||||
|
text = repaired;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ticket.title) ticket.title = cleanAndDecodeText(ticket.title);
|
||||||
|
if (ticket.customer_first) ticket.customer_first = cleanAndDecodeText(ticket.customer_first);
|
||||||
|
if (ticket.customer_last) ticket.customer_last = cleanAndDecodeText(ticket.customer_last);
|
||||||
|
|
||||||
|
const htmlBodyMap = {};
|
||||||
|
for (const row of htmlAttachmentsRes.rows) {
|
||||||
|
if (row.content) {
|
||||||
|
const contentStr = cleanAndDecodeText(row.content, row.content_type);
|
||||||
|
if (contentStr && /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(contentStr)) {
|
||||||
|
const fname = (row.filename || '').toLowerCase();
|
||||||
|
if (!htmlBodyMap[row.article_id] || fname.startsWith('file-1') || fname.startsWith('file-2')) {
|
||||||
|
htmlBodyMap[row.article_id] = contentStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
articlesResult.rows.forEach(a => {
|
||||||
|
// Decode article subjects, bodies, and sender/recipient headers
|
||||||
|
if (a.a_body) {
|
||||||
|
a.a_body = cleanAndDecodeText(a.a_body, a.a_content_type);
|
||||||
|
}
|
||||||
|
if (a.a_subject) {
|
||||||
|
a.a_subject = cleanAndDecodeText(a.a_subject);
|
||||||
|
}
|
||||||
|
if (a.a_from) {
|
||||||
|
a.a_from = cleanAndDecodeText(a.a_from);
|
||||||
|
}
|
||||||
|
if (a.a_to) {
|
||||||
|
a.a_to = cleanAndDecodeText(a.a_to);
|
||||||
|
}
|
||||||
|
if (a.a_cc) {
|
||||||
|
a.a_cc = cleanAndDecodeText(a.a_cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (htmlBodyMap[a.article_id]) {
|
||||||
|
a.html_body = htmlBodyMap[a.article_id];
|
||||||
|
a.has_html = true;
|
||||||
|
} else if (a.a_body && ((a.a_content_type || '').toLowerCase().includes('html') || /<(div|p|br|table|html|body|span|img|a|style)\b/i.test(a.a_body))) {
|
||||||
|
a.html_body = a.a_body;
|
||||||
|
a.has_html = true;
|
||||||
|
} else {
|
||||||
|
a.has_html = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let otrsWebUrl = null;
|
let otrsWebUrl = null;
|
||||||
const apiUrl = process.env.OTRS_API_URL;
|
const apiUrl = process.env.OTRS_API_URL;
|
||||||
if (apiUrl) {
|
if (apiUrl) {
|
||||||
@@ -482,7 +684,7 @@ router.post('/', async (req, res) => {
|
|||||||
|
|
||||||
// Generate ticket number: get next counter value for today (daily reset)
|
// Generate ticket number: get next counter value for today (daily reset)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const systemId = process.env.OTRS_SYSTEM_ID || '10';
|
const systemId = process.env.OTRS_SYSTEM_ID || String(operatorId);
|
||||||
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
|
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
|
||||||
|
|
||||||
// 1. Fetch current maximum counter in ticket table for today
|
// 1. Fetch current maximum counter in ticket table for today
|
||||||
@@ -748,8 +950,9 @@ router.patch('/:id', async (req, res) => {
|
|||||||
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, note }
|
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, note }
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
const timeUnit = parseFloat(updates.time_unit);
|
const timeUnit = parseFloat(updates.time_unit);
|
||||||
|
const noteSubjectText = typeof updates.note_subject === 'string' ? updates.note_subject.trim() : '';
|
||||||
const noteText = typeof updates.note === 'string' ? updates.note.trim() : (typeof updates.note_text === 'string' ? updates.note_text.trim() : '');
|
const noteText = typeof updates.note === 'string' ? updates.note.trim() : (typeof updates.note_text === 'string' ? updates.note_text.trim() : '');
|
||||||
const hasNote = noteText.length > 0;
|
const hasNote = noteText.length > 0 || noteSubjectText.length > 0;
|
||||||
|
|
||||||
// Fetch current ticket first (needed for both REST dummy fields and database fallback)
|
// Fetch current ticket first (needed for both REST dummy fields and database fallback)
|
||||||
let current;
|
let current;
|
||||||
@@ -779,9 +982,19 @@ router.patch('/:id', async (req, res) => {
|
|||||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
||||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||||
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
||||||
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
|
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') {
|
||||||
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
|
try {
|
||||||
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
|
const match = db.prepare(`SELECT login, customer_id FROM customer_user_cache WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE`).get(updates.customer_user_id, updates.customer_user_id);
|
||||||
|
if (match) {
|
||||||
|
updates.customer_user_id = match.login;
|
||||||
|
if (!updates.customer_id && match.customer_id) {
|
||||||
|
updates.customer_id = match.customer_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
ticketFields.CustomerUser = updates.customer_user_id;
|
||||||
|
}
|
||||||
|
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
|
||||||
|
|
||||||
// If ticketFields is empty but we have a note or timeUnit to log, supply a dummy field (StateID) so Ticket is not empty
|
// If ticketFields is empty but we have a note or timeUnit to log, supply a dummy field (StateID) so Ticket is not empty
|
||||||
if (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
|
if (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
|
||||||
@@ -810,8 +1023,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
Ticket: ticketFields
|
Ticket: ticketFields
|
||||||
};
|
};
|
||||||
if (hasNote || (!isNaN(timeUnit) && timeUnit > 0)) {
|
if (hasNote || (!isNaN(timeUnit) && timeUnit > 0)) {
|
||||||
const noteSubject = hasNote ? noteText : 'Consuntivazione';
|
const noteSubject = noteSubjectText || (hasNote ? noteText : 'Consuntivazione');
|
||||||
const noteBody = hasNote ? noteText : 'Consuntivazione';
|
const noteBody = noteText || noteSubjectText || 'Consuntivazione';
|
||||||
const articleObj = {
|
const articleObj = {
|
||||||
CommunicationChannel: 'Internal',
|
CommunicationChannel: 'Internal',
|
||||||
SenderType: 'agent',
|
SenderType: 'agent',
|
||||||
@@ -850,6 +1063,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
const localNow = getLocalTimestamp();
|
||||||
|
|
||||||
// If state is changing, check if the target state is a closed state type to auto-unlock
|
// If state is changing, check if the target state is a closed state type to auto-unlock
|
||||||
if (updates.ticket_state_id && updates.ticket_state_id !== current.ticket_state_id) {
|
if (updates.ticket_state_id && updates.ticket_state_id !== current.ticket_state_id) {
|
||||||
@@ -993,8 +1207,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
const articleId = articleResult.rows[0].id;
|
const articleId = articleResult.rows[0].id;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
|
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
|
||||||
const noteSubject = hasNote ? noteText : 'Consuntivazione';
|
const noteSubject = noteSubjectText || (hasNote ? noteText : 'Consuntivazione');
|
||||||
const noteBodyRaw = hasNote ? noteText : 'Consuntivazione';
|
const noteBodyRaw = noteText || noteSubjectText || 'Consuntivazione';
|
||||||
const safeBody = noteBodyRaw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>');
|
const safeBody = noteBodyRaw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>');
|
||||||
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${safeBody}</body></html>`;
|
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${safeBody}</body></html>`;
|
||||||
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
||||||
@@ -1110,7 +1324,7 @@ router.get('/:id/articles', async (req, res) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/:id/articles', async (req, res) => {
|
router.post('/:id/articles', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = req.body;
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments, ticket_state_id } = 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 isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
||||||
@@ -1130,6 +1344,27 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (ticket_state_id) {
|
||||||
|
payload.Ticket = { StateID: parseInt(ticket_state_id, 10) };
|
||||||
|
try {
|
||||||
|
const stateTypeRes = await pool.query(
|
||||||
|
`SELECT tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.id = $1`,
|
||||||
|
[ticket_state_id]
|
||||||
|
);
|
||||||
|
if (stateTypeRes.rows.length > 0) {
|
||||||
|
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
|
||||||
|
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
|
||||||
|
payload.Ticket.LockID = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking state type for REST API payload:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (time_unit) {
|
if (time_unit) {
|
||||||
payload.Article.TimeUnit = parseFloat(time_unit);
|
payload.Article.TimeUnit = parseFloat(time_unit);
|
||||||
payload.Article.TimeUnits = parseFloat(time_unit);
|
payload.Article.TimeUnits = parseFloat(time_unit);
|
||||||
@@ -1181,7 +1416,7 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = req.body;
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments, ticket_state_id } = req.body;
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
// Check if this article was already inserted (e.g., via REST API or concurrent request)
|
// Check if this article was already inserted (e.g., via REST API or concurrent request)
|
||||||
@@ -1242,12 +1477,67 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Verify ticket exists
|
// Verify ticket exists & get current state
|
||||||
const ticketCheck = await client.query('SELECT id FROM ticket WHERE id = $1', [id]);
|
const ticketCheck = await client.query(
|
||||||
|
'SELECT id, ticket_state_id, ticket_priority_id, queue_id, user_id, type_id FROM ticket WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
if (ticketCheck.rows.length === 0) {
|
if (ticketCheck.rows.length === 0) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
return res.status(404).json({ error: 'Ticket not found' });
|
return res.status(404).json({ error: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
const currentTicket = ticketCheck.rows[0];
|
||||||
|
|
||||||
|
// Check if ticket state should be updated
|
||||||
|
if (ticket_state_id && parseInt(ticket_state_id, 10) !== currentTicket.ticket_state_id) {
|
||||||
|
const newStateId = parseInt(ticket_state_id, 10);
|
||||||
|
const stateTypeRes = await client.query(
|
||||||
|
`SELECT tst.name AS type_name
|
||||||
|
FROM ticket_state ts
|
||||||
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
|
WHERE ts.id = $1`,
|
||||||
|
[newStateId]
|
||||||
|
);
|
||||||
|
let lockIdToSet = null;
|
||||||
|
if (stateTypeRes.rows.length > 0) {
|
||||||
|
const typeName = stateTypeRes.rows[0].type_name.toLowerCase();
|
||||||
|
if (typeName.includes('closed') || typeName === 'closed successful' || typeName === 'closed unsuccessful') {
|
||||||
|
lockIdToSet = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lockIdToSet !== null) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET ticket_state_id = $1, ticket_lock_id = $2, change_time = $3, change_by = $4 WHERE id = $5`,
|
||||||
|
[newStateId, lockIdToSet, getLocalTimestamp(), operatorId, id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET ticket_state_id = $1, change_time = $2, change_by = $3 WHERE id = $4`,
|
||||||
|
[newStateId, getLocalTimestamp(), operatorId, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const htResult = await client.query(
|
||||||
|
`SELECT id FROM ticket_history_type WHERE name = 'StateUpdate'`
|
||||||
|
);
|
||||||
|
if (htResult.rows.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, type_id, queue_id,
|
||||||
|
owner_id, priority_id, state_id, create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
'%%', $1, $2, $3, $4,
|
||||||
|
$5, $6, $7, $8, $9, $8, $9
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
htResult.rows[0].id, id, currentTicket.type_id || 1, currentTicket.queue_id,
|
||||||
|
currentTicket.user_id, currentTicket.ticket_priority_id, newStateId,
|
||||||
|
getLocalTimestamp(), operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get sender type for "agent"
|
// Get sender type for "agent"
|
||||||
const senderResult = await client.query(
|
const senderResult = await client.query(
|
||||||
@@ -1435,9 +1725,19 @@ router.patch('/batch/update', async (req, res) => {
|
|||||||
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
||||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||||
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
||||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') {
|
||||||
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
|
try {
|
||||||
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
|
const match = db.prepare(`SELECT login, customer_id FROM customer_user_cache WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE`).get(updates.customer_user_id, updates.customer_user_id);
|
||||||
|
if (match) {
|
||||||
|
updates.customer_user_id = match.login;
|
||||||
|
if (!updates.customer_id && match.customer_id) {
|
||||||
|
updates.customer_id = match.customer_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
ticketFields.CustomerUser = updates.customer_user_id;
|
||||||
|
}
|
||||||
|
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
|
||||||
|
|
||||||
// Auto sblocco check
|
// Auto sblocco check
|
||||||
if (updates.ticket_state_id) {
|
if (updates.ticket_state_id) {
|
||||||
@@ -1677,6 +1977,9 @@ router.delete('/articles/:articleId', async (req, res) => {
|
|||||||
router.get('/attachments/:id', async (req, res) => {
|
router.get('/attachments/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
return res.status(404).send('Allegato non trovato.');
|
||||||
|
}
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT filename, content_type, content
|
`SELECT filename, content_type, content
|
||||||
FROM article_data_mime_attachment
|
FROM article_data_mime_attachment
|
||||||
@@ -1688,34 +1991,56 @@ router.get('/attachments/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const attachment = result.rows[0];
|
const attachment = result.rows[0];
|
||||||
|
|
||||||
// Decode base64 if necessary
|
|
||||||
let contentBuffer;
|
let contentBuffer;
|
||||||
if (attachment.content) {
|
if (attachment.content) {
|
||||||
let contentStr = '';
|
|
||||||
if (Buffer.isBuffer(attachment.content)) {
|
if (Buffer.isBuffer(attachment.content)) {
|
||||||
contentStr = attachment.content.toString('utf-8');
|
// Check for raw binary signatures (%PDF, JPEG, PNG, Zip)
|
||||||
} else if (typeof attachment.content === 'string') {
|
const isRawBinary = attachment.content.length > 4 && (
|
||||||
contentStr = attachment.content;
|
(attachment.content[0] === 0x25 && attachment.content[1] === 0x50 && attachment.content[2] === 0x44 && attachment.content[3] === 0x46) || // %PDF
|
||||||
}
|
(attachment.content[0] === 0xFF && attachment.content[1] === 0xD8) || // JPEG
|
||||||
|
(attachment.content[0] === 0x89 && attachment.content[1] === 0x50) || // PNG
|
||||||
|
(attachment.content[0] === 0x50 && attachment.content[1] === 0x4B) // ZIP/DOCX/XLSX
|
||||||
|
);
|
||||||
|
|
||||||
const cleaned = contentStr.replace(/\s+/g, '');
|
if (isRawBinary) {
|
||||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
contentBuffer = attachment.content;
|
||||||
if (cleaned.length % 4 === 0 && base64Regex.test(cleaned)) {
|
} else {
|
||||||
|
const str = attachment.content.toString('utf-8');
|
||||||
|
const cleaned = str.replace(/\s+/g, '');
|
||||||
|
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
|
||||||
try {
|
try {
|
||||||
contentBuffer = Buffer.from(cleaned, 'base64');
|
contentBuffer = Buffer.from(cleaned, 'base64');
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content);
|
contentBuffer = attachment.content;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content);
|
contentBuffer = attachment.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof attachment.content === 'string') {
|
||||||
|
const cleaned = attachment.content.replace(/\s+/g, '');
|
||||||
|
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
|
||||||
|
try {
|
||||||
|
contentBuffer = Buffer.from(cleaned, 'base64');
|
||||||
|
} catch (_) {
|
||||||
|
contentBuffer = Buffer.from(attachment.content, 'utf-8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentBuffer = Buffer.from(attachment.content, 'utf-8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentBuffer = Buffer.from(attachment.content);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
contentBuffer = Buffer.alloc(0);
|
contentBuffer = Buffer.alloc(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDownload = req.query.download === '1' || req.query.disposition === 'attachment';
|
||||||
|
const disposition = isDownload ? 'attachment' : 'inline';
|
||||||
const safeFilename = attachment.filename || 'attachment';
|
const safeFilename = attachment.filename || 'attachment';
|
||||||
|
|
||||||
res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream');
|
res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`);
|
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`);
|
||||||
res.send(contentBuffer);
|
res.send(contentBuffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Errore download allegato:', err);
|
console.error('Errore download allegato:', err);
|
||||||
@@ -1723,6 +2048,124 @@ router.get('/attachments/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/tickets/attachments/:id — Update text attachment content
|
||||||
|
router.put('/attachments/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
return res.status(404).json({ error: 'Allegato non trovato' });
|
||||||
|
}
|
||||||
|
const { content } = req.body;
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Contenuto non valido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkRes = await pool.query(
|
||||||
|
`SELECT filename, content_type FROM article_data_mime_attachment WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (checkRes.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Allegato non trovato' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentBuffer = Buffer.from(content, 'utf-8');
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE article_data_mime_attachment SET content = $1, content_size = $2 WHERE id = $3`,
|
||||||
|
[contentBuffer, contentBuffer.length, id]
|
||||||
|
);
|
||||||
|
res.json({ message: 'Allegato di testo aggiornato con successo' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Errore aggiornamento allegato:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tickets/attachments/:id/open — Open attachment using OS default app (Word, Excel, etc.)
|
||||||
|
router.post('/attachments/:id/open', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!/^\d+$/.test(id)) {
|
||||||
|
return res.status(404).json({ error: 'Allegato non trovato' });
|
||||||
|
}
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT filename, content_type, content FROM article_data_mime_attachment WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Allegato non trovato' });
|
||||||
|
}
|
||||||
|
const attachment = result.rows[0];
|
||||||
|
|
||||||
|
let contentBuffer;
|
||||||
|
if (attachment.content) {
|
||||||
|
if (Buffer.isBuffer(attachment.content)) {
|
||||||
|
const isRawBinary = attachment.content.length > 4 && (
|
||||||
|
(attachment.content[0] === 0x25 && attachment.content[1] === 0x50 && attachment.content[2] === 0x44 && attachment.content[3] === 0x46) ||
|
||||||
|
(attachment.content[0] === 0xFF && attachment.content[1] === 0xD8) ||
|
||||||
|
(attachment.content[0] === 0x89 && attachment.content[1] === 0x50) ||
|
||||||
|
(attachment.content[0] === 0x50 && attachment.content[1] === 0x4B)
|
||||||
|
);
|
||||||
|
if (isRawBinary) {
|
||||||
|
contentBuffer = attachment.content;
|
||||||
|
} else {
|
||||||
|
const str = attachment.content.toString('utf-8');
|
||||||
|
const cleaned = str.replace(/\s+/g, '');
|
||||||
|
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
|
||||||
|
try { contentBuffer = Buffer.from(cleaned, 'base64'); } catch (_) { contentBuffer = attachment.content; }
|
||||||
|
} else {
|
||||||
|
contentBuffer = attachment.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof attachment.content === 'string') {
|
||||||
|
const cleaned = attachment.content.replace(/\s+/g, '');
|
||||||
|
if (cleaned.length > 0 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
|
||||||
|
try { contentBuffer = Buffer.from(cleaned, 'base64'); } catch (_) { contentBuffer = Buffer.from(attachment.content, 'utf-8'); }
|
||||||
|
} else {
|
||||||
|
contentBuffer = Buffer.from(attachment.content, 'utf-8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentBuffer = Buffer.from(attachment.content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentBuffer = Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
|
||||||
|
const tempDir = path.join(os.tmpdir(), 'otrs_turbo_attachments');
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = (attachment.filename || `allegato_${id}`).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
const filePath = path.join(tempDir, `${id}_${safeName}`);
|
||||||
|
fs.writeFileSync(filePath, contentBuffer);
|
||||||
|
|
||||||
|
let command = `"" "${filePath}"`;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
command = `start "" "${filePath}"`;
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
command = `open "${filePath}"`;
|
||||||
|
} else {
|
||||||
|
command = `xdg-open "${filePath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(command, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error opening file via OS default app:', err);
|
||||||
|
return res.status(500).json({ error: 'Impossibile aprire il file con l\'applicazione di sistema: ' + err.message });
|
||||||
|
}
|
||||||
|
res.json({ message: 'File aperto con l\'applicazione predefinita di sistema', filePath });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Errore apertura allegato:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/tickets/merge — Merge tickets (Issue #7)
|
// POST /api/tickets/merge — Merge tickets (Issue #7)
|
||||||
router.post('/merge', async (req, res) => {
|
router.post('/merge', async (req, res) => {
|
||||||
const { targetId, sourceIds } = req.body;
|
const { targetId, sourceIds } = req.body;
|
||||||
@@ -1940,22 +2383,19 @@ router.post('/merge', async (req, res) => {
|
|||||||
router.get('/time-accounting/summary', async (req, res) => {
|
router.get('/time-accounting/summary', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
const agentId = parseInt(req.query.agent_id, 10) || operatorId;
|
const agentIdsRaw = req.query.agent_ids || req.query.agent_id || String(operatorId);
|
||||||
|
const agentIds = agentIdsRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x));
|
||||||
const dateStr = req.query.date || new Date().toISOString().slice(0, 10);
|
const dateStr = req.query.date || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const targetDate = new Date(`${dateStr}T00:00:00Z`);
|
const rangeParam = req.query.range || '35';
|
||||||
const startDateObj = new Date(targetDate);
|
const isAll = rangeParam === 'all';
|
||||||
startDateObj.setDate(startDateObj.getDate() - 7);
|
let result;
|
||||||
const endDateObj = new Date(targetDate);
|
if (isAll) {
|
||||||
endDateObj.setDate(endDateObj.getDate() + 7);
|
result = await pool.query(
|
||||||
|
|
||||||
const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`;
|
|
||||||
const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`;
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
`SELECT
|
||||||
a.id AS article_id,
|
a.id AS article_id,
|
||||||
a.ticket_id,
|
a.ticket_id,
|
||||||
|
a.create_by AS agent_id,
|
||||||
t.tn AS ticket_tn,
|
t.tn AS ticket_tn,
|
||||||
t.title AS ticket_title,
|
t.title AS ticket_title,
|
||||||
ast.name AS sender_type,
|
ast.name AS sender_type,
|
||||||
@@ -1976,14 +2416,65 @@ router.get('/time-accounting/summary', async (req, res) => {
|
|||||||
FROM time_accounting
|
FROM time_accounting
|
||||||
GROUP BY article_id
|
GROUP BY article_id
|
||||||
) ta ON a.id = ta.article_id
|
) ta ON a.id = ta.article_id
|
||||||
WHERE a.create_by = $1
|
WHERE a.create_by = ANY($1::int[])
|
||||||
|
ORDER BY a.create_time DESC, a.id DESC`,
|
||||||
|
[agentIds]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const targetDate = new Date(`${dateStr}T00:00:00Z`);
|
||||||
|
let startDateObj, endDateObj;
|
||||||
|
|
||||||
|
if (rangeParam === 'month') {
|
||||||
|
const y = targetDate.getUTCFullYear();
|
||||||
|
const m = targetDate.getUTCMonth();
|
||||||
|
startDateObj = new Date(Date.UTC(y, m, 1));
|
||||||
|
endDateObj = new Date(Date.UTC(y, m + 1, 0));
|
||||||
|
} else {
|
||||||
|
const daysBack = parseInt(rangeParam, 10) || 35;
|
||||||
|
startDateObj = new Date(targetDate);
|
||||||
|
startDateObj.setDate(startDateObj.getDate() - daysBack);
|
||||||
|
endDateObj = new Date(targetDate);
|
||||||
|
endDateObj.setDate(endDateObj.getDate() + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`;
|
||||||
|
const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`;
|
||||||
|
|
||||||
|
result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
a.id AS article_id,
|
||||||
|
a.ticket_id,
|
||||||
|
a.create_by AS agent_id,
|
||||||
|
t.tn AS ticket_tn,
|
||||||
|
t.title AS ticket_title,
|
||||||
|
ast.name AS sender_type,
|
||||||
|
cc.name AS channel,
|
||||||
|
adm.a_subject,
|
||||||
|
adm.a_body,
|
||||||
|
adm.a_from,
|
||||||
|
adm.a_to,
|
||||||
|
a.create_time,
|
||||||
|
COALESCE(ta.time_unit, 0) AS time_unit
|
||||||
|
FROM article a
|
||||||
|
JOIN ticket t ON a.ticket_id = t.id
|
||||||
|
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
|
||||||
|
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||||
|
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT article_id, SUM(time_unit) AS time_unit
|
||||||
|
FROM time_accounting
|
||||||
|
GROUP BY article_id
|
||||||
|
) ta ON a.id = ta.article_id
|
||||||
|
WHERE a.create_by = ANY($1::int[])
|
||||||
AND a.create_time >= $2
|
AND a.create_time >= $2
|
||||||
AND a.create_time <= $3
|
AND a.create_time <= $3
|
||||||
ORDER BY a.create_time DESC, a.id DESC`,
|
ORDER BY a.create_time DESC, a.id DESC`,
|
||||||
[agentId, startTime, endTime]
|
[agentIds, startTime, endTime]
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const dailyTotals = {};
|
const dailyTotals = {};
|
||||||
|
const dailyTotalsByAgent = {};
|
||||||
const dayArticles = [];
|
const dayArticles = [];
|
||||||
let totalMinutes = 0;
|
let totalMinutes = 0;
|
||||||
|
|
||||||
@@ -1998,27 +2489,35 @@ router.get('/time-accounting/summary', async (req, res) => {
|
|||||||
isoDate = String(r.create_time || '').slice(0, 10);
|
isoDate = String(r.create_time || '').slice(0, 10);
|
||||||
}
|
}
|
||||||
const timeVal = parseFloat(r.time_unit || 0);
|
const timeVal = parseFloat(r.time_unit || 0);
|
||||||
|
const agId = r.agent_id;
|
||||||
|
|
||||||
dailyTotals[isoDate] = (dailyTotals[isoDate] || 0) + timeVal;
|
dailyTotals[isoDate] = (dailyTotals[isoDate] || 0) + timeVal;
|
||||||
|
|
||||||
|
if (!dailyTotalsByAgent[agId]) {
|
||||||
|
dailyTotalsByAgent[agId] = {};
|
||||||
|
}
|
||||||
|
dailyTotalsByAgent[agId][isoDate] = (dailyTotalsByAgent[agId][isoDate] || 0) + timeVal;
|
||||||
|
|
||||||
if (isoDate === dateStr) {
|
if (isoDate === dateStr) {
|
||||||
dayArticles.push(r);
|
dayArticles.push(r);
|
||||||
totalMinutes += timeVal;
|
totalMinutes += timeVal;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetMinutes = parseInt(process.env.DAILY_TARGET_TIME, 10) || 480;
|
const targetMinutes = parseInt(process.env.DAILY_TARGET_TIME || process.env.daily_target_time || process.env.Daily_Target_Time, 10) || 480;
|
||||||
const workDaysRaw = process.env.WORK_DAYS || '1,2,3,4,5';
|
const workDaysRaw = process.env.WORK_DAYS || '1,2,3,4,5';
|
||||||
const workDays = workDaysRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x));
|
const workDays = workDaysRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
agent_id: agentId,
|
agent_id: agentIds[0],
|
||||||
|
agent_ids: agentIds,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
total_minutes: totalMinutes,
|
total_minutes: totalMinutes,
|
||||||
target_minutes: targetMinutes,
|
target_minutes: targetMinutes,
|
||||||
work_days: workDays,
|
work_days: workDays,
|
||||||
articles: dayArticles,
|
articles: dayArticles,
|
||||||
daily_totals: dailyTotals,
|
daily_totals: dailyTotals,
|
||||||
|
daily_totals_by_agent: dailyTotalsByAgent,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching time accounting summary:', err);
|
console.error('Error fetching time accounting summary:', err);
|
||||||
@@ -2132,7 +2631,7 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// 1. Calculate remaining time
|
// 1. Calculate remaining time
|
||||||
const targetTime = parseInt(process.env.DAILY_TARGET_TIME, 10) || 480;
|
const targetTime = parseInt(process.env.DAILY_TARGET_TIME || process.env.daily_target_time || process.env.Daily_Target_Time, 10) || 480;
|
||||||
const timeTodayRes = await client.query(
|
const timeTodayRes = await client.query(
|
||||||
`SELECT COALESCE(SUM(time_unit), 0) AS total_today
|
`SELECT COALESCE(SUM(time_unit), 0) AS total_today
|
||||||
FROM time_accounting
|
FROM time_accounting
|
||||||
@@ -2210,7 +2709,7 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
|
|
||||||
// Generate ticket number (daily reset)
|
// Generate ticket number (daily reset)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const systemId = process.env.OTRS_SYSTEM_ID || '10';
|
const systemId = process.env.OTRS_SYSTEM_ID || String(operatorId);
|
||||||
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
|
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
|
||||||
|
|
||||||
const maxTicketResult = await client.query(
|
const maxTicketResult = await client.query(
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const vendorDir = path.join(__dirname, '..', 'public', 'vendor');
|
||||||
|
if (!fs.existsSync(vendorDir)) {
|
||||||
|
fs.mkdirSync(vendorDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
{ url: 'https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.snow.css', dest: 'quill.snow.css' },
|
||||||
|
{ url: 'https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js', dest: 'quill.js' },
|
||||||
|
{ url: 'https://cdn.jsdelivr.net/npm/chart.js', dest: 'chart.js' },
|
||||||
|
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js', dest: 'pdf.min.js' },
|
||||||
|
{ url: 'https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js', dest: 'pdf-lib.min.js' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function download(url, destPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
return download(res.headers.location, destPath).then(resolve).catch(reject);
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(new Error(`Failed to download ${url}: status ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close(() => {
|
||||||
|
console.log(`Downloaded: ${path.basename(destPath)} (${fs.statSync(destPath).size} bytes)`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const f of files) {
|
||||||
|
const destPath = path.join(vendorDir, f.dest);
|
||||||
|
try {
|
||||||
|
await download(f.url, destPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error downloading ${f.dest}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
const iconv = require('iconv-lite');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes MIME encoded-words (e.g. =?UTF-8?B?...?= or =?ISO-8859-1?Q?...?=)
|
||||||
|
*/
|
||||||
|
function decodeMimeWords(str) {
|
||||||
|
if (!str || typeof str !== 'string') return str || '';
|
||||||
|
if (!str.includes('=?')) return str;
|
||||||
|
|
||||||
|
return str.replace(/=\?([^?]+)\?([BQbq])\?([^?]+)\?=/g, (fullMatch, charset, encoding, data) => {
|
||||||
|
try {
|
||||||
|
const cs = charset.toLowerCase();
|
||||||
|
const enc = encoding.toUpperCase();
|
||||||
|
|
||||||
|
let buf;
|
||||||
|
if (enc === 'B') {
|
||||||
|
buf = Buffer.from(data, 'base64');
|
||||||
|
} else if (enc === 'Q') {
|
||||||
|
const qDec = data.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||||
|
buf = Buffer.from(qDec, 'binary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf) {
|
||||||
|
if (iconv.encodingExists(cs)) {
|
||||||
|
return iconv.decode(buf, cs);
|
||||||
|
} else {
|
||||||
|
return buf.toString('utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
return fullMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes raw Quoted-Printable strings (e.g. =E0=E8=E9=F9 or =C3=A8)
|
||||||
|
*/
|
||||||
|
function decodeQuotedPrintable(str, charset = 'utf-8') {
|
||||||
|
if (!str || typeof str !== 'string') return str || '';
|
||||||
|
if (!/=(?:[0-9A-Fa-f]{2}|\r?\n)/.test(str)) return str;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cleaned = str.replace(/=\r?\n/g, '');
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i < cleaned.length; i++) {
|
||||||
|
if (cleaned[i] === '=' && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substring(i + 1, i + 3))) {
|
||||||
|
bytes.push(parseInt(cleaned.substring(i + 1, i + 3), 16));
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
bytes.push(cleaned.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(bytes);
|
||||||
|
if (iconv.encodingExists(charset)) {
|
||||||
|
return iconv.decode(buf, charset);
|
||||||
|
}
|
||||||
|
return buf.toString('utf-8');
|
||||||
|
} catch (_) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main text decoder & Mojibake repair utility
|
||||||
|
*/
|
||||||
|
function cleanAndDecodeText(rawInput, contentTypeHint = '') {
|
||||||
|
if (!rawInput && rawInput !== 0) return '';
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
// 1. Buffer / Base64 / String normalization
|
||||||
|
if (Buffer.isBuffer(rawInput)) {
|
||||||
|
let charset = 'utf-8';
|
||||||
|
const match = (contentTypeHint || '').match(/charset=["']?([^"';\s]+)/i);
|
||||||
|
if (match && match[1]) charset = match[1].toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (iconv.encodingExists(charset)) {
|
||||||
|
text = iconv.decode(rawInput, charset);
|
||||||
|
} else {
|
||||||
|
text = rawInput.toString('utf-8');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
text = rawInput.toString('utf-8');
|
||||||
|
}
|
||||||
|
} else if (typeof rawInput === 'string') {
|
||||||
|
const cleaned = rawInput.replace(/\s+/g, '');
|
||||||
|
if (cleaned.length > 20 && cleaned.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) {
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(cleaned, 'base64');
|
||||||
|
const utfStr = buf.toString('utf-8');
|
||||||
|
// Only accept base64 decode if result doesn't look like corrupted binary noise
|
||||||
|
if (utfStr && !utfStr.includes('\uFFFD')) {
|
||||||
|
text = utfStr;
|
||||||
|
} else {
|
||||||
|
text = rawInput;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
text = rawInput;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text = rawInput;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return String(rawInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decode MIME words (e.g. =?UTF-8?B?...?= or =?ISO-8859-1?Q?...?=)
|
||||||
|
if (text.includes('=?')) {
|
||||||
|
text = decodeMimeWords(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Decode Quoted-Printable if present (and not inside HTML tags)
|
||||||
|
if (/=(?:[0-9A-Fa-f]{2})/.test(text) && !text.includes('<html') && !text.includes('<div') && !text.includes('<p>')) {
|
||||||
|
text = decodeQuotedPrintable(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fix Mojibake / Double-UTF-8 / Win1252 / ISO-8859-1 mis-decoding
|
||||||
|
if (/(?:Ã[\s\xa0¡-ÿ]|â€[™œ]|–|Â[°©®])/.test(text)) {
|
||||||
|
try {
|
||||||
|
const reEncoded = iconv.encode(text, 'win1252');
|
||||||
|
const repaired = iconv.decode(reEncoded, 'utf-8');
|
||||||
|
if (repaired && !repaired.includes('\uFFFD')) {
|
||||||
|
text = repaired;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Clean stray Mojibake/control characters like ý, ÿ, or U+FFFD () replacement symbols
|
||||||
|
if (/[ýÿ\uFFFD]/.test(text)) {
|
||||||
|
text = text.replace(/ý{1,}/g, '')
|
||||||
|
.replace(/ÿ{1,}/g, '')
|
||||||
|
.replace(/\uFFFD{1,}/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cleanAndDecodeText,
|
||||||
|
decodeMimeWords,
|
||||||
|
decodeQuotedPrintable
|
||||||
|
};
|
||||||
Reference in new issue
Block a user