diff --git a/src/routes/_authenticated/billing.tsx b/src/routes/_authenticated/billing.tsx deleted file mode 100644 index 667d611..0000000 --- a/src/routes/_authenticated/billing.tsx +++ /dev/null @@ -1,402 +0,0 @@ -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.
- )} -
-
-
- ); -}