This commit is contained in:
2026-08-07 20:59:30 +00:00
parent 0f8aa47f3a
commit fa2bc58f87
+37 -35
View File
@@ -52,17 +52,22 @@ function ReceivablesPage() {
(await supabase.from("campuses").select("id, name").order("name")).data ?? [], (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({ const { data: invoices } = useQuery({
queryKey: ["receivables-invoices"], queryKey: ["receivables-invoices"],
enabled: allowed, enabled: allowed,
queryFn: async () => queryFn: async () =>
( (
await supabase await supabase
.from("invoices") .from("v_billing_detail")
.select( .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") .order("due_date")
).data ?? [], ).data ?? [],
}); });
@@ -73,28 +78,25 @@ function ReceivablesPage() {
queryFn: async () => queryFn: async () =>
( (
await supabase await supabase
.from("payments") .from("v_payment_detail")
.select( .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) .limit(15)
).data ?? [], ).data ?? [],
}); });
const today = new Date().toISOString().slice(0, 10);
// Aggregated in the client: invoice volumes here are small, and it keeps the // Aggregated in the client: invoice volumes here are small, and it keeps the
// tiles and the table reading from exactly the same rows. // tiles and the table reading from exactly the same rows.
const rows = useMemo(() => { const rows = useMemo(() => {
let r = invoices ?? []; let r = invoices ?? [];
if (campusFilter !== "all") r = r.filter((i) => i.campus_id === campusFilter); 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 === "outstanding") r = r.filter((i) => (i.balance_due_cents ?? 0) > 0);
if (statusFilter === "pastdue") if (statusFilter === "pastdue") r = r.filter((i) => i.is_past_due);
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); if (statusFilter === "paid") r = r.filter((i) => (i.balance_due_cents ?? 0) <= 0);
return r; return r;
}, [invoices, campusFilter, statusFilter, today]); }, [invoices, campusFilter, statusFilter]);
const totals = useMemo(() => { const totals = useMemo(() => {
const scope = const scope =
@@ -104,16 +106,11 @@ function ReceivablesPage() {
const invoiced = scope.reduce((s, i) => s + (i.total_cents ?? 0), 0); 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 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 unpaid = scope.reduce((s, i) => s + Math.max(i.balance_due_cents ?? 0, 0), 0);
const pastDue = scope const overdue = scope.filter((i) => i.is_past_due);
.filter((i) => (i.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today) const pastDue = overdue.reduce((s, i) => s + (i.balance_due_cents ?? 0), 0);
.reduce((s, i) => s + (i.balance_due_cents ?? 0), 0); const delinquent = new Set(overdue.map((i) => i.student_id)).size;
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 }; return { invoiced, collected, unpaid, pastDue, delinquent };
}, [invoices, campusFilter, today]); }, [invoices, campusFilter]);
const record = useMutation({ const record = useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -252,10 +249,13 @@ function ReceivablesPage() {
</thead> </thead>
<tbody> <tbody>
{rows.map((i) => { {rows.map((i) => {
const overdue = (i.balance_due_cents ?? 0) > 0 && (i.due_date ?? "") < today; const overdue = i.is_past_due;
const name = i.students ? `${i.students.first_name} ${i.students.last_name}` : "—"; 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 ( 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 className="p-2.5 font-medium">{i.invoice_number}</td>
<td>{name}</td> <td>{name}</td>
<td className="text-muted-foreground text-xs"> <td className="text-muted-foreground text-xs">
@@ -268,12 +268,12 @@ function ReceivablesPage() {
{money(i.balance_due_cents ?? 0)} {money(i.balance_due_cents ?? 0)}
</td> </td>
<td className="pr-2 text-right"> <td className="pr-2 text-right">
{(i.balance_due_cents ?? 0) > 0 && i.student_id && ( {(i.balance_due_cents ?? 0) > 0 && studentId && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setPayFor({ studentId: i.student_id, name }); setPayFor({ studentId, name });
setAmount(((i.balance_due_cents ?? 0) / 100).toFixed(2)); setAmount(((i.balance_due_cents ?? 0) / 100).toFixed(2));
}} }}
> >
@@ -298,12 +298,13 @@ function ReceivablesPage() {
<div className="bg-card border rounded-lg p-4"> <div className="bg-card border rounded-lg p-4">
<div className="font-medium text-sm mb-3">Recent payments</div> <div className="font-medium text-sm mb-3">Recent payments</div>
<div className="border rounded divide-y text-sm"> <div className="border rounded divide-y text-sm">
{(payments ?? []).map((p) => ( {(payments ?? []).map((p) => {
<div key={p.id} className="flex items-center justify-between p-2.5 gap-3"> 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="min-w-0">
<span className="font-medium"> <span className="font-medium">{p.student_name ?? "—"}</span>
{p.students ? `${p.students.first_name} ${p.students.last_name}` : "—"}
</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{" "} {" "}
· {p.method} · {p.effective_date} · {p.method} · {p.effective_date}
@@ -317,16 +318,16 @@ function ReceivablesPage() {
)} )}
</span> </span>
<span className="flex items-center gap-3 shrink-0"> <span className="flex items-center gap-3 shrink-0">
<span className="tabular-nums font-medium">{money(p.amount_cents)}</span> <span className="tabular-nums font-medium">{money(amount)}</span>
{p.status === "posted" && p.kind === "payment" && ( {p.status === "posted" && p.kind === "payment" && paymentId && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
title="Reverse this payment" title="Reverse this payment"
onClick={() => onClick={() =>
setReverseFor({ setReverseFor({
id: p.id, id: paymentId,
label: `${money(p.amount_cents)} ${p.method}`, label: `${money(amount)} ${p.method}`,
}) })
} }
> >
@@ -335,7 +336,8 @@ function ReceivablesPage() {
)} )}
</span> </span>
</div> </div>
))} );
})}
{(payments ?? []).length === 0 && ( {(payments ?? []).length === 0 && (
<div className="p-3 text-muted-foreground">No payments recorded yet.</div> <div className="p-3 text-muted-foreground">No payments recorded yet.</div>
)} )}