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 { useAuth } from "@/hooks/use-auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useState } from "react";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/attendance")({
|
export const Route = createFileRoute("/_authenticated/attendance")({
|
||||||
@@ -13,86 +29,382 @@ export const Route = createFileRoute("/_authenticated/attendance")({
|
|||||||
component: AttendancePage,
|
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() {
|
function AttendancePage() {
|
||||||
const { user, roles, loading } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = roles.includes("admin");
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
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({
|
const { data: campuses } = useQuery({
|
||||||
// isAdmin is in the key so the query refetches once roles resolve; gated on
|
queryKey: ["campuses"],
|
||||||
// !loading so it never runs with a not-yet-known (false) admin status.
|
queryFn: async () =>
|
||||||
queryKey: ["my-classes", user?.id, isAdmin],
|
(await supabase.from("campuses").select("id, name").eq("is_active", true).order("name"))
|
||||||
queryFn: async () => {
|
.data ?? [],
|
||||||
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: students } = useQuery({
|
// Default to the first campus rather than making the teacher choose each morning.
|
||||||
queryKey: ["class-students", classId, date],
|
const activeCampus = campusId || campuses?.[0]?.id || "";
|
||||||
|
|
||||||
|
const { data: roll, isLoading } = useQuery({
|
||||||
|
queryKey: ["roll-call", activeCampus, date],
|
||||||
|
enabled: !!activeCampus,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!classId) return [];
|
const { data, error } = await supabase.rpc("campus_roll_call", {
|
||||||
const { data } = await supabase.from("students").select("id, first_name, last_name, attendance(status, date, id)").eq("class_id", classId);
|
_campus: activeCampus,
|
||||||
return data ?? [];
|
_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({
|
const mark = useMutation({
|
||||||
mutationFn: async ({ studentId, status }: { studentId: string; status: string }) => {
|
mutationFn: async ({
|
||||||
const { error } = await supabase.from("attendance").upsert(
|
row,
|
||||||
{ student_id: studentId, date, status: status as "present" | "absent" | "late" | "excused", recorded_by: user!.id },
|
status,
|
||||||
{ onConflict: "student_id,date" }
|
overrideReason,
|
||||||
);
|
}: {
|
||||||
if (error) throw error;
|
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),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatus = (s: { attendance: { date: string; status: string }[] }) =>
|
const stamp = useMutation({
|
||||||
s.attendance.find((a) => a.date === date)?.status ?? "";
|
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 (
|
return (
|
||||||
<div className="p-8 max-w-4xl">
|
<div className="p-4 md:p-6 max-w-6xl">
|
||||||
<h1 className="text-2xl font-semibold mb-1">Attendance</h1>
|
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||||
<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>
|
<div>
|
||||||
<Select value={classId} onValueChange={setClassId}>
|
<h1 className="text-2xl font-semibold">Attendance</h1>
|
||||||
<SelectTrigger className="w-60"><SelectValue placeholder="Choose class" /></SelectTrigger>
|
<p className="text-muted-foreground text-sm">
|
||||||
<SelectContent>{(classes ?? []).map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
{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>
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-44 h-12 text-base"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input type="date" className="w-44" defaultValue={date} onChange={(e) => setDate(e.target.value)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{classId && (
|
{ratio && (
|
||||||
<div className="bg-card border rounded-lg divide-y">
|
<div className="flex flex-wrap items-center gap-4 text-sm bg-card border rounded-lg px-4 py-2.5 mb-4">
|
||||||
{(students ?? []).map((s) => {
|
<span className="flex items-center gap-1.5 font-medium">
|
||||||
const status = getStatus(s as { attendance: { date: string; status: string }[] });
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
return (
|
{ratio.present_students} present · {ratio.present_staff} staff
|
||||||
<div key={s.id} className="flex items-center justify-between p-3">
|
</span>
|
||||||
<div className="font-medium">{s.last_name}, {s.first_name}</div>
|
{ratio.required_students_per_staff != null ? (
|
||||||
<div className="flex gap-1">
|
<span className="text-muted-foreground">
|
||||||
{["present", "absent", "late", "excused"].map((opt) => (
|
ratio {ratio.actual_students_per_staff ?? "—"} / required{" "}
|
||||||
<Button key={opt} size="sm" variant={status === opt ? "default" : "outline"} onClick={() => mark.mutate({ studentId: s.id, status: opt })}>
|
{ratio.required_students_per_staff}
|
||||||
{opt}
|
</span>
|
||||||
</Button>
|
) : (
|
||||||
))}
|
<span className="text-muted-foreground">no ratio rule configured</span>
|
||||||
</div>
|
)}
|
||||||
</div>
|
{ratio.is_compliant === false && (
|
||||||
);
|
<span className="px-2 py-0.5 rounded bg-rose-600 text-white text-xs font-medium">
|
||||||
})}
|
Ratio exceeded
|
||||||
{students?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No students in this class.</div>}
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user