/* ================================================================== Test Library — with Test preview / Upload-paper / Student report modals ================================================================== */ const { Icon: LIcon, StatusPill: LStatusPill } = window.AppUI; const TestLibrary = ({ setScreen, onOpenManual }) => { const [filters, setFilters] = useState({ class: "All", subject: "All", topic: "All", status: "All" }); const [search, setSearch] = useState(""); const [selected, setSelected] = useState(null); const [studentsForTest, setStudentsForTest] = useState(null); const [previewTest, setPreviewTest] = useState(null); const [uploadOpen, setUploadOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [openActionMenu, setOpenActionMenu] = useState(null); const [studentReport, setStudentReport] = useState(null); // {test, student} const [extraTests, setExtraTests] = useState([]); const [refreshTick, setRefreshTick] = useState(0); const [editTest, setEditTest] = useState(null); // test row being edited const [uploadSheetsTest, setUploadSheetsTest] = useState(null); // test for upload-sheets modal const [engineConfigTest, setEngineConfigTest] = useState(null); // test for engine-config modal const allTests = [...extraTests, ...window.APP.TESTS]; // refreshTick keeps `allTests` reactive when window.APP.TESTS is replaced after save. // eslint-disable-next-line no-unused-vars const _r = refreshTick; const filtered = allTests.filter((t) => { if (filters.class !== "All" && t.className !== filters.class) return false; if (filters.subject !== "All" && t.subject !== filters.subject) return false; if (filters.status !== "All" && window.AppUI.effectiveStatus(t.status, t.date) !== filters.status) return false; if (search && !t.title.toLowerCase().includes(search.toLowerCase()) && !t.id.toLowerCase().includes(search.toLowerCase())) return false; return true; }); // Close action menu on outside click useEffect(() => { const close = () => setOpenActionMenu(null); if (openActionMenu) { document.addEventListener("click", close); return () => document.removeEventListener("click", close); } }, [openActionMenu]); // Cockpit's "Finalise report" hands off via window.APP.OPEN_UPLOAD_SHEETS_FOR // = . When the library remounts, reopen that test's roster modal. useEffect(() => { const targetId = window.APP.OPEN_UPLOAD_SHEETS_FOR; if (!targetId) return; window.APP.OPEN_UPLOAD_SHEETS_FOR = null; (async () => { try { window.APP.TESTS = await window.AppApi.get("/tests"); const t = window.APP.TESTS.find(x => x.id === targetId); if (t) { setRefreshTick(n => n + 1); setUploadSheetsTest(t); } } catch {} })(); }, []); const onSaveTest = async (newTest) => { try { const sections = newTest._payload?.sections; const title = (newTest.title || "").trim() || "Untitled test"; // `asDraft` controls status: true → keeps test in 'draft'; false → promotes // to 'live' once it has questions. The editor's two save buttons set this. const as_draft = !!newTest._payload?.asDraft; // How descriptive answers are collected: 'per_question' (legacy) | 'bulk'. // The backend coerces 'bulk' back to per_question for all-objective tests. const submission_mode = newTest._payload?.meta?.submissionMode || "per_question"; // If we're updating an existing test (Continue authoring / Edit questions), // reuse its display_id and just replace the structure. if (newTest._payload?.meta?.display_id) { await window.AppApi.post(`/tests/${newTest._payload.meta.display_id}/structure`, { sections, as_draft, submission_mode }); } else { await window.AppApi.post("/tests", { title, submission_mode, // Prefer the explicit class_section_ids from the multi-section // picker; fall back to className-only for old code paths. class_section_ids: (newTest.class_section_ids && newTest.class_section_ids.length) ? newTest.class_section_ids : undefined, className: newTest.className, subject: newTest.subject, topic: (!newTest.topic || newTest.topic === "—") ? undefined : newTest.topic, chapter: (!newTest.chapter || newTest.chapter === "—") ? undefined : newTest.chapter, // Treat empty / missing dates as omitted. Postgres DATE rejects "". scheduled_date: (newTest.date && newTest.date.trim()) ? newTest.date : undefined, language: newTest.language, sections, as_draft, }); } window.APP.TESTS = await window.AppApi.get("/tests"); setExtraTests([]); setRefreshTick(t => t + 1); setUploadOpen(false); setCreateOpen(false); } catch (e) { const msg = e?.message || String(e); // Show inline in the upload modal if it's open; fall back to alert otherwise. if (uploadOpen && typeof window.__KX_LIB_SAVE_ERR === "function") { window.__KX_LIB_SAVE_ERR(msg); } else { alert("Could not save test: " + msg); } throw e; } }; const handleCreateContinue = (method, meta) => { setCreateOpen(false); if (method === "manual") { onOpenManual && onOpenManual(meta, onSaveTest); } else { // paste / upload — open the existing structured-upload modal setUploadOpen(true); } }; const refreshTests = async () => { window.APP.TESTS = await window.AppApi.get("/tests"); setRefreshTick(t => t + 1); }; const handleDeleteTest = async (test) => { if (!window.confirm(`Delete "${test.title}"? This removes all its questions, sheets, and evaluations.`)) return; try { await window.AppApi.del(`/tests/${test.id}`); await refreshTests(); } catch (e) { alert("Could not delete: " + (e?.message || e)); } }; const handleSaveEdit = async (display_id, updates) => { await window.AppApi.put(`/tests/${display_id}`, updates); await refreshTests(); setEditTest(null); }; // "Continue authoring" on a draft test row — fetch its current structure and // open the manual editor prefilled with meta + sections + questions. const handleContinueAuthoring = async (test) => { try { const full = await window.AppApi.get(`/tests/${test.id}/full`); const initialSections = dbSectionsToEditorSections(full.sections); onOpenManual && onOpenManual({ title: full.meta.title, class_section_ids: Array.isArray(full.meta.class_section_ids) && full.meta.class_section_ids.length ? full.meta.class_section_ids : (full.meta.class_section_id ? [full.meta.class_section_id] : []), className: full.meta.className, subject: full.meta.subject, topic: full.meta.topic || "", chapter: full.meta.chapter || "", date: full.meta.date || "", language: full.meta.language || "en", submissionMode: full.meta.submission_mode || "per_question", display_id: full.meta.id, }, onSaveTest, initialSections); } catch (e) { alert("Could not load test: " + (e?.message || e)); } }; return (
{window.APP.CURRENT_SCHOOL ? `${window.APP.CURRENT_SCHOOL.name} · ${window.APP.CURRENT_SCHOOL.academic_year}` : "—"}

Test Library

{allTests.length} {allTests.length === 1 ? "test" : "tests"} · {allTests.filter(t => t.status === "grading").length} in active grading
FiltersReset
{[ { k: "class", label: "Class", opts: ["All", ...(window.APP?.CLASS_OPTIONS || ["IX","X"])] }, { k: "subject", label: "Subject", opts: ["All", ...(window.APP?.SUBJECT_OPTIONS || [])] }, { k: "status", label: "Status", opts: ["All", "draft", "upcoming", "optimised", "grading", "closed"] }, ].map((f) =>
{f.label}
)}
Date range
setSearch(e.target.value)}/>
{filtered.length} of {allTests.length}
{filtered.map((t) => { const isActive = selected === t.id; // Online tests track submissions (auto-graded MCQ); // paper tests track teacher-reviewed evaluations. const isOnline = !!t.type; const numer = isOnline ? (t.submitted ?? 0) : t.reviewed; const pct = t.students ? (numer / t.students) * 100 : 0; return ( setSelected(t.id)} style={isActive ? { background: "var(--bg-2)" } : {}}> ); })}
IDTest name ClassSubject DateStatus ProgressAction
{t.id}
{t.title}
{t.chapter || "—"} · {t.questions ? `${t.questions} Q` : "—"} · lang {t.language.toUpperCase()}
{t.classLabel || t.className} {t.subject} {t.date} {t.students > 0 ?
{numer}/{t.students} {isOnline ? "submitted" : "reviewed"}
: —}
e.stopPropagation()}> setOpenActionMenu(openActionMenu === t.id ? null : t.id)} onPreview={()=>{ setPreviewTest(t); setOpenActionMenu(null); }} onStudents={()=>{ setStudentsForTest(t); setOpenActionMenu(null); }} onContinueAuthoring={()=>{ setOpenActionMenu(null); handleContinueAuthoring(t); }} onUploadSheets={()=>{ setOpenActionMenu(null); setUploadSheetsTest(t); }} onEdit={()=>{ setOpenActionMenu(null); setEditTest(t); }} onEngineConfig={()=>{ setOpenActionMenu(null); setEngineConfigTest(t); }} onDelete={()=>{ setOpenActionMenu(null); handleDeleteTest(t); }} setScreen={setScreen}/>
Showing 1–{filtered.length} of {filtered.length} Page 1 / 1
setStudentsForTest(null)} setScreen={setScreen} onViewReport={(student) => { setStudentsForTest(null); setStudentReport({ test: studentsForTest, student }); }}/> setPreviewTest(null)}/> setUploadOpen(false)} onSave={onSaveTest} onOpenManual={onOpenManual}/> setEditTest(null)} onSave={handleSaveEdit}/> {uploadSheetsTest?.type ? ( setUploadSheetsTest(null)}/> ) : ( setUploadSheetsTest(null)} onRefresh={refreshTests} onOpenCockpit={({ test, student }) => { window.APP.EVAL_CONTEXT = { test, student }; setScreen("evaluate"); }}/> )} {typeof window.TestDetailsModal !== "undefined" && ( setCreateOpen(false)} onContinue={handleCreateContinue}/> )} setStudentReport(null)}/> {typeof window.EngineConfigModal !== "undefined" && ( setEngineConfigTest(null)}/> )}
); }; window.TestLibrary = TestLibrary; // DB shape (snake_case, plural rows) → ManualAuthoring shape (camelCase, nested). function dbSectionsToEditorSections(sections) { const TYPE_MAP = { mcq: "MCQ", short: "Short", long: "Long", tf: "TF", fill: "Fill", match: "Match", numerical: "Short", derivation: "Long", diagram: "Long" }; return sections.map(sec => ({ id: sec.id, label: sec.title ? `Section ${sec.section_label} · ${sec.title}` : `Section ${sec.section_label}`, type: TYPE_MAP[sec.questions[0]?.type] || "MCQ", marks: sec.marks_per_question ?? sec.questions[0]?.marks ?? 1, target: Math.max(sec.questions.length, 5), instructions: sec.instructions || "", questions: sec.questions.map(q => ({ id: q.id, text: q.text, type: TYPE_MAP[q.type] || "Short", options: (q.options || []).map(o => ({ id: o.letter, text: o.text, correct: !!o.is_correct })), subQuestions: (q.sub_questions || []).map(sq => ({ id: sq.id, text: sq.text })), expectedAnswer: q.expected_answer || "", concepts: q.concepts || [], marks: q.marks, })), })); } window.dbSectionsToEditorSections = dbSectionsToEditorSections; /* ---------- Action menu (dropdown) ---------- */ const ActionMenu = ({ test, open, onOpen, onPreview, onStudents, onContinueAuthoring, onUploadSheets, onEdit, onEngineConfig, onDelete, setScreen }) => { const t = test; // Online tests are auto-graded — the "Upload sheets" / "Evaluate" // wording becomes "View submissions" / hidden respectively. const isOnline = !!t.type; const submissionsLabel = isOnline ? "View submissions" : "Upload sheets"; // Three user-facing states: draft → live → completed. All intermediate / legacy // states (optimised, ready_for_review, published) act as "live" for the primary action. // Future-scheduled tests collapse to "upcoming" even if the row says live, so // the teacher sees "Open paper" (preview) rather than a submissions action // that won't have any data yet. const status = window.AppUI.effectiveStatus(t.status, t.date); const primary = { draft: { label: "Continue authoring", onClick: onContinueAuthoring }, live: { label: submissionsLabel, onClick: onUploadSheets, cls: "primary" }, completed: { label: "View report", onClick: () => setScreen("report"), cls: "primary" }, // Legacy fallbacks optimised: { label: submissionsLabel, onClick: onUploadSheets, cls: "primary" }, ready_for_review: { label: submissionsLabel, onClick: onUploadSheets, cls: "primary" }, published: { label: submissionsLabel, onClick: onUploadSheets, cls: "primary" }, closed: { label: "View report", onClick: () => setScreen("report"), cls: "primary" }, upcoming: { label: "Open paper", onClick: onPreview }, grading: { label: isOnline ? submissionsLabel : "Resume grading", onClick: onUploadSheets, cls: "primary" }, }[status] || { label: "Open", onClick: onPreview }; const items = [ { label: "Edit details", onClick: onEdit }, { label: "Edit questions", onClick: onContinueAuthoring }, { label: submissionsLabel, onClick: onUploadSheets, disabled: t.status === "draft" }, { label: "Students table", onClick: onStudents, disabled: t.status === "draft" }, // "Open evaluation" is the teacher's paper-grading cockpit — meaningless // for auto-graded online tests, so it's hidden in that case. ...(isOnline ? [] : [{ label: "Open evaluation", onClick: () => setScreen("evaluate"), disabled: t.status === "draft" }]), { label: "Class report", onClick: () => setScreen("report"), disabled: t.status === "draft" }, { label: "Delete test", onClick: onDelete, danger: true }, ]; return (
{open && (
{items.map((it, i) => (
!it.disabled && it.onClick()} style={{ padding:"6px 10px", fontSize: 12, borderRadius: 4, color: it.disabled ? "var(--ink-4)" : (it.danger ? "var(--red)" : "var(--ink-1)"), cursor: it.disabled ? "not-allowed" : "pointer", background: "transparent", }} onMouseEnter={(e)=>!it.disabled && (e.currentTarget.style.background="var(--bg-3)")} onMouseLeave={(e)=>(e.currentTarget.style.background="transparent")}> {it.label}
))}
)}
); }; /* ---------- Test preview modal (clicking test name) ---------- */ const TestPreviewModal = ({ test, onClose }) => { if (!test) return null; const questions = window.APP.QUESTIONS; return (
e.stopPropagation()} style={{ background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius: 10, width: 820, maxWidth:"100%", maxHeight:"88vh", display:"flex", flexDirection:"column" }}>
{test.id} · {test.className} · {test.subject}
{test.title}
{test.questions || 0} Q {test.language?.toUpperCase()}
{test.chapter} · {test.date} Total: {test.questions || 0} questions · Time: 30 min
{(test.questions ? questions : []).map((q, i) => (
{i + 1}. {q.text} [{q.maxMarks}] {q.type === "mcq" && (
(a) …… (b) …… (c) …… (d) ……
)}
))} {!test.questions && (
This test is still a draft — no questions added yet.
)}
); }; /* ---------- Upload / Create test modal ---------- */ const UploadTestModal = ({ open, onClose, onSave, onOpenManual }) => { const classSections = window.APP?.CLASS_SECTIONS || []; const subjects = window.APP?.SUBJECTS || []; const defaultCs = classSections[0] || null; const [step, setStep] = useState(1); // 1=meta+paste, 2=structured preview const [meta, setMeta] = useState({ title: "", class_section_ids: defaultCs ? [defaultCs.id] : [], className: defaultCs?.class_level || "", subject: subjects[0]?.name || "", topic: "", chapter: "", date: "", language: "en", }); // Keep meta in sync with the bootstrapped catalog (it may load after mount). React.useEffect(() => { if (meta.class_section_ids.length === 0 && defaultCs) { setMeta(m => ({ ...m, class_section_ids: [defaultCs.id], className: defaultCs.class_level })); } if (!meta.subject && subjects[0]) { setMeta(m => ({ ...m, subject: subjects[0].name })); } }, [defaultCs?.id, subjects.length]); const classLevels = Array.from(new Set(classSections.map(c => c.class_level))).sort(); const pickClass = (cl) => { const first = classSections.find(c => c.class_level === cl); setMeta({ ...meta, className: cl, class_section_ids: first ? [first.id] : [] }); }; const [text, setText] = useState(""); const [parsing, setParsing] = useState(false); const [parsed, setParsed] = useState(null); const [jsonText, setJsonText] = useState(""); const [jsonError, setJsonError] = useState(null); const [sourceMode, setSourceMode] = useState("text"); // "text" | "pdf" const [pdfFile, setPdfFile] = useState(null); const pdfInputRef = React.useRef(null); const [saveErr, setSaveErr] = React.useState(null); // Let the parent's onSaveTest forward error messages here. React.useEffect(() => { if (!open) return; window.__KX_LIB_SAVE_ERR = (msg) => setSaveErr(msg); return () => { window.__KX_LIB_SAVE_ERR = null; }; }, [open]); if (!open) return null; const parseNow = async () => { if (!text.trim()) { setJsonError("Paste some paper text first."); return; } setParsing(true); setSaveErr(null); try { // Hit the live backend → Gemini parser. const response = await window.AppApi.post("/papers/parse", { title: meta.title || "Untitled preview", content: text, type: "text", language: meta.language, }); // response.structured matches the parser system-prompt schema: // { sections: [{ section_id, title, marks_per_question, total_questions, // section_total_marks, questions: [{ q_no, text, type, options, prerequisite_tags }] }], // parsing_flags: [...] } const editorParsed = backendToEditorParsed(response.structured, meta); setParsed(editorParsed); setJsonText(JSON.stringify(response.structured, null, 2)); setJsonError(null); setParsing(false); // Hand off to ManualAuthoring with sections pre-filled. if (typeof onOpenManual === "function" && typeof window.parsedToManualSections === "function") { const initialSections = window.parsedToManualSections(editorParsed); const handoffMeta = { title: meta.title || "Untitled test", class_section_ids: meta.class_section_ids, className: meta.className, subject: meta.subject, topic: meta.topic, chapter: meta.chapter, date: meta.date, language: meta.language, source: "gemini-parsed", parseModel: "gemini-2.5-flash", }; onOpenManual(handoffMeta, onSave, initialSections); setStep(1); setText(""); setParsed(null); setJsonText(""); setJsonError(null); onClose && onClose(); return; } setStep(2); } catch (err) { setParsing(false); setJsonError(err?.message || String(err)); } }; // PDF variant: upload a question-paper PDF, let Gemini Vision parse it, // hand off to the manual editor pre-filled. const parsePdfNow = async () => { if (!pdfFile) { setJsonError("Pick a PDF first."); return; } setParsing(true); try { const form = new FormData(); form.append("file", pdfFile, pdfFile.name); if (meta.title) form.append("title", meta.title); if (meta.language) form.append("language", meta.language); const r = await fetch(window.AppApi.base + "/papers/parse-pdf", { method: "POST", body: form }); if (!r.ok) throw new Error(`parse-pdf ${r.status}: ${await r.text()}`); const response = await r.json(); const editorParsed = backendToEditorParsed(response.structured, meta); setParsed(editorParsed); setJsonText(JSON.stringify(response.structured, null, 2)); setJsonError(null); setParsing(false); if (typeof onOpenManual === "function" && typeof window.parsedToManualSections === "function") { const initialSections = window.parsedToManualSections(editorParsed); const handoffMeta = { title: meta.title || (pdfFile.name || "").replace(/\.pdf$/i, "") || "Untitled test", class_section_ids: meta.class_section_ids, className: meta.className, subject: meta.subject, topic: meta.topic, chapter: meta.chapter, date: meta.date, language: meta.language, source: "gemini-pdf", parseModel: "gemini-2.5-flash", }; onOpenManual(handoffMeta, onSave, initialSections); setStep(1); setText(""); setPdfFile(null); setParsed(null); setJsonText(""); setJsonError(null); setSourceMode("text"); onClose && onClose(); return; } setStep(2); } catch (err) { setParsing(false); setJsonError(err?.message || String(err)); } }; // Backend parser JSON → the shape `parsedToManualSections` expects. function backendToEditorParsed(structured, meta) { const sections = (structured?.sections || []).map(sec => ({ title: sec.title ? `Section ${sec.section_id} · ${sec.title}` : `Section ${sec.section_id}`, instructions: "", questions: (sec.questions || []).map(q => ({ stem: q.text, type: q.type, // mcq | short | long | numerical | derivation marks: q.marks ?? sec.marks_per_question ?? (sec.section_total_marks && sec.total_questions ? sec.section_total_marks / sec.total_questions : 1), options: q.options?.length ? q.options.map((t, i) => ({ id: "abcd"[i], text: t, correct: false })) : undefined, concepts: q.prerequisite_tags || [], })), })); return { meta: { title: meta.title, class: meta.className, subject: meta.subject, language: meta.language }, sections, summary: { questions: sections.reduce((a, s) => a + s.questions.length, 0), sections: sections.length, total_marks: sections.reduce((a, s) => a + s.questions.reduce((b, q) => b + (q.marks || 0), 0), 0), }, }; } const validateJson = (txt) => { try { const obj = JSON.parse(txt); setParsed(obj); setJsonError(null); return obj; } catch (e) { setJsonError(e.message); return null; } }; const formatJson = () => { const obj = validateJson(jsonText); if (obj) setJsonText(JSON.stringify(obj, null, 2)); }; const save = async (asDraft) => { setSaveErr(null); const qCount = parsed?.summary?.questions ?? (parsed?.sections || []).reduce((a, s) => a + (s.questions?.length || 0), 0); const newTest = { title: meta.title || "Untitled test", class_section_ids: meta.class_section_ids, className: meta.className, subject: meta.subject, topic: meta.topic || "—", chapter: meta.chapter || "—", date: meta.date, status: asDraft ? "draft" : "live", questions: qCount || 0, students: 0, reviewed: 0, language: meta.language, _payload: { asDraft }, }; try { await onSave(newTest); setStep(1); setText(""); setParsed(null); setJsonText(""); setJsonError(null); setMeta({ ...meta, title:"", topic:"", chapter:"" }); } catch { /* error already surfaced via setSaveErr inline */ } }; return (
e.stopPropagation()} style={{ background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius: 10, width: 880, maxWidth:"100%", maxHeight:"90vh", display:"flex", flexDirection:"column" }}>
Upload question paper · Create new test Step {step} of 2
{step === 1 && ( <>
Test title setMeta({...meta, title:e.target.value})}/>
Class
Subject
Sections tap to add setMeta({ ...meta, class_section_ids: ids })}/>
{/* Chapter + topic dropdowns sourced from the admin catalog. Defined in screen-manual.jsx — both screens share the same picker so the UX is identical. */}
Scheduled date setMeta({...meta, date:e.target.value})}/>
Language
{/* Source tab */}
{sourceMode === "text" && ( <>
Paste question paper text {text.length.toLocaleString()} chars · {text.split(/\n\s*\n/).filter(Boolean).length} blocks