// ===== Admin Dashboard JS ===== function escapeHtml(value) { return String(value ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function activateTab(name, loadDetails = true) { document.querySelectorAll('.tab-btn').forEach(button => button.classList.toggle('active', button.dataset.tab === name)); document.querySelectorAll('.tab-content').forEach(content => content.classList.toggle('active', content.id === 'tab-' + name)); if (name === 'details' && loadDetails && !auditInitialized) loadAuditOptions(); } // ── Tab switching ── document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => activateTab(btn.dataset.tab)); }); // ── Logout ── document.getElementById('logoutBtn').addEventListener('click', async () => { await fetch('/api/logout', { method: 'POST' }); window.location.href = '/login'; }); // ── User info ── fetch('/api/me').then(r => r.json()).then(u => { document.getElementById('userBadge').textContent = u.username; }); // ── State ── let allUsers = []; let allFiles = []; // ── Files ── async function loadFiles() { const res = await fetch('/api/files'); allFiles = await res.json(); const grid = document.getElementById('filesList'); if (allFiles.length === 0) { grid.innerHTML = '

No CSV files found in the data folder.

'; return; } grid.innerHTML = allFiles.map(f => { return `
${escapeHtml(f.filename)}
${f.total_samples} samples ${f.total_assignees} assignee(s)
${f.total_reviews} total reviews
`; }).join(''); } // ── Users ── async function loadUsers() { const res = await fetch('/api/users'); allUsers = await res.json(); const tbody = document.getElementById('usersBody'); tbody.innerHTML = allUsers.map(u => { const roleClass = u.role === 'admin' ? 'role-admin' : 'role-annotator'; const created = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'; return ` ${u.id} ${escapeHtml(u.username)} ${escapeHtml(u.role)} ${created} ${u.role !== 'admin' ? `` : '—'} `; }).join(''); } // ── Create User ── document.getElementById('addUserBtn').addEventListener('click', () => { document.getElementById('createUserForm').style.display = 'block'; document.getElementById('newUsername').focus(); }); document.getElementById('cancelUserBtn').addEventListener('click', () => { document.getElementById('createUserForm').style.display = 'none'; document.getElementById('userFormError').textContent = ''; }); document.getElementById('submitUserBtn').addEventListener('click', async () => { const username = document.getElementById('newUsername').value.trim(); const password = document.getElementById('newPassword').value; const role = document.getElementById('newRole').value; const errEl = document.getElementById('userFormError'); errEl.textContent = ''; const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, role }) }); const data = await res.json(); if (!res.ok) { errEl.textContent = data.error; return; } document.getElementById('createUserForm').style.display = 'none'; document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; loadUsers(); loadAssignDropdowns(); }); async function deleteUser(id) { if (!confirm('Delete this user?')) return; await fetch(`/api/users/${id}`, { method: 'DELETE' }); loadUsers(); loadAssignments(); } // ── Assignments ── async function loadAssignments() { const res = await fetch('/api/assignments'); const data = await res.json(); const tbody = document.getElementById('assignBody'); if (data.length === 0) { tbody.innerHTML = 'No assignments yet'; return; } tbody.innerHTML = data.map(a => { const pct = a.progress; const barColor = pct >= 100 ? '#34d399' : pct > 50 ? '#fbbf24' : '#60a5fa'; const date = a.assigned_at ? new Date(a.assigned_at).toLocaleDateString() : '—'; return ` ${escapeHtml(a.username)} ${escapeHtml(a.filename)}
${a.reviewed_count}/${a.total_samples} (${pct}%)
${date} `; }).join(''); } async function loadAssignDropdowns() { // Populate user dropdown const userSel = document.getElementById('assignUser'); const fileSel = document.getElementById('assignFile'); const usersRes = await fetch('/api/users'); const users = await usersRes.json(); const filesRes = await fetch('/api/files'); const files = await filesRes.json(); userSel.innerHTML = '' + users.filter(u => u.role === 'annotator').map(u => ``).join(''); fileSel.innerHTML = '' + files.map(f => ``).join(''); } document.getElementById('addAssignBtn').addEventListener('click', () => { document.getElementById('createAssignForm').style.display = 'block'; loadAssignDropdowns(); }); document.getElementById('cancelAssignBtn').addEventListener('click', () => { document.getElementById('createAssignForm').style.display = 'none'; document.getElementById('assignFormError').textContent = ''; }); document.getElementById('submitAssignBtn').addEventListener('click', async () => { const user_id = document.getElementById('assignUser').value; const file_id = document.getElementById('assignFile').value; const errEl = document.getElementById('assignFormError'); errEl.textContent = ''; if (!user_id || !file_id) { errEl.textContent = 'Select both user and file'; return; } const res = await fetch('/api/assignments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: parseInt(user_id), file_id: parseInt(file_id) }) }); const data = await res.json(); if (!res.ok) { errEl.textContent = data.error; return; } document.getElementById('createAssignForm').style.display = 'none'; auditInitialized = false; loadAssignments(); loadFiles(); }); async function deleteAssignment(id) { if (!confirm('Remove this assignment?')) return; await fetch(`/api/assignments/${id}`, { method: 'DELETE' }); auditInitialized = false; loadAssignments(); loadFiles(); } // ── Read-only review audit by CSV part ── let auditInitialized = false; let auditFiles = []; let auditAssignments = []; let auditRows = []; let auditCurrentFile = null; let auditCurrentAssignment = null; let auditOptionsToken = 0; let auditDataToken = 0; let auditModalTrigger = null; async function auditFetchJson(url) { const response = await fetch(url); const data = await response.json(); if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); return data; } function auditPartNumber(filename) { const match = filename.match(/part-(\d+)-of-(\d+)\.csv$/i); return match ? Number(match[1]) : Infinity; } function auditPartLabel(filename) { const match = filename.match(/part-(\d+)-of-(\d+)\.csv$/i); return match ? `Part ${match[1]}/${match[2]} · ${filename}` : filename; } function fillAuditAssignmentSelect(preferredId) { const fileId = Number(document.getElementById('auditFileSelect').value); const select = document.getElementById('auditAssignmentSelect'); const matches = auditAssignments.filter(assignment => assignment.file_id === fileId); if (!matches.length) { select.innerHTML = ''; select.value = ''; select.disabled = true; return; } const previous = Number(preferredId) || Number(select.value); select.disabled = false; select.innerHTML = matches.map(assignment => `` ).join(''); select.value = String(matches.some(assignment => assignment.id === previous) ? previous : matches[0].id); } async function loadAuditOptions(preferredFileId, preferredAssignmentId) { const token = ++auditOptionsToken; auditDataToken++; document.getElementById('auditStatus').textContent = 'Loading CSV parts...'; try { const [files, assignments] = await Promise.all([ auditFetchJson('/api/files'), auditFetchJson('/api/assignments'), ]); if (token !== auditOptionsToken) return; auditFiles = files.sort((left, right) => auditPartNumber(left.filename) - auditPartNumber(right.filename) || left.filename.localeCompare(right.filename)); auditAssignments = assignments; const select = document.getElementById('auditFileSelect'); const preferred = Number(preferredFileId) || Number(select.value); const chosen = auditFiles.find(file => file.id === preferred) || auditFiles[0]; select.innerHTML = auditFiles.map(file => ``).join(''); if (!chosen) { auditRows = []; document.getElementById('auditSummary').innerHTML = ''; document.getElementById('auditSamplesBody').innerHTML = ''; document.getElementById('auditStatus').textContent = 'No CSV files available.'; return; } select.value = String(chosen.id); fillAuditAssignmentSelect(preferredAssignmentId); auditInitialized = true; await loadAuditPart(); } catch (error) { if (token === auditOptionsToken) document.getElementById('auditStatus').textContent = `Could not load review details: ${error.message}`; } } async function loadAuditPart() { const token = ++auditDataToken; const fileId = Number(document.getElementById('auditFileSelect').value); const assignmentId = Number(document.getElementById('auditAssignmentSelect').value) || null; if (!fileId) return; document.getElementById('auditStatus').textContent = 'Loading sample reviews...'; document.getElementById('auditSummary').innerHTML = ''; document.getElementById('auditSamplesBody').innerHTML = ''; try { const [fileData, reviews] = await Promise.all([ auditFetchJson(`/api/files/${fileId}/data`), assignmentId ? auditFetchJson(`/api/assignments/${assignmentId}/reviews`) : Promise.resolve({}), ]); if (token !== auditDataToken) return; auditCurrentFile = auditFiles.find(file => file.id === fileId) || null; auditCurrentAssignment = auditAssignments.find(assignment => assignment.id === assignmentId) || null; auditRows = ReviewAudit.buildAuditRows(fileData.samples || [], reviews); renderAuditSummary(); renderAuditRows(); } catch (error) { if (token === auditDataToken) document.getElementById('auditStatus').textContent = `Could not load this part: ${error.message}`; } } function renderAuditSummary() { const metrics = ReviewAudit.summarize(auditRows); const tiles = [ [`${metrics.completed}/${metrics.total}`, 'Completed samples'], [metrics.drafts, 'Drafts in progress'], [metrics.editedSamples, 'Completed samples with edits'], [`${metrics.explainedSections}/${metrics.changedSections}`, 'Changed sections with comment'], [metrics.needsExplanation, 'Changed sections without comment', true], [metrics.unchangedSamples, 'Completed without edits'], ...ReviewAudit.SECTIONS.map(section => [metrics.bySection[section.key], `${section.label} changed`]), ]; document.getElementById('auditSummary').innerHTML = tiles.map(([value, label, warning]) => `
${value}${label}
` ).join(''); } function auditChip(label, state) { return `${escapeHtml(label)}`; } function auditSectionChip(section) { const labels = { edited: 'Edited', kept: 'Kept', 'draft-edit': 'Draft edit', draft: 'Draft', none: '—' }; const state = section.state.startsWith('draft') ? 'draft' : section.state; const warning = section.needsExplanation ? ' !' : ''; return auditChip(labels[section.state] + warning, state); } function renderAuditRows() { const filter = document.getElementById('auditFilterSelect').value; const shown = ReviewAudit.filterRows(auditRows, filter); const tbody = document.getElementById('auditSamplesBody'); tbody.innerHTML = shown.length ? shown.map(audit => { const row = audit.row; const status = audit.status === 'complete' ? auditChip('Complete', 'complete') : audit.status === 'draft' ? auditChip('Draft', 'draft') : auditChip('Not started', 'none'); const missing = audit.needsExplanationCount; return ` #${row.sample_index + 1}${escapeHtml(row.sample_id)}Gold: ${escapeHtml(row.emotion_type || '—')} ${status} ${ReviewAudit.SECTIONS.map(section => `${auditSectionChip(audit.sections[section.key])}`).join('')} ${audit.commentCount || '—'} ${missing ? `${missing} needs explanation` : '—'} `; }).join('') : 'No samples match this filter.'; tbody.querySelectorAll('[data-audit-sample]').forEach(button => { button.addEventListener('click', () => openAuditSample(Number(button.dataset.auditSample), button)); }); const reviewer = auditCurrentAssignment ? auditCurrentAssignment.username : 'No reviewer assigned'; document.getElementById('auditStatus').textContent = `${auditCurrentFile?.filename || 'Part'} · ${reviewer} · Showing ${shown.length}/${auditRows.length} samples`; } function auditPath(path) { return path.map(part => /^\d+$/.test(part) ? `#${Number(part) + 1}` : part.replace(/_/g, ' ').replace(/\b\w/g, letter => letter.toUpperCase())).join(' › '); } function auditValue(value) { if (value === undefined || value === null || value === '') return '∅'; return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value); } function renderAuditSection(audit, definition) { const section = audit.sections[definition.key]; const changes = ReviewAudit.diffValues(audit.original[definition.key], audit.current[definition.key]); const comments = section.comments.map(note => `
${note.path.length ? `${escapeHtml(auditPath(note.path))}
` : ''}${escapeHtml(note.text)}
` ).join(''); const diff = changes.map(change => `
${escapeHtml(auditPath(change.path) || definition.label)}
Original${escapeHtml(auditValue(change.before))}
Reviewed${escapeHtml(auditValue(change.after))}
`).join(''); return `

