/* ==================================================================
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
{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}
);
};
/* ---------- 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.