Terminal
This commit is contained in:
@@ -1,574 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user