${definition.label}

${auditSectionChip(section)}
${section.needsExplanation ? '

Changed without a section-specific reviewer comment.

' : ''} ${comments || '

No reviewer comment for this section.

'} ${diff || '

No annotation changes.

'}
View full annotation
Original
${escapeHtml(auditValue(audit.original[definition.key]))}
Reviewed / current
${escapeHtml(auditValue(audit.current[definition.key]))}
`; } function openAuditSample(sampleIndex, trigger) { const audit = auditRows.find(item => item.row.sample_index === sampleIndex); if (!audit) return; const modal = document.getElementById('auditModal'); auditModalTrigger = trigger; document.getElementById('auditModalTitle').textContent = `Sample #${sampleIndex + 1} · ${audit.row.sample_id || ''}`; const reviewedAt = audit.review?.reviewed_at ? new Date(audit.review.reviewed_at).toLocaleString() : null; document.getElementById('auditModalSubtitle').textContent = `${auditCurrentFile?.filename || ''} · ${auditCurrentAssignment?.username || 'Unassigned'} · ${audit.status} · Gold emotion: ${audit.row.emotion_type || '—'}` + (reviewedAt ? ` · Last completed ${reviewedAt}` : ''); const caution = audit.needsExplanationCount ? `

${audit.needsExplanationCount} changed section(s) lack a reviewer explanation. Please inspect manually.

` : '

No comment-coverage warning. This is not a correctness score.

'; const legacyComment = audit.review?.comment?.trim() ? `
Overall legacy comment
${escapeHtml(audit.review.comment)}
` : ''; document.getElementById('auditModalContent').innerHTML = ` ${caution}${legacyComment}

Conversation

${escapeHtml(audit.row.conversation_cut || '')}
${ReviewAudit.SECTIONS.map(section => renderAuditSection(audit, section)).join('')}`; modal.hidden = false; modal.setAttribute('aria-hidden', 'false'); document.getElementById('auditModalCloseBtn').focus(); } function closeAuditSample() { const modal = document.getElementById('auditModal'); if (modal.hidden) return; modal.hidden = true; modal.setAttribute('aria-hidden', 'true'); document.getElementById('auditModalContent').innerHTML = ''; auditModalTrigger?.focus(); auditModalTrigger = null; } async function openAuditFor(fileId, assignmentId) { activateTab('details', false); await loadAuditOptions(fileId, assignmentId); } document.getElementById('auditFileSelect').addEventListener('change', () => { fillAuditAssignmentSelect(); loadAuditPart(); }); document.getElementById('auditAssignmentSelect').addEventListener('change', loadAuditPart); document.getElementById('auditFilterSelect').addEventListener('change', renderAuditRows); document.getElementById('auditRefreshBtn').addEventListener('click', () => loadAuditOptions(Number(document.getElementById('auditFileSelect').value), Number(document.getElementById('auditAssignmentSelect').value))); document.getElementById('auditModalCloseBtn').addEventListener('click', closeAuditSample); document.getElementById('auditModal').addEventListener('click', event => { if (event.target === event.currentTarget) closeAuditSample(); }); document.addEventListener('keydown', event => { if (event.key === 'Escape' && !document.getElementById('auditModal').hidden) closeAuditSample(); }); // ── Init ── loadFiles(); loadUsers(); loadAssignments();