From df9fa4992ac304a1276140cf56cf0c5d3d8449e4 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 04:15:24 +0000 Subject: [PATCH] Modified by www.SourceFiles.app --- src/routes/_authenticated/students.$id.tsx | 851 +++++++++++++++++++++ 1 file changed, 851 insertions(+) create mode 100644 src/routes/_authenticated/students.$id.tsx diff --git a/src/routes/_authenticated/students.$id.tsx b/src/routes/_authenticated/students.$id.tsx new file mode 100644 index 0000000..d6145ed --- /dev/null +++ b/src/routes/_authenticated/students.$id.tsx @@ -0,0 +1,851 @@ +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
{children}
; +} +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} +function ViewRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ {label} + {value || "—"} +
+ ); +} +function EditToggle({ editing, setEditing, canEdit }: { editing: boolean; setEditing: (v: boolean) => void; canEdit: boolean }) { + if (!canEdit) return null; + return editing + ? + : ; +} + +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 ( +
+ All students +
+ +
+

{student?.first_name} {student?.last_name}

+

{student?.grade_level ? `Grade ${student.grade_level}` : "No grade level set"}

+
+
+ + + + Profile + Family & pickup + Academics + 504 / IEP + Grades + Attendance + Tuition + {isAdmin && Contracts} + + + + + + + + + {isAdmin && } + +
+ ); +} + +// ── 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 ( +
+
+ {url ? Student : } +
+ {canEdit && ( + + )} +
+ ); +} + +// ── 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>({}); + // 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), + tuition_rate_input: s?.daily_tuition_cents != null ? (s.daily_tuition_cents / 100).toFixed(2) : "", + }); + setEditing(true); + }; + const c = (editing ? form : s) as Record | 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>({}); + 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 = { 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
; + 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 ( +
+
{canEdit && }
+
+ + + + + + + +
+
+ + + + +
+
+ + {dismissal.includes("other") && } + +
+
+ + + + {isAdmin && } +
+ {isAdmin && ( +
+ +
+ )} +
+ + +
+
+ ); + } + + // ── Edit mode ── + const T = (k: string, label: string) => upd(k, e.target.value)} />; + // Uncontrolled (defaultValue) so Safari's native date picker doesn't get reset by React's controlled value. + const D = (k: string, label: string) => { dateRefs.current[k] = el; }} onChange={(e) => upd(k, e.target.value)} />; + const A = (k: string, label: string, rows = 2) =>