This commit is contained in:
2026-08-07 20:59:30 +00:00
parent 0f8aa47f3a
commit fa2bc58f87
+63 -61
View File
@@ -52,17 +52,22 @@ function ReceivablesPage() {
(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("invoices")
.from("v_billing_detail")
.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)",
"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",
)
.neq("status", "void")
.order("due_date")
).data ?? [],
});
@@ -73,28 +78,25 @@ function ReceivablesPage() {
queryFn: async () =>
(
await supabase
.from("payments")
.from("v_payment_detail")
.select(
"id, amount_cents, method, kind, status, reference_number, effective_date, student_id, void_reason, students(first_name, last_name)",
"payment_id, amount_cents, method, kind, status, reference_number, effective_date, student_id, void_reason, student_name",
)
.order("created_at", { ascending: false })
.order("received_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 === "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, today]);
}, [invoices, campusFilter, statusFilter]);
const totals = useMemo(() => {
const scope =
@@ -104,16 +106,11 @@ function ReceivablesPage() {
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;
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, today]);
}, [invoices, campusFilter]);
const record = useMutation({
mutationFn: async () => {
@@ -252,10 +249,13 @@ function ReceivablesPage() {
</thead>
<tbody>
{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}` : "—";
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.id} className="border-b last:border-0">
<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">
@@ -268,12 +268,12 @@ function ReceivablesPage() {
{money(i.balance_due_cents ?? 0)}
</td>
<td className="pr-2 text-right">
{(i.balance_due_cents ?? 0) > 0 && i.student_id && (
{(i.balance_due_cents ?? 0) > 0 && studentId && (
<Button
size="sm"
variant="outline"
onClick={() => {
setPayFor({ studentId: i.student_id, name });
setPayFor({ studentId, name });
setAmount(((i.balance_due_cents ?? 0) / 100).toFixed(2));
}}
>
@@ -298,44 +298,46 @@ function ReceivablesPage() {
<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) => (
<div key={p.id} className="flex items-center justify-between p-2.5 gap-3">
<span className="min-w-0">
<span className="font-medium">
{p.students ? `${p.students.first_name} ${p.students.last_name}` : "—"}
{(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="text-muted-foreground">
{" "}
· {p.method} · {p.effective_date}
{p.reference_number && ` · ${p.reference_number}`}
<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>
{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(p.amount_cents)}</span>
{p.status === "posted" && p.kind === "payment" && (
<Button
size="sm"
variant="ghost"
title="Reverse this payment"
onClick={() =>
setReverseFor({
id: p.id,
label: `${money(p.amount_cents)} ${p.method}`,
})
}
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
</span>
</div>
))}
</div>
);
})}
{(payments ?? []).length === 0 && (
<div className="p-3 text-muted-foreground">No payments recorded yet.</div>
)}