diff --git a/src/routes/_authenticated/receivables.tsx b/src/routes/_authenticated/receivables.tsx new file mode 100644 index 0000000..8d44abd --- /dev/null +++ b/src/routes/_authenticated/receivables.tsx @@ -0,0 +1,421 @@ +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 ?? [], + }); + + const { data: invoices } = useQuery({ + queryKey: ["receivables-invoices"], + enabled: allowed, + queryFn: async () => + ( + await supabase + .from("invoices") + .select( + "id, invoice_number, student_id, campus_id, billing_period_start, billing_period_end, due_date, total_cents, amount_paid_cents, balance_due_cents, status, students(first_name, last_name)", + ) + .neq("status", "void") + .order("due_date") + ).data ?? [], + }); + + const { data: payments } = useQuery({ + queryKey: ["recent-payments"], + enabled: allowed, + queryFn: async () => + ( + await supabase + .from("payments") + .select( + "id, amount_cents, method, kind, status, reference_number, effective_date, student_id, void_reason, students(first_name, last_name)", + ) + .order("created_at", { ascending: false }) + .limit(15) + ).data ?? [], + }); + + const today = new Date().toISOString().slice(0, 10); + + // 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.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today); + if (statusFilter === "paid") r = r.filter((i) => (i.balance_due_cents ?? 0) <= 0); + return r; + }, [invoices, campusFilter, statusFilter, today]); + + 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 pastDue = scope + .filter((i) => (i.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today) + .reduce((s, i) => s + (i.balance_due_cents ?? 0), 0); + const delinquent = new Set( + scope + .filter((i) => (i.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today) + .map((i) => i.student_id), + ).size; + return { invoiced, collected, unpaid, pastDue, delinquent }; + }, [invoices, campusFilter, today]); + + 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.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today; + const name = i.students ? `${i.students.first_name} ${i.students.last_name}` : "—"; + 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 && i.student_id && ( + + )} +
+ Nothing matches these filters. +
+
+ +
+
Recent payments
+
+ {(payments ?? []).map((p) => ( +
+ + + {p.students ? `${p.students.first_name} ${p.students.last_name}` : "—"} + + + {" "} + · {p.method} · {p.effective_date} + {p.reference_number && ` · ${p.reference_number}`} + + {p.status !== "posted" && ( + {p.status} + )} + {p.kind !== "payment" && ( + {p.kind} + )} + + + {money(p.amount_cents)} + {p.status === "posted" && p.kind === "payment" && ( + + )} + +
+ ))} + {(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)} + /> + + + + + + +
+ ); +}