/* ==================================================================
Operations → Examinations
------------------------------------------------------------------
Where the office builds an exam, staffs it, and releases the result.
The lifecycle this screen drives (backend/src/routes/exams.ts):
draft ──► entry_open ──► published
▲ │ │
└────────────┘ │ (unpublish)
◄─────────────┘
• draft the paper list is still being laid out. Teachers see
nothing at all.
• entry_open the assigned teachers can fill and submit. Parents
still see nothing.
• published THE one-way door that matters: every submitted paper
becomes visible in the Parents app. Reversible.
A "paper" is one class-section × one subject, carrying its own
written and oral maximums — because Class III English out of 50 and
Class IX English out of 100 are different papers in the same exam.
Admin / HoD only, enforced server-side on /admin/exams*.
NOTE ON NAMING. Every top-level binding in this file is prefixed
Exm/EXM_ on purpose: the panel's screens are Babel-compiled into ONE
shared global scope, so a top-level `const` that collides with
another screen's silently kills whichever file loads second.
Endpoints: /api/admin/exams*, /api/admin/exam-setup
================================================================== */
const { Icon: ExmIcon } = window.AppUI;
// Offered as suggestions in the create form. Free text server-side —
// schools invent exam types faster than a migration can add enum values.
const EXM_TYPES = [
"Unit Test", "First Summative", "Half-Yearly", "Second Summative",
"Pre-Annual", "Annual", "Model", "Pre-Board",
];
// The three rows of the printed progress report. An exam either fills one of
// them or is simply not on the marksheet (a unit test, a model paper).
const EXM_ROWS = [
{ key: "", label: "Not on the marksheet" },
{ key: "first_terminal", label: "1st Terminal Examination" },
{ key: "second_terminal", label: "2nd Terminal Examination" },
{ key: "annual", label: "Annual Examination" },
];
const EXM_ROW_LABEL = Object.fromEntries(EXM_ROWS.map((r) => [r.key, r.label]));
// Which band a subject prints under. Blank means "work it out from the name",
// which is right for every ordinary subject.
const EXM_GROUPS = [
{ key: "", label: "auto" },
{ key: "language", label: "Language" },
{ key: "science", label: "Science" },
{ key: "social_science", label: "Social Science" },
{ key: "co_curricular", label: "Co-curricular" },
];
const EXM_STATUS_TINT = {
draft: { label: "Draft", fg: "var(--ink-3)", bg: "color-mix(in srgb, var(--ink-0) 12%, transparent)", bd: "color-mix(in srgb, var(--ink-0) 30%, transparent)" },
entry_open: { label: "Entry open", fg: "var(--amber)", bg: "color-mix(in srgb, var(--amber-solid) 14%, transparent)", bd: "color-mix(in srgb, var(--amber-solid) 32%, transparent)" },
published: { label: "Published", fg: "var(--green)", bg: "color-mix(in srgb, var(--green-solid) 16%, transparent)", bd: "color-mix(in srgb, var(--green-solid) 36%, transparent)" },
};
const ExmPill = ({ tint, children }) => (
{children ?? tint.label}
);
const exmDate = (d) => {
if (!d) return "—";
try {
return new Date(d).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
} catch { return String(d).slice(0, 10); }
};
// A paper's roster progress. Grey until someone starts, amber part-way,
// green when the sheet is in — so an exam's bottleneck is visible without
// opening a single paper.
const ExmProgress = ({ entered, roll, status }) => {
const pct = roll > 0 ? Math.round((entered / roll) * 100) : 0;
const done = status === "submitted";
const colour = done ? "var(--green)" : entered === 0 ? "var(--ink-3)" : "var(--amber)";
return (
{submitted} of {exam.papers.length} papers submitted
{exam.published_at ? ` · published ${exmDate(exam.published_at)}` : ""}
{/* Which row of the printed progress report this exam fills. A
session has one of each, so the server refuses a clash. */}
{exam.status === "draft" && (
)}
{exam.status === "entry_open" && (
<>
>
)}
{exam.status === "published" && (
)}
{exam.status === "draft" && (
)}
{/* Every class, one row per child per paper — the shape that
holds classes with different subjects in one sheet. */}
{exam.papers.length > 0 && (
)}
{exam.status === "draft" &&
"Draft — teachers cannot see this exam yet. Lay out the papers below, assign a teacher to each, then open it for entry."}
{exam.status === "entry_open" &&
"Open for entry — the assigned teachers can fill and submit their papers. Parents still see nothing until you publish."}
{exam.status === "published" &&
"Published — every submitted paper is now visible in the Parents app. Unpublish to take it back off."}
{err &&
{err}
}
{notice?.kind === "confirm-publish" && (
{notice.text}
{notice.list.slice(0, 12).map((p, i) =>
{p}
)}
{notice.list.length > 12 &&
…and {notice.list.length - 12} more
}
Publishing now shows parents only the subjects that ARE in. The missing ones
appear later, the moment you publish again after they are submitted.
Kept — these papers already hold marks and were not removed:
{kept.map((k, i) =>
{k}
)}
)}
{rows.length === 0 && (
No papers yet. Add a class and its subjects above.
)}
{grouped.map(([label, items]) => (
Class {label}
{items[0].r._roll != null ? `${items[0].r._roll} on roll` : ""}
{/* One row per child, subjects across — the result-sheet shape.
Only once the class's papers are saved: the export reads
what is in the database, not this unsaved grid. */}
{items.some(({ r }) => r._id) && (
)}
SUBJECT · AS EXAMINED
WRITTEN
ORAL
TOTAL
REPORT BAND
DATE
ENTERED BY
PROGRESS
{items.map(({ r, i }) => {
const total = (Number(r.max_written) || 0) + (Number(r.max_oral) || 0);
// What this paper is CALLED: the exam-specific label if one
// was given, otherwise the registry subject's own name.
const registry = r._registry || subjectById[r.subject_id]?.name || "";
const shown = r.subject_label || registry;
const renamed = !!r.subject_label && !!registry && r.subject_label !== registry;
const subj = shown || "—";
const done = r._status === "submitted";
return (
{locked ? (
{subj}
) : (
<>
{/* Editable on every row: this is the name that
goes on the exam and the marksheet, which is
not always what the registry calls it. */}
{
const v = e.target.value;
// Typing the registry name back clears the
// override rather than storing a redundant
// copy that would drift if the subject is
// ever renamed centrally.
patch(i, "subject_label", v === registry ? "" : v);
}} />
{renamed && (
);
};
// ── Bulk add: one class, many subjects, one set of maximums ───────────
const ExmAddPapers = ({ setup, rows, onAdd }) => {
const [classId, setClassId] = React.useState("");
const [picked, setPicked] = React.useState({});
const [written, setWritten] = React.useState(80);
const [oral, setOral] = React.useState(20);
const [custom, setCustom] = React.useState("");
// Mirrors the server's exam_papers.subject_key, so a paper already on this
// class is greyed out here rather than refused on save.
const taken = React.useMemo(() => new Set(rows.map((r) =>
`${r.class_section_id}:${r.subject_id || `label:${(r.subject_label || "").trim().toLowerCase()}`}`,
)), [rows]);
const subjects = (setup?.subjects || []).filter((s) => !taken.has(`${classId}:${s.id}`));
const chosen = subjects.filter((s) => picked[s.id]);
const cls = (setup?.classes || []).find((c) => c.id === classId);
const add = () => {
onAdd(chosen.map((s) => ({
class_section_id: classId, subject_id: s.id, subject_label: "", subject_group: "",
max_written: written, max_oral: oral, exam_date: "",
// Pre-filled from the school's standing assignment matrix — a
// suggestion the admin can override on any row before saving.
assigned_to: setup?.suggested?.[`${classId}:${s.id}`] || "",
_class: cls?.label, _subject: s.name, _registry: s.name, _roll: cls?.students,
})));
setPicked({});
};
// A subject the school examines but the registry has never heard of —
// Drawing, Work Education, Moral Science. It gets no registry link and no
// suggested teacher, because there is no assignment matrix row to read.
const customName = custom.trim();
const customTaken = !!customName
&& taken.has(`${classId}:label:${customName.toLowerCase()}`);
const addCustom = () => {
if (!classId || !customName || customTaken) return;
onAdd([{
class_section_id: classId, subject_id: null, subject_label: customName, subject_group: "",
max_written: written, max_oral: oral, exam_date: "", assigned_to: "",
_class: cls?.label, _subject: customName, _registry: "", _roll: cls?.students,
}]);
setCustom("");
};
return (
{/* Not every examined subject is in the registry. That list drives test
authoring, chapters and the routine, so a paper the school only ever
examines belongs here and not there. */}
{classId && (
)}
{customTaken && (
“{customName}” is already on this class.
)}
{classId && (
{subjects.length === 0 && (
Every registry subject is already on this class's paper list — add one of
your own below.
)}
{subjects.map((s) => (
))}
{subjects.length > 1 && (
)}
)}
);
};
// ── Read-only sheet viewer ────────────────────────────────────────────
// So the office can check a submitted paper before publishing without
// signing in as the teacher.
const ExmSheetViewer = ({ examId, paper, onClose }) => {
const [data, setData] = React.useState(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
let alive = true;
window.AppApi.get(`/admin/exams/${examId}/papers/${paper.id}/marks`)
.then((d) => { if (alive) setData(d); })
.catch((e) => { if (alive) setErr(e.message || "Failed to load"); });
return () => { alive = false; };
}, [examId, paper.id]);
return (