/* ================================================================== Manual Paper Authoring — section/question composer Routed via: Test Library → "Create test" → Test Details modal → here ================================================================== */ const { Icon: MIcon, StatusPill: MStatusPill } = window.AppUI; const TYPE_OPTIONS = [ { value: "MCQ", label: "MCQ", defaultMarks: 1 }, { value: "Short", label: "Short", defaultMarks: 2 }, { value: "Long", label: "Long", defaultMarks: 5 }, { value: "TF", label: "True/False", defaultMarks: 1 }, { value: "Fill", label: "Fill blank", defaultMarks: 1 }, { value: "Match", label: "Match", defaultMarks: 2 }, ]; const SECTION_NAMES = ["A","B","C","D","E","F","G","H"]; let _uidCounter = 0; const uid = (prefix) => `${prefix}-${Date.now().toString(36)}-${++_uidCounter}`; const newSubQuestion = () => ({ id: uid("sq"), text: "" }); const newQuestion = (type = "MCQ") => ({ id: uid("q"), text: "", type, options: type === "MCQ" || type === "TF" ? (type === "TF" ? [{ id:"a", text:"True", correct:false }, { id:"b", text:"False", correct:false }] : [{ id:"a", text:"", correct:false }, { id:"b", text:"", correct:false }, { id:"c", text:"", correct:false }, { id:"d", text:"", correct:false }]) : [], subQuestions: type === "Long" ? [newSubQuestion(), newSubQuestion()] : [], expectedAnswer: "", concepts: [], marks: null, // when null, inherits from section.marks }); const newSection = (idx) => ({ id: uid("s"), label: `Section ${SECTION_NAMES[idx] || (idx + 1)}`, type: "MCQ", marks: 1, target: 5, instructions: "", questions: [newQuestion("MCQ")], }); /* ---------- main component ---------- */ const ManualAuthoring = ({ meta, initialSections, onSave, onCancel }) => { const [title, setTitle] = useState(meta?.title || "Untitled test"); const [sections, setSections] = useState(() => (initialSections && initialSections.length) ? initialSections : [newSection(0)]); const [editing, setEditing] = useState(null); // { sectionId, questionId } const [savedAt, setSavedAt] = useState(null); const [showSaveConfirm, setShowSaveConfirm] = useState(false); /* ------- derived stats ------- */ const totalQ = sections.reduce((a, s) => a + s.questions.length, 0); const totalMarks = sections.reduce( (a, s) => a + s.questions.reduce((b, q) => b + (q.marks ?? s.marks), 0), 0 ); const targetQ = sections.reduce((a, s) => a + s.target, 0); const emptyQuestions = sections.reduce( (a, s) => a + s.questions.filter(q => !q.text.trim()).length, 0 ); const validation = { emptyQ: emptyQuestions, underTarget: sections.filter(s => s.questions.length < s.target).length, mcqMissingCorrect: sections.reduce((a, s) => a + s.questions.filter(q => (q.type === "MCQ" || q.type === "TF") && !q.options.some(o => o.correct) ).length, 0), }; const blocking = validation.emptyQ + validation.mcqMissingCorrect; /* ------- autosave indicator (cosmetic) ------- */ useEffect(() => { const t = setTimeout(() => setSavedAt(new Date()), 600); return () => clearTimeout(t); }, [sections, title]); /* ------- section mutations ------- */ const updateSection = (id, patch) => setSections(s => s.map(x => x.id === id ? { ...x, ...patch } : x)); const insertSection = (afterIdx) => { setSections(s => { const copy = [...s]; copy.splice(afterIdx + 1, 0, newSection(copy.length)); return copy; }); }; const deleteSection = (id) => setSections(s => s.length > 1 ? s.filter(x => x.id !== id) : s); /* ------- question mutations ------- */ const updateQuestion = (sid, qid, patch) => setSections(s => s.map(x => x.id !== sid ? x : { ...x, questions: x.questions.map(q => q.id === qid ? { ...q, ...patch } : q) })); const addQuestion = (sid) => setSections(s => s.map(x => x.id !== sid ? x : { ...x, questions: [...x.questions, newQuestion(x.type)] })); const deleteQuestion = (sid, qid) => setSections(s => s.map(x => x.id !== sid ? x : { ...x, questions: x.questions.filter(q => q.id !== qid) })); const duplicateQuestion = (sid, qid) => setSections(s => s.map(x => x.id !== sid ? x : { ...x, questions: x.questions.flatMap(q => q.id === qid ? [q, { ...q, id: uid("q") }] : [q]) })); /* ------- on section type change, normalise question types & options ------- */ const changeSectionType = (sid, newType) => { setSections(s => s.map(x => { if (x.id !== sid) return x; const defMarks = TYPE_OPTIONS.find(t => t.value === newType)?.defaultMarks ?? x.marks; return { ...x, type: newType, marks: defMarks, questions: x.questions.map(q => { if (q.type === newType) return q; const fresh = newQuestion(newType); return { ...fresh, id: q.id, text: q.text, concepts: q.concepts }; }), }; })); }; /* ------- save ------- */ const doSave = async (asDraft) => { if (!asDraft && blocking > 0) { setShowSaveConfirm(true); return; } const newTest = { title: 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: totalQ, students: 0, reviewed: 0, language: meta.language || "en", _payload: { title, meta, sections, asDraft }, }; try { await onSave(newTest); } catch { /* parent already surfaced the error; stay on the authoring screen */ } }; const editingSection = editing ? sections.find(s => s.id === editing.sectionId) : null; const editingQ = editingSection?.questions.find(q => q.id === editing?.questionId); return (
{/* ----- sticky top bar ----- */}
Test Library New test · manual setTitle(e.target.value)} style={{ background:"transparent", border:"none", outline:"none", color:"var(--ink-0)", fontSize:14, fontWeight:600, padding:"2px 4px", borderRadius:3, minWidth:260, width:`${Math.max(260, title.length * 9)}px` }} onFocus={e => e.target.style.background = "var(--bg-2)"} onBlur={e => e.target.style.background = "transparent"} /> Class {meta.className} {meta.subject} {meta.chapter && {meta.chapter}} {meta.date} {savedAt ? `Autosaved ${savedAt.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}` : "Editing…"}
{/* ----- main split ----- */}
{/* LEFT: outline summary */} { const el = document.querySelector(`[data-sec-id="${sid}"]`); if (el) el.scrollIntoView({ block:"start" }); }} /> {/* RIGHT: workspace */}
{sections.map((sec, idx) => ( updateSection(sec.id, patch)} onTypeChange={(t) => changeSectionType(sec.id, t)} onDelete={() => deleteSection(sec.id)} canDelete={sections.length > 1} onUpdateQuestion={(qid, patch) => updateQuestion(sec.id, qid, patch)} onAddQuestion={() => addQuestion(sec.id)} onDeleteQuestion={(qid) => deleteQuestion(sec.id, qid)} onDuplicateQuestion={(qid) => duplicateQuestion(sec.id, qid)} onEditQuestion={(qid) => setEditing({ sectionId: sec.id, questionId: qid })} questionStartIndex={sections.slice(0, idx).reduce((a, s) => a + s.questions.length, 0)} /> insertSection(idx)} /> ))} {/* footer save */}
{/* Edit question drawer */} {editing && editingQ && ( updateQuestion(editing.sectionId, editing.questionId, patch)} onClose={() => setEditing(null)} onDelete={() => { deleteQuestion(editing.sectionId, editing.questionId); setEditing(null); }} /> )} {/* Save-with-issues confirmation */} {showSaveConfirm && ( setShowSaveConfirm(false)} onSaveDraft={() => { setShowSaveConfirm(false); doSave(true); }} /> )}
); }; /* ---------- Left outline panel ---------- */ const ManualOutline = ({ title, meta, sections, totalQ, totalMarks, targetQ, validation, onJump }) => (
Paper outline
Test
{title || "Untitled test"}
Class {meta.className} · {meta.subject} {meta.chapter ? ` · ${meta.chapter}` : ""}
0 ? "var(--red)" : "var(--green)"}/>
Sections
{sections.map((s, i) => { const sMarks = s.questions.reduce((a, q) => a + (q.marks ?? s.marks), 0); const onTrack = s.questions.length >= s.target; return (
onJump(s.id)} style={{ padding:"10px 14px", borderBottom: i < sections.length - 1 ? "1px solid var(--line-soft)" : "none", cursor:"pointer", display:"flex", alignItems:"center", gap:10 }} onMouseEnter={e => e.currentTarget.style.background = "var(--bg-2)"} onMouseLeave={e => e.currentTarget.style.background = "transparent"}>
{SECTION_NAMES[i] || i + 1}
{s.label}
{s.type} · {s.questions.length}/{s.target} Q · {sMarks} marks
); })}
{(validation.emptyQ + validation.mcqMissingCorrect + validation.underTarget) > 0 && (
Pre-save checks
{validation.emptyQ > 0 && (
{validation.emptyQ} question{validation.emptyQ === 1 ? "" : "s"} missing text
)} {validation.mcqMissingCorrect > 0 && (
{validation.mcqMissingCorrect} MCQ{validation.mcqMissingCorrect === 1 ? "" : "s"} without correct answer
)} {validation.underTarget > 0 && (
{validation.underTarget} section{validation.underTarget === 1 ? "" : "s"} under target count
)}
)}
Tips: Click any question to edit its stem, options, and concept tags. Sections inherit marks per Q; override per question in the editor.
); const Stat3 = ({ label, value, sub, accent }) => (
{label}
{value}
{sub &&
{sub}
}
); /* ---------- Section card ---------- */ const SectionCard = ({ section, index, onUpdate, onTypeChange, onDelete, canDelete, onUpdateQuestion, onAddQuestion, onDeleteQuestion, onDuplicateQuestion, onEditQuestion, questionStartIndex, }) => (
{/* section header row — orange-tinted, matches wireframe */}
onUpdate({ label: e.target.value })} style={{ ...inputStyle, color:"var(--accent)", fontWeight:600 }} /> onUpdate({ marks: parseInt(e.target.value) || 1 })} style={inputStyle}/> onUpdate({ target: parseInt(e.target.value) || 1 })} style={inputStyle}/>
{/* instructions row (optional) */}
Instructions: onUpdate({ instructions: e.target.value })} placeholder={`e.g. ${section.type === "MCQ" ? "Tick the correct option." : "Answer in 2–3 sentences."}`} style={{ flex:1, background:"transparent", border:"none", borderBottom:"1px dashed var(--line-strong)", color:"var(--ink-1)", fontSize:12, padding:"3px 0", outline:"none", fontStyle: section.instructions ? "normal" : "italic" }}/> {section.questions.length}/{section.target} Q · {section.questions.reduce((a,q) => a + (q.marks ?? section.marks), 0)} marks
{/* questions */}
{section.questions.map((q, i) => ( onUpdateQuestion(q.id, patch)} onEdit={() => onEditQuestion(q.id)} onDelete={() => onDeleteQuestion(q.id)} onDuplicate={() => onDuplicateQuestion(q.id)} canDelete={section.questions.length > 1} /> ))} {/* add question button — green pill (matches wireframe) */}
); const SectionField = ({ label, children }) => (
{label} {children}
); const inputStyle = { width:"100%", background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius:5, padding:"6px 9px", color:"var(--ink-0)", fontSize:12.5, outline:"none", fontFamily:"inherit", }; /* ---------- Question row ---------- */ const QuestionRow = ({ q, serial, sectionMarks, onUpdate, onEdit, onDelete, onDuplicate, canDelete }) => { const isMcq = q.type === "MCQ" || q.type === "TF"; const isLong = q.type === "Long"; const hasCorrect = isMcq && q.options.some(o => o.correct); const empty = !q.text.trim() && (!isLong || (q.subQuestions || []).every(sq => !sq.text.trim())); const marks = q.marks ?? sectionMarks; /* ---- LONG ANSWER: stacked sub-question rows ---- */ if (isLong) { const subs = q.subQuestions || []; const updateSub = (sid, patch) => onUpdate({ subQuestions: subs.map(s => s.id === sid ? { ...s, ...patch } : s) }); const addSub = () => onUpdate({ subQuestions: [...subs, newSubQuestion()] }); const removeSub = (sid) => onUpdate({ subQuestions: subs.length > 1 ? subs.filter(s => s.id !== sid) : subs }); return (
{/* tall serial badge spans the whole group */}
Q{serial} {marks}m Long
{/* sub-questions stack */}
{/* optional parent stem (small, dashed) */} onUpdate({ text: e.target.value })} placeholder="Optional context / parent prompt (e.g. “Answer the following parts based on the passage above.”)" style={{ background:"transparent", border:"1px dashed var(--line-strong)", borderRadius:5, color:"var(--ink-1)", padding:"6px 10px", fontSize:11.5, outline:"none", fontStyle: q.text ? "normal" : "italic", }}/> {subs.map((sq, i) => (
{String.fromCharCode(97 + i)}.