diff --git a/src/routes/_authenticated/attendance.tsx b/src/routes/_authenticated/attendance.tsx index 1ca0a37..05defa0 100644 --- a/src/routes/_authenticated/attendance.tsx +++ b/src/routes/_authenticated/attendance.tsx @@ -4,8 +4,24 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { useState } from "react"; +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")({ @@ -13,86 +29,382 @@ export const Route = createFileRoute("/_authenticated/attendance")({ 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, roles, loading } = useAuth(); - const isAdmin = roles.includes("admin"); + const { user } = useAuth(); const qc = useQueryClient(); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); - const [classId, setClassId] = useState(""); + const [campusId, setCampusId] = useState(""); + const [override, setOverride] = useState<{ row: RollRow; status: string } | null>(null); + const [reason, setReason] = useState(""); - const { data: classes } = useQuery({ - // isAdmin is in the key so the query refetches once roles resolve; gated on - // !loading so it never runs with a not-yet-known (false) admin status. - queryKey: ["my-classes", user?.id, isAdmin], - queryFn: async () => { - let q = supabase.from("classes").select("id, name, teacher_id"); - if (!isAdmin) q = q.eq("teacher_id", user!.id); - return (await q.order("name")).data ?? []; - }, - enabled: !!user && !loading, + const { data: campuses } = useQuery({ + queryKey: ["campuses"], + queryFn: async () => + (await supabase.from("campuses").select("id, name").eq("is_active", true).order("name")) + .data ?? [], }); - const { data: students } = useQuery({ - queryKey: ["class-students", classId, date], + // 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 () => { - if (!classId) return []; - const { data } = await supabase.from("students").select("id, first_name, last_name, attendance(status, date, id)").eq("class_id", classId); - return data ?? []; + const { data, error } = await supabase.rpc("campus_roll_call", { + _campus: activeCampus, + _date: date, + }); + if (error) throw error; + return (data ?? []) as RollRow[]; }, - enabled: !!classId, }); + 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 ({ studentId, status }: { studentId: string; status: string }) => { - const { error } = await supabase.from("attendance").upsert( - { student_id: studentId, date, status: status as "present" | "absent" | "late" | "excused", recorded_by: user!.id }, - { onConflict: "student_id,date" } - ); - if (error) throw error; + 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: () => qc.invalidateQueries({ queryKey: ["class-students", classId, date] }), + onSuccess: refresh, onError: (e: Error) => toast.error(e.message), }); - const getStatus = (s: { attendance: { date: string; status: string }[] }) => - s.attendance.find((a) => a.date === date)?.status ?? ""; + 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

-

Mark today's attendance for your class.

- -
+
+
- + + + + + {(campuses ?? []).map((c) => ( + + {c.name} + + ))} + + setDate(e.target.value)} + />
- setDate(e.target.value)} />
- {classId && ( -
- {(students ?? []).map((s) => { - const status = getStatus(s as { attendance: { date: string; status: string }[] }); - return ( -
-
{s.last_name}, {s.first_name}
-
- {["present", "absent", "late", "excused"].map((opt) => ( - - ))} -
-
- ); - })} - {students?.length === 0 &&
No students in this class.
} + {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 + + )}
)} - {!classId &&

Select a class to begin.

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