Modified by www.SourceFiles.app
This commit is contained in:
@@ -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 ?? [];
|
||||
},
|
||||
enabled: !!classId,
|
||||
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<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" }
|
||||
);
|
||||
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 }[] });
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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={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}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{students?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No students in this class.</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>
|
||||
)}
|
||||
{!classId && <p className="text-sm text-muted-foreground">Select a class to begin.</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user