/* ================================================================== 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 (
{entered}/{roll}
); }; // ══════════════════════════════════════════════════════════════════════ // Root — list ⇄ detail // ══════════════════════════════════════════════════════════════════════ const ExamsScreen = () => { const [exams, setExams] = React.useState([]); const [setup, setSetup] = React.useState(null); const [openId, setOpenId] = React.useState(null); const [tab, setTab] = React.useState("exams"); const [loading, setLoad] = React.useState(true); const [err, setErr] = React.useState(null); const refresh = React.useCallback(async () => { setErr(null); try { const [xs, st] = await Promise.all([ window.AppApi.get("/admin/exams"), setup ? Promise.resolve(setup) : window.AppApi.get("/admin/exam-setup"), ]); setExams(xs); setSetup(st); } catch (e) { setErr(e.message || "Failed to load"); } finally { setLoad(false); } }, [setup]); React.useEffect(() => { refresh(); }, []); // eslint-disable-line if (loading) return
Loading examinations…
; if (err) return (
{err}
); if (openId) { return { setOpenId(null); refresh(); }} onChanged={refresh} />; } return (
{[["exams", "Examinations"], ["reports", "Progress reports"]].map(([k, label]) => ( ))}
{tab === "exams" ? : }
); }; // ── The list ────────────────────────────────────────────────────────── const ExmList = ({ exams, setup, onOpen, onChanged }) => { const [creating, setCreating] = React.useState(false); return (
Examinations
Build an exam, set each paper's marks division, assign the teacher who enters them, then publish the result to parents.
{creating && ( setCreating(false)} onCreated={(e) => { setCreating(false); onChanged(); onOpen(e.id); }} /> )} {exams.length === 0 && !creating && (
No examinations yet. “New exam” starts one.
)} {exams.map((e) => { const tint = EXM_STATUS_TINT[e.status] || EXM_STATUS_TINT.draft; return (
onOpen(e.id)}>
{e.name} {e.exam_type}
{e.academic_year} {e.starts_on ? ` · ${exmDate(e.starts_on)}` : ""} {e.ends_on && e.ends_on !== e.starts_on ? ` – ${exmDate(e.ends_on)}` : ""}
{e.submitted}/{e.papers} papers in
{e.unstaffed > 0 && (
{e.unstaffed} unassigned
)}
); })}
); }; // ── Create ──────────────────────────────────────────────────────────── const ExmCreate = ({ setup, onCancel, onCreated }) => { // Default to the academic year the classes are actually in, so the exam // lands against this year's roster rather than a year nobody teaches. const years = [...new Set((setup?.classes || []).map((c) => c.academic_year))]; const [f, setF] = React.useState({ name: "", exam_type: "Half-Yearly", academic_year: years[0] || "", starts_on: "", ends_on: "", marksheet_row: "", }); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const save = async () => { setBusy(true); setErr(null); try { onCreated(await window.AppApi.post("/admin/exams", { ...f, starts_on: f.starts_on || null, ends_on: f.ends_on || null, marksheet_row: f.marksheet_row || null, })); } catch (e) { setErr(e.message || "Could not create"); setBusy(false); } }; return (
New examination
{err &&
{err}
}
); }; // ══════════════════════════════════════════════════════════════════════ // Detail — the paper list, and the state machine // ══════════════════════════════════════════════════════════════════════ const ExmDetail = ({ examId, setup, onBack, onChanged }) => { const [exam, setExam] = React.useState(null); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const [notice, setNotice] = React.useState(null); const [sheet, setSheet] = React.useState(null); // read-only marks viewer const load = React.useCallback(async () => { try { setExam(await window.AppApi.get(`/admin/exams/${examId}`)); } catch (e) { setErr(e.message || "Failed to load"); } }, [examId]); React.useEffect(() => { load(); }, [load]); const move = async (status, force) => { setBusy(true); setErr(null); setNotice(null); try { setExam(await window.AppApi.post(`/admin/exams/${examId}/status`, { status, force })); onChanged(); } catch (e) { // The publish gate answers 409 with the list of outstanding papers — // name them rather than saying "something went wrong". const pending = e.body?.pending; if (pending?.length) { setNotice({ kind: "confirm-publish", text: `${pending.length} paper(s) have not been submitted:`, list: pending, }); } else setErr(e.message || "Could not change status"); } finally { setBusy(false); } }; const reopen = async (paperId) => { setBusy(true); setErr(null); try { setExam(await window.AppApi.post(`/admin/exams/${examId}/papers/${paperId}/reopen`, {})); onChanged(); } catch (e) { setErr(e.message || "Could not reopen"); } finally { setBusy(false); } }; const remove = async () => { if (!window.confirm("Delete this exam? Only possible while it is a draft with no marks.")) return; setBusy(true); setErr(null); try { await window.AppApi.del(`/admin/exams/${examId}`); onBack(); } catch (e) { setErr(e.message || "Could not delete"); setBusy(false); } }; if (!exam) { return (
{err || "Loading…"}
); } const tint = EXM_STATUS_TINT[exam.status] || EXM_STATUS_TINT.draft; const submitted = exam.papers.filter((p) => p.status === "submitted").length; const locked = exam.status === "published"; return (
{exam.name}
{exam.exam_type} · {exam.academic_year} {exam.starts_on ? ` · ${exmDate(exam.starts_on)}` : ""} {exam.ends_on && exam.ends_on !== exam.starts_on ? ` – ${exmDate(exam.ends_on)}` : ""}
{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.
)}
{ setExam(e); onChanged(); }} onReopen={reopen} onViewSheet={setSheet} /> {sheet && ( setSheet(null)} /> )}
); }; // ── The paper grid ──────────────────────────────────────────────────── const ExmPapers = ({ exam, setup, locked, onSaved, onReopen, onViewSheet }) => { // Local working copy. The admin edits freely and saves the whole grid as a // set — which is also how a paper gets removed (leave it out). const [rows, setRows] = React.useState([]); const [dirty, setDirty] = React.useState(false); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const [kept, setKept] = React.useState([]); React.useEffect(() => { setRows(exam.papers.map((p) => ({ class_section_id: p.class_section_id, // Either may be absent: a paper is named by a registry subject, by its // own label, or both (a registry subject renamed for this exam). subject_id: p.subject_id || null, subject_label: p.subject_label || "", subject_group: p.subject_group || "", max_written: p.max_written, max_oral: p.max_oral, exam_date: p.exam_date ? String(p.exam_date).slice(0, 10) : "", assigned_to: p.assigned_to || "", // Read-only, for display only — never sent back. _id: p.id, _status: p.status, _entered: p.entered, _roll: p.roll, _class: p.class_label, _subject: p.subject_name, _registry: p.registry_subject_name || "", _submitted_by: p.submitted_by_name, }))); setDirty(false); setKept([]); }, [exam]); const classById = React.useMemo( () => Object.fromEntries((setup?.classes || []).map((c) => [c.id, c])), [setup]); const subjectById = React.useMemo( () => Object.fromEntries((setup?.subjects || []).map((s) => [s.id, s])), [setup]); const patch = (i, k, v) => { setRows((rs) => rs.map((r, j) => (j === i ? { ...r, [k]: v } : r))); setDirty(true); }; const drop = (i) => { setRows((rs) => rs.filter((_, j) => j !== i)); setDirty(true); }; const save = async () => { setBusy(true); setErr(null); setKept([]); try { const out = await window.AppApi.put(`/admin/exams/${exam.id}/papers`, { papers: rows.map((r) => ({ class_section_id: r.class_section_id, subject_id: r.subject_id || null, subject_label: (r.subject_label || "").trim() || null, subject_group: r.subject_group || null, max_written: Number(r.max_written) || 0, max_oral: Number(r.max_oral) || 0, exam_date: r.exam_date || null, assigned_to: r.assigned_to || null, })), }); setKept(out.kept_because_marked || []); onSaved(out); } catch (e) { setErr(e.message || "Could not save"); } finally { setBusy(false); } }; // Group for display; the payload stays flat. const grouped = React.useMemo(() => { const g = new Map(); rows.forEach((r, i) => { const label = r._class || classById[r.class_section_id]?.label || "—"; if (!g.has(label)) g.set(label, []); g.get(label).push({ r, i }); }); return [...g.entries()].sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true })); }, [rows, classById]); return (
Papers
One row per class and subject. The written and oral maximums are what the teacher's entry screen enforces.
{!locked && ( )}
{locked && (
This exam is published — unpublish it to change the paper list.
)} {!locked && ( { setRows((rs) => [...rs, ...added]); setDirty(true); }} /> )} {err &&
{err}
} {kept.length > 0 && (
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) && ( )}
{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 ( ); })}
SUBJECT · AS EXAMINED WRITTEN ORAL TOTAL REPORT BAND DATE ENTERED BY PROGRESS
{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 && (
{registry} in the subject registry
)} {!r.subject_id && (
exam only
)} )}
patch(i, "max_written", e.target.value)} /> patch(i, "max_oral", e.target.value)} /> 0 ? "var(--ink-1)" : "var(--red)" }}> {total || "—"} {/* Which heading the subject prints under on the progress report. "auto" reads it off the name. */} patch(i, "exam_date", e.target.value)} /> {r._id ? (
{done && In}
) : new}
{r._id && (r._entered > 0 || done) && ( )} {" "} {done && !locked && ( )} {!done && !locked && ( )}
))}
); }; // ── 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 (
{paper.class_label} · {paper.subject_name} {data?.paper ? {" "}out of {data.paper.max_total} {data.paper.max_oral > 0 ? ` (${data.paper.max_written} written + ${data.paper.max_oral} oral)` : ""} : null}
{err &&
{err}
} {!data && !err &&
Loading…
} {data && (
{data.paper.max_written > 0 && } {data.paper.max_oral > 0 && } {data.students.map((s) => ( {data.paper.max_written > 0 && ( )} {data.paper.max_oral > 0 && ( )} ))}
ROLL NAMEWRITTENORALTOTAL
{s.roll_no} {s.name} {s.is_absent ? "—" : (s.written ?? "·")} {s.is_absent ? "—" : (s.oral ?? "·")} {s.is_absent ? Absent : (s.total ?? not entered)}
)}
); }; // ══════════════════════════════════════════════════════════════════════ // Progress reports — generating the printed marksheet // ══════════════════════════════════════════════════════════════════════ // Opens the generated PDF in a new tab rather than downloading it: the // browser's own viewer is where somebody hits Print, which is the whole point // of the file. The session is a Bearer token, so a plain would fetch // it unauthenticated — hence AppApi.download and a blob URL. // Downloads the exam's marks as CSV. Bearer-token session, so a plain // would fetch unauthenticated — fetch via AppApi, then hand the // browser a blob with the server's filename. async function exmDownloadCsv(examId, classSectionId, setErr) { try { const qs = classSectionId ? `?class_section_id=${classSectionId}` : ""; const { blobUrl, filename } = await window.AppApi.download(`/admin/exams/${examId}/marks.csv${qs}`); const a = document.createElement("a"); a.href = blobUrl; a.download = filename || "marks.csv"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } catch (e) { setErr(e.message || "Could not export the marks"); } } async function exmOpenPdf(qs, setBusy, setErr, key) { setBusy(key); setErr(null); try { const { blobUrl } = await window.AppApi.download(`/admin/marksheet.pdf?${qs}`); const w = window.open(blobUrl, "_blank"); if (!w) setErr("Your browser blocked the pop-up — allow pop-ups for this site to open the report."); // Revoked late: revoking immediately races the new tab's own load. setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); } catch (e) { setErr(e.message || "Could not generate the report"); } finally { setBusy(null); } } const ExmMarksheets = () => { const [data, setData] = React.useState(null); const [year, setYear] = React.useState(null); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(null); const [openCls, setOpenCls] = React.useState(null); // The result's publication date, printed in every report's Date field. No // default: it is almost never the day Generate is pressed, so the office // has to choose it. Remembered in this browser between sessions. const [reportDate, setReportDateRaw] = React.useState(() => { try { return localStorage.getItem("app.exams.reportDate") || ""; } catch { return ""; } }); const setReportDate = (v) => { setReportDateRaw(v); try { v ? localStorage.setItem("app.exams.reportDate", v) : localStorage.removeItem("app.exams.reportDate"); } catch {} }; const load = React.useCallback(async (y) => { setErr(null); try { const d = await window.AppApi.get( `/admin/marksheet/sessions${y ? `?academic_year=${encodeURIComponent(y)}` : ""}`); setData(d); setYear(d.academic_year); } catch (e) { setErr(e.message || "Failed to load"); } }, []); React.useEffect(() => { load(null); }, [load]); if (err && !data) return (
{err}
); if (!data) return
Loading…
; const mapped = Object.fromEntries((data.rows || []).map((r) => [r.marksheet_row, r])); const missing = EXM_ROWS.filter((r) => r.key && !mapped[r.key]); return (
Progress reports
One sheet per child for the whole session — the three terminal rows, every subject, and the annual grade. Prints on Legal paper, landscape.
{!reportDate && (
Choose the date the result is published — it is printed on every report. Generating stays off until you do.
)} {/* Which exam fills which row — the one thing that has to be right before anything is printed. */}
{EXM_ROWS.filter((r) => r.key).map((r) => (
{r.label} {mapped[r.key] ? ( <> {mapped[r.key].name} ) : ( not set — this row prints blank )}
))}
{missing.length > 0 && (
Set a row on an exam from its own page (Examinations → open the exam).
)} {err &&
{err}
}
{(data.classes || []).length === 0 && (
No class has papers in this session's marksheet exams yet.
)} {(data.classes || []).map((c) => (
Class {c.label}
{c.students} student{c.students === 1 ? "" : "s"} · {c.subjects} subject {c.subjects === 1 ? "" : "s"} · {c.submitted}/{c.papers} papers submitted
{c.submitted < c.papers && (
{c.papers - c.submitted} paper(s) not submitted — marks already typed into them still print, so this report can change.
)} {openCls === c.id && ( )}
))}
); }; // ── The headmaster's signature, printed on every report ─────────────── // One PNG for the school, stored server-side (backend/data/marksheet). PNG so // a scanned signature keeps its transparency and sits on the ruled line // without a white box around it. const ExmSignature = () => { const [info, setInfo] = React.useState(null); const [preview, setPreview] = React.useState(null); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const fileRef = React.useRef(null); const refresh = React.useCallback(async () => { setErr(null); try { const i = await window.AppApi.get("/admin/marksheet/signature?info=1"); setInfo(i); if (i.present) { const { blobUrl } = await window.AppApi.download(`/admin/marksheet/signature?t=${Date.now()}`); setPreview((old) => { if (old) URL.revokeObjectURL(old); return blobUrl; }); } else setPreview((old) => { if (old) URL.revokeObjectURL(old); return null; }); } catch (e) { setErr(e.message || "Could not load the signature"); } }, []); React.useEffect(() => { refresh(); }, [refresh]); const upload = async (file) => { if (!file) return; setErr(null); if (file.type !== "image/png") { setErr("Use a PNG image — it keeps the transparency around the signature."); return; } if (file.size > 2 * 1024 * 1024) { setErr("That image is larger than 2 MB."); return; } setBusy(true); try { const form = new FormData(); form.append("file", file, file.name); const token = window.AppApi.getToken(); const r = await fetch(window.AppApi.base + "/admin/marksheet/signature", { method: "PUT", body: form, headers: token ? { authorization: `Bearer ${token}` } : {}, }); if (!r.ok) { let msg = `upload failed (${r.status})`; try { const j = await r.json(); msg = j.error || j.message || msg; } catch {} throw new Error(msg); } await refresh(); } catch (e) { setErr(e.message || "Upload failed"); } finally { setBusy(false); if (fileRef.current) fileRef.current.value = ""; } }; const remove = async () => { if (!window.confirm("Remove the headmaster's signature? Reports will print with an empty signature block.")) return; setBusy(true); setErr(null); try { await window.AppApi.del("/admin/marksheet/signature"); await refresh(); } catch (e) { setErr(e.message || "Could not remove it"); } finally { setBusy(false); } }; return (
{preview ? Headmaster's signature : No signature yet}
Headmaster's signature
Printed above the line in the headmaster's block on every report. A PNG with a transparent background works best; up to 2 MB.
{info?.present && (
Updated {exmDate(info.updated_at)}
)} {err &&
{err}
}
upload(e.target.files?.[0])} /> {info?.present && ( )}
); }; const ExmMarksheetRoster = ({ classId, year, busy, setBusy, setErr, reportDate }) => { const [roster, setRoster] = React.useState(null); const [q, setQ] = React.useState(""); React.useEffect(() => { let alive = true; window.AppApi .get(`/admin/marksheet/roster?class_section_id=${classId}&academic_year=${encodeURIComponent(year)}`) .then((d) => { if (alive) setRoster(d); }) .catch((e) => { if (alive) setErr(e.message || "Could not load the roster"); }); return () => { alive = false; }; }, [classId, year, setErr]); if (!roster) return
Loading roster…
; const t = q.trim().toLowerCase(); const rows = t ? roster.students.filter((s) => s.name.toLowerCase().includes(t) || (s.roll_no || "").toLowerCase().includes(t)) : roster.students; return (
{roster.warnings?.length > 0 && (
    {roster.warnings.map((w, i) =>
  • {w}
  • )}
)} setQ(e.target.value)} />
{rows.map((s) => { const key = `${classId}:${s.student_id}`; return ( ); })}
ROLL NAME ANNUAL GRADE
{s.roll_no} {s.name} {s.annual.pct == null ? no marks : `${s.annual.pct}%`} {s.annual.grade || "—"} {/* Promotion is the Annual examination's verdict; before the Annual row exists there is no verdict to show. */} {s.annual.promoted === false && s.rows?.find((r) => r.row === "annual")?.total != null && ( · not promoted )}
{rows.length === 0 && (
Nobody matches “{q}”.
)}
); }; window.ExamsScreen = ExamsScreen;