This commit is contained in:
2026-08-22 23:10:34 +00:00
parent f478078c84
commit 27ccedadcc
-423
View File
@@ -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 (
<div className="p-8 max-w-2xl">
<h1 className="text-2xl font-semibold mb-1">Receivables</h1>
<p className="text-muted-foreground text-sm">You don't have billing access.</p>
</div>
);
}
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 (
<div className="p-6 md:p-8 max-w-6xl">
<div className="flex flex-wrap items-end gap-3 mb-5">
<div>
<h1 className="text-2xl font-semibold">Receivables</h1>
<p className="text-muted-foreground text-sm">
{totals.delinquent} delinquent account{totals.delinquent === 1 ? "" : "s"}
</p>
</div>
<div className="flex gap-2 ml-auto">
<Select value={campusFilter} onValueChange={setCampusFilter}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All campuses</SelectItem>
{(campuses ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="outstanding">Outstanding</SelectItem>
<SelectItem value="pastdue">Past due</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
{tiles.map((t) => (
<div key={t.label} className="bg-card border rounded-lg p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wide">{t.label}</div>
<div
className={`text-2xl font-semibold tabular-nums mt-1 ${
t.warn ? "text-rose-600" : ""
}`}
>
{money(t.value)}
</div>
</div>
))}
</div>
<div className="bg-card border rounded-lg overflow-x-auto mb-6">
<table className="w-full text-sm min-w-[46rem]">
<thead className="text-muted-foreground border-b">
<tr>
<th className="text-left font-normal p-2.5">Invoice</th>
<th className="text-left font-normal">Student</th>
<th className="text-left font-normal">Period</th>
<th className="text-left font-normal">Due</th>
<th className="text-right font-normal">Total</th>
<th className="text-right font-normal">Paid</th>
<th className="text-right font-normal">Balance</th>
<th></th>
</tr>
</thead>
<tbody>
{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 (
<tr key={i.invoice_id} className="border-b last:border-0">
<td className="p-2.5 font-medium">{i.invoice_number}</td>
<td>{name}</td>
<td className="text-muted-foreground text-xs">
{i.billing_period_start} → {i.billing_period_end}
</td>
<td className={overdue ? "text-rose-600 font-medium" : ""}>{i.due_date}</td>
<td className="text-right tabular-nums">{money(i.total_cents ?? 0)}</td>
<td className="text-right tabular-nums">{money(i.amount_paid_cents ?? 0)}</td>
<td className="text-right tabular-nums font-medium">
{money(i.balance_due_cents ?? 0)}
</td>
<td className="pr-2 text-right">
{(i.balance_due_cents ?? 0) > 0 && studentId && (
<Button
size="sm"
variant="outline"
onClick={() => {
setPayFor({ studentId, name });
setAmount(((i.balance_due_cents ?? 0) / 100).toFixed(2));
}}
>
<Banknote className="h-4 w-4 mr-1" /> Pay
</Button>
)}
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={8} className="p-4 text-center text-muted-foreground">
Nothing matches these filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="bg-card border rounded-lg p-4">
<div className="font-medium text-sm mb-3">Recent payments</div>
<div className="border rounded divide-y text-sm">
{(payments ?? []).map((p) => {
const paymentId = p.payment_id;
const amount = p.amount_cents ?? 0;
return (
<div key={paymentId} className="flex items-center justify-between p-2.5 gap-3">
<span className="min-w-0">
<span className="font-medium">{p.student_name ?? "—"}</span>
<span className="text-muted-foreground">
{" "}
· {p.method} · {p.effective_date}
{p.reference_number && ` · ${p.reference_number}`}
</span>
{p.status !== "posted" && (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-muted">{p.status}</span>
)}
{p.kind !== "payment" && (
<span className="ml-1 text-xs px-1.5 py-0.5 rounded bg-muted">{p.kind}</span>
)}
</span>
<span className="flex items-center gap-3 shrink-0">
<span className="tabular-nums font-medium">{money(amount)}</span>
{p.status === "posted" && p.kind === "payment" && paymentId && (
<Button
size="sm"
variant="ghost"
title="Reverse this payment"
onClick={() =>
setReverseFor({
id: paymentId,
label: `${money(amount)} ${p.method}`,
})
}
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
</span>
</div>
);
})}
{(payments ?? []).length === 0 && (
<div className="p-3 text-muted-foreground">No payments recorded yet.</div>
)}
</div>
</div>
<Dialog open={!!payFor} onOpenChange={(o) => !o && setPayFor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Record payment</DialogTitle>
<DialogDescription>
{payFor && `For ${payFor.name}. Applied to the oldest outstanding balance first.`}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div>
<Label className="text-xs">Amount</Label>
<Input value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
<div>
<Label className="text-xs">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Reference (cheque no., transaction id)</Label>
<Input value={reference} onChange={(e) => setReference(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setPayFor(null)}>
Cancel
</Button>
<Button onClick={() => record.mutate()} disabled={record.isPending}>
{record.isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
Record payment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!reverseFor} onOpenChange={(o) => !o && setReverseFor(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reverse payment</DialogTitle>
<DialogDescription>
{reverseFor &&
`Reversing ${reverseFor.label}. The original stays on the ledger and a matching reversal is written against your name.`}
</DialogDescription>
</DialogHeader>
<Input
autoFocus
placeholder="Reason — e.g. cheque returned NSF"
value={reverseReason}
onChange={(e) => setReverseReason(e.target.value)}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setReverseFor(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!reverseReason.trim() || reverse.isPending}
onClick={() => reverse.mutate()}
>
Reverse
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}