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; }; 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(); for (const u of units) unitById.set(u.id, u); const ownerById = new Map(); for (const o of owners) ownerById.set(o.id, o); const ownerByUnit = new Map(); 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(); const collByOwner = new Map(); 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(); 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(); 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(); 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((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 (
setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
{report && report.rows.length > 0 && (
)}
{isLoading ? ( Loading… ) : !report || report.rows.length === 0 ? ( No open balances as of {asOfLabel}. 🎉 ) : ( {/* Summary + Distribution */}

Summary

{report.summaryRows.map(([label, s]) => ( ))}
Charge Balance
{label} ({s.count}) {money(s.balance)}
Total {money(report.grandTotal)}

Distribution

{report.distribution.map((pct, i) => pct > 0 && (
))}
{report.distribution.map((pct, i) => pct > 0 && (
{BUCKETS[i]}: {pct.toFixed(2)} %
))}
{/* Property detail */}
{report.rows.map((r) => ( <> {[...r.byType.entries()].map(([label, t]) => ( ))} ))} {report.totals.map((n, i) => )} {report.counts.map((n, i) => )}
Property 0-30 Over 30 Over 60 Over 90 Balance
{r.label} {r.collStatus &&
Coll Status: {r.collStatus}
}
{dash(r.buckets[0])} {dash(r.buckets[1])} {dash(r.buckets[2])} {dash(r.buckets[3])} {money(r.total)}
{label} {dash(t.buckets[0])} {dash(t.buckets[1])} {dash(t.buckets[2])} {dash(t.buckets[3])} {money(t.total)}
Total:{money(n)}{money(report.grandTotal)}
Property Count:{n}
)}
); }