/* ================================================================== Online Submissions modal (online-typed tests) — teacher view. Replaces the Upload Sheets modal when test.type is set. Shows the roster with per-student submission status + auto-graded score, and opens a per-question detail report when "View report" is clicked. ================================================================== */ const OS_INFO = "var(--blue-solid)"; const OS_SUCCESS = "var(--green-solid)"; const OS_WARNING = "var(--amber-solid)"; const OS_DANGER = "var(--red-solid)"; const OnlineSubmissionsModal = ({ test, onClose }) => { const [roster, setRoster] = React.useState(null); const [detailFor, setDetailFor] = React.useState(null); // student row const [err, setErr] = React.useState(""); React.useEffect(() => { if (!test) return; let cancelled = false; window.AppApi.get(`/tests/${test.id}/students`) .then(r => { if (!cancelled) setRoster(r); }) .catch(e => { if (!cancelled) setErr(e?.message || "Failed to load roster"); }); return () => { cancelled = true; }; }, [test]); if (!test) return null; const submitted = (roster || []).filter(r => r.attempt?.status === "submitted" || r.attempt?.status === "reviewed").length; const inProgress = (roster || []).filter(r => r.attempt?.status === "in_progress").length; const notStarted = (roster || []).filter(r => !r.attempt).length; return (
e.stopPropagation()} style={{ background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius:10, width:920, maxWidth:"100%", maxHeight:"86vh", display:"flex", flexDirection:"column" }}>
{test.id} · {test.type} · Submissions
{test.title}
{roster && ( {submitted} submitted · {inProgress} in progress · {notStarted} not started )}
{(roster || []).map(r => { const att = r.attempt; const status = att?.status || "not_started"; const score = att?.auto_score; const max = att?.max_marks; return ( ); })} {roster && roster.length === 0 && ( )} {!roster && !err && ( )} {err && ( )}
Roll no. Name Submission Score
{r.roll_no} {r.name} {score != null && max != null ? ( {score}/{max} ) : —}
No students enrolled in this test.
Loading…
{err}
{test.type === "descriptive" ? "Open each submission to view the student's photo answers and score them. The score auto-totals across questions." : "MCQ answers are auto-graded the moment a student submits. No teacher action required."}
{detailFor && ( setDetailFor(null)}/> )}
); }; const SubmissionPill = ({ status, submittedAt }) => { if (status === "submitted" || status === "reviewed") { return ( Submitted ); } if (status === "in_progress") { return In progress; } return Not started; }; /* ────────────────────────────────────────────────────────────────── Per-student submission detail — per-question correct/wrong view. Fetches GET /tests/:display_id/students/:student_id/attempt. ────────────────────────────────────────────────────────────────── */ const OnlineSubmissionDetailModal = ({ test, student, onClose }) => { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(""); React.useEffect(() => { if (!test || !student) return; let cancelled = false; window.AppApi.get(`/tests/${test.id}/students/${student.student_id}/attempt`) .then(r => { if (!cancelled) setData(r); }) .catch(e => { if (!cancelled) setErr(e?.message || "Failed to load submission"); }); return () => { cancelled = true; }; }, [test, student]); const score = data?.attempt.teacherScore ?? data?.attempt.autoScore ?? 0; const max = data?.attempt.maxMarks ?? 0; const pct = max > 0 ? Math.round((Number(score) / max) * 100) : 0; return (
e.stopPropagation()} style={{ background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius:10, width:880, maxWidth:"100%", maxHeight:"92vh", display:"flex", flexDirection:"column" }}>
{test.id} · Submission report
{student.name} {student.roll_no}
{data && ( = 60 ? "var(--green)" : pct >= 40 ? "var(--amber)" : "var(--red)" }}> {score}/{max} · {pct}% )}
{!data && !err &&
Loading…
} {err &&
{err}
} {data && data.sections.flatMap(sec => sec.questions.map(q => { const resp = data.responses[q.id]; const isObjective = q.type === "mcq" || q.type === "tf"; const picked = resp?.selectedOptionId ? q.options.find(o => o.id === resp.selectedOptionId) : null; const correctOpt = q.options.find(o => o.is_correct); const photos = Array.isArray(resp?.responseImages) ? resp.responseImages : []; const skipped = isObjective ? !picked : photos.length === 0; const isCorrect = isObjective && picked?.id === correctOpt?.id; const teacherScore = resp?.teacherScore; return (
= Number(q.marks) ? "color-mix(in srgb, var(--green-solid) 5%, transparent)" : "color-mix(in srgb, var(--amber-solid) 5%, transparent)") : "color-mix(in srgb, var(--amber-solid) 4%, transparent)"), }}>
Q{q.q_no} · {q.marks} mark{Number(q.marks) === 1 ? "" : "s"} {skipped ? ( Skipped ) : isObjective ? ( isCorrect ? ✓ Correct : ✗ Wrong ) : teacherScore != null ? ( Graded {teacherScore}/{q.marks} ) : ( Needs grading )}
{q.text}
{q.images?.question?.length ? (
{q.images.question.map((src, i) => ( {`Question ))}
) : null} {isObjective ? (
{q.options.map(o => { const isPicked = picked?.id === o.id; const correctish = o.is_correct; let bg = "var(--bg-0)", border = "var(--line)", color = "var(--ink-2)"; if (correctish) { bg = "color-mix(in srgb, var(--green-solid) 12%, transparent)"; border = "color-mix(in srgb, var(--green-solid) 40%, transparent)"; color = "var(--green-solid)"; } if (isPicked && !correctish) { bg = "color-mix(in srgb, var(--red-solid) 12%, transparent)"; border = "color-mix(in srgb, var(--red-solid) 40%, transparent)"; color = "var(--red-solid)"; } return (
{o.letter}. {o.text} {o.image && ( {`Option )} {isPicked && STUDENT} {correctish && CORRECT}
); })}
) : ( /* Descriptive: photo answers from the student + a manual score input below. The score patch hits the same teacher endpoint the mobile uses (online-tests/attempts/:id/responses/:qid/score). */ { // Re-fetch the detail so the score chip updates. window.AppApi.get(`/tests/${test.id}/students/${student.student_id}/attempt`) .then(setData).catch(() => {}); }} /> )} {q.images?.solution?.length ? (
Solution figures
{q.images.solution.map((src, i) => ( {`Solution ))}
) : null}
); }) )}
); }; /* ────────────────────────────────────────────────────────────────── Descriptive answer block — shown inside the detail modal for any non-MCQ question. Renders the student's photo uploads and a manual scoring form. PATCHes the same teacher scoring endpoint the mobile uses, then triggers onSaved so the parent can refresh. ────────────────────────────────────────────────────────────────── */ const DescriptiveResponseBlock = ({ attemptId, questionId, maxMarks, photos, textAnswer, teacherScore, teacherFeedback, onSaved, }) => { const [score, setScore] = React.useState(teacherScore != null ? String(teacherScore) : ""); const [feedback, setFeedback] = React.useState(teacherFeedback || ""); const [saving, setSaving] = React.useState(false); const [savedAt, setSavedAt] = React.useState(0); const [err, setErr] = React.useState(""); const save = async () => { const n = Number(score); if (!Number.isFinite(n) || n < 0 || n > maxMarks) { setErr(`Score must be between 0 and ${maxMarks}`); return; } setErr(""); setSaving(true); try { await window.AppApi.patch( `/online-tests/attempts/${attemptId}/responses/${questionId}/score`, { teacher_score: n, teacher_feedback: feedback || null }, ); setSavedAt(Date.now()); onSaved && onSaved(); } catch (e) { setErr(e?.message || "Save failed"); } finally { setSaving(false); } }; return (
Student submission
{photos.length === 0 && !textAnswer ? (
No answer submitted.
) : ( <> {photos.length > 0 && (
{photos.map((src, i) => ( {`Answer ))}
)} {textAnswer && (
{textAnswer}
)} )}
{err &&
{err}
}
); }; window.OnlineSubmissionsModal = OnlineSubmissionsModal; window.OnlineSubmissionDetailModal = OnlineSubmissionDetailModal;