const state = { documents: [], reviewers: [], notifications: [], selectedId: null, selectedDocument: null, filter: "all", view: "documents", reviewerAction: "comment", }; const els = { fileInput: document.querySelector("#fileInput"), uploadButton: document.querySelector("#uploadButton"), dropZone: document.querySelector("#dropZone"), ownerName: document.querySelector("#ownerName"), ownerEmail: document.querySelector("#ownerEmail"), ownerSlack: document.querySelector("#ownerSlack"), ownerTeams: document.querySelector("#ownerTeams"), sideTabs: document.querySelectorAll(".side-tab"), documentsView: document.querySelector("#documentsView"), reviewersView: document.querySelector("#reviewersView"), notificationsView: document.querySelector("#notificationsView"), notificationBadge: document.querySelector("#notificationBadge"), filters: document.querySelectorAll(".filter"), documentList: document.querySelector("#documentList"), reviewerForm: document.querySelector("#reviewerForm"), reviewerId: document.querySelector("#reviewerId"), reviewerName: document.querySelector("#reviewerName"), reviewerEmail: document.querySelector("#reviewerEmail"), reviewerSlack: document.querySelector("#reviewerSlack"), reviewerTeams: document.querySelector("#reviewerTeams"), reviewerSaveButton: document.querySelector("#reviewerSaveButton"), reviewerCancelButton: document.querySelector("#reviewerCancelButton"), reviewerList: document.querySelector("#reviewerList"), notificationList: document.querySelector("#notificationList"), emptyState: document.querySelector("#emptyState"), documentPanel: document.querySelector("#documentPanel"), documentMeta: document.querySelector("#documentMeta"), documentTitle: document.querySelector("#documentTitle"), ownerLine: document.querySelector("#ownerLine"), statusPill: document.querySelector("#statusPill"), downloadButton: document.querySelector("#downloadButton"), reviewButton: document.querySelector("#reviewButton"), assignmentSummary: document.querySelector("#assignmentSummary"), assignmentForm: document.querySelector("#assignmentForm"), assignmentChoices: document.querySelector("#assignmentChoices"), assignmentList: document.querySelector("#assignmentList"), reviewerActionForm: document.querySelector("#reviewerActionForm"), reviewerActionSelect: document.querySelector("#reviewerActionSelect"), reviewerSignature: document.querySelector("#reviewerSignature"), reviewerNote: document.querySelector("#reviewerNote"), findings: document.querySelector("#findings"), findingSummary: document.querySelector("#findingSummary"), commentForm: document.querySelector("#commentForm"), commentBody: document.querySelector("#commentBody"), comments: document.querySelector("#comments"), commentCount: document.querySelector("#commentCount"), activityTimeline: document.querySelector("#activityTimeline"), activityCount: document.querySelector("#activityCount"), textPreview: document.querySelector("#textPreview"), wordCount: document.querySelector("#wordCount"), toast: document.querySelector("#toast"), }; async function api(path, options = {}) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.open(options.method || "GET", path, true); for (const [key, value] of Object.entries(options.headers || {})) { request.setRequestHeader(key, value); } request.onload = () => { const contentType = request.getResponseHeader("Content-Type") || ""; const payload = contentType.includes("application/json") && request.responseText ? JSON.parse(request.responseText) : null; if (request.status >= 200 && request.status < 300) { resolve(payload); } else { reject(new Error(payload?.error || `Request failed with ${request.status}`)); } }; request.onerror = () => reject(new Error("Request failed.")); request.send(options.body || null); }); } function showToast(message, isError = false) { els.toast.textContent = message; els.toast.classList.toggle("error", isError); els.toast.classList.remove("hidden"); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => els.toast.classList.add("hidden"), 3600); } function formatDate(value) { if (!value) return "Not recorded"; return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(new Date(value)); } function countsFor(document) { const counts = { high: 0, medium: 0, low: 0 }; for (const finding of document.findings || []) { counts[finding.severity] += 1; } return counts; } function contactLine(contact = {}) { return [contact.email, contact.slack, contact.teams].filter(Boolean).join(" | "); } async function loadAll() { const [documents, reviewers, notifications] = await Promise.all([ api("/api/documents"), api("/api/reviewers"), api("/api/notifications"), ]); state.documents = documents.documents; state.reviewers = reviewers.reviewers; state.notifications = notifications.notifications; renderNavigation(notifications.unread_count || 0); renderList(); renderReviewerList(); renderNotifications(); if (state.selectedId && state.documents.some((doc) => doc.id === state.selectedId)) { await selectDocument(state.selectedId); } else if (state.documents.length > 0 && !state.selectedId) { await selectDocument(state.documents[0].id); } else if (state.documents.length === 0) { renderEmpty(); } } async function refreshSelected() { const [documents, notifications] = await Promise.all([api("/api/documents"), api("/api/notifications")]); state.documents = documents.documents; state.notifications = notifications.notifications; renderNavigation(notifications.unread_count || 0); renderList(); renderNotifications(); if (state.selectedId) { state.selectedDocument = await api(`/api/documents/${state.selectedId}`); renderDocument(); } } function renderNavigation(unreadCount = 0) { els.sideTabs.forEach((button) => button.classList.toggle("active", button.dataset.view === state.view)); els.documentsView.classList.toggle("hidden", state.view !== "documents"); els.reviewersView.classList.toggle("hidden", state.view !== "reviewers"); els.notificationsView.classList.toggle("hidden", state.view !== "notifications"); els.notificationBadge.textContent = `${unreadCount}`; els.notificationBadge.classList.toggle("hidden", unreadCount === 0); } function renderList() { const filtered = state.documents.filter((document) => { if (state.filter === "all") return true; if (state.filter === "in_review") return document.status === "in_review" || document.status === "uploaded"; return document.status === state.filter; }); if (!filtered.length) { els.documentList.innerHTML = `

No documents in this view.

`; return; } els.documentList.innerHTML = filtered .map((document) => { const counts = countsFor(document); const assignments = document.assignment_counts || {}; return ` `; }) .join(""); els.documentList.querySelectorAll("[data-id]").forEach((button) => { button.addEventListener("click", () => selectDocument(button.dataset.id)); }); } function assignmentTotal(counts) { return (counts.pending || 0) + (counts.commented || 0) + (counts.changes_requested || 0) + (counts.approved || 0); } function renderEmpty() { state.selectedDocument = null; els.emptyState.classList.remove("hidden"); els.documentPanel.classList.add("hidden"); } async function selectDocument(id) { state.selectedId = id; state.selectedDocument = await api(`/api/documents/${id}`); renderList(); renderDocument(); } function renderDocument() { const document = state.selectedDocument; if (!document) { renderEmpty(); return; } els.emptyState.classList.add("hidden"); els.documentPanel.classList.remove("hidden"); els.documentTitle.textContent = document.filename; els.documentMeta.textContent = `${document.extension.toUpperCase().replace(".", "")} | Uploaded ${formatDate(document.created_at)}`; els.ownerLine.textContent = `Owner: ${document.owner?.name || "Owner"}${contactLine(document.owner) ? ` | ${contactLine(document.owner)}` : ""}`; els.statusPill.textContent = document.status_label; els.statusPill.className = `status-pill ${document.status}`; els.wordCount.textContent = `${document.word_count || 0} words`; els.textPreview.textContent = document.text || ""; els.downloadButton.onclick = () => { window.location.href = `/api/documents/${document.id}/download`; }; renderAssignments(document); renderFindings(document); renderComments(document); renderActivity(document); } function renderAssignments(document) { const selectedReviewerIds = new Set((document.assignments || []).map((assignment) => assignment.reviewer_id)); if (!state.reviewers.length) { els.assignmentChoices.innerHTML = `

Add reviewers in the sidebar first.

`; } else { els.assignmentChoices.innerHTML = state.reviewers .map( (reviewer) => ` `, ) .join(""); } const counts = document.assignment_counts || {}; const total = assignmentTotal(counts); els.assignmentSummary.textContent = total ? `${counts.approved || 0}/${total} approved` : "No reviewers"; if (!(document.assignments || []).length) { els.assignmentList.innerHTML = `

No reviewers assigned.

`; els.reviewerActionSelect.innerHTML = ``; els.reviewerActionForm.classList.add("muted-panel"); return; } els.reviewerActionForm.classList.remove("muted-panel"); els.assignmentList.innerHTML = document.assignments .map( (assignment) => `
${escapeHtml(assignment.reviewer_name)} ${escapeHtml(contactLine(assignment.reviewer_contact) || "No contact set")}
${escapeHtml(assignment.status_label)} ${assignment.approved_at ? `Approved ${formatDate(assignment.approved_at)} as ${escapeHtml(assignment.approval_name)}` : ""}
`, ) .join(""); els.reviewerActionSelect.innerHTML = document.assignments .map((assignment) => ``) .join(""); } function renderFindings(document) { const findings = document.findings || []; if (!document.reviewed_at) { els.findingSummary.textContent = "Not reviewed"; els.findings.innerHTML = `

Run a review to check required sections, placeholders, and vague language.

`; return; } const counts = countsFor(document); els.findingSummary.textContent = `${counts.high} high, ${counts.medium} medium, ${counts.low} low`; if (!findings.length) { els.findings.innerHTML = `

No findings from the local rubric.

`; return; } const groups = ["high", "medium", "low"] .map((severity) => findings.filter((finding) => finding.severity === severity)) .filter((group) => group.length); els.findings.innerHTML = groups .map((group) => { const severity = group[0].severity; return `
${severity} severity
${group.map(renderFinding).join("")}
`; }) .join(""); } function renderFinding(finding) { return `

${escapeHtml(finding.title)}

${escapeHtml(finding.detail)}

${finding.snippet ? `
${escapeHtml(finding.snippet)}
` : ""}
`; } function renderComments(document) { const comments = document.comments || []; els.commentCount.textContent = `${comments.length}`; if (!comments.length) { els.comments.innerHTML = `

No comments yet.

`; return; } els.comments.innerHTML = comments .slice() .reverse() .map( (comment) => `
${escapeHtml(comment.author)}

${escapeHtml(comment.body)}

`, ) .join(""); } function renderActivity(document) { const activity = document.activity || []; els.activityCount.textContent = `${activity.length}`; if (!activity.length) { els.activityTimeline.innerHTML = `

No activity yet.

`; return; } els.activityTimeline.innerHTML = activity .map( (item) => `
${escapeHtml(item.actor)}

${escapeHtml(item.message)}

`, ) .join(""); } function renderReviewerList() { if (!state.reviewers.length) { els.reviewerList.innerHTML = `

No reviewers yet.

`; return; } els.reviewerList.innerHTML = state.reviewers .map( (reviewer) => `
${escapeHtml(reviewer.name)} ${escapeHtml(contactLine(reviewer) || "No contact set")}
`, ) .join(""); els.reviewerList.querySelectorAll("[data-edit-reviewer]").forEach((button) => { button.addEventListener("click", () => editReviewer(button.dataset.editReviewer)); }); els.reviewerList.querySelectorAll("[data-delete-reviewer]").forEach((button) => { button.addEventListener("click", () => deleteReviewer(button.dataset.deleteReviewer)); }); } function renderNotifications() { if (!state.notifications.length) { els.notificationList.innerHTML = `

No notifications yet.

`; return; } els.notificationList.innerHTML = state.notifications .map( (notification) => `
${escapeHtml(notification.recipient_name)} ${escapeHtml(notification.recipient_type)} | ${formatDate(notification.created_at)}

${escapeHtml(notification.message)}

${ notification.read_at ? `Read` : `` }
`, ) .join(""); els.notificationList.querySelectorAll("[data-copy-notification]").forEach((button) => { button.addEventListener("click", () => copyNotification(button.dataset.copyNotification)); }); els.notificationList.querySelectorAll("[data-read-notification]").forEach((button) => { button.addEventListener("click", () => markNotificationRead(button.dataset.readNotification)); }); } async function uploadFile(file) { if (!file) return; const form = new FormData(); form.append("file", file); form.append("owner_name", els.ownerName.value.trim()); form.append("owner_email", els.ownerEmail.value.trim()); form.append("owner_slack", els.ownerSlack.value.trim()); form.append("owner_teams", els.ownerTeams.value.trim()); els.uploadButton.disabled = true; els.uploadButton.textContent = "Uploading..."; try { const document = await api("/api/upload", { method: "POST", body: form }); showToast("Document uploaded."); state.selectedId = document.id; await loadAll(); } catch (error) { showToast(error.message, true); } finally { els.uploadButton.disabled = false; els.uploadButton.innerHTML = `+Upload document`; els.fileInput.value = ""; } } async function refreshSelectedWith(action, message) { if (!state.selectedId) return; try { state.selectedDocument = await api(`/api/documents/${state.selectedId}/${action}`, { method: "POST" }); showToast(message); await refreshSelected(); } catch (error) { showToast(error.message, true); } } async function saveReviewer(event) { event.preventDefault(); const payload = { name: els.reviewerName.value.trim(), email: els.reviewerEmail.value.trim(), slack: els.reviewerSlack.value.trim(), teams: els.reviewerTeams.value.trim(), }; const id = els.reviewerId.value; try { if (id) { await api(`/api/reviewers/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); showToast("Reviewer updated."); } else { await api("/api/reviewers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); showToast("Reviewer added."); } resetReviewerForm(); await loadAll(); } catch (error) { showToast(error.message, true); } } function editReviewer(id) { const reviewer = state.reviewers.find((item) => item.id === id); if (!reviewer) return; els.reviewerId.value = reviewer.id; els.reviewerName.value = reviewer.name; els.reviewerEmail.value = reviewer.email || ""; els.reviewerSlack.value = reviewer.slack || ""; els.reviewerTeams.value = reviewer.teams || ""; els.reviewerSaveButton.textContent = "Update reviewer"; els.reviewerCancelButton.classList.remove("hidden"); } function resetReviewerForm() { els.reviewerForm.reset(); els.reviewerId.value = ""; els.reviewerSaveButton.textContent = "Save reviewer"; els.reviewerCancelButton.classList.add("hidden"); } async function deleteReviewer(id) { const reviewer = state.reviewers.find((item) => item.id === id); if (!reviewer) return; if (!window.confirm(`Remove ${reviewer.name} from the reviewer directory? Historical assignments stay on documents.`)) { return; } try { await api(`/api/reviewers/${id}`, { method: "DELETE" }); showToast("Reviewer removed."); await loadAll(); } catch (error) { showToast(error.message, true); } } async function assignReviewers(event) { event.preventDefault(); if (!state.selectedId) return; const reviewerIds = Array.from(els.assignmentChoices.querySelectorAll("input[type='checkbox']:checked")).map((input) => input.value); try { await api(`/api/documents/${state.selectedId}/assign-reviewers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reviewer_ids: reviewerIds }), }); showToast("Reviewer assignments updated."); await refreshSelected(); } catch (error) { showToast(error.message, true); } } async function submitReviewerAction(event) { event.preventDefault(); const submitter = event.submitter; const action = submitter?.dataset.reviewerAction || state.reviewerAction; const reviewerId = els.reviewerActionSelect.value; const signature = els.reviewerSignature.value.trim(); const body = els.reviewerNote.value.trim(); if (!reviewerId) { showToast("Choose an assigned reviewer.", true); return; } if (action === "approve" && !signature) { showToast("Enter a name or initials to approve.", true); return; } if ((action === "comment" || action === "request") && !body) { showToast("Add a note first.", true); return; } if (action === "approve" && (state.selectedDocument?.findings || []).length) { if (!window.confirm("This document still has review findings. Approve anyway?")) { return; } } const endpoint = { comment: "reviewer-comment", request: "reviewer-request-changes", approve: "reviewer-approve", }[action]; try { await api(`/api/documents/${state.selectedId}/${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reviewer_id: reviewerId, signature, body }), }); els.reviewerNote.value = ""; els.reviewerSignature.value = ""; showToast(action === "approve" ? "Reviewer approval recorded." : "Reviewer response recorded."); await refreshSelected(); } catch (error) { showToast(error.message, true); } } async function copyNotification(id) { const notification = state.notifications.find((item) => item.id === id); if (!notification) return; try { await navigator.clipboard.writeText(notification.message); showToast("Notification copied."); } catch { showToast(notification.message); } } async function markNotificationRead(id) { try { await api(`/api/notifications/${id}/read`, { method: "POST" }); showToast("Notification marked read."); const payload = await api("/api/notifications"); state.notifications = payload.notifications; renderNavigation(payload.unread_count || 0); renderNotifications(); } catch (error) { showToast(error.message, true); } } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } els.sideTabs.forEach((button) => { button.addEventListener("click", () => { state.view = button.dataset.view; renderNavigation(state.notifications.filter((item) => !item.read_at).length); }); }); els.uploadButton.addEventListener("click", () => els.fileInput.click()); els.fileInput.addEventListener("change", () => uploadFile(els.fileInput.files[0])); ["dragenter", "dragover"].forEach((eventName) => { els.dropZone.addEventListener(eventName, (event) => { event.preventDefault(); els.dropZone.classList.add("dragging"); }); }); ["dragleave", "drop"].forEach((eventName) => { els.dropZone.addEventListener(eventName, (event) => { event.preventDefault(); els.dropZone.classList.remove("dragging"); }); }); els.dropZone.addEventListener("drop", (event) => uploadFile(event.dataTransfer.files[0])); els.filters.forEach((button) => { button.addEventListener("click", () => { state.filter = button.dataset.filter; els.filters.forEach((item) => item.classList.toggle("active", item === button)); renderList(); }); }); els.reviewerForm.addEventListener("submit", saveReviewer); els.reviewerCancelButton.addEventListener("click", resetReviewerForm); els.assignmentForm.addEventListener("submit", assignReviewers); els.reviewerActionForm.addEventListener("submit", submitReviewerAction); els.reviewButton.addEventListener("click", () => refreshSelectedWith("review", "Review complete.")); els.commentForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!state.selectedId) return; const body = els.commentBody.value.trim(); if (!body) return; try { state.selectedDocument = await api(`/api/documents/${state.selectedId}/comments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body, author: state.selectedDocument?.owner?.name || "Owner" }), }); els.commentBody.value = ""; showToast("Comment added."); await refreshSelected(); } catch (error) { showToast(error.message, true); } }); loadAll().catch((error) => showToast(error.message, true));