Terminal
This commit is contained in:
@@ -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,44 +298,46 @@ 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;
|
||||||
<span className="min-w-0">
|
const amount = p.amount_cents ?? 0;
|
||||||
<span className="font-medium">
|
return (
|
||||||
{p.students ? `${p.students.first_name} ${p.students.last_name}` : "—"}
|
<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>
|
||||||
<span className="text-muted-foreground">
|
<span className="flex items-center gap-3 shrink-0">
|
||||||
{" "}
|
<span className="tabular-nums font-medium">{money(amount)}</span>
|
||||||
· {p.method} · {p.effective_date}
|
{p.status === "posted" && p.kind === "payment" && paymentId && (
|
||||||
{p.reference_number && ` · ${p.reference_number}`}
|
<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>
|
</span>
|
||||||
{p.status !== "posted" && (
|
</div>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
{(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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user