/* ==================================================================
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 ─── */}
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 */}
{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}
{isMcq && (
{options.map((opt, i) => (
{opt.letter}
))}
)}
{!isMcq && (
setExpected(e.target.value)}
style={{
width: "100%", padding: "8px 10px", fontSize: 13,
background: "var(--bg-1)", border: "1px solid var(--line)",
borderRadius: 6, color: "var(--ink-0)",
}}
placeholder="e.g. 17 g/mol"/>
)}
setMarks(Math.max(1, Math.min(10, Math.round(Number(e.target.value) || 1))))}
style={{
width: 80, padding: "6px 10px", fontSize: 13,
background: "var(--bg-1)", border: "1px solid var(--line)",
borderRadius: 6, color: "var(--ink-0)",
}}/>
{err &&
{err}
}
);
};
const AiField = ({ label, children }) => (
);
window.AiReviewScreen = AiReviewScreen;