Add user bug reporting with admin triage (open/read/resolved)

Turns the /report-bug placeholder into a real form (POST /bug-reports,
optionally attributed to the logged-in user) and adds a "Segnalazioni bug"
section to /admin to view and triage them. A logged-in reporter can also
check their own report's status via GET /bug-reports/mine, since anonymous
submissions have no user to show a history to.

Status is a three-state lifecycle (open -> read -> resolved) rather than a
plain boolean, so an admin can acknowledge a report distinctly from actually
fixing it. The schema went through two migrations because the first one
(add bug_reports table) had already been applied against the running
instance with a `resolved` boolean before the three-state design was
decided, so a follow-up migration backfills it into `status` instead of
rewriting already-applied history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:50:40 +02:00
co-authored by Claude Sonnet 5
parent 977bb762c7
commit ee4e845c89
12 changed files with 628 additions and 8 deletions
+8
View File
@@ -133,6 +133,7 @@ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
th { color: var(--color-muted-foreground); font-weight: 500; }
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
td.payload-cell { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
.table-wrap { overflow-x: auto; }
.badge {
@@ -144,6 +145,13 @@ td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; m
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
}
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
.bug-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.bug-actions button { width: auto; margin-top: 0; min-height: 30px; padding: 4px 10px; font-size: 0.78rem; }
button.reveal {
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
+17
View File
@@ -30,6 +30,7 @@
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
<span class="nav-tab" id="nav-bugreports" onclick="switchView('bugreports')">Segnalazioni bug</span>
<span class="spacer"></span>
<span class="chain-status-pill">
<span class="status-dot" id="chain-status-dot"></span>
@@ -153,6 +154,22 @@
</div>
</div>
<div class="view" id="view-bugreports">
<h2 class="section-title">Segnalazioni bug</h2>
<p class="hint">Segnalazioni inviate dagli utenti tramite la pagina "Segnala un bug".</p>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr><th>ID</th><th>Descrizione</th><th>Contatto</th><th>Utente</th><th>Quando</th><th>Stato</th></tr>
</thead>
<tbody id="bugreports-tbody"></tbody>
</table>
</div>
</div>
</div>
</main>
</div>
+46 -3
View File
@@ -89,8 +89,8 @@ function stopChainStatusPolling() {
chainStatusInterval = null;
}
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit'];
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog };
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
let currentAdminView = 'parametri';
function switchView(name) {
@@ -109,7 +109,7 @@ function showDashboard() {
}
async function loadDashboard() {
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]);
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
}
async function adminLogin() {
@@ -346,6 +346,49 @@ async function loadAuditLog() {
}
}
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Letta', resolved: 'Risolta' };
function bugReportBadge(status) {
return `<span class="badge bug-status-${escapeHtml(status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}</span>`;
}
async function loadBugReports() {
try {
const reports = await callAdmin('GET', '/admin/bug-reports');
const tbody = document.getElementById('bugreports-tbody');
tbody.innerHTML = reports.map((r) => `
<tr>
<td>${r.id}</td>
<td class="payload-cell">${escapeHtml(r.description)}</td>
<td>${r.contact ? escapeHtml(r.contact) : '—'}</td>
<td>${r.username ? escapeHtml(r.username) : '—'}</td>
<td>${fmtDate(r.created_at)}</td>
<td>
${bugReportBadge(r.status)}
<div class="bug-actions">
${r.status === 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'read', this)">Segna come letta</button>` : ''}
${r.status !== 'resolved' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'resolved', this)">Segna come risolta</button>` : ''}
${r.status !== 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'open', this)">Riapri</button>` : ''}
</div>
</td>
</tr>
`).join('') || '<tr><td colspan="6" class="hint">Nessuna segnalazione ricevuta.</td></tr>';
} catch (e) {
toast('Errore nel caricamento segnalazioni: ' + e.message, 'error');
}
}
async function setBugReportStatus(reportId, newStatus, button) {
await withLoading(button, '…', async () => {
try {
await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus });
await loadBugReports();
} catch (e) {
toast('Errore: ' + e.message, 'error');
}
});
}
document.getElementById('admin-token').addEventListener('keydown', (e) => {
if (e.key === 'Enter') adminLogin();
});
+103 -1
View File
@@ -9,8 +9,110 @@
<body>
<div class="app-shell">
<h1>Segnala un bug</h1>
<p>Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.</p>
<p>Hai trovato un problema? Descrivilo qui sotto: la segnalazione arriva direttamente al pannello di amministrazione.</p>
<div class="card" id="report-form">
<label for="bug-description">Cosa è successo?</label>
<textarea id="bug-description" rows="6" maxlength="5000" placeholder="Descrivi il bug: cosa stavi facendo, cosa ti aspettavi e cosa è successo invece."></textarea>
<label for="bug-contact">Contatto (opzionale)</label>
<input id="bug-contact" type="text" maxlength="256" placeholder="Email o altro recapito, se vuoi essere ricontattato">
<button onclick="submitBugReport()" id="bug-submit-btn">Invia segnalazione</button>
</div>
<div class="card hidden" id="my-reports-card">
<h2>Le tue segnalazioni</h2>
<p class="hint">Solo le segnalazioni inviate da questo account, con lo stato aggiornato dall'amministrazione.</p>
<div id="my-reports-list"></div>
</div>
<p><a class="link" href="/">&larr; Torna alla home</a></p>
</div>
<div id="toast-container" aria-live="polite"></div>
<script>
function toast(message, type) {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Letta', resolved: 'Risolta' };
async function submitBugReport() {
const btn = document.getElementById('bug-submit-btn');
const description = document.getElementById('bug-description').value.trim();
const contact = document.getElementById('bug-contact').value.trim();
if (!description) {
toast('Descrivi il bug prima di inviare.', 'error');
return;
}
const headers = { 'Content-Type': 'application/json' };
const token = localStorage.getItem('plm_token');
if (token) headers['Authorization'] = 'Bearer ' + token;
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Invio…';
try {
const res = await fetch('/bug-reports', {
method: 'POST',
headers,
body: JSON.stringify({ description, contact: contact || null }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
document.getElementById('bug-description').value = '';
document.getElementById('bug-contact').value = '';
toast('Grazie! Segnalazione inviata.', 'success');
loadMyBugReports();
} catch (e) {
toast('Errore nell\'invio: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = original;
}
}
async function loadMyBugReports() {
const token = localStorage.getItem('plm_token');
const card = document.getElementById('my-reports-card');
if (!token) {
card.classList.add('hidden');
return;
}
try {
const res = await fetch('/bug-reports/mine', { headers: { Authorization: 'Bearer ' + token } });
if (!res.ok) {
card.classList.add('hidden');
return;
}
const reports = await res.json();
card.classList.remove('hidden');
const list = document.getElementById('my-reports-list');
list.innerHTML = reports.map((r) => `
<div class="my-report-row">
<span class="badge bug-status-${escapeHtml(r.status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[r.status] || r.status)}</span>
<span class="my-report-desc">${escapeHtml(r.description)}</span>
<span class="my-report-date">${new Date(r.created_at).toLocaleString('it-IT')}</span>
</div>
`).join('') || '<p class="hint">Non hai ancora inviato segnalazioni.</p>';
} catch (e) {
card.classList.add('hidden');
}
}
loadMyBugReports();
</script>
</body>
</html>
+20 -3
View File
@@ -74,16 +74,17 @@ h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; }
label:first-child { margin-top: 0; }
input {
input, textarea {
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface);
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
}
input:focus {
textarea { resize: vertical; }
input:focus, textarea:focus {
outline: none; border-color: var(--color-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
}
input:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
input:disabled, textarea:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
button {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
@@ -253,6 +254,22 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.badge {
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
}
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
.my-report-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
padding: 10px 0; border-bottom: 1px solid var(--color-border);
}
.my-report-row:last-child { border-bottom: none; }
.my-report-desc { flex: 1 1 200px; font-size: 0.9rem; }
.my-report-date { font-size: 0.8rem; color: var(--color-muted-foreground); white-space: nowrap; }
/* --- landing hero (shown only when logged out) --- */
body {
position: relative;