Terminal
This commit is contained in:
@@ -1,410 +0,0 @@
|
|||||||
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<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 ({
|
|
||||||
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 (
|
|
||||||
<div className="p-4 md:p-6 max-w-6xl">
|
|
||||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user