/* ================================================================== Admin → Teachers ------------------------------------------------------------------ The cockpit is the admin panel. Teachers themselves use the mobile app. This screen lets the admin add a teacher, assign them to a set of (class × subject) pairs (controls which tests they can author + grade on mobile), reset passwords, and deactivate (which immediately revokes any live mobile session). ================================================================== */ const { Icon: AdIcon } = window.AppUI; const ROLE_LABEL = { admin: "Admin", hod: "HoD", teacher: "Teacher", coordinator: "Coordinator", boarder: "Boarder", support_staff: "Support Staff" }; const TeachersScreen = () => { const [teachers, setTeachers] = React.useState([]); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const [selectedId, setSelectedId] = React.useState(null); const [addOpen, setAddOpen] = React.useState(false); const [credentialFlash, setCredentialFlash] = React.useState(null); // { username, temp_password } const [search, setSearch] = React.useState(""); const [resets, setResets] = React.useState([]); // pending password-reset requests const [actBusy, setActBusy] = React.useState(null); // id currently being approved/rejected const refresh = React.useCallback(async () => { setLoading(true); setErr(null); try { const [t, pr] = await Promise.all([ window.AppApi.get("/admin/teachers"), window.AppApi.get("/password-resets?status=pending").catch(() => []), ]); setTeachers(t); setResets(pr || []); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, []); React.useEffect(() => { refresh(); }, [refresh]); // Self-registered teachers awaiting approval are surfaced in a dedicated card // above the roster (and kept OUT of the main list until activated). const pendingUsers = teachers.filter(t => t.status === "pending"); const activeUsers = teachers.filter(t => t.status !== "pending"); const approveTeacher = async (id) => { setActBusy(id); try { await window.AppApi.post(`/admin/teachers/${id}/approve`, {}); await refresh(); } catch (e) { setErr(String(e.message || e)); } finally { setActBusy(null); } }; const rejectTeacher = async (id, name) => { if (!window.confirm(`Reject and delete the registration for ${name || "this teacher"}?`)) return; setActBusy(id); try { await window.AppApi.post(`/admin/teachers/${id}/reject`, {}); if (selectedId === id) setSelectedId(null); await refresh(); } catch (e) { setErr(String(e.message || e)); } finally { setActBusy(null); } }; const decideReset = async (id, kind) => { setActBusy(id); try { await window.AppApi.post(`/password-resets/${id}/${kind}`, {}); await refresh(); } catch (e) { setErr(String(e.message || e)); } finally { setActBusy(null); } }; const filtered = activeUsers.filter(t => { if (!search) return true; const q = search.toLowerCase(); return (t.name || "").toLowerCase().includes(q) || (t.email || "").toLowerCase().includes(q) || (t.username || "").toLowerCase().includes(q); }); const selected = selectedId ? teachers.find(t => t.id === selectedId) : null; return (
Admin · Access Control

Teachers

Each teacher logs in to the mobile app and only sees tests for the (class × subject) pairs you assign here. Deactivating a teacher revokes their open sessions immediately.

{credentialFlash && (
Credentials issued — copy now, won't be shown again
Username: {credentialFlash.username}   ·   Password: {credentialFlash.temp_password}
)} {pendingUsers.length > 0 && (
{pendingUsers.length} pending approval{pendingUsers.length === 1 ? "" : "s"} Self-registered from the mobile app
{pendingUsers.map(t => (
setSelectedId(t.id)}>
{t.name} {t.boarding && BOARDING}
{t.email} · {t.phone || "no phone"}
{ROLE_LABEL[t.role] || t.role}
{t.assignment_count} {t.assignment_count === 1 ? "scope" : "scopes"}
))}
)} {resets.length > 0 && (
{resets.length} password reset request{resets.length === 1 ? "" : "s"} Approving renews the password & signs them out
{resets.map(r => (
{r.teacher.name}
{r.teacher.phone || r.teacher.email}
{ROLE_LABEL[r.teacher.role] || r.teacher.role}
))}
)}
{activeUsers.length} {activeUsers.length === 1 ? "user" : "users"}
setSearch(e.target.value)} style={{ paddingLeft: 28, padding: "5px 10px 5px 28px", fontSize: 12.5, width: "100%", background: "var(--bg-2)" }}/>
{loading &&
Loading…
} {err &&
{err}
} {!loading && filtered.length === 0 && (
{teachers.length === 0 ? "No teachers yet. Click Add teacher to create one." : "No matches."}
)} {filtered.map(t => (
setSelectedId(t.id)} onMouseEnter={e => e.currentTarget.style.background = "var(--bg-2)"} onMouseLeave={e => e.currentTarget.style.background = selectedId === t.id ? "var(--bg-2)" : "transparent"} className="cx-tchrow" style={{ padding: "12px 16px", borderBottom: "1px solid var(--line-soft)", gap: 12, alignItems: "center", cursor: "pointer", background: selectedId === t.id ? "var(--bg-2)" : "transparent", }}>
{t.name} {!t.active && DISABLED} {t.must_reset && t.active && RESET PENDING}
{t.email}
{t.username || "—"}
{ROLE_LABEL[t.role] || t.role}
{t.assignment_count} {t.assignment_count === 1 ? "scope" : "scopes"}
{t.active_sessions > 0 ? ● live : "—"}
))}
{selected && ( setSelectedId(null)} onChanged={async (flashCreds) => { if (flashCreds) setCredentialFlash(flashCreds); await refresh(); }} /> )} setAddOpen(false)} onCreated={async (creds) => { setAddOpen(false); setCredentialFlash(creds); await refresh(); setSelectedId(creds.id); }} />
); }; /* ---------- Right-hand detail panel ---------- */ const TeacherDetail = ({ teacherSummary, onClose, onChanged }) => { // { url, expires_in_minutes } — shown once, right under the button that made // it. The server stores only a hash, so there is no way to re-read it later. const [magicLink, setMagicLink] = React.useState(null); const [copied, setCopied] = React.useState(false); const [full, setFull] = React.useState(null); const [classSections, setClassSections] = React.useState([]); const [subjects, setSubjects] = React.useState([]); const [loading, setLoading] = React.useState(true); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); React.useEffect(() => { let cancelled = false; (async () => { setLoading(true); try { const [t, cs, sb] = await Promise.all([ window.AppApi.get(`/admin/teachers/${teacherSummary.id}`), window.AppApi.get("/admin/class-sections"), window.AppApi.get("/admin/subjects"), ]); if (cancelled) return; setFull(t); setClassSections(cs); setSubjects(sb); } catch (e) { if (!cancelled) setErr(String(e.message || e)); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [teacherSummary.id]); // selected set, keyed "class_section_id::subject_id" const [selected, setSelected] = React.useState(new Set()); React.useEffect(() => { if (full) setSelected(new Set(full.assignments.map(a => `${a.class_section_id}::${a.subject_id}`))); }, [full]); const toggle = (csId, sbId) => { const key = `${csId}::${sbId}`; setSelected(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const saveAssignments = async () => { setBusy(true); setErr(null); try { const assignments = Array.from(selected).map(k => { const [class_section_id, subject_id] = k.split("::"); return { class_section_id, subject_id }; }); await window.AppApi.put(`/admin/teachers/${teacherSummary.id}/assignments`, { assignments }); await onChanged(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const patch = async (body) => { setBusy(true); setErr(null); try { await window.AppApi.patch(`/admin/teachers/${teacherSummary.id}`, body); await onChanged(); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const resetPassword = async () => { if (!window.confirm("Generate a new temporary password? Existing mobile sessions will be revoked.")) return; setBusy(true); setErr(null); try { const out = await window.AppApi.post(`/admin/teachers/${teacherSummary.id}/reset-password`, {}); await onChanged({ id: teacherSummary.id, username: out.username, temp_password: out.temp_password }); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const sendMagicLink = async (target) => { setBusy(true); setErr(null); setCopied(false); try { const out = await window.AppApi.post(`/admin/teachers/${teacherSummary.id}/magic-link`, { target }); setMagicLink(out); } catch (e) { setErr(String(e.message || e)); } finally { setBusy(false); } }; const copyMagicLink = async () => { try { await navigator.clipboard.writeText(magicLink.url); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { /* clipboard blocked — the input below is selectable as a fallback */ } }; const removeTeacher = async () => { if (!window.confirm( `Remove ${teacherSummary.name}?\n\n` + `If they've never authored a test, review, or rubric, the account is deleted outright. ` + `Otherwise it's deactivated and the login is dropped — past work they own is preserved.` )) return; setBusy(true); setErr(null); try { const out = await window.AppApi.del(`/admin/teachers/${teacherSummary.id}`); // Soft-delete branch: backend tells us what's blocking. If the only // blocker is the teacher's own tests/submissions, offer a second // confirm that escalates to ?cascade=tests and nukes the lot. if (out && out.removed === "soft") { const b = out.blocked_by || {}; const testsCreated = Number(b.tests_created || 0); const subs = Number(b.submissions || 0); const otherReviews = Number(b.reviews || 0); const offersCascade = testsCreated > 0; if (offersCascade && window.confirm( `${teacherSummary.name} authored ${testsCreated} test${testsCreated === 1 ? "" : "s"} ` + `with ${subs} student submission${subs === 1 ? "" : "s"}.\n\n` + `Delete the teacher's tests AND every submission underneath them, then remove ` + `the account?\n\nThis can't be undone.` )) { const cascade = await window.AppApi.del(`/admin/teachers/${teacherSummary.id}?cascade=tests`); await onChanged(); onClose(); if (cascade?.removed === "hard") { window.alert( `Removed ${teacherSummary.name}. ` + `Cleared ${cascade.cascaded?.tests ?? 0} test(s) and ` + `${cascade.cascaded?.submissions ?? 0} submission(s).`, ); } else { // Still soft — they had reviews on OTHER teachers' work that // we won't touch (NOT NULL teacher_id, audit-trail material). window.alert( `${teacherSummary.name} also has ${cascade?.blocked_by?.reviews ?? otherReviews} ` + `review${(cascade?.blocked_by?.reviews ?? otherReviews) === 1 ? "" : "s"} on ` + `other teachers' work. The account was deactivated (login removed) but the ` + `user row stays as a tombstone so those reviews keep their author.`, ); } return; } await onChanged(); onClose(); window.alert( `${teacherSummary.name} has work that's still referenced ` + `(${testsCreated} test(s), ${otherReviews} review(s) on others, etc.), so the ` + `account was deactivated instead of fully removed. Their login is gone and ` + `they can no longer sign in.`, ); return; } // Hard delete on the first call — row just vanishes after refresh. await onChanged(); onClose(); } catch (e) { setErr(String(e.message || e)); setBusy(false); } }; // Same reasoning as the student roster: while the pane is a bottom sheet the // 28-row teacher list behind it must not scroll, or a drag inside the sheet // moves the page underneath and the sheet appears to drift. 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 ( <>