Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 04:13:15 +00:00
parent a6e8f67a39
commit 1cb86273f7
-851
View File
@@ -1,851 +0,0 @@
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 { 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>
<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="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="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>
);
}