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.`}