/* ================================================================== Admin → Parents ------------------------------------------------------------------ Approval queue + lifecycle controls for the Parent app. Parents self-register and land here with status='pending'. The admin reviews each parent's claimed child(ren) against the school register and clicks Approve — that flips status to 'active' and stamps `verified_at` on every link row. Tabs: • Pending — review queue, [Approve] / [Reject] • Active — approved parents, [Suspend], last-login info • Suspended — paused or rejected, [Restore] All endpoints live under /api/admin/parents/* and require admin. ================================================================== */ const { Icon: PAIcon } = window.AppUI; const AdminParentsScreen = () => { const [tab, setTab] = React.useState("pending"); const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const [openId, setOpenId] = React.useState(null); // parent currently expanded const [openDetail, setOpenDetail] = React.useState(null); // { ...parent, links: [] } const [busy, setBusy] = React.useState({}); // Issued sign-in link, keyed by parent id. Shown once — the raw token is // never stored server-side, so navigating away loses it for good. const [signinLink, setSigninLink] = React.useState(null); // Invite composer — for guardians with NO account yet. Generated against // students, not a parent row, so it lives outside the account list. const [inviteOpen, setInviteOpen] = React.useState(false); const [invQuery, setInvQuery] = React.useState(""); const [invResults, setInvResults] = React.useState([]); const [invPicked, setInvPicked] = React.useState([]); const [invPhone, setInvPhone] = React.useState(""); const [invRelation, setInvRelation] = React.useState("guardian"); const [invBusy, setInvBusy] = React.useState(false); const [invite, setInvite] = React.useState(null); const [invCopied, setInvCopied] = React.useState(false); const [linkCopied, setLinkCopied] = React.useState(false); // { [parentId]: true } const load = React.useCallback(async () => { setLoading(true); setErr(null); try { setRows(await window.AppApi.get(`/admin/parents?status=${tab}`)); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [tab]); React.useEffect(() => { load(); }, [load]); const openDetails = async (parentId) => { if (openId === parentId) { setOpenId(null); setOpenDetail(null); return; } setOpenId(parentId); setOpenDetail(null); try { setOpenDetail(await window.AppApi.get(`/admin/parents/${parentId}`)); } catch (e) { window.alert("Could not load parent: " + (e.message || e)); setOpenId(null); } }; const act = async (parentId, action, prompt) => { if (prompt && !window.confirm(prompt)) return; setBusy(b => ({ ...b, [parentId]: true })); try { await window.AppApi.post(`/admin/parents/${parentId}/${action}`, {}); if (openId === parentId) setOpenId(null); await load(); } catch (e) { window.alert(`Could not ${action}: ` + (e.message || e)); } finally { setBusy(b => { const n = { ...b }; delete n[parentId]; return n; }); } }; // The phone is the parent's login identity — it's what they type on the sign-in // screen and where the code lands — so this is the repair path when a family // changes number. Reloads the list too, since the row shows the phone. const savePhone = async (parentId, phone) => { await window.AppApi.patch(`/admin/parents/${parentId}`, { phone }); setOpenDetail(await window.AppApi.get(`/admin/parents/${parentId}`)); await load(); }; // Debounced student typeahead for the invite composer. React.useEffect(() => { if (!inviteOpen || invQuery.trim().length < 2) { setInvResults([]); return; } const t = setTimeout(async () => { try { setInvResults(await window.AppApi.get( `/admin/parents/student-search?q=${encodeURIComponent(invQuery.trim())}`)); } catch { setInvResults([]); } }, 250); return () => clearTimeout(t); }, [invQuery, inviteOpen]); const pickStudent = (st) => { setInvPicked(ps => ps.some(p => p.id === st.id) ? ps : [...ps, st]); setInvQuery(""); setInvResults([]); }; const unpickStudent = (id) => setInvPicked(ps => ps.filter(p => p.id !== id)); const createInvite = async () => { setInvBusy(true); setInvite(null); setInvCopied(false); try { const out = await window.AppApi.post("/admin/parents/invites", { phone: invPhone.trim(), student_ids: invPicked.map(p => p.id), relation: invRelation, }); setInvite(out); } catch (e) { window.alert("Could not create the invitation: " + (e.message || e)); } finally { setInvBusy(false); } }; const copyInvite = async () => { try { await navigator.clipboard.writeText(invite.url); setInvCopied(true); setTimeout(() => setInvCopied(false), 2000); } catch { /* clipboard blocked — the input is selectable as a fallback */ } }; const resetInvite = () => { setInviteOpen(false); setInvite(null); setInvPicked([]); setInvPhone(""); setInvQuery(""); setInvResults([]); setInvRelation("guardian"); }; const issueSigninLink = async (parentId) => { setSigninLink(null); setLinkCopied(false); setBusy(b => ({ ...b, [parentId]: true })); try { const out = await window.AppApi.post(`/admin/parents/${parentId}/signin-link`, {}); setSigninLink({ parentId, ...out }); } catch (e) { window.alert("Could not create a sign-in link: " + (e.message || e)); } finally { setBusy(b => { const n = { ...b }; delete n[parentId]; return n; }); } }; const copySigninLink = async () => { try { await navigator.clipboard.writeText(signinLink.url); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); } catch { /* clipboard blocked — the input below is selectable as a fallback */ } }; const removeLink = async (parentId, linkId, studentName) => { if (!window.confirm(`Remove ${studentName} from this parent's claimed children?`)) return; try { await window.AppApi.del(`/admin/parents/${parentId}/links/${linkId}`); // refresh the expanded detail setOpenDetail(await window.AppApi.get(`/admin/parents/${parentId}`)); } catch (e) { window.alert("Could not remove link: " + (e.message || e)); } }; const tabs = [ { id: "pending", label: "Pending", icon: "inbox" }, { id: "active", label: "Active", icon: "check" }, { id: "suspended", label: "Suspended", icon: "x" }, ]; const fmt = (iso) => iso ? new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }) : "—"; return (
Parents self-register and wait here. Verify the claimed child(ren) against the school register, then approve. Approved parents can log in and see their child's daily briefing.
{/* Tabs */}