feat: aggiunta tracciatura delle modifiche effettuate

This commit is contained in:
2026-07-07 21:51:58 +02:00
parent 3f6d2cc3a8
commit bf8b4c387e
10 changed files with 914 additions and 0 deletions
+5
View File
@@ -138,6 +138,11 @@ const App = {
titleEl.textContent = 'Apertura Massiva Ticket';
TicketBulkView.render();
} else if (hash === '#/activity') {
document.getElementById('nav-activity')?.classList.add('active');
titleEl.textContent = 'Storico Attività';
ActivityLogView.render();
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
document.getElementById('nav-tickets')?.classList.add('active');
+213
View File
@@ -0,0 +1,213 @@
/**
* ActivityLogView — Storico Attività
* Displays the local SQLite activity log with filters, dual pagination,
* and an expandable JSON detail panel.
*/
const ActivityLogView = {
currentPage: 1,
perPage: 50,
filters: { esito: '', agente_id: '', da: '', a: '' },
async render() {
this.currentPage = 1;
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento storico...</p></div>';
await this._draw();
},
async _draw() {
const container = document.getElementById('view-container');
try {
const params = new URLSearchParams({
page: this.currentPage,
per_page: this.perPage,
});
if (this.filters.esito) params.set('esito', this.filters.esito);
if (this.filters.agente_id) params.set('agente_id', this.filters.agente_id);
if (this.filters.da) params.set('da', this.filters.da);
if (this.filters.a) params.set('a', this.filters.a);
const data = await App.api(`/api/attivita?${params}`);
const { rows, total, page, per_page, total_pages } = data;
container.innerHTML = this._buildHtml(rows, total, page, per_page, total_pages);
this._bind();
} catch (err) {
container.innerHTML = `<div class="empty-state"><p style="color:var(--danger)">Errore caricamento: ${App.escapeHtml(err.message)}</p></div>`;
}
},
_buildHtml(rows, total, page, per_page, total_pages) {
const filtersHtml = this._buildFilters();
const paginationHtml = this._buildPagination(total, page, per_page, total_pages);
const tableHtml = rows.length === 0
? `<div class="empty-state" style="padding:var(--space-2xl);text-align:center;color:var(--text-muted);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px;height:48px;margin:0 auto var(--space-md);display:block;opacity:0.4;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<p>Nessuna attività registrata.</p>
</div>`
: `<div class="table-wrapper" style="overflow-x:auto;">
<table class="tickets-table" id="activity-table" style="width:100%;">
<thead>
<tr>
<th style="width:160px;">Data/Ora</th>
<th style="width:160px;">Agente</th>
<th style="width:180px;">Azione</th>
<th style="min-width:60px;text-align:center;">Esito</th>
<th style="width:48px;text-align:center;">≡</th>
</tr>
</thead>
<tbody>
${rows.map(r => this._buildRow(r)).join('')}
</tbody>
</table>
</div>`;
return `
<div class="view-header" style="padding:var(--space-md) var(--space-xl);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--space-sm);border-bottom:1px solid var(--border-subtle);">
<div style="display:flex;align-items:center;gap:var(--space-sm);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px;height:20px;color:var(--primary);"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span style="font-weight:600;font-size:1rem;">Storico Attività</span>
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);font-size:0.75rem;padding:2px 8px;border-radius:12px;">${total} record</span>
</div>
</div>
${filtersHtml}
${paginationHtml}
${tableHtml}
${rows.length > 0 ? paginationHtml.replace(/id="pagination-top"/g,'id="pagination-bottom"') : ''}
`;
},
_buildRow(r) {
const dt = r.creato_il ? new Date(r.creato_il).toLocaleString('it-IT') : '—';
const esitoBadge = r.esito === 'successo'
? `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(34,197,94,0.15);color:#16a34a;font-size:0.75rem;font-weight:600;">✓ successo</span>`
: `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(239,68,68,0.15);color:#dc2626;font-size:0.75rem;font-weight:600;">✕ errore</span>`;
return `
<tr id="row-${r.id}" data-id="${r.id}">
<td style="font-size:0.8rem;color:var(--text-secondary);white-space:nowrap;">${dt}</td>
<td style="font-size:0.85rem;">${App.escapeHtml(r.agente_nome || '—')}</td>
<td style="font-size:0.85rem;font-weight:500;">${App.escapeHtml(r.titolo_azione)}</td>
<td style="text-align:center;">${esitoBadge}</td>
<td style="text-align:center;">
<button class="btn btn-ghost btn-sm act-detail-btn" data-id="${r.id}" title="Mostra dettaglio" style="padding:4px 8px;font-size:0.85rem;">≡</button>
</td>
</tr>
<tr id="detail-${r.id}" class="act-detail-row" style="display:none;">
<td colspan="5" style="padding:0 var(--space-md) var(--space-md);background:var(--bg-secondary);">
<pre style="margin:0;padding:var(--space-md);background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:0.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-primary);border:1px solid var(--border-subtle);">${App.escapeHtml(this._prettyJson(r.azione))}</pre>
</td>
</tr>
`;
},
_prettyJson(str) {
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch (_) {
return str || '';
}
},
_buildFilters() {
return `
<div id="activity-filters" style="display:flex;flex-wrap:wrap;gap:var(--space-sm);padding:var(--space-md) var(--space-xl);border-bottom:1px solid var(--border-subtle);background:var(--bg-secondary);align-items:flex-end;">
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">Esito</label>
<select id="filter-esito" class="form-select" style="min-width:120px;height:36px;font-size:0.85rem;padding:6px 28px 6px 10px;">
<option value="">Tutti</option>
<option value="successo" ${this.filters.esito === 'successo' ? 'selected' : ''}>✓ Successo</option>
<option value="errore" ${this.filters.esito === 'errore' ? 'selected' : ''}>✕ Errore</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">Da data</label>
<input type="datetime-local" id="filter-da" class="form-input" value="${this.filters.da}" style="height:36px;font-size:0.85rem;padding:6px 10px;">
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:0.75rem;color:var(--text-muted);font-weight:500;">A data</label>
<input type="datetime-local" id="filter-a" class="form-input" value="${this.filters.a}" style="height:36px;font-size:0.85rem;padding:6px 10px;">
</div>
<div style="display:flex;gap:var(--space-xs);align-self:flex-end;">
<button id="btn-apply-filters" class="btn btn-primary btn-sm" style="height:36px;">Applica</button>
<button id="btn-reset-filters" class="btn btn-ghost btn-sm" style="height:36px;">Reset</button>
</div>
</div>
`;
},
_buildPagination(total, page, per_page, total_pages) {
if (total_pages <= 1) return '';
const from = (page - 1) * per_page + 1;
const to = Math.min(page * per_page, total);
const pageBtn = (p, label, disabled, active) => {
const isDisabled = disabled || p === page;
return `<button class="btn btn-ghost btn-sm page-btn" data-page="${p}"
style="min-width:36px;height:32px;${active ? 'background:var(--primary);color:#fff;' : ''}${isDisabled ? 'opacity:0.4;pointer-events:none;' : ''}"
${disabled || active ? 'disabled' : ''}>${label}</button>`;
};
const pages = [];
pages.push(pageBtn(1, '«', page === 1, false));
pages.push(pageBtn(page - 1, '', page === 1, false));
const rangeStart = Math.max(1, page - 2);
const rangeEnd = Math.min(total_pages, page + 2);
for (let p = rangeStart; p <= rangeEnd; p++) {
pages.push(pageBtn(p, p, false, p === page));
}
pages.push(pageBtn(page + 1, '', page === total_pages, false));
pages.push(pageBtn(total_pages, '»', page === total_pages, false));
return `
<div id="pagination-top" style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-sm) var(--space-xl);flex-wrap:wrap;gap:var(--space-sm);">
<span style="font-size:0.8rem;color:var(--text-muted);">Record ${from}${to} di ${total}</span>
<div style="display:flex;gap:4px;flex-wrap:wrap;">${pages.join('')}</div>
</div>
`;
},
_bind() {
// Filter apply
document.getElementById('btn-apply-filters')?.addEventListener('click', () => {
this.filters.esito = document.getElementById('filter-esito')?.value || '';
this.filters.da = document.getElementById('filter-da')?.value || '';
this.filters.a = document.getElementById('filter-a')?.value || '';
this.currentPage = 1;
this._draw();
});
// Filter reset
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
this.filters = { esito: '', agente_id: '', da: '', a: '' };
this.currentPage = 1;
this._draw();
});
// Detail expand/collapse
document.querySelectorAll('.act-detail-btn').forEach(btn => {
btn.addEventListener('click', () => {
const rowId = btn.dataset.id;
const detailRow = document.getElementById(`detail-${rowId}`);
if (!detailRow) return;
const isOpen = detailRow.style.display !== 'none';
detailRow.style.display = isOpen ? 'none' : 'table-row';
btn.textContent = isOpen ? '≡' : '✕';
});
});
// Pagination buttons (top and bottom share same .page-btn class)
document.querySelectorAll('.page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const p = parseInt(btn.dataset.page, 10);
if (!isNaN(p)) {
this.currentPage = p;
this._draw();
document.getElementById('view-container')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
},
};