diff --git a/src/routes/_authenticated/attendance.tsx b/src/routes/_authenticated/attendance.tsx deleted file mode 100644 index 05defa0..0000000 --- a/src/routes/_authenticated/attendance.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { createFileRoute } 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 { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { AlertTriangle, LogIn, LogOut, Pencil, User, Users } from "lucide-react"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; - -export const Route = createFileRoute("/_authenticated/attendance")({ - head: () => ({ meta: [{ title: "Attendance — School Portal" }] }), - component: AttendancePage, -}); - -/** - * The five states a teacher can set. Colour carries the state so the roll can be - * read at arm's length on a tablet; the label repeats it so colour is never the - * only signal. - */ -const STATES = [ - { value: "present", label: "In", cls: "bg-emerald-600 text-white border-emerald-600" }, - { value: "absent", label: "Out", cls: "bg-rose-600 text-white border-rose-600" }, - { value: "late", label: "Late", cls: "bg-amber-500 text-white border-amber-500" }, - { value: "vacation", label: "Vac", cls: "bg-sky-600 text-white border-sky-600" }, - { value: "excused", label: "Exc", cls: "bg-slate-500 text-white border-slate-500" }, -] as const; - -type RollRow = { - student_id: string; - first_name: string; - last_name: string; - preferred_name: string | null; - photo_path: string | null; - class_name: string | null; - was_scheduled: boolean; - expected_arrival: string | null; - expected_departure: string | null; - attendance_id: string | null; - status: string | null; - check_in_at: string | null; - check_out_at: string | null; - is_manual_override: boolean; - early_arrival: boolean; - late_arrival: boolean; - early_pickup: boolean; - late_pickup: boolean; - alert_count: number; -}; - -const hhmm = (ts: string | null) => - ts ? new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null; - -function AttendancePage() { - const { user } = useAuth(); - const qc = useQueryClient(); - const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); - const [campusId, setCampusId] = useState(""); - const [override, setOverride] = useState<{ row: RollRow; status: string } | null>(null); - const [reason, setReason] = useState(""); - - const { data: campuses } = useQuery({ - queryKey: ["campuses"], - queryFn: async () => - (await supabase.from("campuses").select("id, name").eq("is_active", true).order("name")) - .data ?? [], - }); - - // Default to the first campus rather than making the teacher choose each morning. - const activeCampus = campusId || campuses?.[0]?.id || ""; - - const { data: roll, isLoading } = useQuery({ - queryKey: ["roll-call", activeCampus, date], - enabled: !!activeCampus, - queryFn: async () => { - const { data, error } = await supabase.rpc("campus_roll_call", { - _campus: activeCampus, - _date: date, - }); - if (error) throw error; - return (data ?? []) as RollRow[]; - }, - }); - - const { data: ratio } = useQuery({ - queryKey: ["campus-ratio", activeCampus, date], - enabled: !!activeCampus, - queryFn: async () => { - const { data } = await supabase.rpc("campus_ratio", { _campus: activeCampus, _date: date }); - return data?.[0] ?? null; - }, - }); - - // One batch of signed URLs beats one request per child. - const photoPaths = useMemo( - () => (roll ?? []).map((r) => r.photo_path).filter((p): p is string => !!p), - [roll], - ); - const { data: photos } = useQuery({ - queryKey: ["roll-photos", photoPaths.join(",")], - enabled: photoPaths.length > 0, - queryFn: async () => { - const { data } = await supabase.storage - .from("student-photos") - .createSignedUrls(photoPaths, 3600); - const map: Record = {}; - (data ?? []).forEach((d) => { - if (d.path && d.signedUrl) map[d.path] = d.signedUrl; - }); - return map; - }, - }); - - const refresh = () => { - qc.invalidateQueries({ queryKey: ["roll-call", activeCampus, date] }); - qc.invalidateQueries({ queryKey: ["campus-ratio", activeCampus, date] }); - }; - - const mark = useMutation({ - mutationFn: async ({ - row, - status, - overrideReason, - }: { - row: RollRow; - status: string; - overrideReason?: string; - }) => { - if (row.attendance_id) { - const { error } = await supabase - .from("attendance") - .update({ - status: status as "present", - override_reason: overrideReason ?? null, - }) - .eq("id", row.attendance_id); - if (error) throw error; - } else { - const { error } = await supabase.from("attendance").insert({ - student_id: row.student_id, - date, - status: status as "present", - campus_id: activeCampus, - recorded_by: user?.id ?? null, - }); - if (error) throw error; - } - }, - onSuccess: refresh, - onError: (e: Error) => toast.error(e.message), - }); - - const stamp = useMutation({ - mutationFn: async ({ row, field }: { row: RollRow; field: "check_in_at" | "check_out_at" }) => { - const now = new Date().toISOString(); - // Written as explicit branches rather than a computed key: the generated - // Insert/Update types reject an index signature. - const times = - field === "check_in_at" - ? { check_in_at: now, checked_in_by: user?.id ?? null } - : { check_out_at: now, checked_out_by: user?.id ?? null }; - - if (row.attendance_id) { - const { error } = await supabase - .from("attendance") - .update(times) - .eq("id", row.attendance_id); - if (error) throw error; - } else { - const { error } = await supabase.from("attendance").insert({ - student_id: row.student_id, - date, - status: "present", - campus_id: activeCampus, - recorded_by: user?.id ?? null, - ...times, - }); - if (error) throw error; - } - }, - onSuccess: refresh, - onError: (e: Error) => toast.error(e.message), - }); - - // Changing a state that is already recorded is an override, and the spec - // requires a reason for every one. - const onPick = (row: RollRow, status: string) => { - if (row.status && row.status !== status) { - setOverride({ row, status }); - setReason(""); - return; - } - mark.mutate({ row, status }); - }; - - const marked = (roll ?? []).filter((r) => r.status).length; - const expected = (roll ?? []).filter((r) => r.was_scheduled).length; - - return ( -
-
-
-

Attendance

-

- {marked} of {expected} expected marked -

-
-
- - setDate(e.target.value)} - /> -
-
- - {ratio && ( -
- - - {ratio.present_students} present · {ratio.present_staff} staff - - {ratio.required_students_per_staff != null ? ( - - ratio {ratio.actual_students_per_staff ?? "—"} / required{" "} - {ratio.required_students_per_staff} - - ) : ( - no ratio rule configured - )} - {ratio.is_compliant === false && ( - - Ratio exceeded - - )} - {ratio.is_compliant === true && ( - - Within ratio - - )} -
- )} - - {isLoading &&

Loading roll…

} - -
- {(roll ?? []).map((r) => { - const photo = r.photo_path ? photos?.[r.photo_path] : null; - const inTime = hhmm(r.check_in_at); - const outTime = hhmm(r.check_out_at); - return ( -
- {photo ? ( - - ) : ( -
- -
- )} - -
-
- {r.preferred_name || r.first_name} {r.last_name} - {r.alert_count > 0 && ( - - - {r.alert_count} - - )} - {r.is_manual_override && ( - - - - )} -
-
- {r.class_name ?? "No class"} - {!r.was_scheduled && " · not scheduled today"} - {inTime && ` · in ${inTime}`} - {outTime && ` · out ${outTime}`} - {r.late_arrival && " · late in"} - {r.early_pickup && " · early out"} - {r.late_pickup && " · late out"} -
-
- -
- {STATES.map((s) => { - const active = r.status === s.value; - return ( - - ); - })} -
- -
- - -
-
- ); - })} - - {!isLoading && (roll ?? []).length === 0 && activeCampus && ( -
- No enrolled students attached to this campus. Set a student's primary campus or add a - campus schedule on their Enrollment tab. -
- )} -
- - !o && setOverride(null)}> - - - Reason for change - - {override && - `Changing ${override.row.first_name} ${override.row.last_name} from ${override.row.status} to ${override.status}. This is recorded against your name.`} - - -