395 lines
15 KiB
JavaScript
395 lines
15 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');
|
||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||
|
||
try {
|
||
// Fetch lookups for filter dropdowns
|
||
await App.ensureLookups();
|
||
|
||
// Build query params
|
||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||
if (isMyTickets) {
|
||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||
Filters.state.user_id = activeAgentId;
|
||
}
|
||
|
||
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>
|
||
</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">
|
||
<span class="filter-label">Coda</span>
|
||
<select class="filter-select" id="batch-queue">
|
||
<option value="">—</option>
|
||
${(App.lookups.queues || []).map(q => `<option value="${q.id}">${q.name}</option>`).join('')}
|
||
</select>
|
||
</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>
|
||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: var(--accent-secondary); border-color: var(--accent-secondary); margin-left: 8px;">Unisci Selezionati</button>
|
||
<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="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>
|
||
<th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
|
||
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
||
<th>Owner</th>
|
||
<th>Cliente</th>
|
||
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${tickets.length > 0 ? tickets.map(t => `
|
||
<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"><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)}">${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-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
||
</tr>
|
||
`).join('') : `
|
||
<tr>
|
||
<td colspan="7">
|
||
<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 -->
|
||
${total_pages > 1 ? `
|
||
<div class="pagination">
|
||
<div class="pagination-info">
|
||
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
||
</div>
|
||
<div class="pagination-controls">
|
||
<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>
|
||
` : `
|
||
<div class="pagination">
|
||
<div class="pagination-info">${total} ticket totali</div>
|
||
<div></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 merge (Issue #7)
|
||
const batchMerge = document.getElementById('batch-merge');
|
||
if (batchMerge) {
|
||
batchMerge.addEventListener('click', () => this.mergeBatch());
|
||
}
|
||
|
||
// 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');
|
||
});
|
||
this.updateBatchBar();
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
});
|
||
});
|
||
},
|
||
|
||
updateBatchBar() {
|
||
const count = document.getElementById('batch-count');
|
||
if (count) {
|
||
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
||
}
|
||
|
||
// Enable/disable merge button
|
||
const mergeBtn = document.getElementById('batch-merge');
|
||
if (mergeBtn) {
|
||
if (this.selectedIds.size >= 2) {
|
||
mergeBtn.disabled = false;
|
||
} else {
|
||
mergeBtn.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;
|
||
|
||
if (batchState) updates.ticket_state_id = parseInt(batchState);
|
||
if (batchQueue) updates.queue_id = parseInt(batchQueue);
|
||
if (batchOwner) updates.user_id = parseInt(batchOwner);
|
||
|
||
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 = confirm(`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 = [];
|
||
this.render();
|
||
} catch (err) {
|
||
Toast.error('Errore durante l\'unione: ' + err.message);
|
||
this.updateBatchBar();
|
||
}
|
||
},
|
||
};
|