Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 13:03:38 +00:00
parent 640d8e9952
commit de5ebbac7a
+370 -58
View File
@@ -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<string>("");
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<string, string> = {};
(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 (
<div className="p-8 max-w-4xl">
<h1 className="text-2xl font-semibold mb-1">Attendance</h1>
<p className="text-muted-foreground text-sm mb-6">Mark today's attendance for your class.</p>
<div className="flex gap-3 mb-6">
<div className="p-4 md:p-6 max-w-6xl">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger className="w-60"><SelectValue placeholder="Choose class" /></SelectTrigger>
<SelectContent>{(classes ?? []).map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
<h1 className="text-2xl font-semibold">Attendance</h1>
<p className="text-muted-foreground text-sm">
{marked} of {expected} expected marked
</p>
</div>
<div className="flex gap-2 ml-auto">
<Select value={activeCampus} onValueChange={setCampusId}>
<SelectTrigger className="w-44 h-12 text-base">
<SelectValue placeholder="Campus" />
</SelectTrigger>
<SelectContent>
{(campuses ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
className="w-44 h-12 text-base"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<Input type="date" className="w-44" defaultValue={date} onChange={(e) => setDate(e.target.value)} />
</div>
{classId && (
<div className="bg-card border rounded-lg divide-y">
{(students ?? []).map((s) => {
const status = getStatus(s as { attendance: { date: string; status: string }[] });
return (
<div key={s.id} className="flex items-center justify-between p-3">
<div className="font-medium">{s.last_name}, {s.first_name}</div>
<div className="flex gap-1">
{["present", "absent", "late", "excused"].map((opt) => (
<Button key={opt} size="sm" variant={status === opt ? "default" : "outline"} onClick={() => mark.mutate({ studentId: s.id, status: opt })}>
{opt}
</Button>
))}
</div>
</div>
);
})}
{students?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No students in this class.</div>}
{ratio && (
<div className="flex flex-wrap items-center gap-4 text-sm bg-card border rounded-lg px-4 py-2.5 mb-4">
<span className="flex items-center gap-1.5 font-medium">
<Users className="h-4 w-4 text-muted-foreground" />
{ratio.present_students} present · {ratio.present_staff} staff
</span>
{ratio.required_students_per_staff != null ? (
<span className="text-muted-foreground">
ratio {ratio.actual_students_per_staff ?? "—"} / required{" "}
{ratio.required_students_per_staff}
</span>
) : (
<span className="text-muted-foreground">no ratio rule configured</span>
)}
{ratio.is_compliant === false && (
<span className="px-2 py-0.5 rounded bg-rose-600 text-white text-xs font-medium">
Ratio exceeded
</span>
)}
{ratio.is_compliant === true && (
<span className="px-2 py-0.5 rounded bg-emerald-600 text-white text-xs font-medium">
Within ratio
</span>
)}
</div>
)}
{!classId && <p className="text-sm text-muted-foreground">Select a class to begin.</p>}
{isLoading && <p className="text-sm text-muted-foreground">Loading roll…</p>}
<div className="grid gap-2">
{(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 (
<div
key={r.student_id}
className={`bg-card border rounded-lg p-3 flex flex-wrap items-center gap-3 ${
r.was_scheduled ? "" : "opacity-55"
}`}
>
{photo ? (
<img src={photo} alt="" className="h-14 w-14 rounded-full object-cover shrink-0" />
) : (
<div className="h-14 w-14 rounded-full bg-muted flex items-center justify-center shrink-0">
<User className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div className="min-w-40 flex-1">
<div className="font-medium flex items-center gap-2">
{r.preferred_name || r.first_name} {r.last_name}
{r.alert_count > 0 && (
<span
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-rose-600 text-white"
title="Active alerts on this student"
>
<AlertTriangle className="h-3 w-3" />
{r.alert_count}
</span>
)}
{r.is_manual_override && (
<span title="Manually overridden">
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{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"}
</div>
</div>
<div className="flex gap-1.5 flex-wrap">
{STATES.map((s) => {
const active = r.status === s.value;
return (
<button
key={s.value}
onClick={() => onPick(r, s.value)}
aria-pressed={active}
className={`h-12 min-w-14 px-3 rounded-md border text-sm font-medium transition-colors ${
active ? s.cls : "bg-background hover:bg-muted border-input"
}`}
>
{s.label}
</button>
);
})}
</div>
<div className="flex gap-1.5">
<Button
variant="outline"
className="h-12 w-12 p-0"
title="Stamp check-in"
onClick={() => stamp.mutate({ row: r, field: "check_in_at" })}
>
<LogIn className="h-5 w-5" />
</Button>
<Button
variant="outline"
className="h-12 w-12 p-0"
title="Stamp check-out"
onClick={() => stamp.mutate({ row: r, field: "check_out_at" })}
>
<LogOut className="h-5 w-5" />
</Button>
</div>
</div>
);
})}
{!isLoading && (roll ?? []).length === 0 && activeCampus && (
<div className="bg-card border rounded-lg p-4 text-sm text-muted-foreground">
No enrolled students attached to this campus. Set a student's primary campus or add a
campus schedule on their Enrollment tab.
</div>
)}
</div>
<Dialog open={!!override} onOpenChange={(o) => !o && setOverride(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reason for change</DialogTitle>
<DialogDescription>
{override &&
`Changing ${override.row.first_name} ${override.row.last_name} from ${override.row.status} to ${override.status}. This is recorded against your name.`}
</DialogDescription>
</DialogHeader>
<Textarea
autoFocus
placeholder="e.g. Parent called — medical appointment"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setOverride(null)}>
Cancel
</Button>
<Button
disabled={!reason.trim()}
onClick={() => {
if (!override) return;
mark.mutate({
row: override.row,
status: override.status,
overrideReason: reason.trim(),
});
setOverride(null);
}}
>
Save change
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}