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(); for (const a of data.accounts) acctById.set(a.id, a); const txnById = new Map(); for (const t of data.txns) txnById.set(String(t.id), t); // Group lines per journal entry const byJe = new Map(); 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(); 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 (
setFrom(e.target.value || from)} className="w-44 mt-1" />
setTo(e.target.value || to)} className="w-44 mt-1" />
{hasData && (
)}
{isLoading ? ( Loading… ) : !hasData ? ( No disbursements in this period. ) : (
{report!.groups.map((g) => ( <> {g.entries.map((e) => ( <> {e.lines.map((l, li) => ( ))} ))} ))}
Paid Date CheckNo Description Invoice Date Amount
{g.bankLabel}
{fmtDate(e.date)} {e.checkNo} {e.description} {e.invoiceDate ? fmtDate(e.invoiceDate) : ""} {num(e.amount)}
{l.code ? `${l.code} - ` : ""}{l.name}{l.description ? ` - ${l.description}` : ""} {num(l.amount)}
Total {g.bankLabel} {num(g.subtotal)}
Total Disbursements {num(report!.grandTotal)}
)}
); }