mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
e510a76dfc
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>
433 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|