diff --git a/src/routes/_authenticated/billing.tsx b/src/routes/_authenticated/billing.tsx new file mode 100644 index 0000000..667d611 --- /dev/null +++ b/src/routes/_authenticated/billing.tsx @@ -0,0 +1,402 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth, canBill } 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 { money } from "@/lib/reports"; +import { Plus, Receipt, Eye, CheckCircle2, Loader2, AlertTriangle } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/_authenticated/billing")({ + head: () => ({ meta: [{ title: "Billing — School Portal" }] }), + component: BillingPage, +}); + +type PreviewRow = { + student_id: string; + student_name: string; + campus_name: string; + scheduled_days: number; + subtotal_cents: number; + total_cents: number; +}; + +/** Monday–Sunday of the current week, as ISO dates. */ +function currentWeek(): { from: string; to: string } { + const now = new Date(); + const day = (now.getDay() + 6) % 7; // 0 = Monday + const mon = new Date(now); + mon.setDate(now.getDate() - day); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + const iso = (d: Date) => d.toISOString().slice(0, 10); + return { from: iso(mon), to: iso(sun) }; +} + +function BillingPage() { + const { user, roles } = useAuth(); + const qc = useQueryClient(); + const allowed = canBill(roles); + + const { data: campuses } = useQuery({ + queryKey: ["campuses"], + queryFn: async () => + (await supabase.from("campuses").select("id, name").order("name")).data ?? [], + }); + const { data: tiers } = useQuery({ + queryKey: ["tuition-tiers"], + queryFn: async () => + (await supabase.from("tuition_tiers").select("id, name").order("sort_order")).data ?? [], + }); + const { data: rates } = useQuery({ + queryKey: ["tuition-rates"], + enabled: allowed, + queryFn: async () => + ( + await supabase + .from("tuition_rates") + .select( + "id, amount_cents, rate_basis, attendance_basis, is_active, campuses(name), tuition_tiers(name)", + ) + .order("created_at", { ascending: false }) + ).data ?? [], + }); + const { data: batches } = useQuery({ + queryKey: ["invoice-batches"], + enabled: allowed, + queryFn: async () => + ( + await supabase + .from("invoice_batches") + .select( + "id, billing_period_start, billing_period_end, status, student_count, total_cents, confirmed_at", + ) + .order("created_at", { ascending: false }) + .limit(10) + ).data ?? [], + }); + + // ---- rate form ---- + const [rCampus, setRCampus] = useState(""); + const [rTier, setRTier] = useState(""); + const [rBasis, setRBasis] = useState("per_day"); + const [rAttendance, setRAttendance] = useState("part_time"); + const [rAmount, setRAmount] = useState(""); + + const addRate = useMutation({ + mutationFn: async () => { + const dollars = Number(rAmount); + if (!Number.isFinite(dollars) || dollars < 0) throw new Error("Enter a valid amount"); + const { error } = await supabase.from("tuition_rates").insert({ + campus_id: rCampus || null, + tier_id: rTier || null, + rate_basis: rBasis, + attendance_basis: rAttendance || null, + amount_cents: Math.round(dollars * 100), + priority: 200, + }); + if (error) throw error; + }, + onSuccess: () => { + setRAmount(""); + qc.invalidateQueries({ queryKey: ["tuition-rates"] }); + toast.success("Rate added"); + }, + onError: (e: Error) => toast.error(e.message), + }); + + // ---- batch flow ---- + const wk = currentWeek(); + const [from, setFrom] = useState(wk.from); + const [to, setTo] = useState(wk.to); + const [preview, setPreview] = useState<{ batchId: string; rows: PreviewRow[] } | null>(null); + + const createBatch = useMutation({ + mutationFn: async () => { + const { data, error } = await supabase + .from("invoice_batches") + .insert({ + billing_period_start: from, + billing_period_end: to, + criteria: {}, + created_by: user?.id ?? null, + }) + .select("id") + .single(); + if (error) throw error; + return data.id as string; + }, + onSuccess: (id) => { + qc.invalidateQueries({ queryKey: ["invoice-batches"] }); + toast.success("Batch created — preview it before issuing"); + runPreview.mutate(id); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const runPreview = useMutation({ + mutationFn: async (batchId: string) => { + const { data, error } = await supabase.rpc("preview_invoice_batch", { _batch: batchId }); + if (error) throw error; + return { batchId, rows: (data ?? []) as PreviewRow[] }; + }, + onSuccess: (r) => { + setPreview(r); + qc.invalidateQueries({ queryKey: ["invoice-batches"] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + // The spec requires explicit administrative confirmation before bulk + // issuance; generate_invoice_batch() refuses without confirmed_at set. + const confirmAndIssue = useMutation({ + mutationFn: async (batchId: string) => { + const { error: upErr } = await supabase + .from("invoice_batches") + .update({ confirmed_at: new Date().toISOString(), confirmed_by: user?.id ?? null }) + .eq("id", batchId); + if (upErr) throw upErr; + const { data, error } = await supabase.rpc("generate_invoice_batch", { _batch: batchId }); + if (error) throw error; + return data as number; + }, + onSuccess: (n) => { + setPreview(null); + qc.invalidateQueries({ queryKey: ["invoice-batches"] }); + toast.success(`${n} invoice${n === 1 ? "" : "s"} issued`); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!allowed) { + return ( +
You don't have billing access.
+Tuition rates and weekly invoice runs.
+ + {/* ---------- rates ---------- */} +enrollment_statusis enrolled and they
+ have a campus schedule row covering this period. Newly added columns default to
+ prospective, so existing students need updating first.
+ | Student | +Campus | +Days | +Subtotal | +Total | +
|---|---|---|---|---|
| {r.student_name} | +{r.campus_name ?? "—"} | +{Number(r.scheduled_days).toFixed(1)} | +{money(Number(r.subtotal_cents))} | +{money(Number(r.total_cents))} | +