/* ================================================================== Admin → Students ------------------------------------------------------------------ CRUD over the `students` table. Mirrors the Teachers screen — the class+section filter on the left, a roster grid, and a right-hand detail panel for edit / move section / issue or reset portal login. ================================================================== */ const { Icon: StIcon } = window.AppUI; const StudentsScreen = () => { const [students, setStudents] = React.useState([]); const [classSections, setClassSections] = React.useState([]); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const [filterCs, setFilterCs] = React.useState(""); // Residential-type filter ("", "boarder", "day_scholar"). Applied client- // side over the already-fetched roster so it's instant and doesn't add a // round-trip per change. const [filterType, setFilterType] = React.useState(""); const [search, setSearch] = React.useState(""); const [selectedId, setSelectedId] = React.useState(null); const [credentialFlash, setCredentialFlash] = React.useState(null); const loadStudents = React.useCallback(async () => { setLoading(true); setErr(null); try { const qs = filterCs ? `?class_section_id=${encodeURIComponent(filterCs)}` : ""; setStudents(await window.AppApi.get(`/admin/students${qs}`)); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [filterCs]); React.useEffect(() => { loadStudents(); }, [loadStudents]); React.useEffect(() => { window.AppApi.get("/admin/class-sections").then(setClassSections).catch(() => {}); }, []); const filtered = students.filter(s => { if (filterType && s.student_type !== filterType) return false; if (!search) return true; const q = search.toLowerCase(); return (s.name || "").toLowerCase().includes(q) || (s.roll_no || "").toLowerCase().includes(q) || (s.email || "").toLowerCase().includes(q) || (s.username || "").toLowerCase().includes(q) // Searchable by the finance-system id: the office reads it off a // receipt or a Ledger screen and needs to find the child here. || (s.ledger_student_uid || "").toLowerCase().includes(q); }); const selected = selectedId ? students.find(s => s.id === selectedId) : null; // No height:100% / overflow:hidden on the split any more. The app shell is // min-height:100vh, so that height never resolved and the pane simply grew // with the page — and `overflow: hidden` on an ancestor silently disables // position:sticky on the detail pane inside it. return (
Admin · Roster

Students

The roster mirrors the Ledger — students are admitted, corrected and discontinued there, and appear here on the next sync. Portal logins and everything in a student's record beyond these fields are managed here.

{credentialFlash && (
Portal credentials — copy now, won't be shown again
Class: {credentialFlash.class_label}   ·   Roll: {credentialFlash.roll_no}   ·   Password: {credentialFlash.temp_password}
)}
{students.length} {students.length === 1 ? "student" : "students"}
setSearch(e.target.value)} style={{ paddingLeft: 28, padding: "5px 10px 5px 28px", fontSize: 12.5, width: "100%", background: "var(--bg-2)" }}/>
{/* No "Add student" here by design — admissions happen in the Ledger so the child gets a fee record and a permanent id. */} Admit in Ledger ↗
{loading &&
Loading…
} {err &&
{err}
} {!loading && filtered.length === 0 && (
{students.length === 0 ? "No students yet. Add one." : "No matches."}
)} {filtered.map(s => (
setSelectedId(s.id)} className="cx-roster-row" style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)", gap: 12, alignItems: "center", cursor: "pointer", background: selectedId === s.id ? "var(--bg-2)" : "transparent", }}> {s.roll_no}
{s.name} {s.username && !s.password_used_at && NEVER USED} {/* Only the MISSING case is loud. A linked student is the norm and needs no decoration; an unlinked one means their parent's fee screen cannot resolve, which is invisible everywhere else. */} {!s.ledger_student_uid && NO FEE LINK}
{s.email || "—"}
{s.class_label}
{s.username || no login}
{s.attempt_count + s.sheet_count > 0 ? `${s.attempt_count + s.sheet_count} acts` : "—"}
))}
{selected && ( setSelectedId(null)} onChanged={async (flashCreds) => { if (flashCreds) setCredentialFlash(flashCreds); await loadStudents(); }} /> )}
); }; /* ---------- detail panel ---------- */ // A Ledger-owned value, rendered as text. Deliberately not a disabled input: // a greyed-out box still reads as "editable, just not right now". const ReadOnly = ({ value, mono }) => (
{value || "—"}
); const REQUESTABLE = [ { key: "name", label: "Name" }, { key: "roll_no", label: "Roll no" }, { key: "email", label: "Email" }, { key: "class_section_id", label: "Class / section" }, { key: "student_type", label: "Boarder / day scholar" }, { key: "avails_transport", label: "Avails transport" }, ]; // Asks the office to change something in the Ledger. Resolving the request does // not write here — the office edits the Ledger and the roster sync brings it // back, so these fields only ever change by one route. const EditRequest = ({ student, onDone }) => { const [field, setField] = React.useState("name"); const [value, setValue] = React.useState(""); const [reason, setReason] = React.useState(""); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const [sent, setSent] = React.useState(false); const submit = async () => { setBusy(true); setErr(null); try { await window.AppApi.post(`/admin/students/${student.id}/edit-request`, { field, requested_value: value.trim(), reason: reason.trim() || null, }); setSent(true); setTimeout(onDone, 1600); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; if (sent) return (
Sent to the office. It will appear here once they update the Ledger.
); return (
setValue(e.target.value)} placeholder={field === "class_section_id" ? "e.g. VI-B" : "correct value"} style={{ width: "100%" }}/> setReason(e.target.value)} placeholder="optional — helps the office check" style={{ width: "100%" }}/> {err &&
{err}
}
); }; const StudentDetail = ({ studentSummary, classSections, onClose, onChanged }) => { const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const [showRequest, setShowRequest] = React.useState(false); // Issued set-password link. Shown once — only its hash is stored server-side. const [pwLink, setPwLink] = React.useState(null); const [copied, setCopied] = React.useState(false); React.useEffect(() => { setShowRequest(false); setPwLink(null); }, [studentSummary.id]); const issueLogin = async () => { setBusy(true); setErr(null); try { const out = await window.AppApi.post(`/admin/students/${studentSummary.id}/credentials`, {}); await onChanged({ username: out.username, temp_password: out.temp_password, class_label: studentSummary.class_label, roll_no: studentSummary.roll_no, }); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const createPasswordLink = async () => { setBusy(true); setErr(null); setCopied(false); try { const out = await window.AppApi.post(`/admin/students/${studentSummary.id}/password-link`, {}); setPwLink(out); // A first link also creates the login, so the roster needs the username. if (!studentSummary.username) await onChanged(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const copyPasswordLink = async () => { try { await navigator.clipboard.writeText(pwLink.url); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { /* clipboard blocked — the input below is selectable as a fallback */ } }; const removeLogin = async () => { if (!window.confirm("Disable portal login for this student?")) return; setBusy(true); setErr(null); try { await window.AppApi.del(`/admin/students/${studentSummary.id}/credentials`); await onChanged(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; // While the pane is a bottom sheet the page behind it must not scroll — // otherwise a drag on the sheet's own content scrolls the 881-row roster // underneath and the sheet appears to drift. Guarded to the phone layout, // since on desktop the pane is just a column and the page should scroll. useEffect(() => { const sheet = window.matchMedia("(max-width: 1000px)"); if (!sheet.matches) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); }; }, [onClose]); return ( <> {/* Backdrop — only painted on the phone layout, where the pane is a bottom sheet rather than a column. */}