Files
acmcc/src/pages/accounting/components/CashDisbursementReport.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

335 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accounting } from "@/lib/accountingClient";
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 { num } from "../lib/ownerLedger";
import { fmtDate } from "../lib/format";
const TEAL: [number, number, number] = [0, 137, 123];
type GLAccount = { id: string; code: string | null; name: string; is_bank: boolean };
type GLLineRow = {
code: string | null;
name: string;
description: string | null;
amount: number;
};
type Disbursement = {
jeId: string;
date: string;
checkNo: string;
description: string;
invoiceDate: string | null;
amount: number;
lines: GLLineRow[];
};
type BankGroup = { bankLabel: string; entries: Disbursement[]; subtotal: number };
function monthStart() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; }
function today() { return new Date().toISOString().slice(0, 10); }
const acctLabel = (a: GLAccount | undefined) => (a ? `${a.code ? a.code + " - " : ""}${a.name}` : "Unknown account");
/**
* Buildium-style Cash Disbursement: every payment out of a bank account in the
* period, grouped by bank account, with the GL expense breakdown under each.
* Built from the GL (bank-account credit lines), so it works for both
* platform-managed and Buildium-imported companies. Platform entries are
* enriched with check #, vendor and bill info from the banking register.
*/
export function CashDisbursementReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
const [from, setFrom] = useState(monthStart());
const [to, setTo] = useState(today());
const { data, isLoading } = useQuery({
queryKey: ["cash-disbursement", companyId, from, to],
enabled: !!companyId,
queryFn: async () => {
const [accountsRes, linesRes] = await Promise.all([
accounting.from("accounts").select("id,code,name,is_bank").eq("company_id", companyId),
accounting
.from("journal_entry_lines")
.select("id,debit,credit,description,account_id,journal_entries!inner(id,company_id,date,description,reference,external_source,external_id)")
.eq("journal_entries.company_id", companyId)
.gte("journal_entries.date", from)
.lte("journal_entries.date", to)
.limit(50000),
]);
const accounts = (accountsRes.data ?? []) as GLAccount[];
const lines = (linesRes.data ?? []) as any[];
// Enrich register-posted entries with check # / vendor / bill from banking
const txnIds = [...new Set(lines
.filter((l) => l.journal_entries?.external_source === "acmacc_txn" && l.journal_entries?.external_id)
.map((l) => String(l.journal_entries.external_id)))];
let txns: any[] = [];
if (txnIds.length > 0) {
const { data: t } = await accounting
.from("transactions")
.select("id,reference,description,vendor_id,bill_id,vendors(name),bills(number,issue_date,vendors(name))")
.in("id", txnIds);
txns = t ?? [];
}
return { accounts, lines, txns };
},
});
const report = useMemo(() => {
if (!data) return null;
const acctById = new Map<string, GLAccount>();
for (const a of data.accounts) acctById.set(a.id, a);
const txnById = new Map<string, any>();
for (const t of data.txns) txnById.set(String(t.id), t);
// Group lines per journal entry
const byJe = new Map<string, { je: any; lines: any[] }>();
for (const l of data.lines) {
const je = l.journal_entries;
if (!je?.id) continue;
const g = byJe.get(je.id) ?? { je, lines: [] };
g.lines.push(l);
byJe.set(je.id, g);
}
const groups = new Map<string, BankGroup>();
let grandTotal = 0;
for (const { je, lines } of byJe.values()) {
if (je.external_source === "acmacc_xfer") continue; // bank-to-bank transfers aren't disbursements
const bankCredits = lines.filter((l) => Number(l.credit || 0) > 0 && acctById.get(l.account_id)?.is_bank);
if (bankCredits.length === 0) continue;
const nonBankDebits = lines.filter((l) => Number(l.debit || 0) > 0 && !acctById.get(l.account_id)?.is_bank);
if (nonBankDebits.length === 0) continue; // pure transfer between banks
const amount = bankCredits.reduce((s, l) => s + Number(l.credit || 0), 0);
// Attribute the disbursement to the (largest) credited bank account
const mainBank = bankCredits.reduce((a, b) => (Number(b.credit) > Number(a.credit) ? b : a));
const bankLabel = acctLabel(acctById.get(mainBank.account_id));
const txn = je.external_source === "acmacc_txn" && je.external_id ? txnById.get(String(je.external_id)) : null;
const bill = txn?.bills ?? null;
const vendorName = bill?.vendors?.name || txn?.vendors?.name || null;
let checkNo = (txn?.reference || je.reference || "").toString().trim();
const descSource = `${txn?.description ?? ""} ${je.description ?? ""}`.toLowerCase();
if (!checkNo && /\bach\b|autopay|auto-pay|eft/.test(descSource)) checkNo = "ACH";
const description = vendorName
? `${vendorName}${bill?.number ? ` Inv # ${bill.number}` : ""}`
: (txn?.description || je.description || "—");
const entry: Disbursement = {
jeId: je.id,
date: String(je.date).slice(0, 10),
checkNo: checkNo || "—",
description,
invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null,
amount,
lines: nonBankDebits.map((l) => ({
code: acctById.get(l.account_id)?.code ?? null,
name: acctById.get(l.account_id)?.name ?? "Unknown account",
description: l.description ?? null,
amount: Number(l.debit || 0),
})),
};
const g = groups.get(bankLabel) ?? { bankLabel, entries: [], subtotal: 0 };
g.entries.push(entry);
g.subtotal += amount;
groups.set(bankLabel, g);
grandTotal += amount;
}
const out = [...groups.values()].sort((a, b) => a.bankLabel.localeCompare(b.bankLabel));
for (const g of out) g.entries.sort((a, b) => a.date.localeCompare(b.date));
return { groups: out, grandTotal };
}, [data]);
const rangeLabel = `${fmtDate(from)} ${fmtDate(to)}`;
const exportPDF = async () => {
if (!report) return;
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
const ML = 40;
const logo = await loadBrandedLogo(logoUrl);
const startY = drawBrandedHeader(doc, {
logo, title: "Cash Disbursement", subtitle: rangeLabel,
metaLines: [{ label: "Properties:", value: companyName || "" }],
});
const body: any[] = [];
for (const g of report.groups) {
body.push([{
content: g.bankLabel,
colSpan: 5,
styles: { fontStyle: "bold", textColor: TEAL, fontSize: 10, fillColor: [255, 255, 255] },
}]);
for (const e of g.entries) {
body.push([
{ content: fmtDate(e.date), styles: { fontStyle: "bold" } },
{ content: e.checkNo, styles: { fontStyle: "bold" } },
{ content: e.description, styles: { fontStyle: "bold" } },
{ content: e.invoiceDate ? fmtDate(e.invoiceDate) : "", styles: { fontStyle: "bold", halign: "right" } },
{ content: num(e.amount), styles: { fontStyle: "bold", halign: "right" } },
]);
for (const l of e.lines) {
body.push([
"",
"",
{ content: ` ${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`, styles: { textColor: [90, 90, 90] } },
"",
{ content: num(l.amount), styles: { halign: "right", textColor: [90, 90, 90] } },
]);
}
}
body.push([
{ content: `Total ${g.bankLabel}`, colSpan: 4, styles: { fontStyle: "bold", halign: "right" } },
{ content: num(g.subtotal), styles: { fontStyle: "bold", halign: "right" } },
]);
}
body.push([
{ content: "Total Disbursements", colSpan: 4, styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
{ content: num(report.grandTotal), styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
]);
autoTable(doc, {
startY,
head: [["Paid Date", "CheckNo", "Description", "Invoice Date", "Amount"]],
body,
styles: { fontSize: 8, cellPadding: 3 },
headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 0: { cellWidth: 70 }, 1: { cellWidth: 80 }, 3: { cellWidth: 80, halign: "right" }, 4: { cellWidth: 80, halign: "right" } },
margin: { left: ML, right: ML },
});
drawBrandedFooter(doc);
doc.save(`cash-disbursement-${from}-to-${to}.pdf`);
};
const exportCSV = () => {
if (!report) return;
const q = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
const f = (n: number) => n.toFixed(2);
const lines = [["Bank Account", "Paid Date", "CheckNo", "Description", "GL Account", "Invoice Date", "Amount"].join(",")];
for (const g of report.groups) {
for (const e of g.entries) {
lines.push([q(g.bankLabel), e.date, q(e.checkNo), q(e.description), "", e.invoiceDate ?? "", f(e.amount)].join(","));
for (const l of e.lines) {
lines.push([q(g.bankLabel), "", "", "", q(`${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`), "", f(l.amount)].join(","));
}
}
lines.push([q(g.bankLabel), "", "", q(`Total ${g.bankLabel}`), "", "", f(g.subtotal)].join(","));
}
lines.push(["", "", "", "Total Disbursements", "", "", 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 = `cash-disbursement-${from}-to-${to}.csv`;
a.click();
URL.revokeObjectURL(a.href);
};
const hasData = !!report && report.groups.length > 0;
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">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value || from)} className="w-44 mt-1" />
</div>
<div>
<Label className="text-xs text-muted-foreground">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value || to)} className="w-44 mt-1" />
</div>
{hasData && (
<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>
) : !hasData ? (
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No disbursements in this period.</CardContent></Card>
) : (
<ReportSheet title="Cash Disbursement" subtitle={rangeLabel} companyName={companyName} logoUrl={logoUrl}>
<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 w-24">Paid Date</th>
<th className="px-3 py-2 text-left font-semibold w-28">CheckNo</th>
<th className="px-3 py-2 text-left font-semibold">Description</th>
<th className="px-3 py-2 text-right font-semibold w-28">Invoice Date</th>
<th className="px-3 py-2 text-right font-semibold w-28">Amount</th>
</tr>
</thead>
<tbody>
{report!.groups.map((g) => (
<>
<tr key={g.bankLabel}>
<td colSpan={5} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
{g.bankLabel}
</td>
</tr>
{g.entries.map((e) => (
<>
<tr key={e.jeId} className="border-t">
<td className="px-3 py-1.5 font-medium whitespace-nowrap">{fmtDate(e.date)}</td>
<td className="px-3 py-1.5 font-medium">{e.checkNo}</td>
<td className="px-3 py-1.5 font-medium">{e.description}</td>
<td className="px-3 py-1.5 text-right whitespace-nowrap">{e.invoiceDate ? fmtDate(e.invoiceDate) : ""}</td>
<td className="px-3 py-1.5 text-right tabular-nums font-medium">{num(e.amount)}</td>
</tr>
{e.lines.map((l, li) => (
<tr key={`${e.jeId}-${li}`}>
<td />
<td />
<td className="px-3 py-0.5 pl-8 text-muted-foreground text-[13px]">
{l.code ? `${l.code} - ` : ""}{l.name}{l.description ? ` - ${l.description}` : ""}
</td>
<td />
<td className="px-3 py-0.5 text-right tabular-nums text-muted-foreground text-[13px]">{num(l.amount)}</td>
</tr>
))}
</>
))}
<tr className="border-t font-semibold">
<td colSpan={4} className="px-3 py-1.5 text-right">Total {g.bankLabel}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{num(g.subtotal)}</td>
</tr>
</>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-b-2 font-bold">
<td colSpan={4} className="px-3 py-2 text-right">Total Disbursements</td>
<td className="px-3 py-2 text-right tabular-nums">{num(report!.grandTotal)}</td>
</tr>
</tfoot>
</table>
</div>
</ReportSheet>
)}
</div>
);
}