This commit is contained in:
2026-08-07 21:09:01 +00:00
parent e59a462271
commit 6f2240e2e5
+574
View File
@@ -0,0 +1,574 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useAuth, isOrgAdmin } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Check, Loader2, Plus, SlidersHorizontal, X } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/_authenticated/vacation")({
head: () => ({ meta: [{ title: "Vacation — School Portal" }] }),
component: VacationPage,
});
const weeks = (n: number | null | undefined) => (n ?? 0).toFixed(2).replace(/\.00$/, "");
function VacationPage() {
const { user, roles } = useAuth();
const qc = useQueryClient();
// Reading is deliberately not gated here — vacation_requests and
// vacation_balances are both readable by anyone who can access the student,
// so a teacher sees their own class and RLS does the filtering. Only the
// actions below are restricted, matching can_manage_student().
const canManage = isOrgAdmin(roles) || roles.includes("campus_admin");
const [year, setYear] = useState(new Date().getFullYear());
const [campusFilter, setCampusFilter] = useState("all");
const [newOpen, setNewOpen] = useState(false);
const [newStudent, setNewStudent] = useState("");
const [newStart, setNewStart] = useState("");
const [newEnd, setNewEnd] = useState("");
const [newWeeks, setNewWeeks] = useState("1");
const [newReason, setNewReason] = useState("");
const [overrideFor, setOverrideFor] = useState<{
studentId: string;
name: string;
current: number | null;
} | null>(null);
const [overrideWeeks, setOverrideWeeks] = useState("");
const [overrideReason, setOverrideReason] = useState("");
const { data: campuses } = useQuery({
queryKey: ["campuses"],
queryFn: async () =>
(await supabase.from("campuses").select("id, name").order("name")).data ?? [],
});
const { data: balances } = useQuery({
queryKey: ["vacation-balances", year],
queryFn: async () =>
(
await supabase
.from("v_vacation_status")
.select(
"student_id, student_name, primary_campus_id, campus_name, policy_year, weeks_allotted, weeks_used, weeks_remaining, is_overridden, pending_requests",
)
.eq("policy_year", year)
).data ?? [],
});
// vacation_requests is readable under can_access_student(), the same predicate
// that guards public.students, so the nested name select is safe here — unlike
// the billing views, where a billing admin sees invoices but no student rows.
const { data: requests } = useQuery({
queryKey: ["vacation-requests", year],
queryFn: async () =>
(
await supabase
.from("vacation_requests")
.select(
"id, student_id, policy_year, start_date, end_date, weeks_charged, status, tuition_due, reason, approved_at, students(first_name, last_name, primary_campus_id)",
)
.eq("policy_year", year)
.order("start_date")
).data ?? [],
});
const { data: students } = useQuery({
queryKey: ["vacation-student-picker"],
enabled: canManage && newOpen,
queryFn: async () =>
(
await supabase
.from("students")
.select("id, first_name, last_name, primary_campus_id")
.eq("enrollment_status", "enrolled")
.order("last_name")
).data ?? [],
});
const inCampus = (campusId: string | null | undefined) =>
campusFilter === "all" || campusId === campusFilter;
const rows = useMemo(
() => (balances ?? []).filter((b) => inCampus(b.primary_campus_id)),
[balances, campusFilter],
);
const pending = useMemo(
() =>
(requests ?? []).filter(
(r) => r.status === "pending" && inCampus(r.students?.primary_campus_id),
),
[requests, campusFilter],
);
const decided = useMemo(
() =>
(requests ?? []).filter(
(r) => r.status !== "pending" && inCampus(r.students?.primary_campus_id),
),
[requests, campusFilter],
);
const totals = useMemo(() => {
const approvedWeeks = decided
.filter((r) => r.status === "approved")
.reduce((s, r) => s + (r.weeks_charged ?? 0), 0);
return {
pending: pending.length,
approvedWeeks,
overrides: rows.filter((b) => b.is_overridden).length,
entitled: rows.filter((b) => (b.weeks_allotted ?? 0) > 0).length,
};
}, [pending, decided, rows]);
const decide = useMutation({
mutationFn: async ({ id, status }: { id: string; status: "approved" | "denied" }) => {
const { error } = await supabase
.from("vacation_requests")
.update({
status,
approved_by: user?.id ?? null,
approved_at: new Date().toISOString(),
})
.eq("id", id);
// The entitlement guard raises here for a student with no vacation
// allowance. Its message names the student and explains the override
// route, so it is shown verbatim rather than replaced with a generic one.
if (error) throw new Error(error.message);
},
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["vacation-requests", year] });
qc.invalidateQueries({ queryKey: ["vacation-balances", year] });
toast.success(v.status === "approved" ? "Vacation approved" : "Request denied");
},
onError: (e: Error) => toast.error(e.message, { duration: 10000 }),
});
const create = useMutation({
mutationFn: async () => {
const w = Number(newWeeks);
if (!newStudent) throw new Error("Choose a student");
if (!newStart || !newEnd) throw new Error("Enter both dates");
if (!Number.isFinite(w) || w <= 0) throw new Error("Weeks must be greater than zero");
// The vacation year resets on the policy's reset day, not on 1 January, so
// a request in January can belong to the previous policy year. Ask the
// database the same question its own insert trigger would rather than
// assuming the year currently selected in the filter.
const { data: policyYear, error: yErr } = await supabase.rpc("vacation_year_for", {
_student: newStudent,
_on: newStart,
});
if (yErr) throw new Error(yErr.message);
const { error } = await supabase.from("vacation_requests").insert({
student_id: newStudent,
policy_year: policyYear as number,
start_date: newStart,
end_date: newEnd,
weeks_charged: w,
reason: newReason || null,
requested_by: user?.id ?? null,
status: "pending",
});
if (error) throw new Error(error.message);
return policyYear as number;
},
onSuccess: (policyYear) => {
setNewOpen(false);
setNewStudent("");
setNewStart("");
setNewEnd("");
setNewWeeks("1");
setNewReason("");
qc.invalidateQueries({ queryKey: ["vacation-requests"] });
qc.invalidateQueries({ queryKey: ["vacation-balances"] });
if (policyYear !== year) {
// Say so rather than letting it vanish from a filter set to another year.
toast.success(`Request created under policy year ${policyYear}`);
setYear(policyYear);
} else {
toast.success("Request created");
}
},
onError: (e: Error) => toast.error(e.message),
});
const saveOverride = useMutation({
mutationFn: async () => {
const raw = overrideWeeks.trim();
const w = raw === "" ? null : Number(raw);
if (w !== null && (!Number.isFinite(w) || w < 0))
throw new Error("Weeks must be zero or more");
if (w !== null && !overrideReason.trim())
throw new Error("Give a reason — an override is an exception on the record");
const { error } = await supabase
.from("vacation_balances")
.update({
override_weeks: w,
override_reason: w === null ? null : overrideReason.trim(),
overridden_by: w === null ? null : (user?.id ?? null),
})
.eq("student_id", overrideFor!.studentId)
.eq("policy_year", year);
if (error) throw new Error(error.message);
// weeks_allotted only picks the override up when the balance is recomputed;
// without this the row still shows the policy figure and the entitlement
// guard keeps refusing.
const { error: rErr } = await supabase.rpc("recalc_vacation_balance", {
_student: overrideFor!.studentId,
_year: year,
});
if (rErr) throw new Error(rErr.message);
},
onSuccess: () => {
setOverrideFor(null);
setOverrideWeeks("");
setOverrideReason("");
qc.invalidateQueries({ queryKey: ["vacation-balances", year] });
toast.success("Allowance updated");
},
onError: (e: Error) => toast.error(e.message),
});
const years = [year - 1, year, year + 1];
const tiles = [
{ label: "Awaiting decision", value: String(totals.pending), warn: totals.pending > 0 },
{ label: "Weeks approved", value: weeks(totals.approvedWeeks) },
{ label: "Students entitled", value: String(totals.entitled) },
{ label: "Overrides in force", value: String(totals.overrides) },
];
return (
<div className="p-6 md:p-8 max-w-6xl">
<div className="flex flex-wrap items-end gap-3 mb-5">
<div>
<h1 className="text-2xl font-semibold">Vacation</h1>
<p className="text-muted-foreground text-sm">
Vacation is allotted to full calendar year, full-time students. Anyone else needs an
explicit override before a request can be approved.
</p>
</div>
<div className="flex gap-2 ml-auto">
{canManage && (
<Button size="sm" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> New request
</Button>
)}
<Select value={campusFilter} onValueChange={setCampusFilter}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All campuses</SelectItem>
{(campuses ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
{tiles.map((t) => (
<div key={t.label} className="bg-card border rounded-lg p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wide">{t.label}</div>
<div
className={`text-2xl font-semibold tabular-nums mt-1 ${t.warn ? "text-amber-600" : ""}`}
>
{t.value}
</div>
</div>
))}
</div>
<div className="bg-card border rounded-lg p-4 mb-6">
<div className="font-medium text-sm mb-3">Awaiting decision</div>
<div className="border rounded divide-y text-sm">
{pending.map((r) => {
const name = r.students ? `${r.students.first_name} ${r.students.last_name}` : "—";
return (
<div key={r.id} className="flex items-center justify-between gap-3 p-2.5">
<span className="min-w-0">
<span className="font-medium">{name}</span>
<span className="text-muted-foreground">
{" "}
· {r.start_date} → {r.end_date} · {weeks(r.weeks_charged)} wk
{r.tuition_due === false && " · tuition waived"}
</span>
{r.reason && (
<span className="block text-muted-foreground text-xs truncate">{r.reason}</span>
)}
</span>
{canManage && (
<span className="flex gap-1 shrink-0">
<Button
size="sm"
variant="outline"
disabled={decide.isPending}
onClick={() => decide.mutate({ id: r.id, status: "approved" })}
>
<Check className="h-4 w-4 mr-1" /> Approve
</Button>
<Button
size="sm"
variant="ghost"
disabled={decide.isPending}
onClick={() => decide.mutate({ id: r.id, status: "denied" })}
>
<X className="h-4 w-4" />
</Button>
</span>
)}
</div>
);
})}
{pending.length === 0 && (
<div className="p-3 text-muted-foreground">Nothing awaiting a decision.</div>
)}
</div>
</div>
<div className="bg-card border rounded-lg overflow-x-auto mb-6">
<table className="w-full text-sm min-w-[44rem]">
<thead className="text-muted-foreground border-b">
<tr>
<th className="text-left font-normal p-2.5">Student</th>
<th className="text-left font-normal">Campus</th>
<th className="text-right font-normal">Allotted</th>
<th className="text-right font-normal">Used</th>
<th className="text-right font-normal">Remaining</th>
<th className="text-right font-normal">Pending</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((b) => {
const studentId = b.student_id;
const remaining = b.weeks_remaining ?? 0;
const entitled = (b.weeks_allotted ?? 0) > 0;
return (
<tr key={studentId} className="border-b last:border-0">
<td className="p-2.5 font-medium">
{b.student_name ?? "—"}
{b.is_overridden && (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-muted font-normal">
override
</span>
)}
</td>
<td className="text-muted-foreground">{b.campus_name ?? "—"}</td>
<td className="text-right tabular-nums">
{entitled ? (
weeks(b.weeks_allotted)
) : (
<span className="text-muted-foreground">none</span>
)}
</td>
<td className="text-right tabular-nums">{weeks(b.weeks_used)}</td>
<td
className={`text-right tabular-nums font-medium ${
remaining < 0 ? "text-rose-600" : ""
}`}
>
{weeks(remaining)}
</td>
<td className="text-right tabular-nums text-muted-foreground">
{b.pending_requests ?? 0}
</td>
<td className="pr-2 text-right">
{canManage && studentId && (
<Button
size="sm"
variant="ghost"
title="Set an explicit allowance for this student"
onClick={() => {
setOverrideFor({
studentId,
name: b.student_name ?? "this student",
current: b.is_overridden ? (b.weeks_allotted ?? null) : null,
});
setOverrideWeeks(b.is_overridden ? weeks(b.weeks_allotted) : "");
setOverrideReason("");
}}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
)}
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={7} className="p-4 text-center text-muted-foreground">
No vacation balances for {year}.
</td>
</tr>
)}
</tbody>
</table>
</div>
{decided.length > 0 && (
<div className="bg-card border rounded-lg p-4">
<div className="font-medium text-sm mb-3">Decided</div>
<div className="border rounded divide-y text-sm">
{decided.map((r) => (
<div key={r.id} className="flex items-center justify-between gap-3 p-2.5">
<span className="min-w-0">
<span className="font-medium">
{r.students ? `${r.students.first_name} ${r.students.last_name}` : "—"}
</span>
<span className="text-muted-foreground">
{" "}
· {r.start_date} → {r.end_date} · {weeks(r.weeks_charged)} wk
</span>
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${
r.status === "approved" ? "bg-emerald-100 text-emerald-800" : "bg-muted"
}`}
>
{r.status}
</span>
</div>
))}
</div>
</div>
)}
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New vacation request</DialogTitle>
<DialogDescription>
Created as pending. Approving it is what draws down the allowance.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div>
<Label className="text-xs">Student</Label>
<Select value={newStudent} onValueChange={setNewStudent}>
<SelectTrigger>
<SelectValue placeholder="Choose a student" />
</SelectTrigger>
<SelectContent>
{(students ?? []).map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.last_name}, {s.first_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">From</Label>
<Input type="date" value={newStart} onChange={(e) => setNewStart(e.target.value)} />
</div>
<div>
<Label className="text-xs">To</Label>
<Input type="date" value={newEnd} onChange={(e) => setNewEnd(e.target.value)} />
</div>
</div>
<div>
<Label className="text-xs">Weeks charged</Label>
<Input value={newWeeks} onChange={(e) => setNewWeeks(e.target.value)} />
</div>
<div>
<Label className="text-xs">Reason (optional)</Label>
<Input value={newReason} onChange={(e) => setNewReason(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setNewOpen(false)}>
Cancel
</Button>
<Button onClick={() => create.mutate()} disabled={create.isPending}>
{create.isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
Create request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!overrideFor} onOpenChange={(o) => !o && setOverrideFor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Override allowance</DialogTitle>
<DialogDescription>
{overrideFor &&
`Sets ${overrideFor.name}'s allowance for ${year} regardless of policy. Leave the field empty to remove the override and fall back to the policy figure.`}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div>
<Label className="text-xs">Weeks allowed</Label>
<Input
autoFocus
placeholder="e.g. 2 — or empty to clear"
value={overrideWeeks}
onChange={(e) => setOverrideWeeks(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">Reason</Label>
<Input
placeholder="Recorded against your name"
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOverrideFor(null)}>
Cancel
</Button>
<Button onClick={() => saveOverride.mutate()} disabled={saveOverride.isPending}>
{saveOverride.isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}