From 27ccedadcc5f586e71b6442d7d22318f38d17c89 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 22 Aug 2026 23:10:34 +0000 Subject: [PATCH] Terminal --- src/routes/_authenticated/receivables.tsx | 423 ---------------------- 1 file changed, 423 deletions(-) delete mode 100644 src/routes/_authenticated/receivables.tsx diff --git a/src/routes/_authenticated/receivables.tsx b/src/routes/_authenticated/receivables.tsx deleted file mode 100644 index e0160c1..0000000 --- a/src/routes/_authenticated/receivables.tsx +++ /dev/null @@ -1,423 +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 { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { money } from "@/lib/reports"; -import { Banknote, Loader2, RotateCcw } from "lucide-react"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; - -export const Route = createFileRoute("/_authenticated/receivables")({ - head: () => ({ meta: [{ title: "Receivables — School Portal" }] }), - component: ReceivablesPage, -}); - -const METHODS = ["cash", "check", "ach", "card", "scholarship", "third_party", "other"]; - -function ReceivablesPage() { - const { user, roles } = useAuth(); - const qc = useQueryClient(); - const allowed = canBill(roles); - - const [campusFilter, setCampusFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("outstanding"); - const [payFor, setPayFor] = useState<{ studentId: string; name: string } | null>(null); - const [amount, setAmount] = useState(""); - const [method, setMethod] = useState("cash"); - const [reference, setReference] = useState(""); - const [reverseFor, setReverseFor] = useState<{ id: string; label: string } | null>(null); - const [reverseReason, setReverseReason] = useState(""); - - const { data: campuses } = useQuery({ - queryKey: ["campuses"], - queryFn: async () => - (await supabase.from("campuses").select("id, name").order("name")).data ?? [], - }); - - // Read through v_billing_detail rather than invoices + students(...). A - // billing admin can see every invoice but no student rows, so the nested - // select returned a null student on every row; the view resolves the name - // through a helper that re-checks entitlement instead. It also excludes void - // invoices and computes is_past_due from status, balance and due date - // together, which is stricter than comparing due_date alone. - const { data: invoices } = useQuery({ - queryKey: ["receivables-invoices"], - enabled: allowed, - queryFn: async () => - ( - await supabase - .from("v_billing_detail") - .select( - "invoice_id, invoice_number, student_id, student_name, campus_id, billing_period_start, billing_period_end, due_date, total_cents, amount_paid_cents, balance_due_cents, status, is_past_due, days_overdue", - ) - .order("due_date") - ).data ?? [], - }); - - const { data: payments } = useQuery({ - queryKey: ["recent-payments"], - enabled: allowed, - queryFn: async () => - ( - await supabase - .from("v_payment_detail") - .select( - "payment_id, amount_cents, method, kind, status, reference_number, effective_date, student_id, void_reason, student_name", - ) - .order("received_at", { ascending: false }) - .limit(15) - ).data ?? [], - }); - - // Aggregated in the client: invoice volumes here are small, and it keeps the - // tiles and the table reading from exactly the same rows. - const rows = useMemo(() => { - let r = invoices ?? []; - if (campusFilter !== "all") r = r.filter((i) => i.campus_id === campusFilter); - if (statusFilter === "outstanding") r = r.filter((i) => (i.balance_due_cents ?? 0) > 0); - if (statusFilter === "pastdue") r = r.filter((i) => i.is_past_due); - if (statusFilter === "paid") r = r.filter((i) => (i.balance_due_cents ?? 0) <= 0); - return r; - }, [invoices, campusFilter, statusFilter]); - - const totals = useMemo(() => { - const scope = - campusFilter === "all" - ? (invoices ?? []) - : (invoices ?? []).filter((i) => i.campus_id === campusFilter); - const invoiced = scope.reduce((s, i) => s + (i.total_cents ?? 0), 0); - const collected = scope.reduce((s, i) => s + (i.amount_paid_cents ?? 0), 0); - const unpaid = scope.reduce((s, i) => s + Math.max(i.balance_due_cents ?? 0, 0), 0); - const overdue = scope.filter((i) => i.is_past_due); - const pastDue = overdue.reduce((s, i) => s + (i.balance_due_cents ?? 0), 0); - const delinquent = new Set(overdue.map((i) => i.student_id)).size; - return { invoiced, collected, unpaid, pastDue, delinquent }; - }, [invoices, campusFilter]); - - const record = useMutation({ - mutationFn: async () => { - const dollars = Number(amount); - if (!Number.isFinite(dollars) || dollars <= 0) throw new Error("Enter a valid amount"); - const { data, error } = await supabase - .from("payments") - .insert({ - student_id: payFor!.studentId, - method, - amount_cents: Math.round(dollars * 100), - reference_number: reference || null, - received_by: user?.id ?? null, - }) - .select("id") - .single(); - if (error) throw error; - - // Oldest balance first — the spec's default when no explicit split is given. - const { data: applied, error: aErr } = await supabase.rpc("allocate_payment_oldest_first", { - _payment: data.id, - }); - if (aErr) throw aErr; - return applied as number; - }, - onSuccess: (applied) => { - setPayFor(null); - setAmount(""); - setReference(""); - qc.invalidateQueries({ queryKey: ["receivables-invoices"] }); - qc.invalidateQueries({ queryKey: ["recent-payments"] }); - toast.success(`Payment recorded — ${money(applied ?? 0)} applied to invoices`); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const reverse = useMutation({ - mutationFn: async () => { - const { error } = await supabase.rpc("reverse_payment", { - _payment: reverseFor!.id, - _reason: reverseReason.trim(), - }); - if (error) throw error; - }, - onSuccess: () => { - setReverseFor(null); - setReverseReason(""); - qc.invalidateQueries({ queryKey: ["receivables-invoices"] }); - qc.invalidateQueries({ queryKey: ["recent-payments"] }); - toast.success("Payment reversed"); - }, - onError: (e: Error) => toast.error(e.message), - }); - - if (!allowed) { - return ( -
-

Receivables

-

You don't have billing access.

-
- ); - } - - const tiles = [ - { label: "Invoiced", value: totals.invoiced }, - { label: "Collected", value: totals.collected }, - { label: "Unpaid", value: totals.unpaid }, - { label: "Past due", value: totals.pastDue, warn: totals.pastDue > 0 }, - ]; - - return ( -
-
-
-

Receivables

-

- {totals.delinquent} delinquent account{totals.delinquent === 1 ? "" : "s"} -

-
-
- - -
-
- -
- {tiles.map((t) => ( -
-
{t.label}
-
- {money(t.value)} -
-
- ))} -
- -
- - - - - - - - - - - - - - - {rows.map((i) => { - const overdue = i.is_past_due; - const name = i.student_name ?? "—"; - // Every column of a view is nullable in the generated types, so the - // id is pulled into a const the closure below can narrow on. - const studentId = i.student_id; - return ( - - - - - - - - - - - ); - })} - {rows.length === 0 && ( - - - - )} - -
InvoiceStudentPeriodDueTotalPaidBalance
{i.invoice_number}{name} - {i.billing_period_start} → {i.billing_period_end} - {i.due_date}{money(i.total_cents ?? 0)}{money(i.amount_paid_cents ?? 0)} - {money(i.balance_due_cents ?? 0)} - - {(i.balance_due_cents ?? 0) > 0 && studentId && ( - - )} -
- Nothing matches these filters. -
-
- -
-
Recent payments
-
- {(payments ?? []).map((p) => { - const paymentId = p.payment_id; - const amount = p.amount_cents ?? 0; - return ( -
- - {p.student_name ?? "—"} - - {" "} - · {p.method} · {p.effective_date} - {p.reference_number && ` · ${p.reference_number}`} - - {p.status !== "posted" && ( - {p.status} - )} - {p.kind !== "payment" && ( - {p.kind} - )} - - - {money(amount)} - {p.status === "posted" && p.kind === "payment" && paymentId && ( - - )} - -
- ); - })} - {(payments ?? []).length === 0 && ( -
No payments recorded yet.
- )} -
-
- - !o && setPayFor(null)}> - - - Record payment - - {payFor && `For ${payFor.name}. Applied to the oldest outstanding balance first.`} - - -
-
- - setAmount(e.target.value)} /> -
-
- - -
-
- - setReference(e.target.value)} /> -
-
- - - - -
-
- - !o && setReverseFor(null)}> - - - Reverse payment - - {reverseFor && - `Reversing ${reverseFor.label}. The original stays on the ledger and a matching reversal is written against your name.`} - - - setReverseReason(e.target.value)} - /> - - - - - - -
- ); -}