mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Cash Disbursement report: group payments by vendor
Top-level grouping is now by vendor (name as the section header) with a per-vendor subtotal, instead of by bank account. The bank account each payment was drawn from moves to a column on the row. Vendor is resolved from the reliable payment→vendor / payment→bill→vendor linkage; disbursements with no vendor on record (e.g. Buildium GL-pull entries) collect under a 'No vendor on record' group sorted last rather than being guessed. PDF + CSV updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,13 +28,16 @@ type Disbursement = {
|
||||
jeId: string;
|
||||
date: string;
|
||||
checkNo: string;
|
||||
bank: string;
|
||||
description: string;
|
||||
invoiceDate: string | null;
|
||||
amount: number;
|
||||
lines: GLLineRow[];
|
||||
};
|
||||
|
||||
type BankGroup = { bankLabel: string; entries: Disbursement[]; subtotal: number };
|
||||
type VendorGroup = { vendor: string; entries: Disbursement[]; subtotal: number };
|
||||
|
||||
const NO_VENDOR = "No vendor on record";
|
||||
|
||||
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); }
|
||||
@@ -103,7 +106,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
byJe.set(je.id, g);
|
||||
}
|
||||
|
||||
const groups = new Map<string, BankGroup>();
|
||||
const groups = new Map<string, VendorGroup>();
|
||||
let grandTotal = 0;
|
||||
|
||||
for (const { je, lines } of byJe.values()) {
|
||||
@@ -116,26 +119,30 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
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
|
||||
// Which bank account paid (largest credited bank line)
|
||||
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;
|
||||
// Only attribute a vendor when it's reliably linked (payment→vendor or
|
||||
// payment→bill→vendor). GL-pull (Buildium) disbursements carry no vendor,
|
||||
// so they fall into the "No vendor on record" group rather than a guess.
|
||||
const vendor = bill?.vendors?.name || txn?.vendors?.name || NO_VENDOR;
|
||||
|
||||
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}` : ""}`
|
||||
const description = bill?.number
|
||||
? `Inv # ${bill.number}`
|
||||
: (txn?.description || je.description || "—");
|
||||
|
||||
const entry: Disbursement = {
|
||||
jeId: je.id,
|
||||
date: String(je.date).slice(0, 10),
|
||||
checkNo: checkNo || "—",
|
||||
bank: bankLabel,
|
||||
description,
|
||||
invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null,
|
||||
amount,
|
||||
@@ -147,14 +154,19 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
})),
|
||||
};
|
||||
|
||||
const g = groups.get(bankLabel) ?? { bankLabel, entries: [], subtotal: 0 };
|
||||
const g = groups.get(vendor) ?? { vendor, entries: [], subtotal: 0 };
|
||||
g.entries.push(entry);
|
||||
g.subtotal += amount;
|
||||
groups.set(bankLabel, g);
|
||||
groups.set(vendor, g);
|
||||
grandTotal += amount;
|
||||
}
|
||||
|
||||
const out = [...groups.values()].sort((a, b) => a.bankLabel.localeCompare(b.bankLabel));
|
||||
// Vendors alphabetical; the unattributed bucket always sorts last.
|
||||
const out = [...groups.values()].sort((a, b) => {
|
||||
if (a.vendor === NO_VENDOR) return 1;
|
||||
if (b.vendor === NO_VENDOR) return -1;
|
||||
return a.vendor.localeCompare(b.vendor);
|
||||
});
|
||||
for (const g of out) g.entries.sort((a, b) => a.date.localeCompare(b.date));
|
||||
return { groups: out, grandTotal };
|
||||
}, [data]);
|
||||
@@ -174,20 +186,22 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
const body: any[] = [];
|
||||
for (const g of report.groups) {
|
||||
body.push([{
|
||||
content: g.bankLabel,
|
||||
colSpan: 5,
|
||||
content: g.vendor,
|
||||
colSpan: 6,
|
||||
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.bank, 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] } },
|
||||
@@ -197,22 +211,22 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
}
|
||||
}
|
||||
body.push([
|
||||
{ content: `Total ${g.bankLabel}`, colSpan: 4, styles: { fontStyle: "bold", halign: "right" } },
|
||||
{ content: `Total — ${g.vendor}`, colSpan: 5, 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: "Total Disbursements", colSpan: 5, 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"]],
|
||||
head: [["Paid Date", "CheckNo", "Bank", "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" } },
|
||||
columnStyles: { 0: { cellWidth: 64 }, 1: { cellWidth: 64 }, 2: { cellWidth: 110 }, 4: { cellWidth: 70, halign: "right" }, 5: { cellWidth: 75, halign: "right" } },
|
||||
margin: { left: ML, right: ML },
|
||||
});
|
||||
|
||||
@@ -224,17 +238,17 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
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(",")];
|
||||
const lines = [["Vendor", "Paid Date", "CheckNo", "Bank Account", "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(","));
|
||||
lines.push([q(g.vendor), e.date, q(e.checkNo), q(e.bank), 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.vendor), "", "", "", "", 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([q(g.vendor), "", "", "", q(`Total — ${g.vendor}`), "", "", f(g.subtotal)].join(","));
|
||||
}
|
||||
lines.push(["", "", "", "Total Disbursements", "", "", f(report.grandTotal)].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);
|
||||
@@ -273,7 +287,8 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
<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 w-24">CheckNo</th>
|
||||
<th className="px-3 py-2 text-left font-semibold w-44">Bank</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>
|
||||
@@ -282,9 +297,9 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
<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}
|
||||
<tr key={g.vendor}>
|
||||
<td colSpan={6} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
|
||||
{g.vendor}
|
||||
</td>
|
||||
</tr>
|
||||
{g.entries.map((e) => (
|
||||
@@ -292,12 +307,14 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
<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">{e.bank}</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 />
|
||||
<td className="px-3 py-0.5 pl-8 text-muted-foreground text-[13px]">
|
||||
@@ -310,7 +327,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
</>
|
||||
))}
|
||||
<tr className="border-t font-semibold">
|
||||
<td colSpan={4} className="px-3 py-1.5 text-right">Total {g.bankLabel}</td>
|
||||
<td colSpan={5} className="px-3 py-1.5 text-right">Total — {g.vendor}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">{num(g.subtotal)}</td>
|
||||
</tr>
|
||||
</>
|
||||
@@ -318,7 +335,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
||||
</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 colSpan={5} 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>
|
||||
|
||||
Reference in New Issue
Block a user