diff --git a/src/routes/_authenticated/vacation.tsx b/src/routes/_authenticated/vacation.tsx deleted file mode 100644 index c7bf087..0000000 --- a/src/routes/_authenticated/vacation.tsx +++ /dev/null @@ -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 ( -
-
-
-

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)} - /> -
-
- - - - -
-
-
- ); -}