/* ================================================================== AI Test Review — admin / HoD moderation surface ------------------------------------------------------------------ An AI practice-test generator mints tests in real time on student request (the generator lives outside this panel). "Approve before student takes" was rejected (latency). Instead the student takes immediately; teachers and admins sign off afterwards. This screen is the admin counterpart to the Teacher app's AiReviewListPage / AiReviewDetailPage. Admins see ALL practice tests in the school (the visibility model treats role!=='teacher' as privileged) and get two extra controls teachers don't have: • Reopen — flip a reviewed test back to 'pending' for fresh review. • Delete — wipe a hallucination beyond recovery. ================================================================== */ const { Icon: AiIcon } = window.AppUI; const AI_STATUS_LABEL = { pending: "Pending", reviewed: "Reviewed", needs_revision: "Needs work" }; const AI_STATUS_CLS = { pending: "amber", reviewed: "green", needs_revision: "red" }; const relTimeAi = (iso) => { if (!iso) return ""; const ms = Date.now() - new Date(iso).getTime(); const s = Math.floor(ms / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s/60)}m ago`; if (s < 86400) return `${Math.floor(s/3600)}h ago`; return `${Math.floor(s/86400)}d ago`; }; const AiReviewScreen = () => { const [tab, setTab] = React.useState("pending"); const [rows, setRows] = React.useState([]); const [counts, setCounts] = React.useState({ pending: 0, reviewed: 0, needs_revision: 0 }); const [search, setSearch] = React.useState(""); const [subjectFilter, setSubjectFilter] = React.useState(""); const [selectedId, setSelectedId] = React.useState(null); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const subjects = window.APP?.SUBJECTS || []; const refresh = React.useCallback(async () => { setLoading(true); setErr(null); try { const [list, c] = await Promise.all([ window.AppApi.get(`/teacher/ai-tests?status=${tab}`), window.AppApi.get("/teacher/ai-tests/counts"), ]); setRows(list || []); setCounts(c || { pending: 0, reviewed: 0, needs_revision: 0 }); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [tab]); React.useEffect(() => { refresh(); }, [refresh]); const filtered = rows.filter((r) => { if (subjectFilter && r.subjectCode !== subjectFilter && r.subjectName !== subjectFilter) return false; if (!search) return true; const q = search.toLowerCase(); return ( (r.title || "").toLowerCase().includes(q) || (r.displayId || "").toLowerCase().includes(q) || (r.studentName || "").toLowerCase().includes(q) || (r.studentRoll || "").toLowerCase().includes(q) || (r.subjectName || "").toLowerCase().includes(q) ); }); const selected = rows.find((r) => r.id === selectedId) || null; return (
{/* ─── Left: list ─── */}
AI test review

Practice queue

AI-generated practice tests for individual students. Edit any question, sign off when satisfied. Admin / HoD can also reopen or delete a test.

{/* Tabs */}
{["pending", "reviewed", "needs_revision"].map((t) => { const active = tab === t; return ( ); })}
{/* Filter bar */}
setSearch(e.target.value)} placeholder="Search test, student, ID…" style={{ width: "100%", padding: "8px 10px 8px 30px", fontSize: 12.5, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink-0)", }}/>
{err &&
{err}
} {loading &&
Loading…
}
{!loading && filtered.length === 0 && (
{tab === "pending" ? "Inbox zero — no practice tests waiting." : `Nothing ${AI_STATUS_LABEL[tab].toLowerCase()} matches.`}
)} {filtered.map((r) => { const active = r.id === selectedId; return ( ); })}
{/* ─── Right: detail ─── */} {selected ? ( ) : (
Pick a test from the queue to review.
)}
); }; // ───────────────────────────────────────────────────────────────────────── // AiTestDetail — full editing pane for one test. Each question gets an // inline editor (text + options + correctness + marks). Saving a single // question fires a PATCH; the wider Approve / Needs work / Delete / Reopen // actions live in the sticky header. // ───────────────────────────────────────────────────────────────────────── const AiTestDetail = ({ testId, onChanged }) => { const [detail, setDetail] = React.useState(null); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const reload = React.useCallback(async () => { setLoading(true); setErr(null); try { const d = await window.AppApi.get(`/teacher/ai-tests/${testId}`); setDetail(d); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [testId]); React.useEffect(() => { reload(); }, [reload]); const decide = async (decision) => { setBusy(true); try { await window.AppApi.post(`/teacher/ai-tests/${testId}/review`, { decision }); await reload(); onChanged && onChanged(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const reopen = async () => { setBusy(true); try { await window.AppApi.post(`/teacher/ai-tests/${testId}/reopen`, {}); await reload(); onChanged && onChanged(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const remove = async () => { if (!window.confirm("Permanently delete this practice test? Cannot be undone.")) return; setBusy(true); try { await window.AppApi.del(`/teacher/ai-tests/${testId}`); onChanged && onChanged(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; if (loading) { return (
Loading…
); } if (err) { return (
{err}
); } if (!detail) return null; const t = detail.test; return (
{/* Sticky header */}
{t.displayId} · {t.subjectCode || t.subjectName} {t.classLevel ? <> · Class {t.classLevel}{t.section ? `-${t.section}` : ""} : null}
{t.title}
{t.studentName ? <>For {t.studentName} ({t.studentRoll}) · : null} {detail.questions.length} questions · {t.totalMarks ?? "—"} marks {t.reviewedAt ? <> · reviewed {relTimeAi(t.reviewedAt)} : null}
{AI_STATUS_LABEL[t.reviewStatus]}
{/* Action bar */}
{t.reviewStatus !== "pending" && ( )}
{/* Question list */}
{detail.questions.map((q, i) => ( ))}
); }; const AiQuestionEditor = ({ index, testId, question, onSaved }) => { const isMcq = question.type === "mcq"; const [text, setText] = React.useState(question.text); const [marks, setMarks] = React.useState(question.marks); const [expected, setExpected] = React.useState(question.expectedAnswer ?? ""); const [options, setOptions] = React.useState(() => (question.options || []).map((o) => ({ letter: o.letter, text: o.text, is_correct: !!o.is_correct, })) ); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); // Reset local draft when the parent reloads (after a save / decision). React.useEffect(() => { setText(question.text); setMarks(question.marks); setExpected(question.expectedAnswer ?? ""); setOptions((question.options || []).map((o) => ({ letter: o.letter, text: o.text, is_correct: !!o.is_correct, }))); setErr(null); }, [question]); const dirty = text !== question.text || marks !== question.marks || expected !== (question.expectedAnswer ?? "") || JSON.stringify(options) !== JSON.stringify( (question.options || []).map((o) => ({ letter: o.letter, text: o.text, is_correct: !!o.is_correct })) ); const setOpt = (i, patch) => setOptions((xs) => xs.map((o, j) => (j === i ? { ...o, ...patch } : o))); const setCorrect = (i) => setOptions((xs) => xs.map((o, j) => ({ ...o, is_correct: j === i }))); const save = async () => { setBusy(true); setErr(null); try { const optionsChanged = isMcq && JSON.stringify(options) !== JSON.stringify( (question.options || []).map((o) => ({ letter: o.letter, text: o.text, is_correct: !!o.is_correct })) ); if (optionsChanged && options.filter((o) => o.is_correct).length !== 1) { throw new Error("exactly one option must be marked correct"); } const patch = {}; if (text !== question.text) patch.text = text; if (marks !== question.marks) patch.marks = marks; if (expected !== (question.expectedAnswer ?? "")) patch.expected_answer = expected; if (optionsChanged) patch.options = options; await window.AppApi.patch(`/teacher/ai-tests/${testId}/questions/${question.id}`, patch); onSaved && onSaved(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; return (
Q{index + 1} {question.type} {dirty && Unsaved}