Aggiunte stored procedure più utili le altre pfff
This commit is contained in:
+367
-23
@@ -91,7 +91,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Reset & Base ---- */
|
/* ---- Reset & Base ---- */
|
||||||
*, *::before, *::after {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -113,10 +115,23 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar {
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
width: 6px;
|
||||||
::-webkit-scrollbar-thumb { background: var(--text-muted); border-radius: var(--radius-full); }
|
height: 6px;
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Sidebar ---- */
|
/* ---- Sidebar ---- */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -274,8 +289,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% { box-shadow: 0 0 8px rgba(34, 197, 94, 0.3); }
|
|
||||||
50% { box-shadow: 0 0 14px rgba(34, 197, 94, 0.6); }
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 8px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 14px rgba(34, 197, 94, 0.6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Main Content ---- */
|
/* ---- Main Content ---- */
|
||||||
@@ -369,8 +391,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Loading ---- */
|
/* ---- Loading ---- */
|
||||||
@@ -394,7 +423,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Buttons ---- */
|
/* ---- Buttons ---- */
|
||||||
@@ -712,7 +743,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ticket-table tbody tr.selected {
|
.ticket-table tbody tr.selected {
|
||||||
background: rgba(99, 102, 241, 0.08);
|
background: rgba(160, 65, 71, 0.15);
|
||||||
box-shadow: inset 3px 0 0 var(--accent-primary);
|
box-shadow: inset 3px 0 0 var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,24 +932,27 @@ body {
|
|||||||
|
|
||||||
/* ---- Batch Actions Bar ---- */
|
/* ---- Batch Actions Bar ---- */
|
||||||
.batch-bar {
|
.batch-bar {
|
||||||
display: none;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.08));
|
background: linear-gradient(135deg, rgba(160, 65, 71, 0.15), rgba(160, 65, 71, 0.15));
|
||||||
border: 1px solid var(--border-accent);
|
border: 1px solid var(--border-accent);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
animation: slideDown 0.2s ease-out;
|
animation: slideDown 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-bar.visible {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
from { opacity: 0; transform: translateY(-8px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-count {
|
.batch-count {
|
||||||
@@ -1409,13 +1443,27 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateX(40px) scale(0.95); }
|
from {
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
opacity: 0;
|
||||||
|
transform: translateX(40px) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes toastOut {
|
@keyframes toastOut {
|
||||||
from { opacity: 1; transform: translateX(0) scale(1); }
|
from {
|
||||||
to { opacity: 0; transform: translateX(40px) scale(0.95); }
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px) scale(0.95);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Recent Tickets Table (Dashboard) ---- */
|
/* ---- Recent Tickets Table (Dashboard) ---- */
|
||||||
@@ -1525,6 +1573,7 @@ body {
|
|||||||
.ticket-detail {
|
.ticket-detail {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket-sidebar {
|
.ticket-sidebar {
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
@@ -1534,16 +1583,20 @@ body {
|
|||||||
:root {
|
:root {
|
||||||
--sidebar-width: 0px;
|
--sidebar-width: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
width: 260px;
|
width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.open {
|
.sidebar.open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1586,3 +1639,294 @@ body {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Bulk Tickets Grid Styles ---- */
|
||||||
|
.bulk-grid-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-card);
|
||||||
|
min-height: 520px;
|
||||||
|
/* Previene il ritaglio dei menu a tendina se ci sono poche righe */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-grid-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-grid-table tr:focus-within {
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
/* Solleva la riga attiva sopra le altre */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-grid-table td:focus-within {
|
||||||
|
position: relative;
|
||||||
|
z-index: 110;
|
||||||
|
/* Solleva la cella attiva sopra le altre */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-grid-table th {
|
||||||
|
padding: var(--space-md);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-grid-table td {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-grid-table tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-input,
|
||||||
|
.bulk-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
height: 36px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-input:focus,
|
||||||
|
.bulk-select:focus {
|
||||||
|
border-color: var(--border-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-primary-glow);
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-textarea-container {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-textarea {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
/* Si ancora sul bordo destro */
|
||||||
|
width: 180px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
/* Rimosso trasparenza */
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all var(--transition-base) cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-textarea:focus {
|
||||||
|
width: 320px;
|
||||||
|
height: 120px;
|
||||||
|
right: 0;
|
||||||
|
/* Mantiene l'ancoraggio a destra per espandersi verso sinistra */
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 99;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
/* Rimosso trasparenza */
|
||||||
|
box-shadow: var(--shadow-lg), 0 10px 25px rgba(0, 0, 0, 1);
|
||||||
|
border-color: var(--border-accent);
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-select option {
|
||||||
|
background-color: #111827;
|
||||||
|
/* Sfondo scuro per gli stati e i tipi */
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: #1f2937;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-height: 380px;
|
||||||
|
/* Incrementata l'altezza per contenere più elementi */
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 999;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-suggestions-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
/* Compatto */
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid rgba(156, 163, 175, 0.1);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-suggestions-item:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-suggestions-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: var(--space-xs);
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status-dot.pending {
|
||||||
|
background: var(--warning);
|
||||||
|
box-shadow: 0 0 8px var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status-dot.success {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 8px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status-dot.error {
|
||||||
|
background: var(--error);
|
||||||
|
box-shadow: 0 0 8px var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-row-expanded {
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-row-expanded-content {
|
||||||
|
padding: var(--space-md);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ticket List Links */
|
||||||
|
.ticket-tn-link,
|
||||||
|
.ticket-title-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: inherit;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-tn-link:hover,
|
||||||
|
.ticket-title-link:hover {
|
||||||
|
color: var(--accent-primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline Retrodate Editors */
|
||||||
|
.retrodate-container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-btn-edit {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-btn-edit:hover {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-editor {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-input {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-btn-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-btn-action.save {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrodate-btn-action.cancel {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
+20
-1
@@ -50,6 +50,17 @@
|
|||||||
<span>Nuovo Ticket</span>
|
<span>Nuovo Ticket</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<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">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<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>
|
||||||
|
<span>Apertura Massiva</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@@ -66,7 +77,14 @@
|
|||||||
<header class="topbar" id="topbar">
|
<header class="topbar" id="topbar">
|
||||||
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
|
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
|
||||||
<h1 class="page-title" id="page-title">Dashboard</h1>
|
<h1 class="page-title" id="page-title">Dashboard</h1>
|
||||||
<select class="form-select" id="active-agent-select" style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
|
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
||||||
|
<select class="form-select" id="active-agent-select" style="padding: 6px 32px 6px 12px; font-size: 0.85rem; height: 36px; min-width: 180px; margin: 0; background-position: right 10px center; border-color: var(--border-light);"></select>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="refresh-lookups-btn" style="height:36px; padding:0 10px; min-width:36px;" title="Aggiorna dati locali (code, utenti)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||||
|
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<div class="search-bar" id="global-search-container">
|
<div class="search-bar" id="global-search-container">
|
||||||
@@ -105,6 +123,7 @@
|
|||||||
<script src="/js/views/ticketList.js"></script>
|
<script src="/js/views/ticketList.js"></script>
|
||||||
<script src="/js/views/ticketDetail.js"></script>
|
<script src="/js/views/ticketDetail.js"></script>
|
||||||
<script src="/js/views/ticketCreate.js"></script>
|
<script src="/js/views/ticketCreate.js"></script>
|
||||||
|
<script src="/js/views/ticketBulk.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+61
-3
@@ -57,6 +57,12 @@ const App = {
|
|||||||
// Init active agent selector
|
// Init active agent selector
|
||||||
this.initAgentSelector();
|
this.initAgentSelector();
|
||||||
|
|
||||||
|
// Bind refresh lookups button
|
||||||
|
const refreshBtn = document.getElementById('refresh-lookups-btn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => this.refreshLookups());
|
||||||
|
}
|
||||||
|
|
||||||
// Initial route
|
// Initial route
|
||||||
if (!window.location.hash || window.location.hash === '#/') {
|
if (!window.location.hash || window.location.hash === '#/') {
|
||||||
window.location.hash = '#/dashboard';
|
window.location.hash = '#/dashboard';
|
||||||
@@ -67,7 +73,8 @@ const App = {
|
|||||||
|
|
||||||
/** Route based on current hash */
|
/** Route based on current hash */
|
||||||
route() {
|
route() {
|
||||||
const hash = window.location.hash || '#/dashboard';
|
const fullHash = window.location.hash || '#/dashboard';
|
||||||
|
const hash = fullHash.split('?')[0];
|
||||||
const titleEl = document.getElementById('page-title');
|
const titleEl = document.getElementById('page-title');
|
||||||
|
|
||||||
// Update active nav link
|
// Update active nav link
|
||||||
@@ -90,6 +97,11 @@ const App = {
|
|||||||
titleEl.textContent = 'Nuovo Ticket';
|
titleEl.textContent = 'Nuovo Ticket';
|
||||||
TicketCreateView.render();
|
TicketCreateView.render();
|
||||||
|
|
||||||
|
} else if (hash === '#/tickets/bulk') {
|
||||||
|
document.getElementById('nav-bulk-tickets')?.classList.add('active');
|
||||||
|
titleEl.textContent = 'Apertura Massiva Ticket';
|
||||||
|
TicketBulkView.render();
|
||||||
|
|
||||||
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||||
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
||||||
document.getElementById('nav-tickets')?.classList.add('active');
|
document.getElementById('nav-tickets')?.classList.add('active');
|
||||||
@@ -124,8 +136,22 @@ const App = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Ensure lookup data is loaded (cached) */
|
/** Ensure lookup data is loaded (cached) */
|
||||||
async ensureLookups() {
|
async ensureLookups(forceRefresh = false) {
|
||||||
if (this.lookupsLoaded) return;
|
if (this.lookupsLoaded && !forceRefresh) return;
|
||||||
|
|
||||||
|
// Check localStorage cache first if not forcing refresh
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = localStorage.getItem('otrs_lookups');
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
this.lookups = JSON.parse(cached);
|
||||||
|
this.lookupsLoaded = true;
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse cached lookups, reloading...', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [queues, states, priorities, users, types] = await Promise.all([
|
const [queues, states, priorities, users, types] = await Promise.all([
|
||||||
@@ -137,6 +163,7 @@ const App = {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.lookups = { queues, states, priorities, users, types };
|
this.lookups = { queues, states, priorities, users, types };
|
||||||
|
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
|
||||||
this.lookupsLoaded = true;
|
this.lookupsLoaded = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load lookups:', err);
|
console.error('Failed to load lookups:', err);
|
||||||
@@ -144,6 +171,37 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Force refresh of lookups */
|
||||||
|
async refreshLookups() {
|
||||||
|
const btn = document.getElementById('refresh-lookups-btn');
|
||||||
|
const origHtml = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0;"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ensureLookups(true);
|
||||||
|
await this.initAgentSelector();
|
||||||
|
Toast.success('Dati locali (code, utenti, ecc.) aggiornati con successo!');
|
||||||
|
|
||||||
|
// If we are on a view that needs lookups, we can re-render it
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash === '#/tickets/bulk') {
|
||||||
|
TicketBulkView.render();
|
||||||
|
} else if (hash === '#/tickets/new') {
|
||||||
|
TicketCreateView.render();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante l\'aggiornamento: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** Check database connection */
|
/** Check database connection */
|
||||||
async checkConnection() {
|
async checkConnection() {
|
||||||
const dot = document.getElementById('connection-status');
|
const dot = document.getElementById('connection-status');
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const Filters = {
|
|||||||
state_id: '',
|
state_id: '',
|
||||||
priority_id: '',
|
priority_id: '',
|
||||||
user_id: '',
|
user_id: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Load saved filters from localStorage */
|
/** Load saved filters from localStorage */
|
||||||
@@ -29,7 +31,7 @@ const Filters = {
|
|||||||
|
|
||||||
/** Reset all filters */
|
/** Reset all filters */
|
||||||
reset() {
|
reset() {
|
||||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '' };
|
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
|
||||||
this.save();
|
this.save();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -87,6 +89,14 @@ const Filters = {
|
|||||||
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">Da Data/Ora</span>
|
||||||
|
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">A Data/Ora</span>
|
||||||
|
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||||
|
</div>
|
||||||
<div class="filters-actions">
|
<div class="filters-actions">
|
||||||
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,718 @@
|
|||||||
|
/**
|
||||||
|
* Ticket Bulk Creation View
|
||||||
|
* Tabular layout for creating multiple tickets.
|
||||||
|
*/
|
||||||
|
const TicketBulkView = {
|
||||||
|
rowCount: 0,
|
||||||
|
activeRequests: false,
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const container = document.getElementById('view-container');
|
||||||
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dati...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await App.ensureLookups();
|
||||||
|
|
||||||
|
// Check if we have incoming query parameters (e.g. from Teams integration)
|
||||||
|
const hashParts = window.location.hash.split('?');
|
||||||
|
const searchStr = hashParts.length > 1 ? hashParts[1] : window.location.search;
|
||||||
|
const urlParams = new URLSearchParams(searchStr);
|
||||||
|
const incomingBody = urlParams.get('body');
|
||||||
|
const incomingCustomer = urlParams.get('customer');
|
||||||
|
const incomingSubject = urlParams.get('subject');
|
||||||
|
|
||||||
|
if (incomingBody || incomingCustomer || incomingSubject) {
|
||||||
|
let foundCustomer = null;
|
||||||
|
if (incomingCustomer) {
|
||||||
|
try {
|
||||||
|
const results = await App.api(`/api/customer-users/search?q=${encodeURIComponent(incomingCustomer)}`);
|
||||||
|
if (results && results.length > 0) {
|
||||||
|
foundCustomer = results[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to search customer for Teams integration:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialRowData = {
|
||||||
|
state_id: '1',
|
||||||
|
title: 'Segnalazione da Teams',
|
||||||
|
queue_name: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].name : '',
|
||||||
|
queue_id: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].id : '',
|
||||||
|
type_id: '',
|
||||||
|
owner_name: '',
|
||||||
|
owner_id: '',
|
||||||
|
responsible_name: '',
|
||||||
|
responsible_id: '',
|
||||||
|
customer_search: foundCustomer ? `${foundCustomer.first_name} ${foundCustomer.last_name}` : (incomingCustomer || ''),
|
||||||
|
customer_id: foundCustomer ? foundCustomer.customer_id : '',
|
||||||
|
customer_user_id: foundCustomer ? foundCustomer.login : (incomingCustomer || ''),
|
||||||
|
subject: incomingSubject || 'Messaggio da Microsoft Teams',
|
||||||
|
body: incomingBody || '',
|
||||||
|
priority_id: '3',
|
||||||
|
company_display: foundCustomer ? foundCustomer.customer_id : '',
|
||||||
|
isExpanded: true,
|
||||||
|
statusDotClass: '',
|
||||||
|
statusDotTitle: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.savedRows) this.savedRows = [];
|
||||||
|
this.savedRows.unshift(initialRowData);
|
||||||
|
|
||||||
|
// Clean query parameters from URL to prevent duplicate inserts on reload
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash.split('?')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rowCount = 0;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card" style="padding: var(--space-md); overflow: visible;">
|
||||||
|
<div class="card-title" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md);">
|
||||||
|
<span>Apertura Massiva Ticket</span>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-tertiary); font-weight:normal; text-transform:none;">
|
||||||
|
Usa il tasto <kbd style="background:rgba(255,255,255,0.1); padding:2px 6px; border-radius:3px;">Tab</kbd> per spostarti e creare nuove righe in automatico.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bulk-grid-wrapper">
|
||||||
|
<table class="bulk-grid-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40px; text-align:center;">#</th>
|
||||||
|
<th style="width:120px;">Stato *</th>
|
||||||
|
<th style="width:200px;">Titolo *</th>
|
||||||
|
<th style="width:540px;">Coda *</th>
|
||||||
|
<th style="width:110px;">Tipo</th>
|
||||||
|
<th style="width:260px;">Owner</th>
|
||||||
|
<th style="width:260px;">Responsabile</th>
|
||||||
|
<th style="width:300px;">Cliente *</th>
|
||||||
|
<th style="width:200px;">Oggetto</th>
|
||||||
|
<th style="width:200px;">Messaggio</th>
|
||||||
|
<th style="width:90px; text-align:center;">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="bulk-rows-container">
|
||||||
|
<!-- Rows will be added dynamically here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bulk-actions-container">
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="bulk-add-row-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Aggiungi riga
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:var(--space-md); align-items:center;">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="window.location.hash='#/tickets'">Annulla</button>
|
||||||
|
<button class="btn btn-primary" id="bulk-submit-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;">
|
||||||
|
<path d="M5 12l5 5L20 7"/>
|
||||||
|
</svg>
|
||||||
|
Conferma e Invia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.getElementById('bulk-add-row-btn').addEventListener('click', () => this.addRow());
|
||||||
|
document.getElementById('bulk-submit-btn').addEventListener('click', () => this.submitBulk());
|
||||||
|
|
||||||
|
// Add first row or restore saved rows
|
||||||
|
if (this.savedRows && this.savedRows.length > 0) {
|
||||||
|
this.savedRows.forEach(rowData => this.addRow(rowData));
|
||||||
|
} else {
|
||||||
|
this.addRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error rendering bulk view:', err);
|
||||||
|
container.innerHTML = `<div class="error-screen"><p>Errore caricamento: ${err.message}</p></div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addRow(savedData = null) {
|
||||||
|
this.rowCount++;
|
||||||
|
const id = this.rowCount;
|
||||||
|
const container = document.getElementById('bulk-rows-container');
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.id = `bulk-row-${id}`;
|
||||||
|
tr.className = 'bulk-row';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td style="text-align:center; font-weight:600; color:var(--text-tertiary);">
|
||||||
|
<span class="row-status-dot" id="status-dot-${id}"></span><span class="row-num">${id}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select class="bulk-select" id="bulk-state-${id}">
|
||||||
|
${(App.lookups.states || []).map(s => {
|
||||||
|
const sel = s.type_name === 'new' ? 'selected' : '';
|
||||||
|
return `<option value="${s.id}" ${sel}>${s.name}</option>`;
|
||||||
|
}).join('')}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="bulk-input" id="bulk-title-${id}" placeholder="Titolo ticket" autocomplete="new-password" required />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="bulk-input" id="bulk-queue-search-${id}" placeholder="Coda..." autocomplete="new-password" />
|
||||||
|
<input type="hidden" id="bulk-queue-${id}" />
|
||||||
|
<div id="bulk-queue-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select class="bulk-select" id="bulk-type-${id}">
|
||||||
|
<option value="">—</option>
|
||||||
|
${(App.lookups.types || []).map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="bulk-input" id="bulk-owner-search-${id}" placeholder="Cerca owner..." autocomplete="new-password" />
|
||||||
|
<input type="hidden" id="bulk-owner-${id}" />
|
||||||
|
<div id="bulk-owner-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="bulk-input" id="bulk-responsible-search-${id}" placeholder="Cerca responsabile..." autocomplete="new-password" />
|
||||||
|
<input type="hidden" id="bulk-responsible-${id}" />
|
||||||
|
<div id="bulk-responsible-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="bulk-input" id="bulk-customer-search-${id}" placeholder="Nome, email, login..." autocomplete="new-password" required />
|
||||||
|
<input type="hidden" id="bulk-customer-${id}" />
|
||||||
|
<input type="hidden" id="bulk-customer-user-id-${id}" />
|
||||||
|
<div id="bulk-customer-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="bulk-input" id="bulk-subject-${id}" placeholder="Oggetto nota" autocomplete="new-password" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="bulk-textarea-container">
|
||||||
|
<textarea class="bulk-textarea" id="bulk-body-${id}" placeholder="Scrivi nota..."></textarea>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="bulk-expand-btn-${id}" title="Opzioni avanzate">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger btn-xs" id="bulk-delete-btn-${id}" title="Rimuovi riga">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||||
|
<path d="M3 6h18M19 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"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Collapsible advanced row
|
||||||
|
const trExp = document.createElement('tr');
|
||||||
|
trExp.id = `bulk-row-expanded-${id}`;
|
||||||
|
trExp.className = 'bulk-row-expanded';
|
||||||
|
trExp.style.display = 'none';
|
||||||
|
trExp.innerHTML = `
|
||||||
|
<td colspan="11">
|
||||||
|
<div class="bulk-row-expanded-content">
|
||||||
|
<div class="form-group" style="margin-bottom:0;">
|
||||||
|
<label class="form-label" style="font-size:0.75rem;">Priorità</label>
|
||||||
|
<select class="form-select" id="bulk-priority-${id}" style="height:32px; font-size:0.8rem; padding: 4px 8px; margin:0;">
|
||||||
|
${(App.lookups.priorities || []).map(p => {
|
||||||
|
const sel = p.id === 3 ? 'selected' : '';
|
||||||
|
return `<option value="${p.id}" ${sel}>${p.name}</option>`;
|
||||||
|
}).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0;">
|
||||||
|
<label class="form-label" style="font-size:0.75rem;">Azienda Cliente (Società)</label>
|
||||||
|
<input type="text" class="form-input" id="bulk-company-display-${id}" readonly disabled placeholder="Rilevata dal cliente" style="height:32px; font-size:0.8rem; background:rgba(255,255,255,0.05); margin:0;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(tr);
|
||||||
|
container.appendChild(trExp);
|
||||||
|
|
||||||
|
// Populate with savedData if available, otherwise set default values
|
||||||
|
if (savedData) {
|
||||||
|
tr.querySelector(`#bulk-state-${id}`).value = savedData.state_id;
|
||||||
|
tr.querySelector(`#bulk-title-${id}`).value = savedData.title;
|
||||||
|
tr.querySelector(`#bulk-queue-search-${id}`).value = savedData.queue_name;
|
||||||
|
tr.querySelector(`#bulk-queue-${id}`).value = savedData.queue_id;
|
||||||
|
tr.querySelector(`#bulk-type-${id}`).value = savedData.type_id;
|
||||||
|
tr.querySelector(`#bulk-owner-search-${id}`).value = savedData.owner_name;
|
||||||
|
tr.querySelector(`#bulk-owner-${id}`).value = savedData.owner_id;
|
||||||
|
tr.querySelector(`#bulk-responsible-search-${id}`).value = savedData.responsible_name;
|
||||||
|
tr.querySelector(`#bulk-responsible-${id}`).value = savedData.responsible_id;
|
||||||
|
tr.querySelector(`#bulk-customer-search-${id}`).value = savedData.customer_search;
|
||||||
|
tr.querySelector(`#bulk-customer-${id}`).value = savedData.customer_id;
|
||||||
|
tr.querySelector(`#bulk-customer-user-id-${id}`).value = savedData.customer_user_id;
|
||||||
|
tr.querySelector(`#bulk-subject-${id}`).value = savedData.subject;
|
||||||
|
tr.querySelector(`#bulk-body-${id}`).value = savedData.body;
|
||||||
|
trExp.querySelector(`#bulk-priority-${id}`).value = savedData.priority_id;
|
||||||
|
trExp.querySelector(`#bulk-company-display-${id}`).value = savedData.company_display;
|
||||||
|
|
||||||
|
if (savedData.isExpanded) {
|
||||||
|
trExp.style.display = 'table-row';
|
||||||
|
const btn = tr.querySelector(`#bulk-expand-btn-${id}`);
|
||||||
|
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M5 12h14"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = tr.querySelector(`#status-dot-${id}`);
|
||||||
|
if (savedData.statusDotClass) dot.className = savedData.statusDotClass;
|
||||||
|
if (savedData.statusDotTitle) dot.title = savedData.statusDotTitle;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Default queue pre-population
|
||||||
|
const defaultQueueInput = tr.querySelector(`#bulk-queue-search-${id}`);
|
||||||
|
const defaultQueueHidden = tr.querySelector(`#bulk-queue-${id}`);
|
||||||
|
if (App.lookups.queues && App.lookups.queues.length > 0) {
|
||||||
|
defaultQueueInput.value = App.lookups.queues[0].name;
|
||||||
|
defaultQueueHidden.value = App.lookups.queues[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default owner and responsible (Active Agent)
|
||||||
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(activeAgentId));
|
||||||
|
if (activeAgent) {
|
||||||
|
const agentName = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||||
|
tr.querySelector(`#bulk-owner-search-${id}`).value = agentName;
|
||||||
|
tr.querySelector(`#bulk-owner-${id}`).value = activeAgent.id;
|
||||||
|
tr.querySelector(`#bulk-responsible-search-${id}`).value = agentName;
|
||||||
|
tr.querySelector(`#bulk-responsible-${id}`).value = activeAgent.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle expand button
|
||||||
|
tr.querySelector(`#bulk-expand-btn-${id}`).addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const isHidden = trExp.style.display === 'none';
|
||||||
|
trExp.style.display = isHidden ? 'table-row' : 'none';
|
||||||
|
const btn = tr.querySelector(`#bulk-expand-btn-${id}`);
|
||||||
|
btn.innerHTML = isHidden
|
||||||
|
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M5 12h14"/></svg>`
|
||||||
|
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M12 5v14M5 12h14"/></svg>`;
|
||||||
|
this.saveState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
tr.querySelector(`#bulk-delete-btn-${id}`).addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
tr.remove();
|
||||||
|
trExp.remove();
|
||||||
|
this.reorderRows();
|
||||||
|
this.saveState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup autocompletes
|
||||||
|
this.setupAutocomplete(id, 'queue', `bulk-queue-search-${id}`, `bulk-queue-${id}`, `bulk-queue-suggestions-${id}`);
|
||||||
|
this.setupAutocomplete(id, 'owner', `bulk-owner-search-${id}`, `bulk-owner-${id}`, `bulk-owner-suggestions-${id}`);
|
||||||
|
this.setupAutocomplete(id, 'responsible', `bulk-responsible-search-${id}`, `bulk-responsible-${id}`, `bulk-responsible-suggestions-${id}`);
|
||||||
|
this.setupAutocomplete(id, 'customer', `bulk-customer-search-${id}`, `bulk-customer-${id}`, `bulk-customer-suggestions-${id}`);
|
||||||
|
|
||||||
|
// Setup Tab key navigation on the last field (body textarea)
|
||||||
|
const textarea = tr.querySelector(`#bulk-body-${id}`);
|
||||||
|
textarea.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
// Check if this is the last visible bulk-row textarea
|
||||||
|
const activeRows = container.querySelectorAll('.bulk-row');
|
||||||
|
const lastRow = activeRows[activeRows.length - 1];
|
||||||
|
if (tr === lastRow) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addRow();
|
||||||
|
// Focus the next row's first input (Title)
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextId = this.rowCount;
|
||||||
|
document.getElementById(`bulk-title-${nextId}`)?.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll table to make inputs and textareas fully visible when focused
|
||||||
|
tr.querySelectorAll('input, select, textarea').forEach(input => {
|
||||||
|
input.addEventListener('focus', () => {
|
||||||
|
const wrapper = document.querySelector('.bulk-grid-wrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const rect = input.getBoundingClientRect();
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect();
|
||||||
|
if (rect.right > wrapperRect.right) {
|
||||||
|
wrapper.scrollBy({ left: rect.right - wrapperRect.right + 40, behavior: 'smooth' });
|
||||||
|
} else if (rect.left < wrapperRect.left) {
|
||||||
|
wrapper.scrollBy({ left: rect.left - wrapperRect.left - 40, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
if (rect.bottom > wrapperRect.bottom) {
|
||||||
|
wrapper.scrollBy({ top: rect.bottom - wrapperRect.bottom + 40, behavior: 'smooth' });
|
||||||
|
} else if (rect.top < wrapperRect.top) {
|
||||||
|
wrapper.scrollBy({ top: rect.top - wrapperRect.top - 40, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save state on any input change
|
||||||
|
tr.querySelectorAll('input, select, textarea').forEach(input => {
|
||||||
|
input.addEventListener('input', () => this.saveState());
|
||||||
|
input.addEventListener('change', () => this.saveState());
|
||||||
|
});
|
||||||
|
trExp.querySelectorAll('input, select, textarea').forEach(input => {
|
||||||
|
input.addEventListener('input', () => this.saveState());
|
||||||
|
input.addEventListener('change', () => this.saveState());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.saveState();
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderRows() {
|
||||||
|
const container = document.getElementById('bulk-rows-container');
|
||||||
|
const rows = container.querySelectorAll('.bulk-row');
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const numSpan = row.querySelector('.row-num');
|
||||||
|
if (numSpan) numSpan.textContent = index + 1;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveState() {
|
||||||
|
const container = document.getElementById('bulk-rows-container');
|
||||||
|
if (!container) return;
|
||||||
|
const rows = container.querySelectorAll('.bulk-row');
|
||||||
|
const data = [];
|
||||||
|
rows.forEach(row => {
|
||||||
|
const id = row.id.replace('bulk-row-', '');
|
||||||
|
|
||||||
|
const state_id = document.getElementById(`bulk-state-${id}`).value;
|
||||||
|
const title = document.getElementById(`bulk-title-${id}`).value;
|
||||||
|
const queue_name = document.getElementById(`bulk-queue-search-${id}`).value;
|
||||||
|
const queue_id = document.getElementById(`bulk-queue-${id}`).value;
|
||||||
|
const type_id = document.getElementById(`bulk-type-${id}`).value;
|
||||||
|
const owner_name = document.getElementById(`bulk-owner-search-${id}`).value;
|
||||||
|
const owner_id = document.getElementById(`bulk-owner-${id}`).value;
|
||||||
|
const responsible_name = document.getElementById(`bulk-responsible-search-${id}`).value;
|
||||||
|
const responsible_id = document.getElementById(`bulk-responsible-${id}`).value;
|
||||||
|
const customer_search = document.getElementById(`bulk-customer-search-${id}`).value;
|
||||||
|
const customer_id = document.getElementById(`bulk-customer-${id}`).value;
|
||||||
|
const customer_user_id = document.getElementById(`bulk-customer-user-id-${id}`).value;
|
||||||
|
const subject = document.getElementById(`bulk-subject-${id}`).value;
|
||||||
|
const body = document.getElementById(`bulk-body-${id}`).value;
|
||||||
|
|
||||||
|
const priority_id = document.getElementById(`bulk-priority-${id}`).value;
|
||||||
|
const company_display = document.getElementById(`bulk-company-display-${id}`).value;
|
||||||
|
|
||||||
|
const trExp = document.getElementById(`bulk-row-expanded-${id}`);
|
||||||
|
const isExpanded = trExp ? trExp.style.display !== 'none' : false;
|
||||||
|
const statusDot = document.getElementById(`status-dot-${id}`);
|
||||||
|
const statusDotClass = statusDot ? statusDot.className : '';
|
||||||
|
const statusDotTitle = statusDot ? statusDot.title : '';
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
state_id,
|
||||||
|
title,
|
||||||
|
queue_name,
|
||||||
|
queue_id,
|
||||||
|
type_id,
|
||||||
|
owner_name,
|
||||||
|
owner_id,
|
||||||
|
responsible_name,
|
||||||
|
responsible_id,
|
||||||
|
customer_search,
|
||||||
|
customer_id,
|
||||||
|
customer_user_id,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
priority_id,
|
||||||
|
company_display,
|
||||||
|
isExpanded,
|
||||||
|
statusDotClass,
|
||||||
|
statusDotTitle
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.savedRows = data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setupAutocomplete(rowId, type, searchInputId, hiddenInputId, suggestionsDivId) {
|
||||||
|
const input = document.getElementById(searchInputId);
|
||||||
|
const hidden = document.getElementById(hiddenInputId);
|
||||||
|
const suggestions = document.getElementById(suggestionsDivId);
|
||||||
|
if (!input || !suggestions) return;
|
||||||
|
|
||||||
|
let debounce;
|
||||||
|
|
||||||
|
const adjustSuggestionsScroll = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const wrapper = document.querySelector('.bulk-grid-wrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
const rect = suggestions.getBoundingClientRect();
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect();
|
||||||
|
if (rect.right > wrapperRect.right) {
|
||||||
|
wrapper.scrollBy({ left: rect.right - wrapperRect.right + 40, behavior: 'smooth' });
|
||||||
|
} else if (rect.left < wrapperRect.left) {
|
||||||
|
wrapper.scrollBy({ left: rect.left - wrapperRect.left - 40, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
if (rect.bottom > wrapperRect.bottom) {
|
||||||
|
wrapper.scrollBy({ top: rect.bottom - wrapperRect.bottom + 40, behavior: 'smooth' });
|
||||||
|
} else if (rect.top < wrapperRect.top) {
|
||||||
|
wrapper.scrollBy({ top: rect.top - wrapperRect.top - 40, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResults = (results) => {
|
||||||
|
if (results.length === 0) {
|
||||||
|
suggestions.innerHTML = '<div class="bulk-suggestions-item" style="color:var(--text-muted); cursor:default;">Nessun risultato</div>';
|
||||||
|
suggestions.style.display = 'block';
|
||||||
|
adjustSuggestionsScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.innerHTML = results.map(r => {
|
||||||
|
if (type === 'queue') {
|
||||||
|
return `<div class="bulk-suggestions-item" data-id="${r.id}" data-name="${App.escapeHtml(r.name)}">${App.escapeHtml(r.name)}</div>`;
|
||||||
|
} else if (type === 'owner' || type === 'responsible') {
|
||||||
|
return `<div class="bulk-suggestions-item" data-id="${r.id}" data-name="${App.escapeHtml(r.first_name + ' ' + r.last_name)}"><strong>${App.escapeHtml(r.first_name)} ${App.escapeHtml(r.last_name)}</strong> <span style="font-size:0.75rem; color:var(--text-muted);">(${r.login})</span></div>`;
|
||||||
|
} else if (type === 'customer') {
|
||||||
|
return `<div class="bulk-suggestions-item" data-login="${App.escapeHtml(r.login)}" data-customer-id="${App.escapeHtml(r.customer_id || '')}" data-name="${App.escapeHtml(r.first_name + ' ' + r.last_name)}"><strong>${App.escapeHtml(r.first_name)} ${App.escapeHtml(r.last_name)}</strong> <span style="font-size:0.75rem; color:var(--text-muted);">(${r.login} | ${r.customer_id || '—'})</span></div>`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
suggestions.style.display = 'block';
|
||||||
|
adjustSuggestionsScroll();
|
||||||
|
|
||||||
|
// Bind clicks
|
||||||
|
suggestions.querySelectorAll('.bulk-suggestions-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
if (type === 'queue' || type === 'owner' || type === 'responsible') {
|
||||||
|
input.value = item.dataset.name;
|
||||||
|
hidden.value = item.dataset.id;
|
||||||
|
} else if (type === 'customer') {
|
||||||
|
input.value = item.dataset.name;
|
||||||
|
hidden.value = item.dataset.customerId || '';
|
||||||
|
document.getElementById(`bulk-customer-user-id-${rowId}`).value = item.dataset.login;
|
||||||
|
document.getElementById(`bulk-company-display-${rowId}`).value = item.dataset.customerId || '';
|
||||||
|
}
|
||||||
|
suggestions.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAllLocalOrQuery = async () => {
|
||||||
|
if (type === 'queue') {
|
||||||
|
renderResults(App.lookups.queues || []);
|
||||||
|
} else if (type === 'owner' || type === 'responsible') {
|
||||||
|
renderResults(App.lookups.users || []);
|
||||||
|
} else if (type === 'customer') {
|
||||||
|
try {
|
||||||
|
const results = await App.api(`/api/customer-users/search?q=`);
|
||||||
|
renderResults(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
const q = input.value.trim();
|
||||||
|
|
||||||
|
// Clear hidden values if user deletes text
|
||||||
|
if (q.length === 0) {
|
||||||
|
hidden.value = '';
|
||||||
|
if (type === 'customer') {
|
||||||
|
document.getElementById(`bulk-customer-user-id-${rowId}`).value = '';
|
||||||
|
document.getElementById(`bulk-company-display-${rowId}`).value = '';
|
||||||
|
}
|
||||||
|
showAllLocalOrQuery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'queue' && type !== 'owner' && type !== 'responsible' && type !== 'customer' && q.length < 2) {
|
||||||
|
suggestions.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local filtering for fast performance on loaded lookups
|
||||||
|
if (type === 'queue' || type === 'owner' || type === 'responsible') {
|
||||||
|
const lowerQ = q.toLowerCase();
|
||||||
|
let filtered = [];
|
||||||
|
if (type === 'queue') {
|
||||||
|
filtered = (App.lookups.queues || []).filter(r => r.name.toLowerCase().includes(lowerQ));
|
||||||
|
} else {
|
||||||
|
filtered = (App.lookups.users || []).filter(r =>
|
||||||
|
r.first_name.toLowerCase().includes(lowerQ) ||
|
||||||
|
r.last_name.toLowerCase().includes(lowerQ) ||
|
||||||
|
r.login.toLowerCase().includes(lowerQ)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
renderResults(filtered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API search for customer users
|
||||||
|
debounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const results = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
renderResults(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close suggestions clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target !== input && e.target !== suggestions) {
|
||||||
|
suggestions.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close suggestions on blur (tab navigation out of input)
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
suggestions.style.display = 'none';
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empty input and show list on focus/click
|
||||||
|
input.addEventListener('focus', () => {
|
||||||
|
if (type === 'queue' || type === 'owner' || type === 'responsible' || type === 'customer') {
|
||||||
|
input.value = '';
|
||||||
|
hidden.value = '';
|
||||||
|
if (type === 'customer') {
|
||||||
|
document.getElementById(`bulk-customer-user-id-${rowId}`).value = '';
|
||||||
|
document.getElementById(`bulk-company-display-${rowId}`).value = '';
|
||||||
|
}
|
||||||
|
showAllLocalOrQuery();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitBulk() {
|
||||||
|
if (this.activeRequests) return;
|
||||||
|
|
||||||
|
const container = document.getElementById('bulk-rows-container');
|
||||||
|
const rows = container.querySelectorAll('.bulk-row');
|
||||||
|
if (rows.length === 0) {
|
||||||
|
Toast.warning('Nessun ticket da creare');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation pass
|
||||||
|
const ticketsToSend = [];
|
||||||
|
let hasValidationError = false;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const id = row.id.replace('bulk-row-', '');
|
||||||
|
const state_id = document.getElementById(`bulk-state-${id}`).value;
|
||||||
|
const title = document.getElementById(`bulk-title-${id}`).value.trim();
|
||||||
|
const queue_id = document.getElementById(`bulk-queue-${id}`).value;
|
||||||
|
const queue_name = document.getElementById(`bulk-queue-search-${id}`).value;
|
||||||
|
const type_id = document.getElementById(`bulk-type-${id}`).value;
|
||||||
|
const ownerId = document.getElementById(`bulk-owner-${id}`).value;
|
||||||
|
const responsibleId = document.getElementById(`bulk-responsible-${id}`).value;
|
||||||
|
const customerId = document.getElementById(`bulk-customer-${id}`).value;
|
||||||
|
const customerUserId = document.getElementById(`bulk-customer-user-id-${id}`).value;
|
||||||
|
const customerSearch = document.getElementById(`bulk-customer-search-${id}`).value;
|
||||||
|
const subject = document.getElementById(`bulk-subject-${id}`).value.trim();
|
||||||
|
const body = document.getElementById(`bulk-body-${id}`).value.trim();
|
||||||
|
const priority_id = document.getElementById(`bulk-priority-${id}`).value;
|
||||||
|
|
||||||
|
// Reset status indicators
|
||||||
|
const dot = document.getElementById(`status-dot-${id}`);
|
||||||
|
dot.className = 'row-status-dot';
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
Toast.warning(`Titolo obbligatorio alla riga ${id}`);
|
||||||
|
document.getElementById(`bulk-title-${id}`).focus();
|
||||||
|
hasValidationError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!queue_id) {
|
||||||
|
Toast.warning(`Seleziona una coda valida alla riga ${id}`);
|
||||||
|
document.getElementById(`bulk-queue-search-${id}`).focus();
|
||||||
|
hasValidationError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!customerUserId && !customerId) {
|
||||||
|
Toast.warning(`Seleziona un Utente Cliente valido alla riga ${id}`);
|
||||||
|
document.getElementById(`bulk-customer-search-${id}`).focus();
|
||||||
|
hasValidationError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ticketsToSend.push({
|
||||||
|
id,
|
||||||
|
payload: {
|
||||||
|
title,
|
||||||
|
queue_id: parseInt(queue_id),
|
||||||
|
state_id: parseInt(state_id),
|
||||||
|
priority_id: parseInt(priority_id),
|
||||||
|
user_id: ownerId ? parseInt(ownerId) : undefined,
|
||||||
|
responsible_user_id: responsibleId ? parseInt(responsibleId) : undefined,
|
||||||
|
type_id: type_id ? parseInt(type_id) : undefined,
|
||||||
|
customer_id: customerId || undefined,
|
||||||
|
customer_user_id: customerUserId || undefined,
|
||||||
|
subject: subject || undefined,
|
||||||
|
body: body || undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasValidationError) return;
|
||||||
|
|
||||||
|
// Send tickets one by one
|
||||||
|
this.activeRequests = true;
|
||||||
|
const submitBtn = document.getElementById('bulk-submit-btn');
|
||||||
|
const origBtnHtml = submitBtn.innerHTML;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
Toast.info(`Inizio invio di ${ticketsToSend.length} ticket...`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const ticket of ticketsToSend) {
|
||||||
|
const dot = document.getElementById(`status-dot-${ticket.id}`);
|
||||||
|
dot.className = 'row-status-dot pending';
|
||||||
|
|
||||||
|
try {
|
||||||
|
submitBtn.innerHTML = `<div class="spinner" style="width:16px;height:16px;border-width:2px;"></div> Invio riga ${ticket.id}...`;
|
||||||
|
|
||||||
|
const result = await App.api('/api/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(ticket.payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
dot.className = 'row-status-dot success';
|
||||||
|
dot.title = `Successo: Ticket #${result.tn}`;
|
||||||
|
successCount++;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error creating bulk ticket row ${ticket.id}:`, err);
|
||||||
|
dot.className = 'row-status-dot error';
|
||||||
|
dot.title = `Errore: ${err.message}`;
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeRequests = false;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = origBtnHtml;
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
Toast.success(`Tutti i ${successCount} ticket sono stati creati con successo!`);
|
||||||
|
this.savedRows = [];
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.hash = '#/tickets';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
this.saveState();
|
||||||
|
Toast.warning(`Invio completato: ${successCount} creati, ${failCount} falliti. Controlla i pallini colorati per i dettagli.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
+155
-45
@@ -3,6 +3,32 @@
|
|||||||
* Minimal, fast form for creating new tickets.
|
* Minimal, fast form for creating new tickets.
|
||||||
*/
|
*/
|
||||||
const TicketCreateView = {
|
const TicketCreateView = {
|
||||||
|
savedState: null,
|
||||||
|
|
||||||
|
saveState() {
|
||||||
|
const stateEl = document.getElementById('create-state');
|
||||||
|
if (!stateEl) return;
|
||||||
|
this.savedState = {
|
||||||
|
state_id: stateEl.value,
|
||||||
|
title: document.getElementById('create-title').value,
|
||||||
|
queue_name: document.getElementById('create-queue-search').value,
|
||||||
|
queue_id: document.getElementById('create-queue').value,
|
||||||
|
type_id: document.getElementById('create-type').value,
|
||||||
|
owner_name: document.getElementById('create-owner-search').value,
|
||||||
|
owner_id: document.getElementById('create-owner').value,
|
||||||
|
responsible_name: document.getElementById('create-responsible-search').value,
|
||||||
|
responsible_id: document.getElementById('create-responsible').value,
|
||||||
|
user_search: document.getElementById('create-user-search').value,
|
||||||
|
customer_user_id: document.getElementById('create-customer-user-id').value,
|
||||||
|
customer_id: document.getElementById('create-customer-id').value,
|
||||||
|
company_search: document.getElementById('create-company-search').value,
|
||||||
|
priority_id: document.getElementById('create-priority').value,
|
||||||
|
subject: document.getElementById('create-subject').value,
|
||||||
|
body: document.getElementById('create-body').value,
|
||||||
|
isAdvancedVisible: document.getElementById('advanced-options').style.display !== 'none'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento form...</p></div>';
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento form...</p></div>';
|
||||||
@@ -10,6 +36,57 @@ const TicketCreateView = {
|
|||||||
try {
|
try {
|
||||||
await App.ensureLookups();
|
await App.ensureLookups();
|
||||||
|
|
||||||
|
// Check if we have incoming query parameters (e.g. from Teams integration)
|
||||||
|
const hashParts = window.location.hash.split('?');
|
||||||
|
const searchStr = hashParts.length > 1 ? hashParts[1] : window.location.search;
|
||||||
|
const urlParams = new URLSearchParams(searchStr);
|
||||||
|
const incomingBody = urlParams.get('body');
|
||||||
|
const incomingCustomer = urlParams.get('customer');
|
||||||
|
const incomingSubject = urlParams.get('subject');
|
||||||
|
|
||||||
|
if (incomingBody || incomingCustomer || incomingSubject) {
|
||||||
|
let foundCustomer = null;
|
||||||
|
if (incomingCustomer) {
|
||||||
|
try {
|
||||||
|
const results = await App.api(`/api/customer-users/search?q=${encodeURIComponent(incomingCustomer)}`);
|
||||||
|
if (results && results.length > 0) {
|
||||||
|
foundCustomer = results[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to search customer for Teams integration:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepopulate default active agent details
|
||||||
|
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||||
|
const agentName = activeAgent ? `${activeAgent.first_name} ${activeAgent.last_name}` : '';
|
||||||
|
const agentId = activeAgent ? activeAgent.id : '';
|
||||||
|
|
||||||
|
this.savedState = {
|
||||||
|
state_id: '1', // default state
|
||||||
|
title: 'Segnalazione da Teams',
|
||||||
|
queue_name: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].name : '',
|
||||||
|
queue_id: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].id : '',
|
||||||
|
type_id: '',
|
||||||
|
owner_name: agentName,
|
||||||
|
owner_id: agentId,
|
||||||
|
responsible_name: agentName,
|
||||||
|
responsible_id: agentId,
|
||||||
|
user_search: foundCustomer ? `${foundCustomer.first_name} ${foundCustomer.last_name} <${foundCustomer.email}>` : (incomingCustomer || ''),
|
||||||
|
customer_user_id: foundCustomer ? foundCustomer.login : (incomingCustomer || ''),
|
||||||
|
customer_id: foundCustomer ? foundCustomer.customer_id : '',
|
||||||
|
company_search: foundCustomer ? foundCustomer.customer_id : '',
|
||||||
|
priority_id: '3',
|
||||||
|
subject: incomingSubject || 'Messaggio da Microsoft Teams',
|
||||||
|
body: incomingBody || '',
|
||||||
|
isAdvancedVisible: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean query parameters from URL to prevent duplicate inserts on reload
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash.split('?')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<a class="back-link" onclick="history.back()">
|
<a class="back-link" onclick="history.back()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||||
@@ -158,57 +235,83 @@ const TicketCreateView = {
|
|||||||
const advancedOptions = document.getElementById('advanced-options');
|
const advancedOptions = document.getElementById('advanced-options');
|
||||||
const arrow = document.getElementById('advanced-arrow');
|
const arrow = document.getElementById('advanced-arrow');
|
||||||
|
|
||||||
// Pre-populate default values asynchronously
|
// Restore saved state if exists, otherwise load defaults asynchronously
|
||||||
setTimeout(async () => {
|
if (this.savedState) {
|
||||||
// 1. Owner & Responsible pre-population with active agent
|
document.getElementById('create-state').value = this.savedState.state_id;
|
||||||
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
document.getElementById('create-title').value = this.savedState.title;
|
||||||
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
document.getElementById('create-queue-search').value = this.savedState.queue_name;
|
||||||
if (activeAgent) {
|
document.getElementById('create-queue').value = this.savedState.queue_id;
|
||||||
if (ownerSearchInput && ownerIdInput) {
|
if (document.getElementById('create-type')) {
|
||||||
ownerSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
document.getElementById('create-type').value = this.savedState.type_id;
|
||||||
ownerIdInput.value = activeAgent.id;
|
|
||||||
}
|
|
||||||
if (responsibleSearchInput && responsibleIdInput) {
|
|
||||||
responsibleSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
|
||||||
responsibleIdInput.value = activeAgent.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
document.getElementById('create-owner-search').value = this.savedState.owner_name;
|
||||||
|
document.getElementById('create-owner').value = this.savedState.owner_id;
|
||||||
|
document.getElementById('create-responsible-search').value = this.savedState.responsible_name;
|
||||||
|
document.getElementById('create-responsible').value = this.savedState.responsible_id;
|
||||||
|
document.getElementById('create-user-search').value = this.savedState.user_search;
|
||||||
|
document.getElementById('create-customer-user-id').value = this.savedState.customer_user_id;
|
||||||
|
document.getElementById('create-customer-id').value = this.savedState.customer_id;
|
||||||
|
document.getElementById('create-company-search').value = this.savedState.company_search;
|
||||||
|
document.getElementById('create-priority').value = this.savedState.priority_id;
|
||||||
|
document.getElementById('create-subject').value = this.savedState.subject;
|
||||||
|
document.getElementById('create-body').value = this.savedState.body;
|
||||||
|
|
||||||
// 2. Customer User pre-population with first match
|
if (this.savedState.isAdvancedVisible) {
|
||||||
try {
|
advancedOptions.style.display = 'grid';
|
||||||
const companies = await App.api('/api/customer-companies/search?q=cliente');
|
if (arrow) arrow.style.transform = 'rotate(90deg)';
|
||||||
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
}
|
||||||
const defaultCompany = companies[0];
|
} else {
|
||||||
customerIdInput.value = defaultCompany.customer_id;
|
setTimeout(async () => {
|
||||||
companySearchInput.value = defaultCompany.customer_id;
|
// 1. Owner & Responsible pre-population with active agent
|
||||||
|
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
// Search users for this company
|
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||||
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
|
if (activeAgent) {
|
||||||
if (users.length > 0 && userSearchInput && customerUserIdInput) {
|
if (ownerSearchInput && ownerIdInput) {
|
||||||
const defaultUser = users[0];
|
ownerSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||||
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
|
ownerIdInput.value = activeAgent.id;
|
||||||
customerUserIdInput.value = defaultUser.login;
|
}
|
||||||
} else {
|
if (responsibleSearchInput && responsibleIdInput) {
|
||||||
// Fallback: use company name as customer user ID
|
responsibleSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||||
userSearchInput.value = defaultCompany.name;
|
responsibleIdInput.value = activeAgent.id;
|
||||||
customerUserIdInput.value = defaultCompany.customer_id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Error pre-populating defaults:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Queue pre-population
|
// 2. Customer User pre-population with first match
|
||||||
try {
|
try {
|
||||||
const queues = await App.api('/api/queues/search?q=');
|
const companies = await App.api('/api/customer-companies/search?q=cliente');
|
||||||
if (queues.length > 0 && queueSearchInput && queueIdInput) {
|
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
||||||
queueSearchInput.value = queues[0].name;
|
const defaultCompany = companies[0];
|
||||||
queueIdInput.value = queues[0].id;
|
customerIdInput.value = defaultCompany.customer_id;
|
||||||
|
companySearchInput.value = defaultCompany.customer_id;
|
||||||
|
|
||||||
|
// Search users for this company
|
||||||
|
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
|
||||||
|
if (users.length > 0 && userSearchInput && customerUserIdInput) {
|
||||||
|
const defaultUser = users[0];
|
||||||
|
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
|
||||||
|
customerUserIdInput.value = defaultUser.login;
|
||||||
|
} else {
|
||||||
|
// Fallback: use company name as customer user ID
|
||||||
|
userSearchInput.value = defaultCompany.name;
|
||||||
|
customerUserIdInput.value = defaultCompany.customer_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error pre-populating defaults:', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Error pre-populating queues:', err);
|
// 3. Queue pre-population
|
||||||
}
|
try {
|
||||||
}, 50);
|
const queues = await App.api('/api/queues/search?q=');
|
||||||
|
if (queues.length > 0 && queueSearchInput && queueIdInput) {
|
||||||
|
queueSearchInput.value = queues[0].name;
|
||||||
|
queueIdInput.value = queues[0].id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error pre-populating queues:', err);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
// Collapsible Options Toggle
|
// Collapsible Options Toggle
|
||||||
if (toggleBtn && advancedOptions && arrow) {
|
if (toggleBtn && advancedOptions && arrow) {
|
||||||
@@ -491,6 +594,7 @@ const TicketCreateView = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Toast.success(`Ticket #${result.tn} creato!`);
|
Toast.success(`Ticket #${result.tn} creato!`);
|
||||||
|
this.savedState = null; // Clear cached state on success
|
||||||
|
|
||||||
// Navigate to the new ticket
|
// Navigate to the new ticket
|
||||||
window.location.hash = `#/tickets/${result.id}`;
|
window.location.hash = `#/tickets/${result.id}`;
|
||||||
@@ -506,5 +610,11 @@ const TicketCreateView = {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save state on any input/change in the form
|
||||||
|
document.querySelectorAll('.create-form input, .create-form select, .create-form textarea').forEach(el => {
|
||||||
|
el.addEventListener('input', () => this.saveState());
|
||||||
|
el.addEventListener('change', () => this.saveState());
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const TicketDetailView = {
|
|||||||
await App.ensureLookups();
|
await App.ensureLookups();
|
||||||
const data = await App.api(`/api/tickets/${id}`);
|
const data = await App.api(`/api/tickets/${id}`);
|
||||||
const { ticket, articles } = data;
|
const { ticket, articles } = data;
|
||||||
|
const localISO = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
const totalTime = articles.reduce((sum, a) => {
|
const totalTime = articles.reduce((sum, a) => {
|
||||||
const val = parseFloat(a.time_unit);
|
const val = parseFloat(a.time_unit);
|
||||||
return sum + (isNaN(val) ? 0 : val);
|
return sum + (isNaN(val) ? 0 : val);
|
||||||
@@ -131,12 +137,24 @@ const TicketDetailView = {
|
|||||||
<div class="article-header">
|
<div class="article-header">
|
||||||
<div class="article-sender">
|
<div class="article-sender">
|
||||||
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</span>
|
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</span>
|
||||||
|
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
|
||||||
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
||||||
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||||
${a.time_unit ? `<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ ${parseFloat(a.time_unit)} min</span>` : ''}
|
${a.time_unit ? `<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ ${parseFloat(a.time_unit)} min</span>` : ''}
|
||||||
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
|
||||||
|
<div class="retrodate-container" data-article-id="${a.article_id}">
|
||||||
|
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
||||||
|
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
|
||||||
|
<div class="retrodate-editor" style="display:none;">
|
||||||
|
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
|
||||||
|
<div class="retrodate-actions">
|
||||||
|
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
|
||||||
|
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
|
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
|
||||||
@@ -162,7 +180,19 @@ const TicketDetailView = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">Creato</span>
|
<span class="meta-label">Creato</span>
|
||||||
<span class="meta-value">${App.formatDateTime(ticket.create_time)}</span>
|
<span class="meta-value">
|
||||||
|
<div class="retrodate-container" id="ticket-retrodate-container">
|
||||||
|
<span id="ticket-date-text">${App.formatDateTime(ticket.create_time)}</span>
|
||||||
|
<button class="retrodate-btn-edit" id="btn-edit-ticket-date" title="Retrodata Creazione Ticket">✏️</button>
|
||||||
|
<div class="retrodate-editor" id="ticket-date-editor" style="display:none;">
|
||||||
|
<input type="datetime-local" class="retrodate-input" id="input-ticket-date" value="${localISO(ticket.create_time)}" />
|
||||||
|
<div class="retrodate-actions">
|
||||||
|
<button class="retrodate-btn-action save" id="btn-save-ticket-date" title="Salva">✓</button>
|
||||||
|
<button class="retrodate-btn-action cancel" id="btn-cancel-ticket-date" title="Annulla">✗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">Modificato</span>
|
<span class="meta-label">Modificato</span>
|
||||||
@@ -338,5 +368,94 @@ const TicketDetailView = {
|
|||||||
noteSendBtn.innerHTML = 'Invia Nota';
|
noteSendBtn.innerHTML = 'Invia Nota';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retrodate Ticket Event Listeners
|
||||||
|
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
|
||||||
|
const ticketDateEditor = document.getElementById('ticket-date-editor');
|
||||||
|
const ticketDateText = document.getElementById('ticket-date-text');
|
||||||
|
const btnSaveTicketDate = document.getElementById('btn-save-ticket-date');
|
||||||
|
const btnCancelTicketDate = document.getElementById('btn-cancel-ticket-date');
|
||||||
|
const inputTicketDate = document.getElementById('input-ticket-date');
|
||||||
|
|
||||||
|
if (btnEditTicketDate) {
|
||||||
|
btnEditTicketDate.addEventListener('click', () => {
|
||||||
|
btnEditTicketDate.style.display = 'none';
|
||||||
|
ticketDateText.style.display = 'none';
|
||||||
|
ticketDateEditor.style.display = 'flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnCancelTicketDate.addEventListener('click', () => {
|
||||||
|
ticketDateEditor.style.display = 'none';
|
||||||
|
btnEditTicketDate.style.display = 'inline-flex';
|
||||||
|
ticketDateText.style.display = 'inline';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnSaveTicketDate.addEventListener('click', async () => {
|
||||||
|
const val = inputTicketDate.value;
|
||||||
|
if (!val) {
|
||||||
|
Toast.warning('Scegli una data/ora valida.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
btnSaveTicketDate.disabled = true;
|
||||||
|
await App.api(`/api/tickets/${this.ticketId}/retrodata-ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ create_time: val })
|
||||||
|
});
|
||||||
|
Toast.success('Data creazione ticket aggiornata!');
|
||||||
|
this.render(this.ticketId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore retrodatazione ticket: ' + err.message);
|
||||||
|
btnSaveTicketDate.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrodate Articles Event Listeners
|
||||||
|
document.querySelectorAll('.retrodate-container[data-article-id]').forEach(container => {
|
||||||
|
const articleId = container.dataset.articleId;
|
||||||
|
const btnEdit = container.querySelector('.btn-edit-article-date');
|
||||||
|
const editor = container.querySelector('.retrodate-editor');
|
||||||
|
const timeText = container.querySelector('.article-time');
|
||||||
|
const btnSave = container.querySelector('.btn-save-article-date');
|
||||||
|
const btnCancel = container.querySelector('.btn-cancel-article-date');
|
||||||
|
const input = container.querySelector('.input-article-date');
|
||||||
|
|
||||||
|
if (btnEdit) {
|
||||||
|
btnEdit.addEventListener('click', () => {
|
||||||
|
btnEdit.style.display = 'none';
|
||||||
|
timeText.style.display = 'none';
|
||||||
|
editor.style.display = 'flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnCancel.addEventListener('click', () => {
|
||||||
|
editor.style.display = 'none';
|
||||||
|
btnEdit.style.display = 'inline-flex';
|
||||||
|
timeText.style.display = 'inline';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnSave.addEventListener('click', async () => {
|
||||||
|
const val = input.value;
|
||||||
|
if (!val) {
|
||||||
|
Toast.warning('Scegli una data/ora valida.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
btnSave.disabled = true;
|
||||||
|
await App.api(`/api/tickets/articles/${articleId}/retrodata-article`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ create_time: val })
|
||||||
|
});
|
||||||
|
Toast.success('Data creazione articolo aggiornata!');
|
||||||
|
this.render(this.ticketId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore retrodatazione articolo: ' + err.message);
|
||||||
|
btnSave.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,11 +51,12 @@ const TicketListView = {
|
|||||||
const { total, page, per_page, total_pages } = data;
|
const { total, page, per_page, total_pages } = data;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
${Filters.renderBar(App.lookups)}
|
|
||||||
|
|
||||||
<!-- Batch Actions Bar -->
|
<!-- Batch Actions Bar -->
|
||||||
<div class="batch-bar" id="batch-bar">
|
<div class="batch-bar" id="batch-bar">
|
||||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||||
|
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||||
|
</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">
|
||||||
@@ -81,14 +82,13 @@ const TicketListView = {
|
|||||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${Filters.renderBar(App.lookups)}
|
||||||
|
|
||||||
<!-- Ticket Table -->
|
<!-- Ticket Table -->
|
||||||
<div class="ticket-table-wrapper">
|
<div class="ticket-table-wrapper">
|
||||||
<table class="ticket-table" id="ticket-table">
|
<table class="ticket-table" id="ticket-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="checkbox-cell">
|
|
||||||
<input type="checkbox" id="select-all" title="Seleziona tutti" />
|
|
||||||
</th>
|
|
||||||
<th class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
<th class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
||||||
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
||||||
<th class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
<th class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
||||||
@@ -96,27 +96,22 @@ const TicketListView = {
|
|||||||
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
||||||
<th>Owner</th>
|
<th>Owner</th>
|
||||||
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||||||
<th class="sortable ${this.sortBy === 'change_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="change_time">Modificato</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${tickets.length > 0 ? tickets.map(t => `
|
${tickets.length > 0 ? tickets.map(t => `
|
||||||
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}">
|
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}" style="cursor:pointer;">
|
||||||
<td class="checkbox-cell" onclick="event.stopPropagation()">
|
<td><span class="ticket-tn"><a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a></span></td>
|
||||||
<input type="checkbox" class="ticket-checkbox" value="${t.id}" ${this.selectedIds.has(String(t.id)) ? 'checked' : ''} />
|
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
|
||||||
</td>
|
|
||||||
<td><span class="ticket-tn">${t.tn}</span></td>
|
|
||||||
<td class="ticket-title-cell">${App.escapeHtml(t.title || '(senza titolo)')}</td>
|
|
||||||
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
||||||
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${t.priority_name}</span></td>
|
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
|
||||||
<td><span class="badge badge-queue">${t.queue_name}</span></td>
|
<td><span class="badge badge-queue">${t.queue_name}</span></td>
|
||||||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
||||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.create_time)}</td>
|
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
||||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.change_time)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`).join('') : `
|
`).join('') : `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9">
|
<td colspan="7">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📭</div>
|
<div class="empty-state-icon">📭</div>
|
||||||
<div class="empty-state-text">Nessun ticket trovato</div>
|
<div class="empty-state-text">Nessun ticket trovato</div>
|
||||||
@@ -186,42 +181,22 @@ const TicketListView = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Row click → detail
|
// Row click → Toggle selection
|
||||||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
|
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
|
||||||
row.addEventListener('click', (e) => {
|
row.addEventListener('click', (e) => {
|
||||||
if (e.target.type === 'checkbox' || e.target.closest('.checkbox-cell')) return;
|
// If click was on a link, let the browser perform navigation
|
||||||
window.location.hash = `#/tickets/${row.dataset.ticketId}`;
|
if (e.target.closest('.ticket-tn-link') || e.target.closest('.ticket-title-link')) {
|
||||||
});
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
// Checkbox selection
|
const id = String(row.dataset.ticketId);
|
||||||
const selectAll = document.getElementById('select-all');
|
if (this.selectedIds.has(id)) {
|
||||||
if (selectAll) {
|
this.selectedIds.delete(id);
|
||||||
selectAll.addEventListener('change', (e) => {
|
row.classList.remove('selected');
|
||||||
const checkboxes = document.querySelectorAll('.ticket-checkbox');
|
} else {
|
||||||
checkboxes.forEach(cb => {
|
|
||||||
cb.checked = e.target.checked;
|
|
||||||
const id = cb.value;
|
|
||||||
if (e.target.checked) {
|
|
||||||
this.selectedIds.add(id);
|
|
||||||
} else {
|
|
||||||
this.selectedIds.delete(id);
|
|
||||||
}
|
|
||||||
cb.closest('tr').classList.toggle('selected', e.target.checked);
|
|
||||||
});
|
|
||||||
this.updateBatchBar();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
|
|
||||||
cb.addEventListener('change', (e) => {
|
|
||||||
const id = e.target.value;
|
|
||||||
if (e.target.checked) {
|
|
||||||
this.selectedIds.add(id);
|
this.selectedIds.add(id);
|
||||||
} else {
|
row.classList.add('selected');
|
||||||
this.selectedIds.delete(id);
|
|
||||||
}
|
}
|
||||||
e.target.closest('tr').classList.toggle('selected', e.target.checked);
|
|
||||||
this.updateBatchBar();
|
this.updateBatchBar();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -232,17 +207,27 @@ const TicketListView = {
|
|||||||
batchApply.addEventListener('click', () => this.applyBatch());
|
batchApply.addEventListener('click', () => this.applyBatch());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch select all visible
|
||||||
|
const batchSelectAll = document.getElementById('batch-select-all');
|
||||||
|
if (batchSelectAll) {
|
||||||
|
batchSelectAll.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
|
||||||
|
const id = String(row.dataset.ticketId);
|
||||||
|
this.selectedIds.add(id);
|
||||||
|
row.classList.add('selected');
|
||||||
|
});
|
||||||
|
this.updateBatchBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Batch cancel
|
// Batch cancel
|
||||||
const batchCancel = document.getElementById('batch-cancel');
|
const batchCancel = document.getElementById('batch-cancel');
|
||||||
if (batchCancel) {
|
if (batchCancel) {
|
||||||
batchCancel.addEventListener('click', () => {
|
batchCancel.addEventListener('click', () => {
|
||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
|
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
|
||||||
cb.checked = false;
|
tr.classList.remove('selected');
|
||||||
cb.closest('tr').classList.remove('selected');
|
|
||||||
});
|
});
|
||||||
const selectAll = document.getElementById('select-all');
|
|
||||||
if (selectAll) selectAll.checked = false;
|
|
||||||
this.updateBatchBar();
|
this.updateBatchBar();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -258,13 +243,9 @@ const TicketListView = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateBatchBar() {
|
updateBatchBar() {
|
||||||
const bar = document.getElementById('batch-bar');
|
|
||||||
const count = document.getElementById('batch-count');
|
const count = document.getElementById('batch-count');
|
||||||
if (this.selectedIds.size > 0) {
|
if (count) {
|
||||||
bar.classList.add('visible');
|
|
||||||
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
||||||
} else {
|
|
||||||
bar.classList.remove('visible');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+32
-21
@@ -153,10 +153,7 @@ router.get('/customer-companies/search', async (req, res) => {
|
|||||||
// GET /api/customer-users/search — Search customer users
|
// GET /api/customer-users/search — Search customer users
|
||||||
router.get('/customer-users/search', async (req, res) => {
|
router.get('/customer-users/search', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q, customer_company_id } = req.query;
|
const { q = '', customer_company_id } = req.query;
|
||||||
if (!q) {
|
|
||||||
return res.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try OTRS API first if configured
|
// Try OTRS API first if configured
|
||||||
const OTRS_API_URL = process.env.OTRS_API_URL;
|
const OTRS_API_URL = process.env.OTRS_API_URL;
|
||||||
@@ -164,7 +161,7 @@ router.get('/customer-users/search', async (req, res) => {
|
|||||||
if (OTRS_API_URL && OTRS_API_USER) {
|
if (OTRS_API_URL && OTRS_API_USER) {
|
||||||
try {
|
try {
|
||||||
const searchParams = {
|
const searchParams = {
|
||||||
Search: `*${q}*`,
|
Search: q ? `*${q}*` : '*',
|
||||||
Valid: 1
|
Valid: 1
|
||||||
};
|
};
|
||||||
if (customer_company_id) {
|
if (customer_company_id) {
|
||||||
@@ -217,22 +214,36 @@ router.get('/customer-users/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: local DB query
|
// Fallback: local DB query
|
||||||
const searchTerm = `%${q}%`;
|
let queryText;
|
||||||
let queryText = `
|
let queryParams;
|
||||||
SELECT login, email, first_name, last_name, customer_id
|
if (q) {
|
||||||
FROM customer_user
|
const searchTerm = `%${q}%`;
|
||||||
WHERE valid_id = 1 AND (
|
queryText = `
|
||||||
login ILIKE $1 OR
|
SELECT login, email, first_name, last_name, customer_id
|
||||||
email ILIKE $1 OR
|
FROM customer_user
|
||||||
first_name ILIKE $1 OR
|
WHERE valid_id = 1 AND (
|
||||||
last_name ILIKE $1
|
login ILIKE $1 OR
|
||||||
)
|
email ILIKE $1 OR
|
||||||
`;
|
first_name ILIKE $1 OR
|
||||||
const queryParams = [searchTerm];
|
last_name ILIKE $1
|
||||||
|
)
|
||||||
if (customer_company_id) {
|
`;
|
||||||
queryText += ` AND customer_id = $2`;
|
queryParams = [searchTerm];
|
||||||
queryParams.push(customer_company_id);
|
if (customer_company_id) {
|
||||||
|
queryText += ` AND customer_id = $2`;
|
||||||
|
queryParams.push(customer_company_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queryText = `
|
||||||
|
SELECT login, email, first_name, last_name, customer_id
|
||||||
|
FROM customer_user
|
||||||
|
WHERE valid_id = 1
|
||||||
|
`;
|
||||||
|
queryParams = [];
|
||||||
|
if (customer_company_id) {
|
||||||
|
queryText += ` AND customer_id = $1`;
|
||||||
|
queryParams.push(customer_company_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queryText += ` ORDER BY last_name, first_name LIMIT 20`;
|
queryText += ` ORDER BY last_name, first_name LIMIT 20`;
|
||||||
|
|||||||
+53
-1
@@ -38,7 +38,8 @@ router.get('/', async (req, res) => {
|
|||||||
const {
|
const {
|
||||||
queue_id, state_id, priority_id, user_id, type_id,
|
queue_id, state_id, priority_id, user_id, type_id,
|
||||||
search, sort_by = 'create_time', sort_dir = 'DESC',
|
search, sort_by = 'create_time', sort_dir = 'DESC',
|
||||||
page = 1, per_page = 50
|
page = 1, per_page = 50,
|
||||||
|
date_from, date_to
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
@@ -65,6 +66,14 @@ router.get('/', async (req, res) => {
|
|||||||
conditions.push(`t.type_id = $${paramIdx++}`);
|
conditions.push(`t.type_id = $${paramIdx++}`);
|
||||||
params.push(parseInt(type_id));
|
params.push(parseInt(type_id));
|
||||||
}
|
}
|
||||||
|
if (date_from) {
|
||||||
|
conditions.push(`t.create_time >= $${paramIdx++}`);
|
||||||
|
params.push(new Date(date_from));
|
||||||
|
}
|
||||||
|
if (date_to) {
|
||||||
|
conditions.push(`t.create_time <= $${paramIdx++}`);
|
||||||
|
params.push(new Date(date_to));
|
||||||
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push(`(t.title ILIKE $${paramIdx} OR t.tn ILIKE $${paramIdx})`);
|
conditions.push(`(t.title ILIKE $${paramIdx} OR t.tn ILIKE $${paramIdx})`);
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
@@ -865,4 +874,47 @@ router.patch('/batch/update', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/tickets/:id/retrodata-ticket — Retrodate ticket creation time
|
||||||
|
router.post('/:id/retrodata-ticket', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { create_time } = req.body;
|
||||||
|
if (!create_time) {
|
||||||
|
return res.status(400).json({ error: 'Specificare la data di creazione.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch TN for the ticket
|
||||||
|
const tnResult = await pool.query('SELECT tn FROM ticket WHERE id = $1', [id]);
|
||||||
|
if (tnResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Ticket non trovato.' });
|
||||||
|
}
|
||||||
|
const tn = tnResult.rows[0].tn;
|
||||||
|
|
||||||
|
// Execute stored procedure
|
||||||
|
await pool.query('CALL prretrodataticket($1, $2)', [tn, new Date(create_time)]);
|
||||||
|
res.json({ message: 'Ticket retrodatato correttamente.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Errore in prretrodataticket:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tickets/articles/:articleId/retrodata-article — Retrodate article creation time
|
||||||
|
router.post('/articles/:articleId/retrodata-article', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { articleId } = req.params;
|
||||||
|
const { create_time } = req.body;
|
||||||
|
if (!create_time) {
|
||||||
|
return res.status(400).json({ error: 'Specificare la data di creazione.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute stored procedure
|
||||||
|
await pool.query('CALL prretrodataarticolo($1, $2)', [parseInt(articleId), new Date(create_time)]);
|
||||||
|
res.json({ message: 'Articolo retrodatato correttamente.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Errore in prretrodataarticolo:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user