Files
acmcc/src/pages/accounting/components/ARAgingPropertyReport.tsx
T
admin e510a76dfc Accounting reports: AR Aging (Property), Pre-Paid Homeowners, Cash Disbursement
Buildium-style reports built on the owner ledger and GL:
- AR Aging (Property): FIFO-aged buckets (0-30/over 30/60/90) per unit with
  charge-type breakdown, collection status, summary + distribution bar
- Pre-Paid Homeowners: units with net credit balances as of a date
- Cash Disbursement: bank-credit GL entries grouped by bank account with
  check#/vendor/invoice enrichment from the banking register and GL line detail
All with branded PDF/CSV exports; shared owner-ledger helpers in lib/ownerLedger.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:26:30 -04:00

433 lines
19 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { FileDown, Download } from "lucide-react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { ReportSheet } from "./ReportSheet";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
import {
fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners,
unitLabel, chargeTypeLabel, money,
type OwnerLedgerEntry, type UnitInfo, type OwnerInfo,
} from "../lib/ownerLedger";
const TEAL: [number, number, number] = [0, 137, 123];
const BUCKETS = ["0-30", "Over 30", "Over 60", "Over 90"] as const;
const BUCKET_COLORS: [number, number, number][] = [
[141, 178, 85], // green (0-30)
[234, 179, 8], // amber (over 30)
[234, 124, 8], // orange (over 60)
[220, 68, 68], // red (over 90)
];
type Buckets = [number, number, number, number];
type UnitAging = {
key: string;
label: string;
collStatus: string | null;
buckets: Buckets;
total: number;
byType: Map<string, { buckets: Buckets; total: number }>;
};
function emptyBuckets(): Buckets { return [0, 0, 0, 0]; }
function bucketIndex(asOf: string, chargeDate: string): number {
const days = Math.floor((new Date(asOf + "T00:00:00").getTime() - new Date(chargeDate + "T00:00:00").getTime()) / 86400000);
if (days <= 30) return 0;
if (days <= 60) return 1;
if (days <= 90) return 2;
return 3;
}
const prettyStatus = (s: string | null | undefined) =>
s ? s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : null;
const dash = (n: number) => (n ? money(n) : "-");
/**
* Buildium-style AR Aging: per-property open charge balances aged into
* 0-30 / Over 30 / Over 60 / Over 90 buckets, with charge-type breakdown,
* collection status, summary and distribution. Payments and credits apply to
* charges oldest-first (FIFO), so only genuinely open charge amounts age.
*/
export function ARAgingPropertyReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
const { data, isLoading } = useQuery({
queryKey: ["ar-aging-property", companyId, asOf],
enabled: !!companyId,
queryFn: async () => {
const associationId = await fetchAssociationId(companyId);
if (!associationId) return null;
const [entries, { units, owners }, collectionsRes] = await Promise.all([
fetchOwnerLedger(associationId, asOf),
fetchUnitsAndOwners(associationId),
supabase.from("collections").select("unit_id, owner_id, status, updated_at").eq("association_id", associationId).order("updated_at", { ascending: false }),
]);
return { entries, units, owners, collections: (collectionsRes.data ?? []) as any[] };
},
});
const report = useMemo(() => {
if (!data) return null;
const { entries, units, owners, collections } = data;
const unitById = new Map<string, UnitInfo>();
for (const u of units) unitById.set(u.id, u);
const ownerById = new Map<string, OwnerInfo>();
for (const o of owners) ownerById.set(o.id, o);
const ownerByUnit = new Map<string, OwnerInfo>();
for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o);
// Latest collection status per unit (rows came back newest-first)
const collByUnit = new Map<string, string>();
const collByOwner = new Map<string, string>();
for (const c of collections) {
if (c.unit_id && !collByUnit.has(c.unit_id)) collByUnit.set(c.unit_id, c.status);
if (c.owner_id && !collByOwner.has(c.owner_id)) collByOwner.set(c.owner_id, c.status);
}
// Group ledger entries per unit (fall back to owner when the entry has no unit)
const byUnit = new Map<string, OwnerLedgerEntry[]>();
for (const e of entries) {
const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null;
if (!key) continue;
const list = byUnit.get(key) ?? [];
list.push(e);
byUnit.set(key, list);
}
const rows: UnitAging[] = [];
for (const [key, list] of byUnit) {
list.sort((a, b) => a.date.localeCompare(b.date));
// FIFO: total credits pay down the oldest charges first
let creditPool = list.reduce((s, e) => s + e.credit, 0);
const buckets = emptyBuckets();
const byType = new Map<string, { buckets: Buckets; total: number }>();
let total = 0;
for (const e of list) {
if (e.debit <= 0) continue;
let open = e.debit;
if (creditPool > 0) {
const applied = Math.min(creditPool, open);
creditPool -= applied;
open -= applied;
}
if (open <= 0.004) continue;
const bi = bucketIndex(asOf, e.date);
buckets[bi] += open;
total += open;
const label = chargeTypeLabel(e.transaction_type);
const t = byType.get(label) ?? { buckets: emptyBuckets(), total: 0 };
t.buckets[bi] += open;
t.total += open;
byType.set(label, t);
}
if (total <= 0.004) continue;
const unitId = key.startsWith("u:") ? key.slice(2) : null;
const ownerId = key.startsWith("o:") ? key.slice(2) : null;
const unit = unitId ? unitById.get(unitId) : undefined;
const owner = (unitId ? ownerByUnit.get(unitId) : null) ?? (ownerId ? ownerById.get(ownerId) : null) ?? null;
const collStatus = prettyStatus((unitId && collByUnit.get(unitId)) || (owner && collByOwner.get(owner.id)) || null);
rows.push({
key,
label: unitLabel(unit, owner?.last_name ?? null),
collStatus,
buckets,
total,
byType,
});
}
rows.sort((a, b) => a.label.localeCompare(b.label));
// Charge-type summary across all properties
const summary = new Map<string, { count: number; balance: number }>();
for (const r of rows) {
for (const [label, t] of r.byType) {
const s = summary.get(label) ?? { count: 0, balance: 0 };
s.count += 1;
s.balance += t.total;
summary.set(label, s);
}
}
const summaryRows = [...summary.entries()].sort((a, b) => b[1].balance - a[1].balance);
const totals = rows.reduce<Buckets>((t, r) => [t[0] + r.buckets[0], t[1] + r.buckets[1], t[2] + r.buckets[2], t[3] + r.buckets[3]], emptyBuckets());
const counts = rows.reduce<[number, number, number, number]>(
(t, r) => [t[0] + (r.buckets[0] > 0.004 ? 1 : 0), t[1] + (r.buckets[1] > 0.004 ? 1 : 0), t[2] + (r.buckets[2] > 0.004 ? 1 : 0), t[3] + (r.buckets[3] > 0.004 ? 1 : 0)],
[0, 0, 0, 0],
);
const grandTotal = totals.reduce((s, n) => s + n, 0);
const distribution = totals.map((n) => (grandTotal > 0 ? (n / grandTotal) * 100 : 0));
return { rows, summaryRows, totals, counts, grandTotal, distribution };
}, [data, asOf]);
const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" });
const exportPDF = async () => {
if (!report) return;
const doc = new jsPDF({ unit: "pt", format: "letter" });
const ML = 40;
const pageW = doc.internal.pageSize.getWidth();
const logo = await loadBrandedLogo(logoUrl);
let y = drawBrandedHeader(doc, {
logo, title: "AR Aging", subtitle: `As of ${asOfLabel}`,
metaLines: [{ label: "Properties:", value: companyName || "" }],
});
// Summary (left) — charge types
autoTable(doc, {
startY: y,
head: [["Charge", "Balance"]],
body: [
...report.summaryRows.map(([label, s]) => [`${label} (${s.count})`, money(s.balance)]),
[{ content: "Total", styles: { fontStyle: "bold" } } as any, { content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } } as any],
],
styles: { fontSize: 8, cellPadding: 4 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 1: { halign: "right" } },
margin: { left: ML, right: pageW / 2 + 20 },
tableWidth: pageW / 2 - ML - 30,
});
const summaryEndY = (doc as any).lastAutoTable.finalY;
// Distribution (right) — stacked bucket bar with % legend
const barX = pageW / 2 + 20;
const barW = pageW - ML - barX;
let dy = y + 4;
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(100);
doc.text("DISTRIBUTION", barX, dy);
dy += 8;
if (report.grandTotal > 0) {
let x = barX;
report.distribution.forEach((pct, i) => {
const w = (pct / 100) * barW;
if (w <= 0) return;
const c = BUCKET_COLORS[i];
doc.setFillColor(c[0], c[1], c[2]);
doc.rect(x, dy, w, 14, "F");
x += w;
});
dy += 24;
doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); doc.setTextColor(60);
report.distribution.forEach((pct, i) => {
if (pct <= 0) return;
const c = BUCKET_COLORS[i];
doc.setFillColor(c[0], c[1], c[2]);
doc.rect(barX, dy - 6, 7, 7, "F");
doc.text(`${BUCKETS[i]}: ${pct.toFixed(2)} %`, barX + 11, dy);
dy += 12;
});
}
y = Math.max(summaryEndY, dy) + 16;
// Property detail
const body: any[] = [];
for (const r of report.rows) {
body.push([
{ content: r.label + (r.collStatus ? `\nColl Status: ${r.collStatus}` : ""), styles: { fontStyle: "bold" } },
{ content: dash(r.buckets[0]), styles: { fontStyle: "bold", halign: "right" } },
{ content: dash(r.buckets[1]), styles: { fontStyle: "bold", halign: "right" } },
{ content: dash(r.buckets[2]), styles: { fontStyle: "bold", halign: "right" } },
{ content: dash(r.buckets[3]), styles: { fontStyle: "bold", halign: "right" } },
{ content: money(r.total), styles: { fontStyle: "bold", halign: "right" } },
]);
for (const [label, t] of r.byType) {
body.push([
{ content: ` ${label}`, styles: {} },
{ content: dash(t.buckets[0]), styles: { halign: "right" } },
{ content: dash(t.buckets[1]), styles: { halign: "right" } },
{ content: dash(t.buckets[2]), styles: { halign: "right" } },
{ content: dash(t.buckets[3]), styles: { halign: "right" } },
{ content: money(t.total), styles: { halign: "right" } },
]);
}
}
body.push([
{ content: "Total:", styles: { fontStyle: "bold" } },
...report.totals.map((n) => ({ content: money(n), styles: { fontStyle: "bold", halign: "right" } })),
{ content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } },
]);
body.push([
{ content: "Property Count:", styles: { fontStyle: "bold" } },
...report.counts.map((n) => ({ content: String(n), styles: { fontStyle: "bold", halign: "right" } })),
{ content: "", styles: {} },
]);
autoTable(doc, {
startY: y,
head: [["Property", "0-30", "Over 30", "Over 60", "Over 90", "Balance"]],
body,
styles: { fontSize: 8, cellPadding: 3 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 0: { cellWidth: 220 }, 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" }, 5: { halign: "right" } },
margin: { left: ML, right: ML },
});
drawBrandedFooter(doc);
doc.save(`ar-aging-${asOf}.pdf`);
};
const exportCSV = () => {
if (!report) return;
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
const lines = [["Property", "Coll Status", "Charge Type", "0-30", "Over 30", "Over 60", "Over 90", "Balance"].join(",")];
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
for (const r of report.rows) {
lines.push([q(r.label), q(r.collStatus ?? ""), "", f(r.buckets[0]), f(r.buckets[1]), f(r.buckets[2]), f(r.buckets[3]), f(r.total)].join(","));
for (const [label, t] of r.byType) {
lines.push([q(r.label), "", q(label), f(t.buckets[0]), f(t.buckets[1]), f(t.buckets[2]), f(t.buckets[3]), f(t.total)].join(","));
}
}
lines.push(["TOTAL", "", "", ...report.totals.map(f), f(report.grandTotal)].join(","));
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `ar-aging-${asOf}.csv`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-wrap items-end gap-4 py-4">
<div>
<Label className="text-xs text-muted-foreground">As of</Label>
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
</div>
{report && report.rows.length > 0 && (
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</div>
)}
</CardContent>
</Card>
{isLoading ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading</CardContent></Card>
) : !report || report.rows.length === 0 ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">
No open balances as of {asOfLabel}. 🎉
</CardContent></Card>
) : (
<ReportSheet title="AR Aging" subtitle={`As of ${asOfLabel}`} companyName={companyName} logoUrl={logoUrl}>
{/* Summary + Distribution */}
<div className="grid gap-6 sm:grid-cols-2 mb-6">
<div>
<p className="text-[11px] uppercase tracking-wide font-semibold text-muted-foreground mb-2 text-center">Summary</p>
<table className="w-full text-sm">
<thead>
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-1.5 text-left font-semibold">Charge</th>
<th className="px-3 py-1.5 text-right font-semibold">Balance</th>
</tr>
</thead>
<tbody>
{report.summaryRows.map(([label, s]) => (
<tr key={label} className="border-b">
<td className="px-3 py-1.5">{label} ({s.count})</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(s.balance)}</td>
</tr>
))}
<tr className="font-bold border-b-2">
<td className="px-3 py-1.5 text-right">Total</td>
<td className="px-3 py-1.5 text-right tabular-nums">{money(report.grandTotal)}</td>
</tr>
</tbody>
</table>
</div>
<div>
<p className="text-[11px] uppercase tracking-wide font-semibold text-muted-foreground mb-2 text-center">Distribution</p>
<div className="flex h-5 w-full overflow-hidden rounded">
{report.distribution.map((pct, i) => pct > 0 && (
<div
key={i}
style={{ width: `${pct}%`, backgroundColor: `rgb(${BUCKET_COLORS[i].join(",")})` }}
title={`${BUCKETS[i]}: ${pct.toFixed(2)}%`}
/>
))}
</div>
<div className="mt-3 space-y-1">
{report.distribution.map((pct, i) => pct > 0 && (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-block h-3 w-3 rounded-sm" style={{ backgroundColor: `rgb(${BUCKET_COLORS[i].join(",")})` }} />
{BUCKETS[i]}: {pct.toFixed(2)} %
</div>
))}
</div>
</div>
</div>
{/* Property detail */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">Property</th>
<th className="px-3 py-2 text-right font-semibold">0-30</th>
<th className="px-3 py-2 text-right font-semibold">Over 30</th>
<th className="px-3 py-2 text-right font-semibold">Over 60</th>
<th className="px-3 py-2 text-right font-semibold">Over 90</th>
<th className="px-3 py-2 text-right font-semibold">Balance</th>
</tr>
</thead>
<tbody>
{report.rows.map((r) => (
<>
<tr key={r.key} className="border-b bg-muted/30">
<td className="px-3 py-1.5 font-semibold">
{r.label}
{r.collStatus && <div className="text-[11px] font-medium text-amber-700">Coll Status: {r.collStatus}</div>}
</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[0])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[1])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[2])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[3])}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{money(r.total)}</td>
</tr>
{[...r.byType.entries()].map(([label, t]) => (
<tr key={`${r.key}-${label}`} className="border-b">
<td className="px-3 py-1 pl-8 text-muted-foreground">{label}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[0])}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[1])}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[2])}</td>
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[3])}</td>
<td className="px-3 py-1 text-right tabular-nums">{money(t.total)}</td>
</tr>
))}
</>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-b font-bold">
<td className="px-3 py-2">Total:</td>
{report.totals.map((n, i) => <td key={i} className="px-3 py-2 text-right tabular-nums">{money(n)}</td>)}
<td className="px-3 py-2 text-right tabular-nums">{money(report.grandTotal)}</td>
</tr>
<tr className="border-b-2 font-semibold text-muted-foreground">
<td className="px-3 py-2">Property Count:</td>
{report.counts.map((n, i) => <td key={i} className="px-3 py-2 text-right tabular-nums">{n}</td>)}
<td />
</tr>
</tfoot>
</table>
</div>
</ReportSheet>
)}
</div>
);
}