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;
|
jeId: string;
|
||||||
date: string;
|
date: string;
|
||||||
checkNo: string;
|
checkNo: string;
|
||||||
|
bank: string;
|
||||||
description: string;
|
description: string;
|
||||||
invoiceDate: string | null;
|
invoiceDate: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
lines: GLLineRow[];
|
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 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); }
|
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);
|
byJe.set(je.id, g);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = new Map<string, BankGroup>();
|
const groups = new Map<string, VendorGroup>();
|
||||||
let grandTotal = 0;
|
let grandTotal = 0;
|
||||||
|
|
||||||
for (const { je, lines } of byJe.values()) {
|
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
|
if (nonBankDebits.length === 0) continue; // pure transfer between banks
|
||||||
|
|
||||||
const amount = bankCredits.reduce((s, l) => s + Number(l.credit || 0), 0);
|
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 mainBank = bankCredits.reduce((a, b) => (Number(b.credit) > Number(a.credit) ? b : a));
|
||||||
const bankLabel = acctLabel(acctById.get(mainBank.account_id));
|
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 txn = je.external_source === "acmacc_txn" && je.external_id ? txnById.get(String(je.external_id)) : null;
|
||||||
const bill = txn?.bills ?? 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();
|
let checkNo = (txn?.reference || je.reference || "").toString().trim();
|
||||||
const descSource = `${txn?.description ?? ""} ${je.description ?? ""}`.toLowerCase();
|
const descSource = `${txn?.description ?? ""} ${je.description ?? ""}`.toLowerCase();
|
||||||
if (!checkNo && /\bach\b|autopay|auto-pay|eft/.test(descSource)) checkNo = "ACH";
|
if (!checkNo && /\bach\b|autopay|auto-pay|eft/.test(descSource)) checkNo = "ACH";
|
||||||
|
|
||||||
const description = vendorName
|
const description = bill?.number
|
||||||
? `${vendorName}${bill?.number ? ` Inv # ${bill.number}` : ""}`
|
? `Inv # ${bill.number}`
|
||||||
: (txn?.description || je.description || "—");
|
: (txn?.description || je.description || "—");
|
||||||
|
|
||||||
const entry: Disbursement = {
|
const entry: Disbursement = {
|
||||||
jeId: je.id,
|
jeId: je.id,
|
||||||
date: String(je.date).slice(0, 10),
|
date: String(je.date).slice(0, 10),
|
||||||
checkNo: checkNo || "—",
|
checkNo: checkNo || "—",
|
||||||
|
bank: bankLabel,
|
||||||
description,
|
description,
|
||||||
invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null,
|
invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null,
|
||||||
amount,
|
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.entries.push(entry);
|
||||||
g.subtotal += amount;
|
g.subtotal += amount;
|
||||||
groups.set(bankLabel, g);
|
groups.set(vendor, g);
|
||||||
grandTotal += amount;
|
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));
|
for (const g of out) g.entries.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
return { groups: out, grandTotal };
|
return { groups: out, grandTotal };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -174,20 +186,22 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
|||||||
const body: any[] = [];
|
const body: any[] = [];
|
||||||
for (const g of report.groups) {
|
for (const g of report.groups) {
|
||||||
body.push([{
|
body.push([{
|
||||||
content: g.bankLabel,
|
content: g.vendor,
|
||||||
colSpan: 5,
|
colSpan: 6,
|
||||||
styles: { fontStyle: "bold", textColor: TEAL, fontSize: 10, fillColor: [255, 255, 255] },
|
styles: { fontStyle: "bold", textColor: TEAL, fontSize: 10, fillColor: [255, 255, 255] },
|
||||||
}]);
|
}]);
|
||||||
for (const e of g.entries) {
|
for (const e of g.entries) {
|
||||||
body.push([
|
body.push([
|
||||||
{ content: fmtDate(e.date), styles: { fontStyle: "bold" } },
|
{ content: fmtDate(e.date), styles: { fontStyle: "bold" } },
|
||||||
{ content: e.checkNo, styles: { fontStyle: "bold" } },
|
{ content: e.checkNo, styles: { fontStyle: "bold" } },
|
||||||
|
{ content: e.bank, styles: { fontStyle: "bold" } },
|
||||||
{ content: e.description, styles: { fontStyle: "bold" } },
|
{ content: e.description, styles: { fontStyle: "bold" } },
|
||||||
{ content: e.invoiceDate ? fmtDate(e.invoiceDate) : "", styles: { fontStyle: "bold", halign: "right" } },
|
{ content: e.invoiceDate ? fmtDate(e.invoiceDate) : "", styles: { fontStyle: "bold", halign: "right" } },
|
||||||
{ content: num(e.amount), styles: { fontStyle: "bold", halign: "right" } },
|
{ content: num(e.amount), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
]);
|
]);
|
||||||
for (const l of e.lines) {
|
for (const l of e.lines) {
|
||||||
body.push([
|
body.push([
|
||||||
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
{ content: ` ${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`, styles: { textColor: [90, 90, 90] } },
|
{ 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([
|
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" } },
|
{ content: num(g.subtotal), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
body.push([
|
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] } },
|
{ content: num(report.grandTotal), styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY,
|
startY,
|
||||||
head: [["Paid Date", "CheckNo", "Description", "Invoice Date", "Amount"]],
|
head: [["Paid Date", "CheckNo", "Bank", "Description", "Invoice Date", "Amount"]],
|
||||||
body,
|
body,
|
||||||
styles: { fontSize: 8, cellPadding: 3 },
|
styles: { fontSize: 8, cellPadding: 3 },
|
||||||
headStyles: { fillColor: TEAL, textColor: 255 },
|
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 },
|
margin: { left: ML, right: ML },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,17 +238,17 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
|||||||
if (!report) return;
|
if (!report) return;
|
||||||
const q = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
|
const q = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
|
||||||
const f = (n: number) => n.toFixed(2);
|
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 g of report.groups) {
|
||||||
for (const e of g.entries) {
|
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) {
|
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 blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
@@ -273,7 +287,8 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
|
<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-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-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">Invoice Date</th>
|
||||||
<th className="px-3 py-2 text-right font-semibold w-28">Amount</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>
|
<tbody>
|
||||||
{report!.groups.map((g) => (
|
{report!.groups.map((g) => (
|
||||||
<>
|
<>
|
||||||
<tr key={g.bankLabel}>
|
<tr key={g.vendor}>
|
||||||
<td colSpan={5} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
|
<td colSpan={6} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
|
||||||
{g.bankLabel}
|
{g.vendor}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{g.entries.map((e) => (
|
{g.entries.map((e) => (
|
||||||
@@ -292,12 +307,14 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
|||||||
<tr key={e.jeId} className="border-t">
|
<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 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.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 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 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>
|
<td className="px-3 py-1.5 text-right tabular-nums font-medium">{num(e.amount)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{e.lines.map((l, li) => (
|
{e.lines.map((l, li) => (
|
||||||
<tr key={`${e.jeId}-${li}`}>
|
<tr key={`${e.jeId}-${li}`}>
|
||||||
|
<td />
|
||||||
<td />
|
<td />
|
||||||
<td />
|
<td />
|
||||||
<td className="px-3 py-0.5 pl-8 text-muted-foreground text-[13px]">
|
<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">
|
<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>
|
<td className="px-3 py-1.5 text-right tabular-nums">{num(g.subtotal)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</>
|
</>
|
||||||
@@ -318,7 +335,7 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl, from:
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t-2 border-b-2 font-bold">
|
<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>
|
<td className="px-3 py-2 text-right tabular-nums">{num(report!.grandTotal)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|||||||
Reference in New Issue
Block a user