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 ( +
+

Billing

+

You don't have billing access.

+
+ ); + } + + const previewTotal = preview?.rows.reduce((s, r) => s + Number(r.total_cents), 0) ?? 0; + + return ( +
+

Billing

+

Tuition rates and weekly invoice runs.

+ + {/* ---------- rates ---------- */} +
+
Tuition rates
+ +
+ + + + + setRAmount(e.target.value)} + /> + +
+ +
+ {(rates ?? []).map((r) => ( +
+ + {r.campuses?.name ?? "All campuses"} · {r.tuition_tiers?.name ?? "All tiers"} ·{" "} + + {r.attendance_basis ?? "any"} / {r.rate_basis} + + + {money(r.amount_cents)} +
+ ))} + {(rates ?? []).length === 0 && ( +
+ No rates yet. Invoices cannot be calculated until at least one exists. +
+ )} +
+
+ + {/* ---------- batch run ---------- */} +
+
Weekly invoice run
+
+
+ + setFrom(e.target.value)} /> +
+
+ + setTo(e.target.value)} /> +
+ +
+
+ + {/* ---------- preview ---------- */} + {preview && ( +
+
+
+ Preview — {preview.rows.length} student{preview.rows.length === 1 ? "" : "s"} +
+
+ Total {money(previewTotal)} +
+
+ + {preview.rows.length === 0 ? ( +
+ +
+ No eligible students. A student is only picked up when + 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. +
+
+ ) : ( + <> + + + + + + + + + + + + {preview.rows.map((r) => ( + + + + + + + + ))} + +
StudentCampusDaysSubtotalTotal
{r.student_name}{r.campus_name ?? "—"}{Number(r.scheduled_days).toFixed(1)}{money(Number(r.subtotal_cents))}{money(Number(r.total_cents))}
+
+ + +
+ + )} +
+ )} + + {/* ---------- history ---------- */} +
+
Recent runs
+
+ {(batches ?? []).map((b) => ( +
+ + + {b.billing_period_start} → {b.billing_period_end} + + {b.status} + + + + + {b.student_count} invoice{b.student_count === 1 ? "" : "s"} + + {money(b.total_cents)} + {b.status === "previewed" && ( + + )} + +
+ ))} + {(batches ?? []).length === 0 && ( +
No runs yet.
+ )} +
+
+
+ ); +}