mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50: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>
335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|