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