Aggiunte stored procedure più utili le altre pfff

This commit is contained in:
2026-07-05 18:48:29 +02:00
parent 457c3eacf6
commit 4f41b50d37
10 changed files with 1578 additions and 156 deletions
+367 -23
View File
@@ -91,7 +91,9 @@
}
/* ---- Reset & Base ---- */
*, *::before, *::after {
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
@@ -113,10 +115,23 @@ body {
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-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); }
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-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 {
@@ -274,8 +289,15 @@ body {
}
@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 ---- */
@@ -369,8 +391,15 @@ body {
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ---- Loading ---- */
@@ -394,7 +423,9 @@ body {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
/* ---- Buttons ---- */
@@ -712,7 +743,7 @@ body {
}
.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);
}
@@ -901,24 +932,27 @@ body {
/* ---- Batch Actions Bar ---- */
.batch-bar {
display: none;
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) 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-radius: var(--radius-lg);
animation: slideDown 0.2s ease-out;
}
.batch-bar.visible {
display: flex;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.batch-count {
@@ -1409,13 +1443,27 @@ body {
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(40px) scale(0.95); }
to { opacity: 1; transform: translateX(0) scale(1); }
from {
opacity: 0;
transform: translateX(40px) scale(0.95);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(0) scale(1); }
to { opacity: 0; transform: translateX(40px) scale(0.95); }
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(40px) scale(0.95);
}
}
/* ---- Recent Tickets Table (Dashboard) ---- */
@@ -1525,6 +1573,7 @@ body {
.ticket-detail {
grid-template-columns: 1fr;
}
.ticket-sidebar {
order: -1;
}
@@ -1534,16 +1583,20 @@ body {
:root {
--sidebar-width: 0px;
}
.sidebar {
transform: translateX(-100%);
width: 260px;
}
.sidebar.open {
transform: translateX(0);
}
.search-bar {
width: 200px;
}
.form-grid {
grid-template-columns: 1fr;
}
@@ -1586,3 +1639,294 @@ body {
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);
}
+19
View File
@@ -50,6 +50,17 @@
<span>Nuovo Ticket</span>
</a>
</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>
<div class="sidebar-footer">
@@ -66,7 +77,14 @@
<header class="topbar" id="topbar">
<div class="topbar-left" style="display:flex; align-items:center; gap:var(--space-md);">
<h1 class="page-title" id="page-title">Dashboard</h1>
<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 class="topbar-right">
<div class="search-bar" id="global-search-container">
@@ -105,6 +123,7 @@
<script src="/js/views/ticketList.js"></script>
<script src="/js/views/ticketDetail.js"></script>
<script src="/js/views/ticketCreate.js"></script>
<script src="/js/views/ticketBulk.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
+61 -3
View File
@@ -57,6 +57,12 @@ const App = {
// Init active agent selector
this.initAgentSelector();
// Bind refresh lookups button
const refreshBtn = document.getElementById('refresh-lookups-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => this.refreshLookups());
}
// Initial route
if (!window.location.hash || window.location.hash === '#/') {
window.location.hash = '#/dashboard';
@@ -67,7 +73,8 @@ const App = {
/** Route based on current hash */
route() {
const hash = window.location.hash || '#/dashboard';
const fullHash = window.location.hash || '#/dashboard';
const hash = fullHash.split('?')[0];
const titleEl = document.getElementById('page-title');
// Update active nav link
@@ -90,6 +97,11 @@ const App = {
titleEl.textContent = 'Nuovo Ticket';
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+)$/)) {
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
document.getElementById('nav-tickets')?.classList.add('active');
@@ -124,8 +136,22 @@ const App = {
},
/** Ensure lookup data is loaded (cached) */
async ensureLookups() {
if (this.lookupsLoaded) return;
async ensureLookups(forceRefresh = false) {
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 {
const [queues, states, priorities, users, types] = await Promise.all([
@@ -137,6 +163,7 @@ const App = {
]);
this.lookups = { queues, states, priorities, users, types };
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
this.lookupsLoaded = true;
} catch (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 */
async checkConnection() {
const dot = document.getElementById('connection-status');
+11 -1
View File
@@ -8,6 +8,8 @@ const Filters = {
state_id: '',
priority_id: '',
user_id: '',
date_from: '',
date_to: '',
},
/** Load saved filters from localStorage */
@@ -29,7 +31,7 @@ const Filters = {
/** Reset all filters */
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();
},
@@ -87,6 +89,14 @@ const Filters = {
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
</select>
</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">
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
</div>
+718
View File
@@ -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.`);
}
}
};
+111 -1
View File
@@ -3,6 +3,32 @@
* Minimal, fast form for creating new tickets.
*/
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() {
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento form...</p></div>';
@@ -10,6 +36,57 @@ const TicketCreateView = {
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);
}
}
// 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 = `
<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>
@@ -158,7 +235,32 @@ const TicketCreateView = {
const advancedOptions = document.getElementById('advanced-options');
const arrow = document.getElementById('advanced-arrow');
// Pre-populate default values asynchronously
// Restore saved state if exists, otherwise load defaults asynchronously
if (this.savedState) {
document.getElementById('create-state').value = this.savedState.state_id;
document.getElementById('create-title').value = this.savedState.title;
document.getElementById('create-queue-search').value = this.savedState.queue_name;
document.getElementById('create-queue').value = this.savedState.queue_id;
if (document.getElementById('create-type')) {
document.getElementById('create-type').value = this.savedState.type_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;
if (this.savedState.isAdvancedVisible) {
advancedOptions.style.display = 'grid';
if (arrow) arrow.style.transform = 'rotate(90deg)';
}
} else {
setTimeout(async () => {
// 1. Owner & Responsible pre-population with active agent
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
@@ -209,6 +311,7 @@ const TicketCreateView = {
console.error('Error pre-populating queues:', err);
}
}, 50);
}
// Collapsible Options Toggle
if (toggleBtn && advancedOptions && arrow) {
@@ -491,6 +594,7 @@ const TicketCreateView = {
});
Toast.success(`Ticket #${result.tn} creato!`);
this.savedState = null; // Clear cached state on success
// Navigate to the new ticket
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());
});
},
};
+120 -1
View File
@@ -15,6 +15,12 @@ const TicketDetailView = {
await App.ensureLookups();
const data = await App.api(`/api/tickets/${id}`);
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 val = parseFloat(a.time_unit);
return sum + (isNaN(val) ? 0 : val);
@@ -131,12 +137,24 @@ const TicketDetailView = {
<div class="article-header">
<div class="article-sender">
<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>
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
</div>
<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>` : ''}
<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>
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
@@ -162,7 +180,19 @@ const TicketDetailView = {
</div>
<div class="meta-row">
<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 class="meta-row">
<span class="meta-label">Modificato</span>
@@ -338,5 +368,94 @@ const TicketDetailView = {
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;
}
});
}
});
},
};
+37 -56
View File
@@ -51,11 +51,12 @@ const TicketListView = {
const { total, page, per_page, total_pages } = data;
container.innerHTML = `
${Filters.renderBar(App.lookups)}
<!-- Batch Actions Bar -->
<div class="batch-bar" id="batch-bar">
<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">
<span class="filter-label">Stato</span>
<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>
</div>
${Filters.renderBar(App.lookups)}
<!-- Ticket Table -->
<div class="ticket-table-wrapper">
<table class="ticket-table" id="ticket-table">
<thead>
<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 === '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>
@@ -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>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 === 'change_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="change_time">Modificato</th>
</tr>
</thead>
<tbody>
${tickets.length > 0 ? tickets.map(t => `
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}">
<td class="checkbox-cell" onclick="event.stopPropagation()">
<input type="checkbox" class="ticket-checkbox" value="${t.id}" ${this.selectedIds.has(String(t.id)) ? 'checked' : ''} />
</td>
<td><span class="ticket-tn">${t.tn}</span></td>
<td class="ticket-title-cell">${App.escapeHtml(t.title || '(senza titolo)')}</td>
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}" style="cursor:pointer;">
<td><span class="ticket-tn"><a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a></span></td>
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
<td><span class="badge badge-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 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.formatDate(t.change_time)}</td>
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
</tr>
`).join('') : `
<tr>
<td colspan="9">
<td colspan="7">
<div class="empty-state">
<div class="empty-state-icon">📭</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 => {
row.addEventListener('click', (e) => {
if (e.target.type === 'checkbox' || e.target.closest('.checkbox-cell')) return;
window.location.hash = `#/tickets/${row.dataset.ticketId}`;
});
});
// Checkbox selection
const selectAll = document.getElementById('select-all');
if (selectAll) {
selectAll.addEventListener('change', (e) => {
const checkboxes = document.querySelectorAll('.ticket-checkbox');
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();
});
// If click was on a link, let the browser perform navigation
if (e.target.closest('.ticket-tn-link') || e.target.closest('.ticket-title-link')) {
return;
}
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => {
const id = e.target.value;
if (e.target.checked) {
this.selectedIds.add(id);
} else {
const id = String(row.dataset.ticketId);
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
row.classList.remove('selected');
} else {
this.selectedIds.add(id);
row.classList.add('selected');
}
e.target.closest('tr').classList.toggle('selected', e.target.checked);
this.updateBatchBar();
});
});
@@ -232,17 +207,27 @@ const TicketListView = {
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
const batchCancel = document.getElementById('batch-cancel');
if (batchCancel) {
batchCancel.addEventListener('click', () => {
this.selectedIds.clear();
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
cb.checked = false;
cb.closest('tr').classList.remove('selected');
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
tr.classList.remove('selected');
});
const selectAll = document.getElementById('select-all');
if (selectAll) selectAll.checked = false;
this.updateBatchBar();
});
}
@@ -258,13 +243,9 @@ const TicketListView = {
},
updateBatchBar() {
const bar = document.getElementById('batch-bar');
const count = document.getElementById('batch-count');
if (this.selectedIds.size > 0) {
bar.classList.add('visible');
if (count) {
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
} else {
bar.classList.remove('visible');
}
},
+19 -8
View File
@@ -153,10 +153,7 @@ router.get('/customer-companies/search', async (req, res) => {
// GET /api/customer-users/search — Search customer users
router.get('/customer-users/search', async (req, res) => {
try {
const { q, customer_company_id } = req.query;
if (!q) {
return res.json([]);
}
const { q = '', customer_company_id } = req.query;
// Try OTRS API first if configured
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) {
try {
const searchParams = {
Search: `*${q}*`,
Search: q ? `*${q}*` : '*',
Valid: 1
};
if (customer_company_id) {
@@ -217,8 +214,11 @@ router.get('/customer-users/search', async (req, res) => {
}
// Fallback: local DB query
let queryText;
let queryParams;
if (q) {
const searchTerm = `%${q}%`;
let queryText = `
queryText = `
SELECT login, email, first_name, last_name, customer_id
FROM customer_user
WHERE valid_id = 1 AND (
@@ -228,12 +228,23 @@ router.get('/customer-users/search', async (req, res) => {
last_name ILIKE $1
)
`;
const queryParams = [searchTerm];
queryParams = [searchTerm];
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`;
+53 -1
View File
@@ -38,7 +38,8 @@ router.get('/', async (req, res) => {
const {
queue_id, state_id, priority_id, user_id, type_id,
search, sort_by = 'create_time', sort_dir = 'DESC',
page = 1, per_page = 50
page = 1, per_page = 50,
date_from, date_to
} = req.query;
const conditions = [];
@@ -65,6 +66,14 @@ router.get('/', async (req, res) => {
conditions.push(`t.type_id = $${paramIdx++}`);
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) {
conditions.push(`(t.title ILIKE $${paramIdx} OR t.tn ILIKE $${paramIdx})`);
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;