Add full-page student intake form matching Bayside paper forms

- Migration: student intake fields (health, academic, dismissal, agreement),
  student_guardians + student_curriculum_logins tables, extend authorized_pickups
  (alt_phone, notes, kind) with RLS mirroring existing policies
- New /students/new full-page multi-section form (replaces the add dialog)
- Students list links to the full page; regenerate Supabase types

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:21:38 -04:00
co-authored by Claude Opus 4.8
parent f509fa6528
commit cee27a6189
5 changed files with 663 additions and 58 deletions
+354
View File
@@ -0,0 +1,354 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ArrowLeft, Plus, Trash2, Lock, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/_authenticated/students/new")({
head: () => ({ meta: [{ title: "New student — School Portal" }] }),
component: NewStudentPage,
});
type Guardian = {
full_name: string; relationship: string; phone: string; address: string;
city: string; state: string; zip: string; email: string; employer: string;
};
type Contact = { name: string; relationship: string; phone: string; alt_phone: string };
type Pickup = { name: string; relationship: string; phone: string; notes: string };
type Login = { website: string; student_login: string; student_password: string; parent_account: string; parent_password: string };
const emptyGuardian: Guardian = { full_name: "", relationship: "", phone: "", address: "", city: "", state: "", zip: "", email: "", employer: "" };
const emptyLogin: Login = { website: "", student_login: "", student_password: "", parent_account: "", parent_password: "" };
const DISMISSAL_OPTIONS = [
{ value: "not_allowed", label: "NOT ALLOWED" },
{ value: "bicycle", label: "Bicycle" },
{ value: "scooter", label: "Scooter" },
{ value: "walking", label: "Walking" },
{ value: "rideshare", label: "Rideshare (Uber/Lyft)" },
{ value: "other", label: "Other" },
];
// Small labeled-field helpers to keep the long form readable.
function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) {
return <div className={className}><Label className="text-xs">{label}</Label>{children}</div>;
}
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
<section className="bg-card border rounded-lg p-5 space-y-4">
<div>
<h2 className="font-semibold">{title}</h2>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
{children}
</section>
);
}
// Top-level (not defined inside the page) so the inputs don't remount / lose focus on each keystroke.
function GuardianFields({ g, setG }: { g: Guardian; setG: (v: Guardian) => void }) {
const f = (k: keyof Guardian) => (e: React.ChangeEvent<HTMLInputElement>) => setG({ ...g, [k]: e.target.value });
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full name" className="col-span-2 md:col-span-1"><Input value={g.full_name} onChange={f("full_name")} /></Field>
<Field label="Relationship to student"><Input value={g.relationship} onChange={f("relationship")} /></Field>
<Field label="Phone number"><Input value={g.phone} onChange={f("phone")} /></Field>
<Field label="Address" className="col-span-2 md:col-span-3"><Input value={g.address} onChange={f("address")} /></Field>
<Field label="City"><Input value={g.city} onChange={f("city")} /></Field>
<Field label="State"><Input value={g.state} onChange={f("state")} /></Field>
<Field label="Zip code"><Input value={g.zip} onChange={f("zip")} /></Field>
<Field label="Email address"><Input type="email" value={g.email} onChange={f("email")} /></Field>
<Field label="Employer"><Input value={g.employer} onChange={f("employer")} /></Field>
</div>
);
}
function NewStudentPage() {
const { roles } = useAuth();
const navigate = useNavigate();
const qc = useQueryClient();
const { data: classes } = useQuery({
queryKey: ["classes"],
queryFn: async () => (await supabase.from("classes").select("id, name").order("name")).data ?? [],
});
// ── form state ─────────────────────────────────────────────────────────────
const [s, setS] = useState({
first_name: "", last_name: "", dob: "", gender: "", grade_level: "", preferred_start_date: "", class_id: "",
allergies: "", chronic_conditions: "", primary_physician: "", physician_phone: "",
home_ed_eval_due: "", previous_schools: "", special_needs: "", portfolio_keeper: "", disciplinary_history: "", custody_agreement: "",
dismissal_other: "", backup_transport_plan: "", interests: "", other_info: "",
agreement_signed_by: "", agreement_signed_date: "",
});
const set = (k: keyof typeof s) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setS({ ...s, [k]: e.target.value });
const [primary, setPrimary] = useState<Guardian>({ ...emptyGuardian });
const [secondary, setSecondary] = useState<Guardian>({ ...emptyGuardian });
const [contacts, setContacts] = useState<Contact[]>([
{ name: "", relationship: "", phone: "", alt_phone: "" },
{ name: "", relationship: "", phone: "", alt_phone: "" },
]);
const [pickups, setPickups] = useState<Pickup[]>([
{ name: "", relationship: "", phone: "", notes: "" },
{ name: "", relationship: "", phone: "", notes: "" },
{ name: "", relationship: "", phone: "", notes: "" },
]);
const [logins, setLogins] = useState<Login[]>([{ ...emptyLogin }]);
const [dismissal, setDismissal] = useState<string[]>([]);
const [agree, setAgree] = useState(false);
const toggleDismissal = (v: string) =>
setDismissal((d) => (d.includes(v) ? d.filter((x) => x !== v) : [...d, v]));
const save = useMutation({
mutationFn: async () => {
// 1. student
const { data: student, error } = await supabase
.from("students")
.insert({
first_name: s.first_name.trim(),
last_name: s.last_name.trim(),
dob: s.dob || null,
gender: s.gender || null,
grade_level: s.grade_level || null,
preferred_start_date: s.preferred_start_date || null,
class_id: s.class_id || null,
allergies: s.allergies || null,
chronic_conditions: s.chronic_conditions || null,
primary_physician: s.primary_physician || null,
physician_phone: s.physician_phone || null,
home_ed_eval_due: s.home_ed_eval_due || null,
previous_schools: s.previous_schools || null,
special_needs: s.special_needs || null,
portfolio_keeper: s.portfolio_keeper || null,
disciplinary_history: s.disciplinary_history || null,
custody_agreement: s.custody_agreement || null,
dismissal_methods: dismissal,
dismissal_other: s.dismissal_other || null,
backup_transport_plan: s.backup_transport_plan || null,
interests: s.interests || null,
other_info: s.other_info || null,
agreement_signed_by: s.agreement_signed_by || null,
agreement_signed_date: s.agreement_signed_date || null,
})
.select("id")
.single();
if (error) throw error;
const studentId = student.id;
// 2. guardians
const guardianRows = [
{ g: primary, is_primary: true },
{ g: secondary, is_primary: false },
]
.filter(({ g }) => g.full_name.trim())
.map(({ g, is_primary }) => ({
student_id: studentId, is_primary,
full_name: g.full_name.trim(), relationship: g.relationship || null, phone: g.phone || null,
address: g.address || null, city: g.city || null, state: g.state || null, zip: g.zip || null,
email: g.email || null, employer: g.employer || null,
}));
if (guardianRows.length) {
const { error: gErr } = await supabase.from("student_guardians").insert(guardianRows);
if (gErr) throw gErr;
}
// 3. authorized pickups + emergency contacts (one table, distinguished by kind)
const pickupRows = [
...pickups.filter((p) => p.name.trim()).map((p, i) => ({
student_id: studentId, kind: "pickup", sort_order: i,
name: p.name.trim(), relationship: p.relationship || null, phone: p.phone || null,
alt_phone: null as string | null, notes: p.notes || null,
})),
...contacts.filter((c) => c.name.trim()).map((c, i) => ({
student_id: studentId, kind: "emergency", sort_order: i,
name: c.name.trim(), relationship: c.relationship || null, phone: c.phone || null,
alt_phone: c.alt_phone || null, notes: null as string | null,
})),
];
if (pickupRows.length) {
const { error: pErr } = await supabase.from("authorized_pickups").insert(pickupRows);
if (pErr) throw pErr;
}
// 4. curriculum logins
const loginRows = logins
.filter((l) => Object.values(l).some((v) => v.trim()))
.map((l) => ({
student_id: studentId,
website: l.website || null, student_login: l.student_login || null, student_password: l.student_password || null,
parent_account: l.parent_account || null, parent_password: l.parent_password || null,
}));
if (loginRows.length) {
const { error: lErr } = await supabase.from("student_curriculum_logins").insert(loginRows);
if (lErr) throw lErr;
}
return studentId as string;
},
onSuccess: (id) => {
toast.success("Student added");
qc.invalidateQueries({ queryKey: ["students"] });
navigate({ to: "/students/$id", params: { id } });
},
onError: (e: Error) => toast.error(e.message),
});
if (!roles.includes("admin")) {
return <div className="p-8"><Lock className="h-6 w-6 text-muted-foreground" /><h1 className="mt-2 text-xl font-semibold">Admins only</h1><p className="text-sm text-muted-foreground">Only admins can add students.</p></div>;
}
const canSave = s.first_name.trim() && s.last_name.trim() && !save.isPending;
return (
<div className="p-6 md:p-8 max-w-4xl mx-auto pb-24">
<Link to="/students" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"><ArrowLeft className="h-4 w-4" /> Back to students</Link>
<h1 className="text-2xl font-semibold mb-1">New student</h1>
<p className="text-muted-foreground text-sm mb-6">Student Information & Authorized Pick-Up — Bayside Academy</p>
<form onSubmit={(e) => { e.preventDefault(); if (canSave) save.mutate(); }} className="space-y-5">
{/* Student */}
<Section title="Student information">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="First name"><Input value={s.first_name} onChange={set("first_name")} required /></Field>
<Field label="Last name"><Input value={s.last_name} onChange={set("last_name")} required /></Field>
<Field label="Date of birth"><Input type="date" value={s.dob} onChange={set("dob")} /></Field>
<Field label="Gender">
<Select value={s.gender} onValueChange={(v) => setS({ ...s, gender: v })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent><SelectItem value="male">Male</SelectItem><SelectItem value="female">Female</SelectItem><SelectItem value="other">Other</SelectItem></SelectContent>
</Select>
</Field>
<Field label="Grade level applying for"><Input value={s.grade_level} onChange={set("grade_level")} /></Field>
<Field label="Preferred start date"><Input type="date" value={s.preferred_start_date} onChange={set("preferred_start_date")} /></Field>
<Field label="Class (optional)">
<Select value={s.class_id} onValueChange={(v) => setS({ ...s, class_id: v })}>
<SelectTrigger><SelectValue placeholder="Assign class" /></SelectTrigger>
<SelectContent>{(classes ?? []).map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
</div>
</Section>
{/* Guardians */}
<Section title="Primary parent / guardian"><GuardianFields g={primary} setG={setPrimary} /></Section>
<Section title="Secondary parent / guardian" description="Optional"><GuardianFields g={secondary} setG={setSecondary} /></Section>
{/* Health */}
<Section title="Health information">
<Field label="Allergies (leave blank if none)"><Textarea rows={2} value={s.allergies} onChange={set("allergies")} /></Field>
<Field label="Chronic illnesses or medical conditions (leave blank if none)"><Textarea rows={2} value={s.chronic_conditions} onChange={set("chronic_conditions")} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Primary physician"><Input value={s.primary_physician} onChange={set("primary_physician")} /></Field>
<Field label="Physician's phone"><Input value={s.physician_phone} onChange={set("physician_phone")} /></Field>
</div>
</Section>
{/* Emergency contacts */}
<Section title="Emergency contacts" description="If different from the guardians above.">
{contacts.map((c, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Full name"><Input value={c.name} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /></Field>
<Field label="Relationship"><Input value={c.relationship} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, relationship: e.target.value } : x))} /></Field>
<Field label="Phone"><Input value={c.phone} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, phone: e.target.value } : x))} /></Field>
<Field label="Alternate phone"><Input value={c.alt_phone} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, alt_phone: e.target.value } : x))} /></Field>
</div>
))}
</Section>
{/* Authorized pickups */}
<Section title="Authorized persons for pick-up" description="Only these individuals will be allowed to pick up the student. All must show valid ID.">
{pickups.map((p, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Name"><Input value={p.name} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /></Field>
<Field label="Relationship"><Input value={p.relationship} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, relationship: e.target.value } : x))} /></Field>
<Field label="Phone"><Input value={p.phone} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, phone: e.target.value } : x))} /></Field>
<Field label="Notes / restrictions"><Input value={p.notes} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, notes: e.target.value } : x))} /></Field>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setPickups([...pickups, { name: "", relationship: "", phone: "", notes: "" }])}><Plus className="h-4 w-4 mr-1" /> Add another</Button>
</Section>
{/* Academic */}
<Section title="Academic information">
<div className="space-y-3">
<Label className="text-xs">Curriculum platform logins</Label>
{logins.map((l, i) => (
<div key={i} className="border rounded-md p-3 space-y-2 relative">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
<Field label="Website"><Input value={l.website} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, website: e.target.value } : x))} /></Field>
<Field label="Student login"><Input value={l.student_login} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, student_login: e.target.value } : x))} /></Field>
<Field label="Student password"><Input value={l.student_password} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, student_password: e.target.value } : x))} /></Field>
<Field label="Parent account"><Input value={l.parent_account} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, parent_account: e.target.value } : x))} /></Field>
<Field label="Parent password"><Input value={l.parent_password} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, parent_password: e.target.value } : x))} /></Field>
</div>
{logins.length > 1 && <Button type="button" size="icon" variant="ghost" className="absolute top-1 right-1" onClick={() => setLogins(logins.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>}
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setLogins([...logins, { ...emptyLogin }])}><Plus className="h-4 w-4 mr-1" /> Add another platform</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Home Education Annual Evaluation due to BPS"><Input type="date" value={s.home_ed_eval_due} onChange={set("home_ed_eval_due")} /></Field>
<Field label="Previous school(s) attended"><Input value={s.previous_schools} onChange={set("previous_schools")} /></Field>
</div>
<Field label="Special academic needs or accommodations (leave blank if none)"><Textarea rows={2} value={s.special_needs} onChange={set("special_needs")} /></Field>
<Field label="Who will keep a portfolio of work">
<Select value={s.portfolio_keeper} onValueChange={(v) => setS({ ...s, portfolio_keeper: v })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent><SelectItem value="parent">Parent/Guardian</SelectItem><SelectItem value="bayside">Bayside Academy</SelectItem></SelectContent>
</Select>
</Field>
<Field label="Disciplinary history (suspension, expulsion, asked not to return, etc.) — describe circumstances & supports, or leave blank"><Textarea rows={3} value={s.disciplinary_history} onChange={set("disciplinary_history")} /></Field>
<Field label="Custody agreement we should be aware of (leave blank if none — please provide documentation)"><Textarea rows={2} value={s.custody_agreement} onChange={set("custody_agreement")} /></Field>
</Section>
{/* Independent dismissal */}
<Section title="Independent dismissal permission" description="Check all methods by which the student may leave campus independently.">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{DISMISSAL_OPTIONS.map((o) => (
<label key={o.value} className="flex items-center gap-2 text-sm">
<Checkbox checked={dismissal.includes(o.value)} onCheckedChange={() => toggleDismissal(o.value)} />
{o.label}
</label>
))}
</div>
{dismissal.includes("other") && <Field label="Other (describe)"><Input value={s.dismissal_other} onChange={set("dismissal_other")} /></Field>}
<Field label="Backup transportation plan (required if independent dismissal is allowed)"><Textarea rows={2} value={s.backup_transport_plan} onChange={set("backup_transport_plan")} /></Field>
</Section>
{/* Additional */}
<Section title="Additional information">
<Field label="Interests, hobbies, or extracurricular activities"><Textarea rows={2} value={s.interests} onChange={set("interests")} /></Field>
<Field label="Anything else we should know about the student"><Textarea rows={2} value={s.other_info} onChange={set("other_info")} /></Field>
</Section>
{/* Agreement */}
<Section title="Agreement & signature">
<label className="flex items-start gap-2 text-sm">
<Checkbox checked={agree} onCheckedChange={(v) => setAgree(!!v)} className="mt-0.5" />
<span>I certify that the information provided is accurate and complete to the best of my knowledge, and I understand false statements may result in discontinuation of enrollment. I understand the student will only be released to authorized individuals and I am responsible for keeping this information current.</span>
</label>
<div className="grid grid-cols-2 gap-3">
<Field label="Signed by (parent/guardian name)"><Input value={s.agreement_signed_by} onChange={set("agreement_signed_by")} /></Field>
<Field label="Date"><Input type="date" value={s.agreement_signed_date} onChange={set("agreement_signed_date")} /></Field>
</div>
</Section>
<div className="sticky bottom-0 -mx-6 md:-mx-8 px-6 md:px-8 py-3 bg-background/90 backdrop-blur border-t flex items-center gap-3">
<Button type="submit" disabled={!canSave}>{save.isPending ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Saving…</> : "Save student"}</Button>
<Link to="/students"><Button type="button" variant="ghost">Cancel</Button></Link>
{!s.first_name.trim() || !s.last_name.trim() ? <span className="text-xs text-muted-foreground">First and last name are required.</span> : null}
</div>
</form>
</div>
);
}