Empathy-Label / static /admin.js
Anonymous-EmpathyAI's picture
Add per-part admin review inspection
cb49a8f verified
Raw History Blame Contribute Delete
20.9 kB
// ===== Admin Dashboard JS =====
function escapeHtml(value) {
return String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 = '<div class="empty-state"><p>No CSV files found in the data folder.</p></div>';
return;
}
grid.innerHTML = allFiles.map(f => {
return `
<div class="file-card" style="cursor:default">
<div class="file-card-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent);flex-shrink:0">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span class="file-card-name">${escapeHtml(f.filename)}</span>
</div>
<div class="file-card-stats">
<span>${f.total_samples} samples</span>
<span>${f.total_assignees} assignee(s)</span>
</div>
<div class="file-card-stats" style="margin-top:4px">
<span>${f.total_reviews} total reviews</span>
</div>
<button class="ghost-btn" type="button" onclick="openAuditFor(${Number(f.id)})">View review details</button>
</div>`;
}).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 `<tr>
<td>${u.id}</td>
<td style="font-weight:600;color:var(--text-primary)">${escapeHtml(u.username)}</td>
<td><span class="role-badge ${roleClass}">${escapeHtml(u.role)}</span></td>
<td>${created}</td>
<td>${u.role !== 'admin' ? `<button class="danger-btn" onclick="deleteUser(${u.id})">Delete</button>` : '—'}</td>
</tr>`;
}).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 = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted)">No assignments yet</td></tr>';
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 `<tr>
<td style="font-weight:600;color:var(--text-primary)">${escapeHtml(a.username)}</td>
<td>${escapeHtml(a.filename)}</td>
<td>
<div class="progress-cell">
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%;background:${barColor}"></div></div>
<span class="progress-text" style="color:${barColor}">${a.reviewed_count}/${a.total_samples} (${pct}%)</span>
</div>
</td>
<td>${date}</td>
<td>
<button class="ghost-btn" type="button" onclick="openAuditFor(${Number(a.file_id)},${Number(a.id)})">Inspect</button>
<button class="danger-btn" onclick="deleteAssignment(${a.id})">Remove</button>
</td>
</tr>`;
}).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 = '<option value="">Select User...</option>' +
users.filter(u => u.role === 'annotator').map(u => `<option value="${u.id}">${escapeHtml(u.username)}</option>`).join('');
fileSel.innerHTML = '<option value="">Select File...</option>' +
files.map(f => `<option value="${f.id}">${escapeHtml(f.filename)} (${f.total_samples} samples)</option>`).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 = '<option value="">No reviewer assigned</option>';
select.value = '';
select.disabled = true;
return;
}
const previous = Number(preferredId) || Number(select.value);
select.disabled = false;
select.innerHTML = matches.map(assignment =>
`<option value="${assignment.id}">${escapeHtml(assignment.username)} · ${assignment.reviewed_count}/${assignment.total_samples} complete</option>`
).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 =>
`<option value="${file.id}">${escapeHtml(auditPartLabel(file.filename))}</option>`).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]) =>
`<div class="audit-metric ${warning && value ? 'is-warning' : ''}"><strong>${value}</strong><span>${label}</span></div>`
).join('');
}
function auditChip(label, state) {
return `<span class="audit-chip is-${state}">${escapeHtml(label)}</span>`;
}
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 `<tr>
<td><strong>#${row.sample_index + 1}</strong><span class="audit-sample-id">${escapeHtml(row.sample_id)}</span><span class="audit-sample-id">Gold: ${escapeHtml(row.emotion_type || '—')}</span></td>
<td>${status}</td>
${ReviewAudit.SECTIONS.map(section => `<td>${auditSectionChip(audit.sections[section.key])}</td>`).join('')}
<td>${audit.commentCount || '—'}</td>
<td>${missing ? `<span class="audit-check">${missing} needs explanation</span>` : '<span class="audit-check is-clear">—</span>'}</td>
<td><button class="ghost-btn audit-inspect-btn" type="button" data-audit-sample="${row.sample_index}">Inspect</button></td>
</tr>`;
}).join('') : '<tr><td colspan="9" class="audit-empty-row">No samples match this filter.</td></tr>';
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 =>
`<div class="audit-comment">${note.path.length ? `<strong>${escapeHtml(auditPath(note.path))}</strong><br>` : ''}${escapeHtml(note.text)}</div>`
).join('');
const diff = changes.map(change => `<div class="audit-field-change">
<strong>${escapeHtml(auditPath(change.path) || definition.label)}</strong>
<div class="audit-diff-grid">
<div><span>Original</span>${escapeHtml(auditValue(change.before))}</div>
<div><span>Reviewed</span>${escapeHtml(auditValue(change.after))}</div>
</div>
</div>`).join('');
return `<section class="audit-section">
<div class="audit-section-head"><h3>${definition.label}</h3>${auditSectionChip(section)}</div>
${section.needsExplanation ? '<p class="audit-warning">Changed without a section-specific reviewer comment.</p>' : ''}
${comments || '<p class="audit-check is-clear">No reviewer comment for this section.</p>'}
${diff || '<p class="audit-check is-clear">No annotation changes.</p>'}
<details><summary>View full annotation</summary>
<div class="audit-diff-grid">
<div><span>Original</span><pre class="audit-json">${escapeHtml(auditValue(audit.original[definition.key]))}</pre></div>
<div><span>Reviewed / current</span><pre class="audit-json">${escapeHtml(auditValue(audit.current[definition.key]))}</pre></div>
</div>
</details>
</section>`;
}
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
? `<p class="audit-warning">${audit.needsExplanationCount} changed section(s) lack a reviewer explanation. Please inspect manually.</p>`
: '<p class="audit-check is-clear">No comment-coverage warning. This is not a correctness score.</p>';
const legacyComment = audit.review?.comment?.trim()
? `<div class="audit-comment"><strong>Overall legacy comment</strong><br>${escapeHtml(audit.review.comment)}</div>` : '';
document.getElementById('auditModalContent').innerHTML = `
${caution}${legacyComment}
<div class="audit-conversation"><h3>Conversation</h3><pre>${escapeHtml(audit.row.conversation_cut || '')}</pre></div>
${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();