Files

1107 lines
51 KiB
JavaScript

/**
* Ricerca Spudorata View
* Full-featured text-first ticket search with local in-memory caching and client-side filtering.
*/
const RicercaSpudorataView = {
searchTerm: '',
isRegex: false,
cachedTickets: null,
currentPage: 1,
perPage: 50,
sortBy: 'create_time',
sortDir: 'DESC',
selectedIds: new Set(),
selectedOrder: [],
hasSearched: false,
isFetching: false,
invalidateCache() {
this.cachedTickets = null;
this.hasSearched = false;
},
async render() {
await App.ensureLookups();
Filters.currentMode = 'spudorata';
Filters.load();
const container = document.getElementById('view-container');
if (!container) return;
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
const qParam = urlParams.get('q');
const regexParam = urlParams.get('regex');
if (regexParam !== null) {
this.isRegex = regexParam === '1' || regexParam === 'true';
}
if (qParam && qParam !== this.searchTerm) {
this.renderContainer(container);
this.bindEvents();
const input = document.getElementById('spudorata-search-input');
if (input) input.value = qParam;
this.executeSearch(qParam);
return;
}
// Instant render from memory cache if already searched
if (this.hasSearched && this.cachedTickets) {
this.renderContainer(container);
this.bindEvents();
this.renderLocalResults();
return;
}
this.renderContainer(container);
this.bindEvents();
setTimeout(() => {
const input = document.getElementById('spudorata-search-input');
if (input) {
input.focus();
input.select();
}
}, 50);
},
renderContainer(container) {
container.innerHTML = `
<!-- Hero Text Search Area -->
<div class="ricerca-spudorata-hero" style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); box-shadow: var(--shadow-md); position: relative;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-sm); flex-wrap:wrap; gap:10px;">
<div style="display:flex; align-items:center; gap: 10px;">
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(235, 114, 110, 0.2), rgba(160, 65, 71, 0.3)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">🚀</div>
<div>
<div style="display:flex; align-items:center; gap: 8px;">
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);">Ricerca Testuale Spudorata</h2>
<div style="position: relative; display: inline-flex; align-items: center;">
<button type="button" id="spudorata-info-btn" aria-label="Informazioni sulle regole avanzate e Regex" style="background: rgba(79, 70, 229, 0.1); border: 1px solid rgba(79, 70, 229, 0.25); border-radius: 50%; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: var(--accent-primary); padding: 0; transition: all 0.2s ease;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
<!-- Modal / Popover indicazioni regole avanzate -->
<div id="spudorata-info-modal" class="spudorata-rules-popover" style="display:none; position:absolute; top:calc(100% + 10px); left:0; width:460px; max-width:90vw; background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-lg); box-shadow:0 12px 32px rgba(0,0,0,0.22); z-index:1100; padding:18px; font-size:0.85rem; color:var(--text-primary); backdrop-filter:blur(12px);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; border-bottom:1px solid var(--border-subtle); padding-bottom:8px;">
<div style="display:flex; align-items:center; gap:6px; font-weight:700; font-size:0.95rem; color:var(--accent-primary);">
<span>💡</span>
<span>Guida Ricerca & Regole Avanzate (Regex)</span>
</div>
<span style="font-size:0.75rem; color:var(--text-muted); background:var(--bg-tertiary); padding:2px 6px; border-radius:4px;">Regole avanzate</span>
</div>
<div style="line-height:1.45; color:var(--text-secondary); margin-bottom:12px;">
Puoi cercare normalmente per parole chiave o attivare l'opzione <strong>Regex</strong> per ricerche con espressioni regolari (POSIX / JavaScript) sia sul database che sui filtri in cache.
</div>
<div style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:10px 12px; margin-bottom:12px;">
<div style="font-weight:600; color:var(--text-primary); margin-bottom:6px; font-size:0.82rem;">Esempi di espressioni regolari:</div>
<ul style="margin:0; padding-left:18px; display:flex; flex-direction:column; gap:6px;">
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">vpn|firewall</code> — Trova ticket contenenti <em>vpn</em> <strong>oppure</strong> <em>firewall</em></li>
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">\b(errore|fatal)\b</code> — Trova parole esatte "errore" o "fatal"</li>
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">^INC-[0-9]+</code> — Ticket o codici che iniziano per "INC-" seguito da cifre</li>
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">host(name)?.*down</code> — Trova "host down" o "hostname down" anche con parole in mezzo</li>
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}</code> — Trova indirizzi IP nel corpo o nell'oggetto</li>
</ul>
</div>
<div style="font-size:0.78rem; color:var(--text-tertiary); display:flex; flex-direction:column; gap:4px;">
<div>⚡ <strong>Case-insensitive:</strong> Le ricerche con Regex non distinguono automaticamente tra maiuscole e minuscole.</div>
<div>⚡ <strong>Cache locale:</strong> Anche il campo <em>"Cerca in Cache"</em> rispetta la modalità Regex o il formato <code>/pattern/</code>.</div>
</div>
</div>
</div>
</div>
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano <strong>istantaneamente in cache locale</strong> senza fare nuove richieste al server.
</p>
</div>
</div>
<div id="spudorata-cache-info" style="font-size:0.82rem; color:var(--text-tertiary);"></div>
</div>
<div style="display:flex; align-items:center; gap: var(--space-sm); flex-wrap:wrap; margin-top: var(--space-md);">
<div style="position:relative; flex:1; min-width:300px;">
<input type="text" id="spudorata-search-input" class="form-input" placeholder="${this.isRegex ? 'Cerca con espressione regolare (es. vpn|firewall, ^INC-\\d+)...' : 'Cerca nel testo (oggetto, corpo, numero ticket, cliente)...'}" value="${App.escapeHtml(this.searchTerm)}" style="width:100%; height:42px; font-size:0.95rem; padding-left:14px; padding-right:36px; border-radius:var(--radius-md);" />
<button id="spudorata-input-clear" style="position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-muted); cursor:pointer; display:${this.searchTerm ? 'flex' : 'none'}; align-items:center; justify-content:center;" title="Cancella testo">
✕
</button>
</div>
<!-- Toggle Regex -->
<label id="spudorata-regex-toggle" style="display:inline-flex; align-items:center; gap:6px; cursor:pointer; user-select:none; height:42px; padding:0 12px; background:var(--bg-secondary); border:1px solid ${this.isRegex ? 'var(--accent-primary)' : 'var(--border-light)'}; border-radius:var(--radius-md); font-size:0.85rem; font-weight:600; color:${this.isRegex ? 'var(--accent-primary)' : 'var(--text-secondary)'}; transition:all 0.15s ease;" title="Abilita ricerca tramite Regular Expression">
<input type="checkbox" id="spudorata-regex-checkbox" ${this.isRegex ? 'checked' : ''} style="cursor:pointer; accent-color:var(--accent-primary);" />
<span style="font-family:monospace; font-size:0.95rem; font-weight:700;">.*</span>
<span>Regex</span>
</label>
<button class="btn btn-primary" id="spudorata-search-btn" style="height:42px; padding:0 var(--space-xl); font-size:0.9rem; font-weight:600; min-width:130px; display:flex; align-items:center; justify-content:center; gap:6px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<span>Cerca</span>
</button>
${this.hasSearched ? `
<button class="btn btn-ghost" id="spudorata-reset-cache-btn" style="height:42px; font-size:0.85rem; color:var(--accent-primary);" title="Resetta la cache e la ricerca">
Azzera cache
</button>
` : ''}
</div>
</div>
<!-- Batch Actions Bar -->
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
<div class="filter-group">
<span class="filter-label">Stato</span>
<select class="filter-select" id="batch-state">
<option value="">—</option>
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
</select>
</div>
<div class="filter-group" style="position:relative;">
<span class="filter-label">Coda</span>
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:180px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
<input type="hidden" id="batch-queue" />
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; min-width: 480px; width: max-content; max-width: 85vw; z-index: 1001;"></div>
</div>
<div class="filter-group">
<span class="filter-label">Owner</span>
<select class="filter-select" id="batch-owner">
<option value="">—</option>
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
</select>
</div>
<div class="filter-group">
<span class="filter-label">Responsabile</span>
<select class="filter-select" id="batch-responsible">
<option value="">—</option>
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
</select>
</div>
${(App.lookups.types || []).length > 0 ? `
<div class="filter-group">
<span class="filter-label">Tipo</span>
<select class="filter-select" id="batch-type">
<option value="">—</option>
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
</select>
</div>
` : ''}
</div>
<!-- Colonna Destra: Pulsanti delle Azioni -->
<div class="batch-bar-right" style="display: flex; flex-direction: column; align-items: flex-end; gap: 6px;">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled title="Aggiungi a gruppo" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
<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>
</button>
<button class="btn btn-primary btn-sm" id="batch-merge" disabled title="Unisci Selezionati" style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
<circle cx="18" cy="18" r="3"></circle>
<circle cx="6" cy="6" r="3"></circle>
<path d="M6 21V9a9 9 0 0 0 9 9"></path>
</svg>
</button>
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled title="Apri ticket in schede" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
<path d="M2 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9z"></path>
<path d="M6 3h12a2 2 0 0 1 2 2v12"></path>
</svg>
</button>
</div>
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
<button class="btn btn-ghost btn-xs" id="batch-copy-tns">Copia numeri</button>
<button class="btn btn-ghost btn-xs" id="batch-cancel" title="Annulla selezione">Annulla</button>
</div>
</div>
</div>
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
${Filters.renderBar(App.lookups)}
<!-- Results Container -->
<div id="spudorata-results-area">
${this.hasSearched ? '' : `
<div class="empty-state" style="padding: 40px var(--space-lg); background:var(--bg-card); border-radius:var(--radius-lg); border:1px dashed var(--border-subtle);">
<div class="empty-state-icon">🔍</div>
<div class="empty-state-text">Nessuna ricerca effettuata</div>
<div class="empty-state-sub">Scrivi una parola nel campo in alto e clicca su "Cerca" per caricare i risultati in cache locale.</div>
</div>
`}
</div>
`;
},
async executeSearch(query) {
if (!query) {
Toast.error('Inserisci un termine di ricerca');
return;
}
if (this.isRegex) {
try {
new RegExp(query);
} catch (e) {
Toast.error('Espressione regolare non valida: ' + e.message);
return;
}
}
this.searchTerm = query;
this.isFetching = true;
const resultsArea = document.getElementById('spudorata-results-area');
if (resultsArea) {
resultsArea.innerHTML = `
<div class="loading-screen" style="padding: 60px 0;">
<div class="spinner"></div>
<p>Ricerca testuale ${this.isRegex ? 'con Regex ' : ''}nel server in corso per "${App.escapeHtml(query)}"...</p>
</div>
`;
}
try {
const regexParam = this.isRegex ? '&is_regex=1' : '';
const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}${regexParam}&per_page=2000`);
this.cachedTickets = data.tickets || [];
this.otrsBaseUrl = data.otrsBaseUrl || '';
this.hasSearched = true;
this.currentPage = 1;
this.selectedIds.clear();
this.selectedOrder = [];
Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"${this.isRegex ? ' (Regex)' : ''}`);
this.renderContainer(document.getElementById('view-container'));
this.bindEvents();
this.renderLocalResults();
} catch (err) {
console.error(err);
Toast.error('Errore durante la ricerca testuale: ' + err.message);
if (resultsArea) {
resultsArea.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Errore durante la ricerca</div>
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
</div>
`;
}
} finally {
this.isFetching = false;
}
},
renderLocalResults() {
const resultsArea = document.getElementById('spudorata-results-area');
if (!resultsArea || !this.hasSearched) return;
if (!this.cachedTickets || this.cachedTickets.length === 0) {
resultsArea.innerHTML = `
<div class="empty-state" style="padding: 40px var(--space-lg); background:var(--bg-card); border-radius:var(--radius-lg);">
<div class="empty-state-icon">📭</div>
<div class="empty-state-text">Nessun risultato trovato</div>
<div class="empty-state-sub">Nessun ticket corrisponde alla ricerca "${App.escapeHtml(this.searchTerm)}".</div>
</div>
`;
return;
}
const filtered = this.getFilteredTickets();
const total = filtered.length;
const totalPages = Math.ceil(total / this.perPage) || 1;
if (this.currentPage > totalPages) {
this.currentPage = 1;
}
const cacheInfo = document.getElementById('spudorata-cache-info');
if (cacheInfo) {
cacheInfo.innerHTML = `
<span class="badge" style="background:rgba(16,112,202,0.15); color:var(--accent-primary); padding:4px 10px; border-radius:var(--radius-full); font-weight:600;">
⚡ Cache locale: ${total} visibil${total === 1 ? 'o' : 'i'} su ${this.cachedTickets.length} caricati
</span>
`;
}
const sorted = this.sortTickets(filtered);
const startIndex = (this.currentPage - 1) * this.perPage;
const paginatedTickets = sorted.slice(startIndex, startIndex + this.perPage);
resultsArea.innerHTML = `
<!-- Pagination Top -->
${this.renderPagination(this.currentPage, this.perPage, total, totalPages, true)}
<!-- Ticket Table -->
<div class="ticket-table-wrapper">
<table class="ticket-table" id="ticket-table">
<thead>
<tr>
<th style="width: 75px;" class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
<th style="width: 125px;" class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
<th style="width: 90px;" class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
<th style="width: 130px;" class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
<th style="width: 120px;">Owner</th>
<th style="width: 140px;">Cliente</th>
<th style="width: 80px;" class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
</tr>
</thead>
<tbody>
${paginatedTickets.length > 0 ? paginatedTickets.map(t => {
const displayQueue = t.queue_name.includes('::') ? t.queue_name.split('::').pop() : t.queue_name;
const shortQueue = displayQueue.length > 15 ? displayQueue.substring(0, 12) + '...' : displayQueue;
return `
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
<td>
<span class="ticket-tn" style="display:inline-flex; align-items:center;">
<span class="copy-ticket-btn" data-tn="${t.tn}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia numero ticket">📋</span>
${this.otrsBaseUrl ? `
<a href="${this.otrsBaseUrl}index.pl?Action=AgentTicketZoom;TicketID=${t.id}" target="_blank" title="Apri in OTRS" onclick="event.stopPropagation()" style="display:inline-flex; align-items:center; text-decoration:none; margin-right: 4px;">
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; vertical-align:middle; line-height:16px;">O</span>
</a>
` : ''}
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title, true); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
</span>
</td>
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
<td>
<span class="badge badge-state" data-state-type="${App.escapeHtml(t.state_type || '')}">
${App.escapeHtml(t.state_name)}
</span>
</td>
<td>
<a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title)}</a>
</td>
<td>
<span class="ticket-queue" title="${App.escapeHtml(t.queue_name)}">${App.escapeHtml(shortQueue)}</span>
</td>
<td style="font-size:0.82rem;color:var(--text-secondary);">
${t.owner_first ? `${App.escapeHtml(t.owner_first)} ${App.escapeHtml(t.owner_last || '')}` : '—'}
</td>
<td style="font-size:0.82rem;color:var(--text-secondary);">
${t.customer_first ? `${App.escapeHtml(t.customer_first)} ${App.escapeHtml(t.customer_last || '')}` : (t.customer_id || '—')}
</td>
<td>
<span class="badge badge-priority" data-priority="${App.escapeHtml(t.priority_name || '')}">
${App.escapeHtml(t.priority_name)}
</span>
</td>
</tr>
`;
}).join('') : `
<tr>
<td colspan="8" style="text-align:center; padding: 30px; color: var(--text-tertiary);">
Nessun ticket corrisponde ai filtri selezionati sulla cache locale.
</td>
</tr>
`}
</tbody>
</table>
</div>
<!-- Pagination Bottom -->
${this.renderPagination(this.currentPage, this.perPage, total, totalPages, false)}
`;
this.bindTableEvents();
this.updateBatchBar();
},
getFilteredTickets() {
if (!this.cachedTickets) return [];
let list = [...this.cachedTickets];
const s = Filters.state;
if (s.sub_search) {
const rawQ = String(s.sub_search).trim();
if (rawQ) {
let regexMatcher = null;
if (this.isRegex) {
try {
regexMatcher = new RegExp(rawQ, 'i');
} catch (e) {
// If invalid while typing, fallback or ignore
}
} else if (rawQ.startsWith('/') && rawQ.length > 2 && rawQ.endsWith('/')) {
try {
regexMatcher = new RegExp(rawQ.slice(1, -1), 'i');
} catch (e) {}
}
const qLower = rawQ.toLowerCase();
list = list.filter(t => {
const fields = [
t.tn,
t.title,
t.queue_name,
t.state_name,
t.priority_name,
t.owner_first,
t.owner_last,
`${t.owner_first || ''} ${t.owner_last || ''}`,
t.customer_first,
t.customer_last,
`${t.customer_first || ''} ${t.customer_last || ''}`,
t.customer_id,
t.customer_user_id
];
if (regexMatcher) {
return fields.some(f => f && regexMatcher.test(String(f)));
}
return fields.some(f => f && String(f).toLowerCase().includes(qLower));
});
}
}
if (s.state_id) {
const selectedStates = String(s.state_id).split(',').filter(Boolean);
list = list.filter(t => selectedStates.includes(String(t.ticket_state_id)));
}
if (s.queue_id) {
const selectedQueues = String(s.queue_id).split(',').filter(Boolean);
list = list.filter(t => selectedQueues.includes(String(t.queue_id)));
}
if (s.priority_id) {
const selectedPriorities = String(s.priority_id).split(',').filter(Boolean);
list = list.filter(t => selectedPriorities.includes(String(t.ticket_priority_id)));
}
if (s.user_id) {
const selectedUsers = String(s.user_id).split(',').filter(Boolean);
list = list.filter(t => selectedUsers.includes(String(t.user_id)));
}
if (s.responsible_user_id) {
list = list.filter(t => String(t.responsible_user_id) === String(s.responsible_user_id));
}
if (s.type_id) {
list = list.filter(t => String(t.type_id) === String(s.type_id));
}
if (s.customer_user_id) {
list = list.filter(t => String(t.customer_user_id || '').toLowerCase() === String(s.customer_user_id).toLowerCase());
}
if (s.customer_id) {
list = list.filter(t => String(t.customer_id || '').toLowerCase() === String(s.customer_id).toLowerCase());
}
if (s.date_from) {
const fromDate = new Date(s.date_from);
list = list.filter(t => new Date(t.create_time) >= fromDate);
}
if (s.date_to) {
const toDate = new Date(s.date_to + 'T23:59:59');
list = list.filter(t => new Date(t.create_time) <= toDate);
}
return list;
},
sortTickets(tickets) {
const list = [...tickets];
const key = this.sortBy;
const isAsc = this.sortDir === 'ASC';
list.sort((a, b) => {
let valA, valB;
if (key === 'queue') {
valA = a.queue_name || '';
valB = b.queue_name || '';
} else if (key === 'state') {
valA = a.state_name || '';
valB = b.state_name || '';
} else if (key === 'priority') {
valA = a.ticket_priority_id || 0;
valB = b.ticket_priority_id || 0;
} else {
valA = a[key] || '';
valB = b[key] || '';
}
if (typeof valA === 'string') {
return isAsc ? valA.localeCompare(valB, 'it', { sensitivity: 'base' }) : valB.localeCompare(valA, 'it', { sensitivity: 'base' });
}
return isAsc ? valA - valB : valB - valA;
});
return list;
},
renderPagination(current, perPage, total, totalPages, isTop = false) {
const limitSelectHtml = `
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary);">
<span>Righe:</span>
<select id="spudorata-per-page-select-${isTop ? 'top' : 'bottom'}" name="spudorata_per_page_${isTop ? 'top' : 'bottom'}" class="form-select spudorata-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
<option value="10" ${perPage === 10 ? 'selected' : ''}>10</option>
<option value="20" ${perPage === 20 ? 'selected' : ''}>20</option>
<option value="50" ${perPage === 50 ? 'selected' : ''}>50</option>
<option value="100" ${perPage === 100 ? 'selected' : ''}>100</option>
<option value="200" ${perPage === 200 ? 'selected' : ''}>200</option>
<option value="500" ${perPage === 500 ? 'selected' : ''}>500</option>
</select>
</div>
`;
return `
<div class="pagination" style="${isTop ? 'margin-bottom:var(--space-md);' : 'margin-top:var(--space-md);'} align-items:center; justify-content:space-between; display:flex;">
<div class="pagination-info" style="font-size:0.82rem; color:var(--text-tertiary);">
Mostrati ${total > 0 ? Math.min(total, (current - 1) * perPage + 1) : 0}-${Math.min(total, current * perPage)} di ${total} ticket
</div>
<div class="pagination-controls" style="display:flex; align-items:center; gap:var(--space-sm);">
${limitSelectHtml}
<div class="pagination-buttons" style="display:flex; gap:4px;">
<button class="pagination-btn spudorata-page-btn" data-page="${Math.max(1, current - 1)}" ${current === 1 ? 'disabled' : ''}>&laquo;</button>
${this.renderPageNumbers(current, totalPages)}
<button class="pagination-btn spudorata-page-btn" data-page="${Math.min(totalPages, current + 1)}" ${current === totalPages ? 'disabled' : ''}>&raquo;</button>
</div>
</div>
</div>
`;
},
renderPageNumbers(current, totalPages) {
const pages = [];
const start = Math.max(1, current - 2);
const end = Math.min(totalPages, current + 2);
for (let i = start; i <= end; i++) {
pages.push(`<button class="pagination-btn spudorata-page-btn ${i === current ? 'active' : ''}" data-page="${i}">${i}</button>`);
}
return pages.join('');
},
bindEvents() {
const searchInput = document.getElementById('spudorata-search-input');
const searchBtn = document.getElementById('spudorata-search-btn');
const clearBtn = document.getElementById('spudorata-input-clear');
const resetCacheBtn = document.getElementById('spudorata-reset-cache-btn');
if (searchInput) {
searchInput.addEventListener('input', () => {
if (clearBtn) clearBtn.style.display = searchInput.value.trim() ? 'flex' : 'none';
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.executeSearch(searchInput.value.trim());
}
});
}
if (searchBtn) {
searchBtn.addEventListener('click', () => {
if (searchInput) this.executeSearch(searchInput.value.trim());
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
if (searchInput) searchInput.value = '';
clearBtn.style.display = 'none';
searchInput.focus();
});
}
const regexCheckbox = document.getElementById('spudorata-regex-checkbox');
if (regexCheckbox) {
regexCheckbox.addEventListener('change', () => {
this.isRegex = regexCheckbox.checked;
const toggleLabel = document.getElementById('spudorata-regex-toggle');
if (toggleLabel) {
toggleLabel.style.borderColor = this.isRegex ? 'var(--accent-primary)' : 'var(--border-light)';
toggleLabel.style.color = this.isRegex ? 'var(--accent-primary)' : 'var(--text-secondary)';
}
if (searchInput) {
searchInput.placeholder = this.isRegex
? 'Cerca con espressione regolare (es. vpn|firewall, ^INC-\\d+)...'
: 'Cerca nel testo (oggetto, corpo, numero ticket, cliente)...';
searchInput.focus();
}
// If we already have cached tickets, update the local filtered view immediately
if (this.hasSearched && this.cachedTickets) {
this.currentPage = 1;
this.renderLocalResults();
}
});
}
// Info button & hover modal
const infoBtn = document.getElementById('spudorata-info-btn');
const infoModal = document.getElementById('spudorata-info-modal');
if (infoBtn && infoModal) {
let closeTimeout = null;
const openModal = () => {
if (closeTimeout) {
clearTimeout(closeTimeout);
closeTimeout = null;
}
infoModal.style.display = 'block';
};
const closeModalWithDelay = () => {
if (closeTimeout) clearTimeout(closeTimeout);
closeTimeout = setTimeout(() => {
infoModal.style.display = 'none';
}, 250);
};
infoBtn.addEventListener('mouseenter', openModal);
infoBtn.addEventListener('mouseleave', closeModalWithDelay);
infoModal.addEventListener('mouseenter', () => {
if (closeTimeout) {
clearTimeout(closeTimeout);
closeTimeout = null;
}
});
infoModal.addEventListener('mouseleave', closeModalWithDelay);
// Also toggle on click for touch/mobile devices or explicit clicks
infoBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (infoModal.style.display === 'block') {
infoModal.style.display = 'none';
} else {
openModal();
}
});
document.addEventListener('click', (e) => {
if (!infoBtn.contains(e.target) && !infoModal.contains(e.target)) {
infoModal.style.display = 'none';
}
});
}
if (resetCacheBtn) {
resetCacheBtn.addEventListener('click', () => {
this.invalidateCache();
if (searchInput) searchInput.value = '';
Filters.reset();
this.renderContainer(document.getElementById('view-container'));
this.bindEvents();
});
}
Filters.bindEvents(() => {
this.currentPage = 1;
this.renderLocalResults();
});
const batchApply = document.getElementById('batch-apply');
if (batchApply) batchApply.addEventListener('click', () => this.applyBatch());
const batchAddGroup = document.getElementById('batch-add-group');
if (batchAddGroup) batchAddGroup.addEventListener('click', () => this.addToGroup());
const batchMerge = document.getElementById('batch-merge');
if (batchMerge) batchMerge.addEventListener('click', () => this.mergeBatch());
const batchOpenTabs = document.getElementById('batch-open-tabs');
if (batchOpenTabs) batchOpenTabs.addEventListener('click', () => this.openBatchInTabs());
const batchCopyTns = document.getElementById('batch-copy-tns');
if (batchCopyTns) batchCopyTns.addEventListener('click', () => this.copySelectedTns());
const batchSelectAll = document.getElementById('batch-select-all');
if (batchSelectAll) batchSelectAll.addEventListener('click', () => this.selectAllVisible());
const batchCancel = document.getElementById('batch-cancel');
if (batchCancel) batchCancel.addEventListener('click', () => this.cancelBatchSelection());
const batchQueueSearchInput = document.getElementById('batch-queue-search');
const batchQueueSuggestionsDiv = document.getElementById('batch-queue-suggestions');
const batchQueueIdInput = document.getElementById('batch-queue');
if (batchQueueSearchInput) {
batchQueueSearchInput.addEventListener('input', async () => {
const q = batchQueueSearchInput.value.trim();
try {
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
if (queues.length === 0) {
batchQueueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
} else {
batchQueueSuggestionsDiv.innerHTML = queues.map(queue => `
<div class="autocomplete-suggestion-item" data-id="${queue.id}" data-name="${App.escapeHtml(queue.name)}">
<strong>${App.escapeHtml(queue.name)}</strong>
</div>
`).join('');
}
batchQueueSuggestionsDiv.style.display = 'block';
batchQueueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
item.addEventListener('click', () => {
batchQueueSearchInput.value = item.dataset.name;
if (batchQueueIdInput) batchQueueIdInput.value = item.dataset.id;
batchQueueSuggestionsDiv.style.display = 'none';
});
});
} catch (err) { console.error(err); }
});
App.setupAutocompleteKeyboardNavigation(batchQueueSearchInput, batchQueueSuggestionsDiv, (item) => {
if (item && item.dataset.id) {
batchQueueSearchInput.value = item.dataset.name;
if (batchQueueIdInput) batchQueueIdInput.value = item.dataset.id;
batchQueueSuggestionsDiv.style.display = 'none';
}
});
batchQueueSearchInput.addEventListener('focus', () => {
batchQueueSearchInput.value = '';
if (batchQueueIdInput) batchQueueIdInput.value = '';
batchQueueSearchInput.dispatchEvent(new Event('input'));
});
}
document.addEventListener('click', (e) => {
if (batchQueueSearchInput && e.target !== batchQueueSearchInput && e.target !== batchQueueSuggestionsDiv) {
if (batchQueueSuggestionsDiv) batchQueueSuggestionsDiv.style.display = 'none';
}
});
},
bindTableEvents() {
document.querySelectorAll('#spudorata-results-area th.sortable').forEach(th => {
th.addEventListener('click', () => {
const sortKey = th.dataset.sort;
if (this.sortBy === sortKey) {
this.sortDir = this.sortDir === 'ASC' ? 'DESC' : 'ASC';
} else {
this.sortBy = sortKey;
this.sortDir = 'DESC';
}
this.renderLocalResults();
});
});
document.querySelectorAll('.spudorata-per-page-select').forEach(sel => {
sel.addEventListener('change', (e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val > 0) {
this.perPage = val;
this.currentPage = 1;
this.renderLocalResults();
}
});
});
document.querySelectorAll('.spudorata-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const page = parseInt(btn.dataset.page, 10);
if (!isNaN(page) && page > 0) {
this.currentPage = page;
this.renderLocalResults();
}
});
});
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(row => {
row.addEventListener('click', (e) => {
if (e.target.closest('.ticket-tn-link') || e.target.closest('.ticket-title-link') || e.target.closest('.copy-ticket-btn') || e.target.closest('.open-tab-btn')) {
return;
}
const id = String(row.dataset.ticketId);
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
this.selectedOrder = this.selectedOrder.filter(x => x !== id);
} else {
this.selectedIds.add(id);
this.selectedOrder.push(id);
}
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(tr => {
const trId = String(tr.dataset.ticketId);
if (this.selectedIds.has(trId)) tr.classList.add('selected');
else tr.classList.remove('selected');
if (this.selectedOrder[0] === trId) tr.classList.add('first-selected');
else tr.classList.remove('first-selected');
});
this.updateBatchBar();
});
});
document.querySelectorAll('.copy-ticket-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tn = btn.dataset.tn;
if (tn) {
navigator.clipboard.writeText(tn).then(() => {
Toast.success(`Numero ticket ${tn} copiato!`);
}).catch(err => Toast.error('Errore durante la copia: ' + err.message));
}
});
});
},
updateBatchBar() {
const countEl = document.getElementById('batch-count');
const addGroupBtn = document.getElementById('batch-add-group');
const mergeBtn = document.getElementById('batch-merge');
const openTabsBtn = document.getElementById('batch-open-tabs');
const count = this.selectedIds.size;
if (countEl) countEl.textContent = `${count} selezionat${count === 1 ? 'o' : 'i'}`;
if (addGroupBtn) addGroupBtn.disabled = count === 0;
if (mergeBtn) mergeBtn.disabled = count < 2;
if (openTabsBtn) openTabsBtn.disabled = count === 0;
},
selectAllVisible() {
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(row => {
const id = String(row.dataset.ticketId);
if (!this.selectedIds.has(id)) {
this.selectedIds.add(id);
this.selectedOrder.push(id);
}
});
document.querySelectorAll('#spudorata-results-area tr[data-ticket-id]').forEach(tr => {
const trId = String(tr.dataset.ticketId);
tr.classList.add('selected');
if (this.selectedOrder[0] === trId) tr.classList.add('first-selected');
else tr.classList.remove('first-selected');
});
this.updateBatchBar();
},
cancelBatchSelection() {
this.selectedIds.clear();
this.selectedOrder = [];
document.querySelectorAll('#spudorata-results-area tr').forEach(tr => {
tr.classList.remove('selected');
tr.classList.remove('first-selected');
});
this.updateBatchBar();
},
copySelectedTns() {
const selectedTns = [];
this.selectedIds.forEach(id => {
const row = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${id}"]`);
if (row) {
const tnLink = row.querySelector('.ticket-tn-link');
if (tnLink) selectedTns.push(tnLink.textContent.trim());
}
});
if (selectedTns.length > 0) {
navigator.clipboard.writeText(selectedTns.join('\n')).then(() => {
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
}).catch(err => Toast.error('Errore durante la copia: ' + err.message));
}
},
openBatchInTabs() {
let count = 0;
this.selectedOrder.forEach(id => {
const row = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${id}"]`);
if (row) {
const tn = row.querySelector('.ticket-tn-link')?.textContent.trim() || id;
const title = row.querySelector('.ticket-title-link')?.textContent.trim() || '';
App.openTab(parseInt(id), tn, title);
count++;
}
});
if (count > 0) {
Toast.success(`${count} ticket aperti in nuove schede!`);
this.cancelBatchSelection();
}
},
async applyBatch() {
if (this.selectedIds.size === 0) {
Toast.error('Seleziona almeno un ticket per applicare le modifiche');
return;
}
const updates = {};
const stateVal = document.getElementById('batch-state')?.value;
const queueVal = document.getElementById('batch-queue')?.value;
const ownerVal = document.getElementById('batch-owner')?.value;
const responsibleVal = document.getElementById('batch-responsible')?.value;
const typeVal = document.getElementById('batch-type')?.value;
if (stateVal) updates.ticket_state_id = parseInt(stateVal);
if (queueVal) updates.queue_id = parseInt(queueVal);
if (ownerVal) updates.user_id = parseInt(ownerVal);
if (responsibleVal) updates.responsible_user_id = parseInt(responsibleVal);
if (typeVal) updates.type_id = parseInt(typeVal);
if (Object.keys(updates).length === 0) {
Toast.error('Seleziona almeno un campo da modificare dalla barra');
return;
}
try {
const ticket_ids = Array.from(this.selectedIds).map(id => parseInt(id, 10));
const res = await App.api('/api/tickets/batch/update', {
method: 'PATCH',
body: JSON.stringify({ ticket_ids, updates })
});
Toast.success(res.message || 'Ticket aggiornati con successo');
if (this.cachedTickets) {
this.cachedTickets.forEach(t => {
if (this.selectedIds.has(String(t.id))) {
if (updates.ticket_state_id) {
t.ticket_state_id = updates.ticket_state_id;
const foundState = App.lookups.states.find(s => s.id === updates.ticket_state_id);
if (foundState) t.state_name = foundState.name;
}
if (updates.queue_id) {
t.queue_id = updates.queue_id;
const foundQueue = App.lookups.queues.find(q => q.id === updates.queue_id);
if (foundQueue) t.queue_name = foundQueue.name;
}
if (updates.user_id) {
t.user_id = updates.user_id;
const foundUser = App.lookups.users.find(u => u.id === updates.user_id);
if (foundUser) {
t.owner_first = foundUser.first_name;
t.owner_last = foundUser.last_name;
}
}
if (updates.responsible_user_id) {
t.responsible_user_id = updates.responsible_user_id;
}
if (updates.type_id) {
t.type_id = updates.type_id;
}
}
});
}
this.cancelBatchSelection();
this.renderLocalResults();
} catch (err) {
Toast.error('Errore aggiornamento batch: ' + err.message);
}
},
async addToGroup() {
if (this.selectedIds.size === 0) return;
try {
const groups = await App.api('/api/groups');
if (!groups || groups.length === 0) {
Toast.warning('non sono presenti gruppi');
return;
}
const groupOptions = groups.map(g => `<option value="${g.id}">${App.escapeHtml(g.nome)}</option>`).join('');
const dialogHtml = `
<div id="batch-group-modal" style="position: fixed; inset: 0; z-index: 8000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center;">
<div style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); width: 400px; max-width: 90vw; padding: var(--space-lg); box-shadow: var(--shadow-lg);">
<h4 style="margin: 0 0 var(--space-md) 0; font-size: 1rem; font-weight: 600; color: var(--text-primary);">Aggiungi a Gruppo</h4>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: var(--space-md);">Seleziona il gruppo a cui aggiungere i <strong>${this.selectedIds.size}</strong> ticket selezionati:</p>
<select id="batch-group-select" class="form-select" style="width: 100%; margin-bottom: var(--space-lg);">
${groupOptions}
</select>
<div style="display: flex; gap: var(--space-sm); justify-content: flex-end;">
<button class="btn btn-ghost btn-sm" id="batch-group-cancel">Annulla</button>
<button class="btn btn-primary btn-sm" id="batch-group-confirm">Aggiungi</button>
</div>
</div>
</div>
`;
const modalContainer = document.createElement('div');
modalContainer.innerHTML = dialogHtml;
document.body.appendChild(modalContainer);
const closeModal = () => modalContainer.remove();
document.getElementById('batch-group-cancel').addEventListener('click', closeModal);
document.getElementById('batch-group-confirm').addEventListener('click', async () => {
const groupId = document.getElementById('batch-group-select').value;
if (!groupId) return;
const confirmBtn = document.getElementById('batch-group-confirm');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Aggiunta...';
let addedCount = 0;
let errorsCount = 0;
for (const ticketId of this.selectedIds) {
try {
await App.api(`/api/groups/${groupId}/tickets`, {
method: 'POST',
body: JSON.stringify({ ticket_identifier: ticketId })
});
addedCount++;
} catch (err) {
errorsCount++;
}
}
closeModal();
if (addedCount > 0) {
Toast.success(`${addedCount} ticket aggiunti al gruppo!`);
this.cancelBatchSelection();
} else {
Toast.error('Impossibile aggiungere i ticket al gruppo selezionato.');
}
});
} catch (err) {
Toast.error('Errore caricamento gruppi: ' + err.message);
}
},
async mergeBatch() {
if (this.selectedOrder.length < 2) return;
const targetId = this.selectedOrder[0];
const sourceIds = this.selectedOrder.slice(1);
const targetRow = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${targetId}"]`);
const targetTn = targetRow ? targetRow.querySelector('.ticket-tn-link').textContent.trim() : targetId;
const sourceTns = sourceIds.map(id => {
const row = document.querySelector(`#spudorata-results-area tr[data-ticket-id="${id}"]`);
return row ? row.querySelector('.ticket-tn-link').textContent.trim() : `#${id}`;
});
const confirmed = await App.confirm('Unione Ticket', `Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}?`);
if (!confirmed) return;
try {
const res = await App.api('/api/tickets/merge', {
method: 'POST',
body: JSON.stringify({
targetId: parseInt(targetId),
sourceIds: sourceIds.map(x => parseInt(x))
})
});
Toast.success(res.message || 'Ticket uniti con successo');
if (this.cachedTickets) {
this.cachedTickets.forEach(t => {
if (sourceIds.includes(String(t.id))) {
t.ticket_state_id = 9;
t.state_name = 'merged';
t.state_type = 'merged';
}
});
}
this.cancelBatchSelection();
this.renderLocalResults();
} catch (err) {
Toast.error('Errore durante l\'unione: ' + err.message);
}
}
};