From 6f2240e2e588de0e3d0bbf61e1ff0f696b5f12c7 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 21:09:01 +0000 Subject: [PATCH] Terminal --- src/routes/_authenticated/vacation.tsx | 574 +++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 src/routes/_authenticated/vacation.tsx diff --git a/src/routes/_authenticated/vacation.tsx b/src/routes/_authenticated/vacation.tsx new file mode 100644 index 0000000..c7bf087 --- /dev/null +++ b/src/routes/_authenticated/vacation.tsx @@ -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 ( +
+
+
+

Vacation

+

+ Vacation is allotted to full calendar year, full-time students. Anyone else needs an + explicit override before a request can be approved. +

+
+
+ {canManage && ( + + )} + + +
+
+ +
+ {tiles.map((t) => ( +
+
{t.label}
+
+ {t.value} +
+
+ ))} +
+ +
+
Awaiting decision
+
+ {pending.map((r) => { + const name = r.students ? `${r.students.first_name} ${r.students.last_name}` : "—"; + return ( +
+ + {name} + + {" "} + · {r.start_date} → {r.end_date} · {weeks(r.weeks_charged)} wk + {r.tuition_due === false && " · tuition waived"} + + {r.reason && ( + {r.reason} + )} + + {canManage && ( + + + + + )} +
+ ); + })} + {pending.length === 0 && ( +
Nothing awaiting a decision.
+ )} +
+
+ +
+ + + + + + + + + + + + + + {rows.map((b) => { + const studentId = b.student_id; + const remaining = b.weeks_remaining ?? 0; + const entitled = (b.weeks_allotted ?? 0) > 0; + return ( + + + + + + + + + + ); + })} + {rows.length === 0 && ( + + + + )} + +
StudentCampusAllottedUsedRemainingPending
+ {b.student_name ?? "—"} + {b.is_overridden && ( + + override + + )} + {b.campus_name ?? "—"} + {entitled ? ( + weeks(b.weeks_allotted) + ) : ( + none + )} + {weeks(b.weeks_used)} + {weeks(remaining)} + + {b.pending_requests ?? 0} + + {canManage && studentId && ( + + )} +
+ No vacation balances for {year}. +
+
+ + {decided.length > 0 && ( +
+
Decided
+
+ {decided.map((r) => ( +
+ + + {r.students ? `${r.students.first_name} ${r.students.last_name}` : "—"} + + + {" "} + · {r.start_date} → {r.end_date} · {weeks(r.weeks_charged)} wk + + + + {r.status} + +
+ ))} +
+
+ )} + + + + + New vacation request + + Created as pending. Approving it is what draws down the allowance. + + +
+
+ + +
+
+
+ + setNewStart(e.target.value)} /> +
+
+ + setNewEnd(e.target.value)} /> +
+
+
+ + setNewWeeks(e.target.value)} /> +
+
+ + setNewReason(e.target.value)} /> +
+
+ + + + +
+
+ + !o && setOverrideFor(null)}> + + + Override allowance + + {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.`} + + +
+
+ + setOverrideWeeks(e.target.value)} + /> +
+
+ + setOverrideReason(e.target.value)} + /> +
+
+ + + + +
+
+
+ ); +}