813 lines
35 KiB
JavaScript
813 lines
35 KiB
JavaScript
/**
|
||
* Ticket List View
|
||
* Full-featured ticket list with filters, sorting, batch actions, and pagination.
|
||
*/
|
||
const TicketListView = {
|
||
currentPage: 1,
|
||
perPage: 50,
|
||
sortBy: 'create_time',
|
||
sortDir: 'DESC',
|
||
selectedIds: new Set(),
|
||
selectedOrder: [],
|
||
searchTimeout: null,
|
||
|
||
async render() {
|
||
const container = document.getElementById('view-container');
|
||
if (!container.querySelector('.ticket-table-wrapper') && !container.querySelector('.ticket-table')) {
|
||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||
}
|
||
|
||
try {
|
||
// Fetch lookups for filter dropdowns
|
||
await App.ensureLookups();
|
||
|
||
// Fetch agent settings for tickets_per_page
|
||
try {
|
||
const settings = await App.api('/api/dashboard/settings');
|
||
if (settings && settings.tickets_per_page) {
|
||
this.perPage = settings.tickets_per_page;
|
||
}
|
||
} catch (err) {
|
||
console.warn('Failed to load agent settings:', err);
|
||
}
|
||
|
||
// Build query params
|
||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||
Filters.currentMode = isMyTickets ? 'my' : 'general';
|
||
Filters.load(); // Load state for current mode
|
||
|
||
if (isMyTickets) {
|
||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||
Filters.state.user_id = activeAgentId;
|
||
Filters.save();
|
||
}
|
||
|
||
const params = Filters.toQueryParams();
|
||
params.set('page', this.currentPage);
|
||
params.set('per_page', this.perPage);
|
||
params.set('sort_by', this.sortBy);
|
||
params.set('sort_dir', this.sortDir);
|
||
|
||
const searchInput = document.getElementById('global-search');
|
||
if (searchInput && searchInput.value.trim()) {
|
||
params.set('search', searchInput.value.trim());
|
||
}
|
||
|
||
const data = await App.api(`/api/tickets?${params.toString()}`);
|
||
|
||
this.renderContent(container, data);
|
||
this.bindEvents(data);
|
||
|
||
} catch (err) {
|
||
container.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">⚠️</div>
|
||
<div class="empty-state-text">Errore caricamento ticket</div>
|
||
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
},
|
||
|
||
renderContent(container, data) {
|
||
const tickets = data.tickets || [];
|
||
const { total, page, per_page, total_pages } = data;
|
||
|
||
container.innerHTML = `
|
||
<!-- 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>
|
||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
||
</div>
|
||
<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:160px; 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; width: 280px; 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>
|
||
` : ''}
|
||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
|
||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||
</div>
|
||
|
||
${Filters.renderBar(App.lookups)}
|
||
|
||
<!-- Pagination Top -->
|
||
${this.renderPagination(page, per_page, total, total_pages, 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>
|
||
${tickets.length > 0 ? tickets.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>
|
||
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
||
${data.otrsBaseUrl ? `
|
||
<a href="${data.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;">
|
||
<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; margin-left:6px; 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); 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-left:6px; cursor:pointer; line-height:16px;">+</button>
|
||
</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="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
||
<td class="ticket-title-cell"><span class="copy-ticket-btn" data-tn="${t.title}" style="cursor: pointer; font-size: 0.82rem; display: inline-flex; align-items: center; margin-right: 4px;" onclick="event.stopPropagation();" title="Copia titolo ticket">📋</span><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
|
||
<td class="queue-cell"><span class="badge badge-queue">${App.escapeHtml(shortQueue)}</span><div class="queue-tooltip">${App.escapeHtml(t.queue_name)}</div></td>
|
||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
||
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
|
||
</tr>
|
||
`;
|
||
}).join('') : `
|
||
<tr>
|
||
<td colspan="8">
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">📭</div>
|
||
<div class="empty-state-text">Nessun ticket trovato</div>
|
||
<div class="empty-state-sub">Prova a cambiare i filtri o crea un nuovo ticket.</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Pagination Bottom -->
|
||
${this.renderPagination(page, per_page, total, total_pages, false)}
|
||
`;
|
||
},
|
||
|
||
renderPagination(page, per_page, total, total_pages, isTop) {
|
||
const marginStyle = isTop ? 'margin-bottom: var(--space-md); margin-top: 0;' : 'margin-top: var(--space-md); margin-bottom: 0;';
|
||
const limitSelectHtml = `
|
||
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary); margin-right:var(--space-md);">
|
||
<span>Righe:</span>
|
||
<select class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
|
||
<option value="10" ${per_page === 10 ? 'selected' : ''}>10</option>
|
||
<option value="20" ${per_page === 20 ? 'selected' : ''}>20</option>
|
||
<option value="50" ${per_page === 50 ? 'selected' : ''}>50</option>
|
||
<option value="100" ${per_page === 100 ? 'selected' : ''}>100</option>
|
||
<option value="200" ${per_page === 200 ? 'selected' : ''}>200</option>
|
||
</select>
|
||
</div>
|
||
`;
|
||
|
||
if (total_pages > 1) {
|
||
return `
|
||
<div class="pagination" style="${marginStyle}">
|
||
<div class="pagination-info">
|
||
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
||
</div>
|
||
<div class="pagination-controls">
|
||
${limitSelectHtml}
|
||
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
|
||
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>‹</button>
|
||
${this.renderPageButtons(page, total_pages)}
|
||
<button class="pagination-btn" data-page="${page + 1}" ${page >= total_pages ? 'disabled' : ''}>›</button>
|
||
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
return `
|
||
<div class="pagination" style="${marginStyle}">
|
||
<div class="pagination-info">${total} ticket totali</div>
|
||
<div class="pagination-controls">
|
||
${limitSelectHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
},
|
||
|
||
renderPageButtons(current, total) {
|
||
const pages = [];
|
||
const start = Math.max(1, current - 2);
|
||
const end = Math.min(total, current + 2);
|
||
|
||
for (let i = start; i <= end; i++) {
|
||
pages.push(`<button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}">${i}</button>`);
|
||
}
|
||
return pages.join('');
|
||
},
|
||
|
||
bindEvents(data) {
|
||
// Filter events
|
||
Filters.bindEvents(() => {
|
||
this.currentPage = 1;
|
||
this.selectedIds.clear();
|
||
this.selectedOrder = [];
|
||
this.render();
|
||
});
|
||
|
||
// Sort events
|
||
document.querySelectorAll('.ticket-table 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.currentPage = 1;
|
||
this.render();
|
||
});
|
||
});
|
||
|
||
// Row click → Toggle selection
|
||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
|
||
row.addEventListener('click', (e) => {
|
||
// 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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Update classes on all rows
|
||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(tr => {
|
||
const trId = String(tr.dataset.ticketId);
|
||
if (this.selectedIds.has(trId)) {
|
||
tr.classList.add('selected');
|
||
} else {
|
||
tr.classList.remove('selected');
|
||
}
|
||
if (this.selectedOrder[0] === trId) {
|
||
tr.classList.add('first-selected');
|
||
} else {
|
||
tr.classList.remove('first-selected');
|
||
}
|
||
});
|
||
|
||
this.updateBatchBar();
|
||
});
|
||
});
|
||
|
||
// Batch apply
|
||
const batchApply = document.getElementById('batch-apply');
|
||
if (batchApply) {
|
||
batchApply.addEventListener('click', () => this.applyBatch());
|
||
}
|
||
|
||
// Batch add to group
|
||
const batchAddGroup = document.getElementById('batch-add-group');
|
||
if (batchAddGroup) {
|
||
batchAddGroup.addEventListener('click', () => this.addToGroup());
|
||
}
|
||
|
||
// Batch merge (Issue #7)
|
||
const batchMerge = document.getElementById('batch-merge');
|
||
if (batchMerge) {
|
||
batchMerge.addEventListener('click', () => this.mergeBatch());
|
||
}
|
||
|
||
// Batch open tabs
|
||
const batchOpenTabsBtn = document.getElementById('batch-open-tabs');
|
||
if (batchOpenTabsBtn) {
|
||
batchOpenTabsBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (this.selectedIds.size === 0) return;
|
||
|
||
let count = 0;
|
||
this.selectedIds.forEach(id => {
|
||
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
|
||
if (row) {
|
||
const tnLink = row.querySelector('.ticket-tn-link');
|
||
const titleLink = row.querySelector('.ticket-title-link');
|
||
const tn = tnLink ? tnLink.textContent.trim() : '';
|
||
const title = titleLink ? titleLink.textContent.trim() : '';
|
||
App.openTab(parseInt(id, 10), tn, title);
|
||
count++;
|
||
}
|
||
});
|
||
|
||
if (count > 0) {
|
||
Toast.success(`${count} ticket aperti in nuove schede!`);
|
||
this.selectedIds.clear();
|
||
this.selectedOrder = [];
|
||
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
|
||
tr.classList.remove('selected');
|
||
tr.classList.remove('first-selected');
|
||
});
|
||
this.updateBatchBar();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Batch Copy Ticket Numbers
|
||
const batchCopyTns = document.getElementById('batch-copy-tns');
|
||
if (batchCopyTns) {
|
||
batchCopyTns.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const selectedTns = [];
|
||
this.selectedIds.forEach(id => {
|
||
const row = document.querySelector(`.ticket-table tbody 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) {
|
||
const textToCopy = selectedTns.join('\n');
|
||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||
Toast.success(`${selectedTns.length} numeri ticket copiati!`);
|
||
}).catch(err => {
|
||
Toast.error('Errore durante la copia: ' + err.message);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Individual copy buttons
|
||
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);
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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);
|
||
if (!this.selectedIds.has(id)) {
|
||
this.selectedIds.add(id);
|
||
this.selectedOrder.push(id);
|
||
}
|
||
});
|
||
|
||
// Update classes
|
||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(tr => {
|
||
const trId = String(tr.dataset.ticketId);
|
||
tr.classList.add('selected');
|
||
if (this.selectedOrder[0] === trId) {
|
||
tr.classList.add('first-selected');
|
||
} else {
|
||
tr.classList.remove('first-selected');
|
||
}
|
||
});
|
||
|
||
this.updateBatchBar();
|
||
});
|
||
}
|
||
|
||
// Batch cancel
|
||
const batchCancel = document.getElementById('batch-cancel');
|
||
if (batchCancel) {
|
||
batchCancel.addEventListener('click', () => {
|
||
this.selectedIds.clear();
|
||
this.selectedOrder = [];
|
||
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
|
||
tr.classList.remove('selected');
|
||
tr.classList.remove('first-selected');
|
||
});
|
||
const batchCustomerSearch = document.getElementById('batch-customer-search');
|
||
const batchCustomerUserId = document.getElementById('batch-customer-user-id');
|
||
const batchCustomerId = document.getElementById('batch-customer-id');
|
||
if (batchCustomerSearch) batchCustomerSearch.value = '';
|
||
if (batchCustomerUserId) batchCustomerUserId.value = '';
|
||
if (batchCustomerId) batchCustomerId.value = '';
|
||
const batchState = document.getElementById('batch-state');
|
||
if (batchState) batchState.value = '';
|
||
const batchQueueSearch = document.getElementById('batch-queue-search');
|
||
if (batchQueueSearch) batchQueueSearch.value = '';
|
||
const batchQueue = document.getElementById('batch-queue');
|
||
if (batchQueue) batchQueue.value = '';
|
||
const batchOwner = document.getElementById('batch-owner');
|
||
if (batchOwner) batchOwner.value = '';
|
||
const batchResponsible = document.getElementById('batch-responsible');
|
||
if (batchResponsible) batchResponsible.value = '';
|
||
const batchType = document.getElementById('batch-type');
|
||
if (batchType) batchType.value = '';
|
||
|
||
this.updateBatchBar();
|
||
});
|
||
}
|
||
|
||
// Batch Customer User Autocomplete
|
||
const batchCustomerSearchInput = document.getElementById('batch-customer-search');
|
||
const batchCustomerSuggestionsDiv = document.getElementById('batch-customer-suggestions');
|
||
const batchCustomerUserIdInput = document.getElementById('batch-customer-user-id');
|
||
const batchCustomerIdInput = document.getElementById('batch-customer-id');
|
||
|
||
let batchCustomerDebounce;
|
||
if (batchCustomerSearchInput) {
|
||
batchCustomerSearchInput.addEventListener('input', () => {
|
||
clearTimeout(batchCustomerDebounce);
|
||
const q = batchCustomerSearchInput.value.trim();
|
||
// Do not block empty query to allow all results on focus
|
||
|
||
batchCustomerDebounce = setTimeout(async () => {
|
||
try {
|
||
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||
if (users.length === 0) {
|
||
batchCustomerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
|
||
batchCustomerSuggestionsDiv.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
batchCustomerSuggestionsDiv.innerHTML = users.map(u => `
|
||
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login)}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
|
||
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
|
||
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
|
||
</div>
|
||
`).join('');
|
||
batchCustomerSuggestionsDiv.style.display = 'block';
|
||
|
||
// Bind click
|
||
batchCustomerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||
if (item.dataset.login) {
|
||
item.addEventListener('click', () => {
|
||
batchCustomerSearchInput.value = item.dataset.name;
|
||
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = item.dataset.login;
|
||
if (batchCustomerIdInput) batchCustomerIdInput.value = item.dataset.customerId || '';
|
||
batchCustomerSuggestionsDiv.style.display = 'none';
|
||
});
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}, 300);
|
||
});
|
||
batchCustomerSearchInput.addEventListener('focus', () => {
|
||
batchCustomerSearchInput.value = '';
|
||
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = '';
|
||
if (batchCustomerIdInput) batchCustomerIdInput.value = '';
|
||
batchCustomerSearchInput.dispatchEvent(new Event('input'));
|
||
});
|
||
}
|
||
|
||
// Batch Queue Autocomplete
|
||
const batchQueueSearchInput = document.getElementById('batch-queue-search');
|
||
const batchQueueSuggestionsDiv = document.getElementById('batch-queue-suggestions');
|
||
const batchQueueIdInput = document.getElementById('batch-queue');
|
||
|
||
let batchQueueDebounce;
|
||
if (batchQueueSearchInput) {
|
||
batchQueueSearchInput.addEventListener('input', () => {
|
||
clearTimeout(batchQueueDebounce);
|
||
const q = batchQueueSearchInput.value.trim();
|
||
|
||
batchQueueDebounce = setTimeout(async () => {
|
||
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>';
|
||
batchQueueSuggestionsDiv.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
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';
|
||
|
||
// Bind click
|
||
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);
|
||
}
|
||
}, 300);
|
||
});
|
||
batchQueueSearchInput.addEventListener('focus', () => {
|
||
batchQueueSearchInput.value = '';
|
||
if (batchQueueIdInput) batchQueueIdInput.value = '';
|
||
batchQueueSearchInput.dispatchEvent(new Event('input'));
|
||
});
|
||
}
|
||
|
||
// Close suggestions on click outside
|
||
document.addEventListener('click', (e) => {
|
||
if (batchCustomerSearchInput && e.target !== batchCustomerSearchInput && e.target !== batchCustomerSuggestionsDiv) {
|
||
batchCustomerSuggestionsDiv.style.display = 'none';
|
||
}
|
||
if (batchQueueSearchInput && e.target !== batchQueueSearchInput && e.target !== batchQueueSuggestionsDiv) {
|
||
batchQueueSuggestionsDiv.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Pagination
|
||
document.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
this.currentPage = parseInt(btn.dataset.page);
|
||
this.selectedIds.clear();
|
||
this.selectedOrder = [];
|
||
this.render();
|
||
});
|
||
});
|
||
|
||
// Page size change
|
||
document.querySelectorAll('.ticket-per-page-select').forEach(select => {
|
||
select.addEventListener('change', async () => {
|
||
const newLimit = parseInt(select.value, 10);
|
||
try {
|
||
await App.api('/api/dashboard/settings', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ tickets_per_page: newLimit }),
|
||
});
|
||
Toast.success(`Righe per pagina aggiornate a ${newLimit}!`);
|
||
this.perPage = newLimit;
|
||
this.currentPage = 1;
|
||
this.render();
|
||
} catch (err) {
|
||
Toast.error('Errore durante il salvataggio dell\'impostazione: ' + err.message);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
updateBatchBar() {
|
||
const count = document.getElementById('batch-count');
|
||
if (count) {
|
||
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
||
}
|
||
|
||
// Enable/disable add to group button
|
||
const addGroupBtn = document.getElementById('batch-add-group');
|
||
if (addGroupBtn) {
|
||
addGroupBtn.disabled = this.selectedIds.size === 0;
|
||
}
|
||
|
||
// Enable/disable merge button
|
||
const mergeBtn = document.getElementById('batch-merge');
|
||
if (mergeBtn) {
|
||
if (this.selectedIds.size >= 2) {
|
||
mergeBtn.disabled = false;
|
||
} else {
|
||
mergeBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
// Enable/disable open tabs button
|
||
const openTabsBtn = document.getElementById('batch-open-tabs');
|
||
if (openTabsBtn) {
|
||
if (this.selectedIds.size > 0) {
|
||
openTabsBtn.disabled = false;
|
||
} else {
|
||
openTabsBtn.disabled = true;
|
||
}
|
||
}
|
||
},
|
||
|
||
async applyBatch() {
|
||
if (this.selectedIds.size === 0) return;
|
||
|
||
const updates = {};
|
||
const batchState = document.getElementById('batch-state')?.value;
|
||
const batchQueue = document.getElementById('batch-queue')?.value;
|
||
const batchOwner = document.getElementById('batch-owner')?.value;
|
||
const batchResponsible = document.getElementById('batch-responsible')?.value;
|
||
const batchType = document.getElementById('batch-type')?.value;
|
||
const batchCustomerSearch = document.getElementById('batch-customer-search')?.value.trim();
|
||
let batchCustomerUserId = document.getElementById('batch-customer-user-id')?.value;
|
||
let batchCustomerId = document.getElementById('batch-customer-id')?.value;
|
||
|
||
if (!batchCustomerUserId && batchCustomerSearch) {
|
||
batchCustomerUserId = batchCustomerSearch;
|
||
if (!batchCustomerId) {
|
||
batchCustomerId = batchCustomerSearch;
|
||
}
|
||
}
|
||
|
||
if (batchState) updates.ticket_state_id = parseInt(batchState);
|
||
if (batchQueue) updates.queue_id = parseInt(batchQueue);
|
||
if (batchType) updates.type_id = parseInt(batchType);
|
||
if (batchOwner) updates.user_id = parseInt(batchOwner);
|
||
if (batchResponsible) {
|
||
updates.responsible_user_id = parseInt(batchResponsible);
|
||
} else if (batchOwner) {
|
||
updates.responsible_user_id = parseInt(batchOwner);
|
||
}
|
||
if (batchCustomerUserId) updates.customer_user_id = batchCustomerUserId;
|
||
if (batchCustomerId) updates.customer_id = batchCustomerId;
|
||
|
||
if (Object.keys(updates).length === 0) {
|
||
Toast.warning('Seleziona almeno un campo da modificare');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await App.api('/api/tickets/batch/update', {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({
|
||
ticket_ids: Array.from(this.selectedIds),
|
||
updates,
|
||
}),
|
||
});
|
||
Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`);
|
||
this.selectedIds.clear();
|
||
this.selectedOrder = [];
|
||
this.render();
|
||
} catch (err) {
|
||
Toast.error('Errore aggiornamento batch: ' + err.message);
|
||
}
|
||
},
|
||
|
||
async mergeBatch() {
|
||
if (this.selectedOrder.length < 2) return;
|
||
|
||
const targetId = this.selectedOrder[0];
|
||
const sourceIds = this.selectedOrder.slice(1);
|
||
|
||
// Get target ticket number
|
||
const targetRow = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${targetId}"]`);
|
||
const targetTn = targetRow ? targetRow.querySelector('.ticket-tn-link').textContent.trim() : targetId;
|
||
|
||
// Collect source ticket numbers
|
||
const sourceTns = [];
|
||
sourceIds.forEach(id => {
|
||
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
|
||
if (row) {
|
||
sourceTns.push(row.querySelector('.ticket-tn-link').textContent.trim());
|
||
} else {
|
||
sourceTns.push(`#${id}`);
|
||
}
|
||
});
|
||
|
||
const confirmed = await App.confirm('Unione Ticket', `Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`);
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const mergeBtn = document.getElementById('batch-merge');
|
||
if (mergeBtn) {
|
||
mergeBtn.disabled = true;
|
||
mergeBtn.textContent = 'Unione in corso...';
|
||
}
|
||
|
||
const res = await App.api('/api/tickets/merge', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
targetId: parseInt(targetId),
|
||
sourceIds: sourceIds.map(x => parseInt(x))
|
||
})
|
||
});
|
||
|
||
Toast.success(res.message || 'Ticket uniti con successo');
|
||
this.selectedIds.clear();
|
||
this.selectedOrder = [];
|
||
} catch (err) {
|
||
Toast.error('Errore durante l\'unione: ' + err.message);
|
||
this.updateBatchBar();
|
||
}
|
||
},
|
||
|
||
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;
|
||
}
|
||
|
||
// Show group selection prompt/dialog
|
||
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>
|
||
`;
|
||
|
||
// Append modal to body
|
||
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) {
|
||
// Conflict (already in group) or other errors
|
||
errorsCount++;
|
||
}
|
||
}
|
||
|
||
closeModal();
|
||
|
||
if (addedCount > 0) {
|
||
Toast.success(`${addedCount} ticket aggiunti al gruppo!`);
|
||
this.selectedIds.clear();
|
||
this.selectedOrder = [];
|
||
this.render();
|
||
} else if (errorsCount > 0) {
|
||
Toast.warning('I ticket selezionati appartengono già a questo gruppo.');
|
||
}
|
||
});
|
||
|
||
} catch (err) {
|
||
Toast.error('Errore durante il recupero dei gruppi: ' + err.message);
|
||
}
|
||
},
|
||
};
|