Files
info-share-spot/src/routes/_authenticated/students.$id.tsx
T
2026-08-07 14:09:53 +00:00

860 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute, 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 { 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, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy, Link2, Mail } from "lucide-react";
import { useState, useRef } from "react";
import { toast } from "sonner";
import { createUserFn } from "@/lib/user-admin.functions";
import { createIntakeToken } from "@/lib/intake.functions";
import { genTempPassword } from "@/lib/temp-password";
import { StudentGradeReport } from "@/components/gradebook";
import { StudentPlansTab } from "@/components/plans";
import { StudentEnrollmentTab } from "@/components/enrollment";
import { StudentAlertsBanner, StudentAlertsTab } from "@/components/alerts";
import { money } from "@/lib/reports";
export const Route = createFileRoute("/_authenticated/students/$id")({
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
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" },
];
const dismissalLabel = (v: string) => DISMISSAL_OPTIONS.find((o) => o.value === v)?.label ?? v;
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 ViewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="flex justify-between gap-4 py-1.5 border-b last:border-0 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="font-medium text-right break-words">{value || "—"}</span>
</div>
);
}
function EditToggle({ editing, setEditing, canEdit }: { editing: boolean; setEditing: (v: boolean) => void; canEdit: boolean }) {
if (!canEdit) return null;
return editing
? <Button size="sm" variant="ghost" onClick={() => setEditing(false)}><Check className="h-4 w-4 mr-1" /> Done</Button>
: <Button size="sm" variant="outline" onClick={() => setEditing(true)}><Pencil className="h-4 w-4 mr-1" /> Edit</Button>;
}
function StudentDetail() {
const { id } = Route.useParams();
const { roles } = useAuth();
const isAdmin = roles.includes("admin");
const canEdit = isAdmin; // student profile data is admin-edit-only; parents/teachers view only
const { data: student } = useQuery({
queryKey: ["student", id],
queryFn: async () => {
const { data, error } = await supabase.from("students").select("*, classes(name)").eq("id", id).single();
if (error) throw error;
return data;
},
});
return (
<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>
<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?.grade_level ? `Grade ${student.grade_level}` : "No grade level set"}</p>
</div>
</div>
<StudentAlertsBanner studentId={id} />
<Tabs defaultValue="profile" className="mt-6">
<TabsList className="flex-wrap h-auto">
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="enrollment">Enrollment & campus</TabsTrigger>
<TabsTrigger value="alerts">Alerts</TabsTrigger>
<TabsTrigger value="family">Family & pickup</TabsTrigger>
<TabsTrigger value="academics">Academics</TabsTrigger>
<TabsTrigger value="plans">504 / IEP</TabsTrigger>
<TabsTrigger value="grades">Grades</TabsTrigger>
<TabsTrigger value="attendance">Attendance</TabsTrigger>
<TabsTrigger value="ledger">Tuition</TabsTrigger>
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
</TabsList>
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="enrollment"><StudentEnrollmentTab studentId={id} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="alerts"><StudentAlertsTab studentId={id} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="family"><FamilyTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={canEdit} /></TabsContent>
<TabsContent value="plans"><StudentPlansTab studentId={id} isAdmin={isAdmin} /></TabsContent>
<TabsContent value="grades" className="mt-4"><StudentGradeReport studentId={id} classId={(student?.class_id as string | null) ?? null} /></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>}
</Tabs>
</div>
);
}
// ── 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 ──────────────────────────────────────────────────────────────────
function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit: boolean; isAdmin: boolean }) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
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 ?? [],
});
// In edit mode we work on a snapshot (`form`) taken when Edit is pressed, so a
// background refetch can never clobber in-progress input. Functional updates
// avoid stale-closure races.
const [form, setForm] = useState<Record<string, unknown>>({});
// daily_tuition_cents is stored in cents but edited in dollars, so it gets its
// own form key and is converted on save rather than going through the string
// field helpers below.
const startEdit = () => {
setForm({
...(s as Record<string, unknown>),
tuition_rate_input: s?.daily_tuition_cents != null ? (s.daily_tuition_cents / 100).toFixed(2) : "",
});
setEditing(true);
};
const c = (editing ? form : s) as Record<string, unknown> | null;
const upd = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
// Read date inputs straight from the DOM at save time (Safari doesn't reliably
// fire onChange for native date pickers, so state can be stale/empty).
const dateRefs = useRef<Record<string, HTMLInputElement | null>>({});
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",
"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) || "";
// Dates: read the live DOM value so a Safari pick can't be lost.
for (const k of ["dob", "preferred_start_date", "agreement_signed_date"]) {
const el = dateRefs.current[k];
if (el) payload[k] = el.value || null;
}
// Dollars -> cents. Blank clears the rate, which stops auto-charging.
const rate = String(c.tuition_rate_input ?? "").trim();
const rateNum = Number(rate);
if (rate !== "" && (!Number.isFinite(rateNum) || rateNum < 0)) {
throw new Error("Daily tuition rate must be a positive amount");
}
payload.daily_tuition_cents = rate === "" ? null : Math.round(rateNum * 100);
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] }); setEditing(false); },
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 className = (classes ?? []).find((cl) => cl.id === c.class_id)?.name ?? null;
// ── View mode ──
if (!editing) {
return (
<div className="bg-card border rounded-lg p-6 space-y-6 mt-4">
<div className="flex justify-end">{canEdit && <Button size="sm" variant="outline" onClick={startEdit}><Pencil className="h-4 w-4 mr-1" /> Edit</Button>}</div>
<Section title="Student">
<ViewRow label="First name" value={val("first_name")} />
<ViewRow label="Last name" value={val("last_name")} />
<ViewRow label="Date of birth" value={val("dob")} />
<ViewRow label="Gender" value={val("gender") ? val("gender")[0].toUpperCase() + val("gender").slice(1) : ""} />
<ViewRow label="Grade level" value={val("grade_level")} />
<ViewRow label="Preferred start date" value={val("preferred_start_date")} />
<ViewRow label="Class" value={className} />
</Section>
<Section title="Health">
<ViewRow label="Allergies" value={val("allergies")} />
<ViewRow label="Chronic conditions" value={val("chronic_conditions")} />
<ViewRow label="Primary physician" value={val("primary_physician")} />
<ViewRow label="Physician phone" value={val("physician_phone")} />
</Section>
<Section title="Independent dismissal">
<ViewRow label="Allowed methods" value={dismissal.map(dismissalLabel).join(", ")} />
{dismissal.includes("other") && <ViewRow label="Other" value={val("dismissal_other")} />}
<ViewRow label="Backup transportation plan" value={val("backup_transport_plan")} />
</Section>
<Section title="Additional">
<ViewRow label="Interests / hobbies" value={val("interests")} />
<ViewRow label="Other notes" value={val("other_info")} />
<ViewRow label="Photo release" value={c.photo_release ? "Granted" : "Not granted"} />
{isAdmin && <ViewRow label="Internal notes" value={val("notes")} />}
</Section>
{isAdmin && (
<Section title="Tuition">
<ViewRow
label="Daily rate"
value={
c.daily_tuition_cents != null
? `${money(c.daily_tuition_cents as number)} — charged automatically when marked present or late`
: "Not set — attendance does not create charges"
}
/>
</Section>
)}
<Section title="Agreement">
<ViewRow label="Signed by" value={val("agreement_signed_by")} />
<ViewRow label="Date" value={val("agreement_signed_date")} />
</Section>
</div>
);
}
// ── Edit mode ──
const T = (k: string, label: string) => <Field label={label}><Input value={val(k)} onChange={(e) => upd(k, e.target.value)} /></Field>;
// Uncontrolled (defaultValue) so Safari's native date picker doesn't get reset by React's controlled value.
const D = (k: string, label: string) => <Field label={label}><Input type="date" defaultValue={val(k)} ref={(el) => { dateRefs.current[k] = el; }} onChange={(e) => upd(k, e.target.value)} /></Field>;
const A = (k: string, label: string, rows = 2) => <Field label={label}><Textarea rows={rows} 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)}>
<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)}>
<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)} onCheckedChange={() => 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 checked={!!c.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
{isAdmin && A("notes", "Internal notes (staff only)")}
</Section>
{isAdmin && (
<Section title="Tuition">
<Field label="Daily rate in dollars — leave blank for no automatic charging">
<Input
inputMode="decimal"
placeholder="e.g. 45.00"
value={(c.tuition_rate_input as string) ?? ""}
onChange={(e) => upd("tuition_rate_input", e.target.value)}
/>
</Field>
<p className="text-xs text-muted-foreground">
When set, marking this student present or late adds a tuition charge for that day. Changing
a status to absent or excused removes the charge again. Existing attendance is not billed
retroactively.
</p>
</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>
<div className="flex gap-2">
<Button onClick={() => save.mutate()} disabled={save.isPending}>{save.isPending ? "Saving…" : "Save profile"}</Button>
<Button variant="ghost" onClick={() => setEditing(false)}>Cancel</Button>
</div>
</div>
);
}
// ── Family & pickup ──────────────────────────────────────────────────────────
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, isAdmin }: { studentId: string; canEdit: boolean; isAdmin: boolean }) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
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="flex justify-end"><EditToggle editing={editing} setEditing={setEditing} canEdit={canEdit} /></div>
<div className="space-y-3">
<div className="flex items-center justify-between"><h3 className="font-semibold">Guardians</h3>
{editing && <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} editing={editing} />)}
{guardians?.length === 0 && <p className="text-sm text-muted-foreground">No guardians yet.</p>}
</div>
<PickupList studentId={studentId} editing={editing} kind="pickup" title="Authorized pick-up" subtitle="Only these individuals may pick up the student (valid ID required)." />
<PickupList studentId={studentId} editing={editing} kind="emergency" title="Emergency contacts" subtitle="Contacted if guardians are unreachable." />
{isAdmin && <ParentAccessSection studentId={studentId} />}
{isAdmin && <IntakeLinkSection studentId={studentId} />}
</div>
);
}
function IntakeLinkSection({ studentId }: { studentId: string }) {
const { data: student } = useQuery({
queryKey: ["student-name", studentId],
queryFn: async () => (await supabase.from("students").select("first_name, last_name").eq("id", studentId).maybeSingle()).data,
});
const name = student ? `${student.first_name} ${student.last_name}` : "this student";
const [link, setLink] = useState<string | null>(null);
const gen = useMutation({
mutationFn: async () => {
const { token } = await createIntakeToken({ data: { studentId } });
return `${window.location.origin}/intake/${token}`;
},
onSuccess: (url) => { setLink(url); toast.success("Intake link created"); },
onError: (e: Error) => toast.error(e.message),
});
const mailto = link
? `mailto:?subject=${encodeURIComponent(`Student intake for ${name} — Bayside Academy`)}&body=${encodeURIComponent(`Please complete the student intake form for ${name} using this one-time link (valid 14 days):\n\n${link}\n\nThank you,\nBayside Academy`)}`
: "#";
return (
<div className="space-y-3 border-t pt-6">
<div><h3 className="font-semibold flex items-center gap-2"><Link2 className="h-4 w-4" /> Intake form link</h3><p className="text-xs text-muted-foreground">Generate a one-time link a parent can use to fill out this student's intake — no login required, valid 14 days.</p></div>
<Button size="sm" variant="outline" onClick={() => gen.mutate()} disabled={gen.isPending}>{gen.isPending ? "Creating…" : link ? "Generate a new link" : "Generate intake link"}</Button>
{link && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm space-y-2">
<div className="font-mono text-xs break-all">{link}</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => { navigator.clipboard.writeText(link); toast.success("Copied"); }}><Copy className="h-4 w-4 mr-1" /> Copy link</Button>
<a href={mailto}><Button size="sm"><Mail className="h-4 w-4 mr-1" /> Email to parent</Button></a>
</div>
<p className="text-xs text-muted-foreground">Note: this link lets whoever opens it fill out {name}'s intake once. Share it only with the parent.</p>
</div>
)}
</div>
);
}
function ParentAccessSection({ studentId }: { studentId: string }) {
const qc = useQueryClient();
const { data: parents } = useQuery({
queryKey: ["student-parents", studentId],
queryFn: async () => {
const { data } = await supabase.from("parent_students").select("parent_id").eq("student_id", studentId);
const ids = (data ?? []).map((d) => d.parent_id);
if (!ids.length) return [];
return (await supabase.from("profiles").select("id, full_name, email").in("id", ids)).data ?? [];
},
});
const { data: studentLogin } = useQuery({
queryKey: ["student-login", studentId],
queryFn: async () => {
const { data: s } = await supabase.from("students").select("user_id").eq("id", studentId).single();
if (!s?.user_id) return null;
return (await supabase.from("profiles").select("id, full_name, email").eq("id", s.user_id).maybeSingle()).data;
},
});
const [np, setNp] = useState({ fullName: "", email: "", role: "parent" as "parent" | "student" });
const [created, setCreated] = useState<{ email: string; password: string; role: string } | null>(null);
const invite = useMutation({
mutationFn: async () => {
const password = genTempPassword();
const res = await createUserFn({ data: { fullName: np.fullName, email: np.email, password, role: np.role, linkStudentId: studentId } });
return { email: res.email, password, role: np.role };
},
onSuccess: (r) => { setCreated(r); setNp({ fullName: "", email: "", role: "parent" }); qc.invalidateQueries({ queryKey: ["student-parents", studentId] }); qc.invalidateQueries({ queryKey: ["student-login", studentId] }); toast.success(`${r.role === "student" ? "Student" : "Parent"} account created & linked`); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-3 border-t pt-6">
<div><h3 className="font-semibold flex items-center gap-2"><UserPlus className="h-4 w-4" /> Portal access</h3><p className="text-xs text-muted-foreground">Create view-only logins so a parent or the student can sign in to see this student's info and grades.</p></div>
<div className="bg-card border rounded-lg divide-y">
{studentLogin && <div className="p-3"><div className="font-medium text-sm">{studentLogin.full_name || "Student"} <span className="text-[10px] uppercase tracking-wide bg-primary/10 text-primary rounded px-1.5 py-0.5">Student</span></div><div className="text-xs text-muted-foreground">{studentLogin.email}</div></div>}
{(parents ?? []).map((p) => <div key={p.id} className="p-3"><div className="font-medium text-sm">{p.full_name || "—"} <span className="text-[10px] uppercase tracking-wide bg-muted rounded px-1.5 py-0.5">Parent</span></div><div className="text-xs text-muted-foreground">{p.email}</div></div>)}
{!studentLogin && parents?.length === 0 && <div className="p-3 text-sm text-muted-foreground">No logins yet.</div>}
</div>
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
<Select value={np.role} onValueChange={(v) => setNp({ ...np, role: v as "parent" | "student" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="parent">Parent</SelectItem><SelectItem value="student">Student</SelectItem></SelectContent>
</Select>
<Input placeholder="Full name" value={np.fullName} onChange={(e) => setNp({ ...np, fullName: e.target.value })} />
<Input type="email" placeholder="Email" value={np.email} onChange={(e) => setNp({ ...np, email: e.target.value })} />
<Button onClick={() => invite.mutate()} disabled={!np.fullName || !np.email || invite.isPending}>{invite.isPending ? "Creating…" : "Create login"}</Button>
</div>
{created && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
<div className="font-medium">Login created — give these to the {created.role}:</div>
<div className="mt-1 flex flex-wrap items-center gap-2 font-mono text-xs">
<span className="rounded bg-background border px-2 py-1">{created.email}</span>
<span className="rounded bg-background border px-2 py-1">{created.password}</span>
<Button size="icon" variant="ghost" onClick={() => { navigator.clipboard.writeText(`Sign in at ${window.location.origin}/auth\nEmail: ${created.email}\nPassword: ${created.password}`); toast.success("Copied"); }}><Copy className="h-4 w-4" /></Button>
</div>
<p className="text-xs text-muted-foreground mt-1">They sign in at the portal to {created.role === "student" ? "view grades" : "complete this profile"}.</p>
</div>
)}
</div>
</div>
);
}
function GuardianCard({ g, studentId, editing }: { g: GuardianRow; studentId: string; editing: 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] }),
});
if (!editing) {
const loc = [g.city, g.state, g.zip].filter(Boolean).join(", ");
return (
<div className="bg-card border rounded-lg p-4">
<div className="flex items-center gap-2"><span className="font-medium">{g.full_name}</span>{g.is_primary && <span className="text-[10px] uppercase tracking-wide bg-primary/10 text-primary rounded px-1.5 py-0.5">Primary</span>}</div>
<div className="text-sm text-muted-foreground mt-1 space-y-0.5">
{g.relationship && <div>{g.relationship}</div>}
{g.phone && <div>{g.phone}</div>}
{g.email && <div>{g.email}</div>}
{(g.address || loc) && <div>{[g.address, loc].filter(Boolean).join(" · ")}</div>}
{g.employer && <div>Employer: {g.employer}</div>}
</div>
</div>
);
}
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} onCheckedChange={(v) => setRow({ ...row, is_primary: !!v })} /> Primary guardian</label>
<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 value={row.full_name ?? ""} onChange={f("full_name")} /></Field>
<Field label="Relationship"><Input value={row.relationship ?? ""} onChange={f("relationship")} /></Field>
<Field label="Phone"><Input value={row.phone ?? ""} onChange={f("phone")} /></Field>
<Field label="Address" className="col-span-2 md:col-span-3"><Input value={row.address ?? ""} onChange={f("address")} /></Field>
<Field label="City"><Input value={row.city ?? ""} onChange={f("city")} /></Field>
<Field label="State"><Input value={row.state ?? ""} onChange={f("state")} /></Field>
<Field label="Zip"><Input value={row.zip ?? ""} onChange={f("zip")} /></Field>
<Field label="Email"><Input value={row.email ?? ""} onChange={f("email")} /></Field>
<Field label="Employer"><Input value={row.employer ?? ""} onChange={f("employer")} /></Field>
</div>
<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, editing, kind, title, subtitle }: { studentId: string; editing: 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>
{editing && <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} editing={editing} showNotes={kind === "pickup"} showAlt={kind === "emergency"} />)}
{rows.length === 0 && <p className="text-sm text-muted-foreground">None added.</p>}
</div>
);
}
function PickupCard({ p, studentId, editing, showNotes, showAlt }: { p: PickupRow; studentId: string; editing: 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] }),
});
if (!editing) {
return (
<div className="bg-card border rounded-lg p-4">
<div className="font-medium">{p.name}</div>
<div className="text-sm text-muted-foreground mt-0.5 space-y-0.5">
{p.relationship && <div>{p.relationship}</div>}
{p.phone && <div>{p.phone}{p.alt_phone ? ` · alt ${p.alt_phone}` : ""}</div>}
{p.notes && <div>Note: {p.notes}</div>}
</div>
</div>
);
}
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 value={row.name ?? ""} onChange={f("name")} /></Field>
<Field label="Relationship"><Input value={row.relationship ?? ""} onChange={f("relationship")} /></Field>
<Field label="Phone"><Input value={row.phone ?? ""} onChange={f("phone")} /></Field>
{showAlt && <Field label="Alternate phone"><Input value={row.alt_phone ?? ""} onChange={f("alt_phone")} /></Field>}
{showNotes && <Field label="Notes / restrictions"><Input value={row.notes ?? ""} onChange={f("notes")} /></Field>}
</div>
<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 ────────────────────────────────────────────────────────────────
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 [editing, setEditing] = useState(false);
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>>({});
const startEdit = () => { setForm({ ...(s as Record<string, unknown>) }); setEditing(true); };
const c = (editing ? form : s) as Record<string, unknown> | null;
const upd = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
const val = (k: string) => (c?.[k] as string) ?? "";
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 () => {
const { error } = await supabase.from("students").update({
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] }); },
onError: (e: Error) => toast.error(e.message),
});
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),
});
if (!c) return <div className="p-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>;
const portfolio = val("portfolio_keeper") === "parent" ? "Parent/Guardian" : val("portfolio_keeper") === "bayside" ? "Bayside Academy" : "";
return (
<div className="space-y-6 mt-4">
<div className="flex justify-end"><EditToggle editing={editing} setEditing={(v) => (v ? startEdit() : setEditing(false))} canEdit={canEdit} /></div>
<div className="bg-card border rounded-lg p-6 space-y-4">
{!editing ? (
<>
<ViewRow label="Home Ed. eval due to BPS" value={val("home_ed_eval_due")} />
<ViewRow label="Previous school(s)" value={val("previous_schools")} />
<ViewRow label="Special needs / accommodations" value={val("special_needs")} />
<ViewRow label="Portfolio kept by" value={portfolio} />
<ViewRow label="Disciplinary history" value={val("disciplinary_history")} />
<ViewRow label="Custody agreement" value={val("custody_agreement")} />
</>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<Field label="Home Ed. Annual Evaluation due to BPS"><Input type="date" defaultValue={val("home_ed_eval_due")} onChange={(e) => upd("home_ed_eval_due", e.target.value)} /></Field>
<Field label="Previous school(s)"><Input value={val("previous_schools")} onChange={(e) => upd("previous_schools", e.target.value)} /></Field>
</div>
<Field label="Special academic needs / accommodations"><Textarea rows={2} 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)}>
<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} value={val("disciplinary_history")} onChange={(e) => upd("disciplinary_history", e.target.value)} /></Field>
<Field label="Custody agreement"><Textarea rows={2} value={val("custody_agreement")} onChange={(e) => upd("custody_agreement", e.target.value)} /></Field>
<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>
{editing && <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} editing={editing} />)}
{logins?.length === 0 && <p className="text-sm text-muted-foreground">No logins added.</p>}
</div>
</div>
);
}
function LoginCard({ l, studentId, editing }: { l: LoginRow; studentId: string; editing: 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] }),
});
if (!editing) {
return (
<div className="bg-card border rounded-lg p-4">
<div className="font-medium">{l.website || "Platform"}</div>
<div className="text-sm text-muted-foreground mt-0.5 space-y-0.5">
{l.student_login && <div>Student: {l.student_login}{l.student_password ? ` / ${l.student_password}` : ""}</div>}
{l.parent_account && <div>Parent: {l.parent_account}{l.parent_password ? ` / ${l.parent_password}` : ""}</div>}
</div>
</div>
);
}
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 value={row.website ?? ""} onChange={f("website")} /></Field>
<Field label="Student login"><Input value={row.student_login ?? ""} onChange={f("student_login")} /></Field>
<Field label="Student password"><Input value={row.student_password ?? ""} onChange={f("student_password")} /></Field>
<Field label="Parent account"><Input value={row.parent_account ?? ""} onChange={f("parent_account")} /></Field>
<Field label="Parent password"><Input value={row.parent_password ?? ""} onChange={f("parent_password")} /></Field>
</div>
<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 ──────────────────────────────────────────
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 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>
))}
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No attendance recorded.</div>}
</div>
);
}
function LedgerTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const { data } = useQuery({
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 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);
if (!amt) throw new Error("Enter a valid amount");
const { error } = await supabase.from("ledger_entries").insert({
student_id: studentId, kind: form.kind as "charge" | "payment", category: form.category as "tuition" | "late_pickup" | "activity" | "other",
amount_cents: amt, note: form.note || null, date: form.date,
});
if (error) throw error;
},
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 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>
</div>
<div className="bg-card border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted text-xs"><tr><th className="text-left p-2">Date</th><th className="text-left p-2">Type</th><th className="text-left p-2">Category</th><th className="text-left p-2">Note</th><th className="text-right p-2">Amount</th></tr></thead>
<tbody className="divide-y">
{(data ?? []).map((e) => (
<tr key={e.id}>
<td className="p-2">{new Date(e.date).toLocaleDateString()}</td>
<td className="p-2 capitalize">{e.kind}</td>
<td className="p-2 capitalize">{e.category.replace("_", " ")}</td>
<td className="p-2 text-muted-foreground">{e.note}</td>
<td className={`p-2 text-right ${e.kind === "charge" ? "text-destructive" : "text-green-700"}`}>{e.kind === "payment" ? "−" : ""}${(e.amount_cents / 100).toFixed(2)}</td>
</tr>
))}
{data?.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-muted-foreground">No entries.</td></tr>}
</tbody>
</table>
</div>
{canEdit && (
<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>
<Input type="date" defaultValue={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 })} />
</div>
<Button onClick={() => add.mutate()} disabled={add.isPending}>Add entry</Button>
</div>
)}
</div>
);
}
function ContractsTab({ studentId }: { studentId: string }) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["contracts", studentId],
queryFn: async () => (await supabase.from("contracts").select("*").eq("student_id", studentId).order("created_at", { ascending: false })).data ?? [],
});
const [title, setTitle] = useState("");
const [uploading, setUploading] = useState(false);
const onUpload = async (file: File) => {
if (!file) return;
setUploading(true);
try {
const path = `${studentId}/${Date.now()}-${file.name}`;
const { error: upErr } = await supabase.storage.from("contracts").upload(path, file);
if (upErr) throw upErr;
const { data: u } = await supabase.auth.getUser();
const { error } = await supabase.from("contracts").insert({ student_id: studentId, file_path: path, title: title || file.name, uploaded_by: u.user?.id });
if (error) throw error;
setTitle("");
qc.invalidateQueries({ queryKey: ["contracts", studentId] });
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 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)} />
<Input type="file" disabled={uploading} onChange={(e) => e.target.files && onUpload(e.target.files[0])} />
</div>
<div className="bg-card border rounded-lg divide-y">
{(data ?? []).map((c) => (
<button key={c.id} onClick={() => download(c.file_path)} className="w-full flex justify-between items-center p-3 hover:bg-muted/50 text-left">
<span className="flex items-center gap-2 text-sm"><FileText className="h-4 w-4" /> {c.title}</span>
<span className="text-xs text-muted-foreground">{new Date(c.created_at).toLocaleDateString()}</span>
</button>
))}
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No contracts uploaded.</div>}
</div>
</div>
);
}