/* ================================================================== 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 (
Admin · Parents

Parent accounts

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 */}
{tabs.map(t => ( ))}
{/* Invitation composer — for a guardian with NO account yet. Generated against the students, so it lives above the account list rather than inside a parent row. */} {inviteOpen && (
Invite a parent who hasn't registered
Pick the child (or siblings), type the guardian's mobile, and send them the link. Opening it asks only for their name — no registration form, no approval needed.
{invResults.length > 0 && (
{invResults.map(r => { // A student may have at most two verified parent accounts. // Showing this here means the office never picks a full student // and then gets refused at "Create invitation". const full = r.linked_parents >= 2; return ( ); })}
)} {invPicked.length > 0 && (
{invPicked.map(st => ( {st.name} {st.class_label} ))}
)}
{invite && (
Invitation link — copy now, shown once
e.target.select()} style={{ flex: 1, minWidth: 0, fontSize: 11.5, padding: "6px 8px" }}/>
Send only to {invite.phone_mask}. Works once, expires in {invite.expires_in_hours} hours, and links {invite.students.map(s => s.name).join(", ")} to whoever opens it — so it must reach the right person.
)}
)} {err && (
{err}
)}
{loading &&
Loading…
} {!loading && rows.length === 0 && (
No {tab} parents.
)} {rows.map(p => (
openDetails(p.id)} style={{ padding: "12px 16px", cursor: "pointer", display: "grid", gridTemplateColumns: "1fr 160px 140px auto", gap: 12, alignItems: "center", }}>
{p.name}
{p.phone} {p.email && <> · {p.email}}
{p.verified_count}/{p.link_count} child{p.link_count === 1 ? "" : "ren"} verified
{tab === "pending" && <>requested {fmt(p.requested_at)}} {tab === "active" && <>last login {fmt(p.last_login_at)}} {tab === "suspended" && <>since {fmt(p.requested_at)}}
{tab === "pending" && ( <> )} {tab === "active" && ( )} {tab === "suspended" && ( )}
{openId === p.id && (
{!openDetail &&
Loading children…
} {openDetail && (
Sign-in number
savePhone(p.id, v)}/>
Claimed children — verify against the school register
{openDetail.links.length === 0 && (
No claimed children.
)} {openDetail.links.map(l => (
{l.roll_no}
{l.student_name}
{l.class_label || "—"}
{l.relation}
{l.verified_at ? ( verified ) : ( pending )}
))} {/* One-tap sign-in link. For the parent who cannot get in and is on the phone to the office: passwords here are scrypt-hashed and unrecoverable, and OTP depends on an SMS actually arriving. */}
Sign-in link
{p.status !== "active" ? (
Approve this account before sending a sign-in link.
) : ( <> {signinLink && signinLink.parentId === p.id && (
Sign-in link — copy now, shown once
{/* readOnly input rather than plain text: it stays selectable when the clipboard API is blocked. */} e.target.select()} style={{ flex: 1, minWidth: 0, fontSize: 11.5, padding: "6px 8px" }}/>
Works once, expires in {signinLink.expires_in_minutes} minutes, and signs {" "}{p.name} straight into the parent app. Creating another cancels this one. Send it to {signinLink.parent?.phone || p.phone} — anyone who opens it is signed in.
)} )}
)}
)}
))}
); }; // Inline editor for the parent's sign-in number. Kept as its own component so // each expanded row owns its draft state — the parent list renders these inside // a .map, and shared state would leak one row's edit into another. // // The server does the real validation (E.164 normalisation + duplicate check); // this only blocks the obviously-empty case and surfaces whatever the server // says. It deliberately does NOT pre-normalise the draft, so the admin sees // exactly what they typed if the server rejects it. const PhoneEditor = ({ value, onSave }) => { const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(value || ""); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); React.useEffect(() => { setDraft(value || ""); setErr(null); }, [value]); const commit = async () => { setBusy(true); setErr(null); try { await onSave(draft.trim()); setEditing(false); } catch (e) { setErr(e.message || String(e)); } finally { setBusy(false); } }; if (!editing) { return (
{value || "—"}
); } return (
setDraft(e.target.value)} style={{ flex: "0 0 200px" }}/>
Receives the sign-in code. Saved as +91…; any code already sent to the old number stops working.
{err &&
{err}
}
); }; window.AdminParentsScreen = AdminParentsScreen;