Profile model: quick-create + full editable student profile

- /students/new: quick create (name + DOB) -> redirects into the profile
- /students/$id: full editable profile with photo upload, and tabs for
  Profile / Family & pickup (guardians, emergency contacts, pickups) /
  Academics (curriculum logins + records) / Attendance / Tuition / Contracts
- Migration: students.photo_path + private student-photos storage bucket
  (admin write, linked-user read); regenerate types

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:13:36 -04:00
co-authored by Claude Opus 4.8
parent 9d5b11bb10
commit 48cf441cba
4 changed files with 449 additions and 410 deletions
+399 -96
View File
@@ -7,8 +7,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ArrowLeft, Upload, Trash2, Plus, FileText } from "lucide-react";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
@@ -17,11 +19,31 @@ export const Route = createFileRoute("/_authenticated/students/$id")({
component: StudentDetail,
});
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" },
];
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, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</h3>
{children}
</div>
);
}
function StudentDetail() {
const { id } = Route.useParams();
const { roles } = useAuth();
const isAdmin = roles.includes("admin");
const qc = useQueryClient();
const { data: student } = useQuery({
queryKey: ["student", id],
@@ -33,21 +55,28 @@ function StudentDetail() {
});
return (
<div className="p-8 max-w-5xl">
<div className="p-6 md:p-8 max-w-5xl">
<Link to="/students" className="text-sm text-muted-foreground inline-flex items-center gap-1 mb-3"><ArrowLeft className="h-4 w-4" /> All students</Link>
<h1 className="text-2xl font-semibold">{student?.first_name} {student?.last_name}</h1>
<p className="text-muted-foreground text-sm">{(student?.classes as { name: string } | null)?.name ?? "No class"}</p>
<div className="flex items-center gap-4">
<PhotoAvatar studentId={id} photoPath={student?.photo_path as string | null} canEdit={isAdmin} />
<div>
<h1 className="text-2xl font-semibold">{student?.first_name} {student?.last_name}</h1>
<p className="text-muted-foreground text-sm">{(student?.classes as { name: string } | null)?.name ?? "No class"}</p>
</div>
</div>
<Tabs defaultValue="info" className="mt-6">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="pickups">Authorized pickup</TabsTrigger>
<Tabs defaultValue="profile" className="mt-6">
<TabsList className="flex-wrap h-auto">
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="family">Family & pickup</TabsTrigger>
<TabsTrigger value="academics">Academics</TabsTrigger>
<TabsTrigger value="attendance">Attendance</TabsTrigger>
<TabsTrigger value="ledger">Tuition ledger</TabsTrigger>
<TabsTrigger value="ledger">Tuition</TabsTrigger>
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
</TabsList>
<TabsContent value="info"><InfoTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="pickups"><PickupsTab studentId={id} /></TabsContent>
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="family"><FamilyTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
{isAdmin && <TabsContent value="contracts"><ContractsTab studentId={id} /></TabsContent>}
@@ -56,108 +85,392 @@ function StudentDetail() {
);
}
function InfoTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
// ── Photo ───────────────────────────────────────────────────────────────────
function PhotoAvatar({ studentId, photoPath, canEdit }: { studentId: string; photoPath: string | null | undefined; canEdit: boolean }) {
const qc = useQueryClient();
const [uploading, setUploading] = useState(false);
const { data: url } = useQuery({
queryKey: ["student-photo", photoPath],
enabled: !!photoPath,
queryFn: async () => {
const { data } = await supabase.storage.from("student-photos").createSignedUrl(photoPath as string, 3600);
return data?.signedUrl ?? null;
},
});
const onUpload = async (file: File) => {
if (!file) return;
setUploading(true);
try {
const ext = file.name.split(".").pop() || "jpg";
const path = `${studentId}/photo-${Date.now()}.${ext}`;
const { error: upErr } = await supabase.storage.from("student-photos").upload(path, file, { upsert: true });
if (upErr) throw upErr;
const { error } = await supabase.from("students").update({ photo_path: path }).eq("id", studentId);
if (error) throw error;
qc.invalidateQueries({ queryKey: ["student", studentId] });
toast.success("Photo updated");
} catch (e) { toast.error((e as Error).message); } finally { setUploading(false); }
};
return (
<div className="relative">
<div className="h-20 w-20 rounded-full bg-muted border overflow-hidden flex items-center justify-center">
{url ? <img src={url} alt="Student" className="h-full w-full object-cover" /> : <User className="h-8 w-8 text-muted-foreground" />}
</div>
{canEdit && (
<label className="absolute -bottom-1 -right-1 h-7 w-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center cursor-pointer shadow" title="Upload photo">
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ImageUp className="h-3.5 w-3.5" />}
<input type="file" accept="image/*" className="hidden" disabled={uploading} onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
</label>
)}
</div>
);
}
// ── Profile (all single-value student fields) ────────────────────────────────
function ProfileTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const { data: s } = useQuery({
queryKey: ["student", studentId],
queryFn: async () => (await supabase.from("students").select("*").eq("id", studentId).single()).data,
});
const { data: classes } = useQuery({
queryKey: ["classes"],
queryFn: async () => (await supabase.from("classes").select("id, name").order("name")).data ?? [],
});
const [form, setForm] = useState<Record<string, unknown> | null>(null);
const c = (form ?? s) as Record<string, unknown> | null;
const upd = (k: string, v: unknown) => setForm({ ...(c as Record<string, unknown>), [k]: v });
const dismissal = (c?.dismissal_methods as string[]) ?? [];
const toggleDismissal = (v: string) => upd("dismissal_methods", dismissal.includes(v) ? dismissal.filter((x) => x !== v) : [...dismissal, v]);
const save = useMutation({
mutationFn: async () => {
if (!c) return;
const keys = [
"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", "notes",
];
const payload: Record<string, unknown> = { dismissal_methods: dismissal, photo_release: !!c.photo_release };
for (const k of keys) payload[k] = (c[k] as string) || null;
payload.first_name = (c.first_name as string) || "";
payload.last_name = (c.last_name as string) || "";
const { error } = await supabase.from("students").update(payload as never).eq("id", studentId);
if (error) throw error;
},
onSuccess: () => { toast.success("Profile saved"); qc.invalidateQueries({ queryKey: ["student", studentId] }); setForm(null); },
onError: (e: Error) => toast.error(e.message),
});
if (!c) return <div className="p-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>;
const val = (k: string) => (c[k] as string) ?? "";
const T = (k: string, label: string, className = "") => <Field label={label} className={className}><Input disabled={!canEdit} value={val(k)} onChange={(e) => upd(k, e.target.value)} /></Field>;
const D = (k: string, label: string) => <Field label={label}><Input type="date" disabled={!canEdit} value={val(k)} onChange={(e) => upd(k, e.target.value)} /></Field>;
const A = (k: string, label: string, rows = 2) => <Field label={label}><Textarea rows={rows} disabled={!canEdit} value={val(k)} onChange={(e) => upd(k, e.target.value)} /></Field>;
return (
<div className="bg-card border rounded-lg p-6 space-y-6 mt-4">
<Section title="Student">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{T("first_name", "First name")}
{T("last_name", "Last name")}
{D("dob", "Date of birth")}
<Field label="Gender">
<Select value={val("gender")} onValueChange={(v) => upd("gender", v)} disabled={!canEdit}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent><SelectItem value="male">Male</SelectItem><SelectItem value="female">Female</SelectItem><SelectItem value="other">Other</SelectItem></SelectContent>
</Select>
</Field>
{T("grade_level", "Grade level")}
{D("preferred_start_date", "Preferred start date")}
<Field label="Class">
<Select value={val("class_id")} onValueChange={(v) => upd("class_id", v)} disabled={!canEdit}>
<SelectTrigger><SelectValue placeholder="Assign class" /></SelectTrigger>
<SelectContent>{(classes ?? []).map((cl) => <SelectItem key={cl.id} value={cl.id}>{cl.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
</div>
</Section>
<Section title="Health">
{A("allergies", "Allergies (blank if none)")}
{A("chronic_conditions", "Chronic conditions (blank if none)")}
<div className="grid grid-cols-2 gap-3">{T("primary_physician", "Primary physician")}{T("physician_phone", "Physician phone")}</div>
</Section>
<Section title="Independent dismissal">
<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)} disabled={!canEdit} onCheckedChange={() => canEdit && toggleDismissal(o.value)} />{o.label}
</label>
))}
</div>
{dismissal.includes("other") && T("dismissal_other", "Other (describe)")}
{A("backup_transport_plan", "Backup transportation plan")}
</Section>
<Section title="Additional">
{A("interests", "Interests / hobbies / activities")}
{A("other_info", "Anything else we should know")}
<div className="flex items-center justify-between max-w-sm"><Label className="text-xs">Photo release granted</Label><Switch disabled={!canEdit} checked={!!c.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
{A("notes", "Internal notes (staff only)")}
</Section>
<Section title="Agreement">
<div className="grid grid-cols-2 gap-3">{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}</div>
</Section>
{canEdit && <Button onClick={() => save.mutate()} disabled={save.isPending}>{save.isPending ? "Saving…" : "Save profile"}</Button>}
</div>
);
}
// ── Family & pickup (guardians, emergency contacts, authorized pickups) ───────
type GuardianRow = { id: string; is_primary: boolean; full_name: string; relationship: string | null; phone: string | null; address: string | null; city: string | null; state: string | null; zip: string | null; email: string | null; employer: string | null };
function FamilyTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const { data: guardians } = useQuery({
queryKey: ["guardians", studentId],
queryFn: async () => (await supabase.from("student_guardians").select("*").eq("student_id", studentId).order("is_primary", { ascending: false })).data ?? [],
});
const addGuardian = useMutation({
mutationFn: async (isPrimary: boolean) => {
const { error } = await supabase.from("student_guardians").insert({ student_id: studentId, is_primary: isPrimary, full_name: "New guardian" });
if (error) throw error;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["guardians", studentId] }),
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-6 mt-4">
<div className="space-y-3">
<div className="flex items-center justify-between"><h3 className="font-semibold">Guardians</h3>
{canEdit && <Button size="sm" variant="outline" onClick={() => addGuardian.mutate((guardians ?? []).length === 0)}><Plus className="h-4 w-4 mr-1" /> Add guardian</Button>}
</div>
{(guardians ?? []).map((g) => <GuardianCard key={g.id} g={g as GuardianRow} studentId={studentId} canEdit={canEdit} />)}
{guardians?.length === 0 && <p className="text-sm text-muted-foreground">No guardians yet.</p>}
</div>
<PickupList studentId={studentId} canEdit={canEdit} kind="pickup" title="Authorized pick-up" subtitle="Only these individuals may pick up the student (valid ID required)." />
<PickupList studentId={studentId} canEdit={canEdit} kind="emergency" title="Emergency contacts" subtitle="Contacted if guardians are unreachable." />
</div>
);
}
function GuardianCard({ g, studentId, canEdit }: { g: GuardianRow; studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const [row, setRow] = useState<GuardianRow>(g);
const f = (k: keyof GuardianRow) => (e: React.ChangeEvent<HTMLInputElement>) => setRow({ ...row, [k]: e.target.value });
const save = useMutation({
mutationFn: async () => {
const { id, ...rest } = row;
const { error } = await supabase.from("student_guardians").update(rest).eq("id", id);
if (error) throw error;
},
onSuccess: () => { toast.success("Guardian saved"); qc.invalidateQueries({ queryKey: ["guardians", studentId] }); },
onError: (e: Error) => toast.error(e.message),
});
const del = useMutation({
mutationFn: async () => { const { error } = await supabase.from("student_guardians").delete().eq("id", g.id); if (error) throw error; },
onSuccess: () => qc.invalidateQueries({ queryKey: ["guardians", studentId] }),
});
return (
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm font-medium"><Checkbox checked={row.is_primary} disabled={!canEdit} onCheckedChange={(v) => setRow({ ...row, is_primary: !!v })} /> Primary guardian</label>
{canEdit && <Button size="icon" variant="ghost" onClick={() => del.mutate()}><Trash2 className="h-4 w-4" /></Button>}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full name"><Input disabled={!canEdit} value={row.full_name ?? ""} onChange={f("full_name")} /></Field>
<Field label="Relationship"><Input disabled={!canEdit} value={row.relationship ?? ""} onChange={f("relationship")} /></Field>
<Field label="Phone"><Input disabled={!canEdit} value={row.phone ?? ""} onChange={f("phone")} /></Field>
<Field label="Address" className="col-span-2 md:col-span-3"><Input disabled={!canEdit} value={row.address ?? ""} onChange={f("address")} /></Field>
<Field label="City"><Input disabled={!canEdit} value={row.city ?? ""} onChange={f("city")} /></Field>
<Field label="State"><Input disabled={!canEdit} value={row.state ?? ""} onChange={f("state")} /></Field>
<Field label="Zip"><Input disabled={!canEdit} value={row.zip ?? ""} onChange={f("zip")} /></Field>
<Field label="Email"><Input disabled={!canEdit} value={row.email ?? ""} onChange={f("email")} /></Field>
<Field label="Employer"><Input disabled={!canEdit} value={row.employer ?? ""} onChange={f("employer")} /></Field>
</div>
{canEdit && <Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>Save</Button>}
</div>
);
}
type PickupRow = { id: string; name: string; relationship: string | null; phone: string | null; alt_phone: string | null; notes: string | null; kind: string };
function PickupList({ studentId, canEdit, kind, title, subtitle }: { studentId: string; canEdit: boolean; kind: string; title: string; subtitle: string }) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["pickups", studentId],
queryFn: async () => (await supabase.from("authorized_pickups").select("*").eq("student_id", studentId).order("sort_order")).data ?? [],
});
const rows = (data ?? []).filter((p) => (p.kind ?? "pickup") === kind);
const add = useMutation({
mutationFn: async () => { const { error } = await supabase.from("authorized_pickups").insert({ student_id: studentId, kind, name: "New person" }); if (error) throw error; },
onSuccess: () => qc.invalidateQueries({ queryKey: ["pickups", studentId] }),
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-3">
<div className="flex items-center justify-between"><div><h3 className="font-semibold">{title}</h3><p className="text-xs text-muted-foreground">{subtitle}</p></div>
{canEdit && <Button size="sm" variant="outline" onClick={() => add.mutate()}><Plus className="h-4 w-4 mr-1" /> Add</Button>}
</div>
{rows.map((p) => <PickupCard key={p.id} p={p as PickupRow} studentId={studentId} canEdit={canEdit} showNotes={kind === "pickup"} showAlt={kind === "emergency"} />)}
{rows.length === 0 && <p className="text-sm text-muted-foreground">None added.</p>}
</div>
);
}
function PickupCard({ p, studentId, canEdit, showNotes, showAlt }: { p: PickupRow; studentId: string; canEdit: boolean; showNotes: boolean; showAlt: boolean }) {
const qc = useQueryClient();
const [row, setRow] = useState<PickupRow>(p);
const f = (k: keyof PickupRow) => (e: React.ChangeEvent<HTMLInputElement>) => setRow({ ...row, [k]: e.target.value });
const save = useMutation({
mutationFn: async () => {
const { error } = await supabase.from("authorized_pickups").update({ name: row.name, relationship: row.relationship, phone: row.phone, alt_phone: row.alt_phone, notes: row.notes }).eq("id", row.id);
if (error) throw error;
},
onSuccess: () => { toast.success("Saved"); qc.invalidateQueries({ queryKey: ["pickups", studentId] }); },
onError: (e: Error) => toast.error(e.message),
});
const del = useMutation({
mutationFn: async () => { const { error } = await supabase.from("authorized_pickups").delete().eq("id", p.id); if (error) throw error; },
onSuccess: () => qc.invalidateQueries({ queryKey: ["pickups", studentId] }),
});
return (
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Name"><Input disabled={!canEdit} value={row.name ?? ""} onChange={f("name")} /></Field>
<Field label="Relationship"><Input disabled={!canEdit} value={row.relationship ?? ""} onChange={f("relationship")} /></Field>
<Field label="Phone"><Input disabled={!canEdit} value={row.phone ?? ""} onChange={f("phone")} /></Field>
{showAlt && <Field label="Alternate phone"><Input disabled={!canEdit} value={row.alt_phone ?? ""} onChange={f("alt_phone")} /></Field>}
{showNotes && <Field label="Notes / restrictions"><Input disabled={!canEdit} value={row.notes ?? ""} onChange={f("notes")} /></Field>}
</div>
{canEdit && <div className="flex gap-2"><Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>Save</Button><Button size="sm" variant="ghost" onClick={() => del.mutate()}><Trash2 className="h-4 w-4" /></Button></div>}
</div>
);
}
// ── Academics (curriculum logins + academic notes) ───────────────────────────
type LoginRow = { id: string; website: string | null; student_login: string | null; student_password: string | null; parent_account: string | null; parent_password: string | null };
function AcademicsTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const { data: s } = useQuery({
queryKey: ["student", studentId],
queryFn: async () => (await supabase.from("students").select("*").eq("id", studentId).single()).data,
});
const [form, setForm] = useState<Record<string, unknown> | null>(null);
const current = form ?? (s as Record<string, unknown> | null);
const c = (form ?? s) as Record<string, unknown> | null;
const upd = (k: string, v: unknown) => setForm({ ...(c as Record<string, unknown>), [k]: v });
const val = (k: string) => (c?.[k] as string) ?? "";
const save = useMutation({
const { data: logins } = useQuery({
queryKey: ["curriculum", studentId],
queryFn: async () => (await supabase.from("student_curriculum_logins").select("*").eq("student_id", studentId).order("created_at")).data ?? [],
});
const saveAcademics = useMutation({
mutationFn: async () => {
if (!current) return;
const { error } = await supabase.from("students").update({
first_name: current.first_name as string,
last_name: current.last_name as string,
dob: (current.dob as string) || null,
allergies: (current.allergies as string) || null,
photo_release: !!current.photo_release,
notes: (current.notes as string) || null,
home_ed_eval_due: val("home_ed_eval_due") || null,
previous_schools: val("previous_schools") || null,
special_needs: val("special_needs") || null,
portfolio_keeper: val("portfolio_keeper") || null,
disciplinary_history: val("disciplinary_history") || null,
custody_agreement: val("custody_agreement") || null,
}).eq("id", studentId);
if (error) throw error;
},
onSuccess: () => { toast.success("Saved"); qc.invalidateQueries({ queryKey: ["student", studentId] }); setForm(null); },
onError: (e: Error) => toast.error(e.message),
});
if (!current) return null;
const upd = (k: string, v: unknown) => setForm({ ...(current as Record<string, unknown>), [k]: v });
return (
<div className="bg-card border rounded-lg p-6 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-3">
<div><Label>First name</Label><Input disabled={!canEdit} value={current.first_name as string ?? ""} onChange={(e) => upd("first_name", e.target.value)} /></div>
<div><Label>Last name</Label><Input disabled={!canEdit} value={current.last_name as string ?? ""} onChange={(e) => upd("last_name", e.target.value)} /></div>
</div>
<div><Label>Date of birth</Label><Input disabled={!canEdit} type="date" value={(current.dob as string) ?? ""} onChange={(e) => upd("dob", e.target.value)} /></div>
<div><Label>Allergies</Label><Input disabled={!canEdit} value={(current.allergies as string) ?? ""} onChange={(e) => upd("allergies", e.target.value)} /></div>
<div className="flex items-center justify-between"><Label>Photo release granted</Label><Switch disabled={!canEdit} checked={!!current.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
<div><Label>Notes</Label><Textarea disabled={!canEdit} value={(current.notes as string) ?? ""} onChange={(e) => upd("notes", e.target.value)} /></div>
{canEdit && <Button onClick={() => save.mutate()} disabled={save.isPending}>Save changes</Button>}
</div>
);
}
function PickupsTab({ studentId }: { studentId: string }) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["pickups", studentId],
queryFn: async () => (await supabase.from("authorized_pickups").select("*").eq("student_id", studentId).order("name")).data ?? [],
});
const [form, setForm] = useState({ name: "", phone: "", relationship: "" });
const add = useMutation({
mutationFn: async () => {
const { error } = await supabase.from("authorized_pickups").insert({ student_id: studentId, ...form });
if (error) throw error;
},
onSuccess: () => { setForm({ name: "", phone: "", relationship: "" }); qc.invalidateQueries({ queryKey: ["pickups", studentId] }); },
const addLogin = useMutation({
mutationFn: async () => { const { error } = await supabase.from("student_curriculum_logins").insert({ student_id: studentId }); if (error) throw error; },
onSuccess: () => qc.invalidateQueries({ queryKey: ["curriculum", studentId] }),
onError: (e: Error) => toast.error(e.message),
});
const del = useMutation({
mutationFn: async (id: string) => { const { error } = await supabase.from("authorized_pickups").delete().eq("id", id); if (error) throw error; },
onSuccess: () => qc.invalidateQueries({ queryKey: ["pickups", studentId] }),
});
if (!c) return <div className="p-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>;
return (
<div className="space-y-4 max-w-2xl">
<div className="bg-card border rounded-lg divide-y">
{(data ?? []).map((p) => (
<div key={p.id} className="flex justify-between items-center p-3">
<div><div className="font-medium">{p.name}</div><div className="text-xs text-muted-foreground">{p.relationship} · {p.phone}</div></div>
<Button size="icon" variant="ghost" onClick={() => del.mutate(p.id)}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No one added yet.</div>}
</div>
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">Add authorized pickup</div>
<div className="grid grid-cols-3 gap-2">
<Input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input placeholder="Relationship" value={form.relationship} onChange={(e) => setForm({ ...form, relationship: e.target.value })} />
<Input placeholder="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<div className="space-y-6 mt-4">
<div className="bg-card border rounded-lg p-6 space-y-4">
<div className="grid grid-cols-2 gap-3">
<Field label="Home Ed. Annual Evaluation due to BPS"><Input type="date" disabled={!canEdit} value={val("home_ed_eval_due")} onChange={(e) => upd("home_ed_eval_due", e.target.value)} /></Field>
<Field label="Previous school(s)"><Input disabled={!canEdit} value={val("previous_schools")} onChange={(e) => upd("previous_schools", e.target.value)} /></Field>
</div>
<Button onClick={() => add.mutate()} disabled={!form.name || add.isPending}><Plus className="h-4 w-4 mr-1" /> Add</Button>
<Field label="Special academic needs / accommodations"><Textarea rows={2} disabled={!canEdit} value={val("special_needs")} onChange={(e) => upd("special_needs", e.target.value)} /></Field>
<Field label="Who keeps the portfolio">
<Select value={val("portfolio_keeper")} onValueChange={(v) => upd("portfolio_keeper", v)} disabled={!canEdit}>
<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"><Textarea rows={2} disabled={!canEdit} value={val("disciplinary_history")} onChange={(e) => upd("disciplinary_history", e.target.value)} /></Field>
<Field label="Custody agreement"><Textarea rows={2} disabled={!canEdit} value={val("custody_agreement")} onChange={(e) => upd("custody_agreement", e.target.value)} /></Field>
{canEdit && <Button onClick={() => saveAcademics.mutate()} disabled={saveAcademics.isPending}>Save academics</Button>}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between"><h3 className="font-semibold">Curriculum platform logins</h3>
{canEdit && <Button size="sm" variant="outline" onClick={() => addLogin.mutate()}><Plus className="h-4 w-4 mr-1" /> Add platform</Button>}
</div>
{(logins ?? []).map((l) => <LoginCard key={l.id} l={l as LoginRow} studentId={studentId} canEdit={canEdit} />)}
{logins?.length === 0 && <p className="text-sm text-muted-foreground">No logins added.</p>}
</div>
</div>
);
}
function LoginCard({ l, studentId, canEdit }: { l: LoginRow; studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const [row, setRow] = useState<LoginRow>(l);
const f = (k: keyof LoginRow) => (e: React.ChangeEvent<HTMLInputElement>) => setRow({ ...row, [k]: e.target.value });
const save = useMutation({
mutationFn: async () => {
const { id, ...rest } = row;
const { error } = await supabase.from("student_curriculum_logins").update(rest).eq("id", id);
if (error) throw error;
},
onSuccess: () => { toast.success("Saved"); qc.invalidateQueries({ queryKey: ["curriculum", studentId] }); },
onError: (e: Error) => toast.error(e.message),
});
const del = useMutation({
mutationFn: async () => { const { error } = await supabase.from("student_curriculum_logins").delete().eq("id", l.id); if (error) throw error; },
onSuccess: () => qc.invalidateQueries({ queryKey: ["curriculum", studentId] }),
});
return (
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Website"><Input disabled={!canEdit} value={row.website ?? ""} onChange={f("website")} /></Field>
<Field label="Student login"><Input disabled={!canEdit} value={row.student_login ?? ""} onChange={f("student_login")} /></Field>
<Field label="Student password"><Input disabled={!canEdit} value={row.student_password ?? ""} onChange={f("student_password")} /></Field>
<Field label="Parent account"><Input disabled={!canEdit} value={row.parent_account ?? ""} onChange={f("parent_account")} /></Field>
<Field label="Parent password"><Input disabled={!canEdit} value={row.parent_password ?? ""} onChange={f("parent_password")} /></Field>
</div>
{canEdit && <div className="flex gap-2"><Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>Save</Button><Button size="sm" variant="ghost" onClick={() => del.mutate()}><Trash2 className="h-4 w-4" /></Button></div>}
</div>
);
}
// ── Attendance / Ledger / Contracts (unchanged) ──────────────────────────────
function AttendanceTab({ studentId }: { studentId: string }) {
const { data } = useQuery({
queryKey: ["attendance", studentId],
queryFn: async () => (await supabase.from("attendance").select("*").eq("student_id", studentId).order("date", { ascending: false }).limit(60)).data ?? [],
});
return (
<div className="bg-card border rounded-lg divide-y max-w-xl">
<div className="bg-card border rounded-lg divide-y max-w-xl mt-4">
{(data ?? []).map((a) => (
<div key={a.id} className="flex justify-between p-3 text-sm">
<span>{new Date(a.date).toLocaleDateString()}</span>
<span className="capitalize">{a.status}</span>
</div>
<div key={a.id} className="flex justify-between p-3 text-sm"><span>{new Date(a.date).toLocaleDateString()}</span><span className="capitalize">{a.status}</span></div>
))}
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No attendance recorded.</div>}
</div>
@@ -170,10 +483,8 @@ function LedgerTab({ studentId, canEdit }: { studentId: string; canEdit: boolean
queryKey: ["ledger", studentId],
queryFn: async () => (await supabase.from("ledger_entries").select("*").eq("student_id", studentId).order("date", { ascending: false })).data ?? [],
});
const [form, setForm] = useState({ kind: "charge", category: "tuition", amount: "", note: "", date: new Date().toISOString().slice(0,10) });
const [form, setForm] = useState({ kind: "charge", category: "tuition", amount: "", note: "", date: new Date().toISOString().slice(0, 10) });
const balance = (data ?? []).reduce((sum, e) => sum + (e.kind === "charge" ? e.amount_cents : -e.amount_cents), 0);
const add = useMutation({
mutationFn: async () => {
const amt = Math.round(parseFloat(form.amount) * 100);
@@ -187,9 +498,8 @@ function LedgerTab({ studentId, canEdit }: { studentId: string; canEdit: boolean
onSuccess: () => { setForm({ ...form, amount: "", note: "" }); qc.invalidateQueries({ queryKey: ["ledger", studentId] }); toast.success("Entry added"); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-4 max-w-3xl">
<div className="space-y-4 max-w-3xl mt-4">
<div className="bg-card border rounded-lg p-4 flex justify-between items-center">
<div className="text-sm text-muted-foreground">Current balance</div>
<div className={`text-2xl font-semibold ${balance > 0 ? "text-destructive" : ""}`}>${(balance / 100).toFixed(2)}</div>
@@ -215,12 +525,8 @@ function LedgerTab({ studentId, canEdit }: { studentId: string; canEdit: boolean
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">Add entry</div>
<div className="grid grid-cols-5 gap-2">
<select className="border rounded-md px-2 text-sm" value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
<option value="charge">Charge</option><option value="payment">Payment</option>
</select>
<select className="border rounded-md px-2 text-sm" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
<option value="tuition">Tuition</option><option value="late_pickup">Late pickup</option><option value="activity">Activity</option><option value="other">Other</option>
</select>
<select className="border rounded-md px-2 text-sm" value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}><option value="charge">Charge</option><option value="payment">Payment</option></select>
<select className="border rounded-md px-2 text-sm" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}><option value="tuition">Tuition</option><option value="late_pickup">Late pickup</option><option value="activity">Activity</option><option value="other">Other</option></select>
<Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
<Input placeholder="Amount ($)" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} />
<Input placeholder="Note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
@@ -240,7 +546,6 @@ function ContractsTab({ studentId }: { studentId: string }) {
});
const [title, setTitle] = useState("");
const [uploading, setUploading] = useState(false);
const onUpload = async (file: File) => {
if (!file) return;
setUploading(true);
@@ -256,15 +561,13 @@ function ContractsTab({ studentId }: { studentId: string }) {
toast.success("Contract uploaded");
} catch (e) { toast.error((e as Error).message); } finally { setUploading(false); }
};
const download = async (path: string) => {
const { data, error } = await supabase.storage.from("contracts").createSignedUrl(path, 60);
if (error) return toast.error(error.message);
window.open(data.signedUrl, "_blank");
};
return (
<div className="space-y-4 max-w-2xl">
<div className="space-y-4 max-w-2xl mt-4">
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">Upload signed contract (admin only)</div>
<Input placeholder="Title (optional)" value={title} onChange={(e) => setTitle(e.target.value)} />
+21 -314
View File
@@ -1,209 +1,40 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { 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 { ArrowLeft, 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,
errorComponent: ({ error }) => (
<div className="p-8 max-w-2xl">
<h1 className="text-lg font-semibold text-destructive">This page hit an error</h1>
<pre className="mt-3 whitespace-pre-wrap break-words text-xs bg-muted p-3 rounded border">{error?.message}{"\n\n"}{error?.stack}</pre>
</div>
),
});
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 [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [dob, setDob] = useState("");
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({
const create = useMutation({
mutationFn: async () => {
// 1. student
const { data: student, error } = await supabase
const { data, 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,
})
.insert({ first_name: firstName.trim(), last_name: lastName.trim(), dob: dob || 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;
return data.id as string;
},
onSuccess: (id) => {
toast.success("Student added");
qc.invalidateQueries({ queryKey: ["students"] });
toast.success("Student created — fill out the rest of the profile");
navigate({ to: "/students/$id", params: { id } });
},
onError: (e: Error) => toast.error(e.message),
@@ -213,147 +44,23 @@ function NewStudentPage() {
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;
const canSave = firstName.trim() && lastName.trim() && !create.isPending;
return (
<div className="p-6 md:p-8 max-w-4xl mx-auto pb-24">
<div className="p-8 max-w-lg">
<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>
<p className="text-muted-foreground text-sm mb-6">Start with a name — you'll fill out the full profile (or send it to a parent) next.</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}
<form onSubmit={(e) => { e.preventDefault(); if (canSave) create.mutate(); }} className="bg-card border rounded-lg p-5 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div><Label>First name</Label><Input value={firstName} onChange={(e) => setFirstName(e.target.value)} required autoFocus /></div>
<div><Label>Last name</Label><Input value={lastName} onChange={(e) => setLastName(e.target.value)} required /></div>
</div>
<div><Label>Date of birth (optional)</Label><Input type="date" value={dob} onChange={(e) => setDob(e.target.value)} /></div>
<Button type="submit" disabled={!canSave} className="w-full">
{create.isPending ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Creating…</> : "Create profile"}
</Button>
</form>
</div>
);