Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
cc996601d0
commit
18f829e540
@@ -633,120 +633,204 @@ export function CollectionDetail({
|
||||
a.click();
|
||||
};
|
||||
|
||||
const exportPdf = () => {
|
||||
const name = promptFilename(`Ledger_${ho?.last_name || "homeowner"}`, "pdf");
|
||||
const exportPdf = async () => {
|
||||
const name = promptFilename(`Statement_${ho?.last_name || "homeowner"}`, "pdf");
|
||||
if (!name) return;
|
||||
|
||||
// Fetch firm settings for footer/branding
|
||||
const { data: firm } = await supabase
|
||||
.from("firm_settings")
|
||||
.select("company_name")
|
||||
.maybeSingle();
|
||||
const firmName = (firm as any)?.company_name || "";
|
||||
|
||||
const doc = new jsPDF({ orientation: "landscape", unit: "pt", format: "letter" });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 40;
|
||||
|
||||
// Header
|
||||
// ── Top header ──────────────────────────────────────────────
|
||||
// Right side: ACCOUNT STATEMENT title + meta
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(16);
|
||||
doc.text("Homeowner Account Ledger", 40, 40);
|
||||
|
||||
doc.setFontSize(22);
|
||||
doc.text("ACCOUNT STATEMENT", pageWidth - margin, 50, { align: "right" });
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(10);
|
||||
const headerLines = [
|
||||
`${ho?.first_name ?? ""} ${ho?.last_name ?? ""}`.trim(),
|
||||
ho?.unit_number ? `Unit ${ho.unit_number}` : null,
|
||||
ho?.address || null,
|
||||
ho?.email || null,
|
||||
ho?.phone || null,
|
||||
].filter(Boolean) as string[];
|
||||
headerLines.forEach((line, i) => doc.text(line, 40, 60 + i * 14));
|
||||
const stmtDate = formatDate(new Date().toISOString().slice(0, 10));
|
||||
doc.text(`Statement Date: ${stmtDate}`, pageWidth - margin, 68, { align: "right" });
|
||||
doc.text(`Association: ${client?.name || "N/A"}`, pageWidth - margin, 82, { align: "right" });
|
||||
|
||||
const rightMeta = [
|
||||
`Ledger: ${collection.name || "—"}`,
|
||||
`Status: ${collection.status}`,
|
||||
`Opened: ${formatDate(collection.opened_at)}`,
|
||||
`Interest rate: ${effectiveRate || 0}% / yr`,
|
||||
`Generated: ${formatDate(new Date().toISOString().slice(0, 10))}`,
|
||||
];
|
||||
rightMeta.forEach((line, i) => doc.text(line, pageWidth - 40, 60 + i * 14, { align: "right" }));
|
||||
|
||||
const startY = 60 + Math.max(headerLines.length, rightMeta.length) * 14 + 10;
|
||||
|
||||
// Summary buckets
|
||||
// Left side: Account holder / property address blocks
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(11);
|
||||
doc.text(`Total Due: ${formatCurrency(Math.max(0, computed.total))}`, 40, startY);
|
||||
doc.setFontSize(10);
|
||||
doc.text("ACCOUNT HOLDER:", margin, 50);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(9);
|
||||
const bucketLine = BUCKETS.map((b) => `${BUCKET_LABEL[b]}: ${formatCurrency(Math.max(0, computed[b]))}`).join(" ");
|
||||
doc.text(bucketLine, 40, startY + 14, { maxWidth: pageWidth - 80 });
|
||||
const holderName = `${ho?.first_name ?? ""} ${ho?.last_name ?? ""}`.trim() || "N/A";
|
||||
doc.text(holderName, margin, 64);
|
||||
|
||||
// Table
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("PROPERTY ADDRESS:", margin, 88);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const addrLines: string[] = [];
|
||||
if (ho?.address) addrLines.push(ho.address);
|
||||
if (ho?.unit_number) addrLines.push(`Unit ${ho.unit_number}`);
|
||||
if (addrLines.length === 0) addrLines.push("N/A");
|
||||
addrLines.forEach((line, i) => doc.text(line, margin, 102 + i * 12));
|
||||
|
||||
// ── Account number / Due / Total Due card (top-right, below meta) ──
|
||||
const totalDue = Math.max(0, computed.total);
|
||||
autoTable(doc, {
|
||||
startY: 100,
|
||||
margin: { left: pageWidth / 2 + 40, right: margin },
|
||||
head: [["Account Number", "Due Date", "Total Due"]],
|
||||
body: [[
|
||||
ho?.unit_number || (ho?.id ? String(ho.id).slice(0, 8).toUpperCase() : "N/A"),
|
||||
"Upon Receipt",
|
||||
formatCurrency(totalDue),
|
||||
]],
|
||||
styles: { fontSize: 11, cellPadding: 6, halign: "center" },
|
||||
headStyles: { fillColor: [20, 20, 20], textColor: 255, fontStyle: "bold", halign: "center" },
|
||||
bodyStyles: { fontStyle: "bold", fillColor: [255, 255, 255], textColor: 20 },
|
||||
theme: "grid",
|
||||
});
|
||||
|
||||
// ── Amounts Due Breakdown (left), with sub-account subtotals ──
|
||||
// Group entries by bucket -> account label -> subtotal
|
||||
const subAccountTotals: Record<string, Record<string, number>> = {};
|
||||
BUCKETS.forEach((b) => (subAccountTotals[b] = {}));
|
||||
entries.forEach((e: any) => {
|
||||
const acctLabel = (e.account && String(e.account).trim()) || "—";
|
||||
BUCKETS.forEach((b) => {
|
||||
const v = num(e[b]);
|
||||
if (v) {
|
||||
subAccountTotals[b][acctLabel] = (subAccountTotals[b][acctLabel] || 0) + v;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const breakdownBody: any[] = [];
|
||||
// Order matches screenshot: Assessments, Late, Admin, Legal, Violations, Interest, Bank
|
||||
const DISPLAY_ORDER: Array<typeof BUCKETS[number]> = [
|
||||
"assess", "late", "admin", "legal", "viol", "interest", "bank",
|
||||
];
|
||||
DISPLAY_ORDER.forEach((b) => {
|
||||
const bucketTotal = Math.max(0, computed[b] ?? 0);
|
||||
const label =
|
||||
b === "interest"
|
||||
? `Interest @ ${(effectiveRate || 0).toFixed(2)}%`
|
||||
: BUCKET_LABEL[b];
|
||||
breakdownBody.push([
|
||||
{ content: label, styles: { fontStyle: "bold", fillColor: [245, 245, 245] } },
|
||||
{ content: formatCurrency(bucketTotal), styles: { halign: "right", fontStyle: "bold", fillColor: [245, 245, 245] } },
|
||||
]);
|
||||
// Sub-account rows (only if there's more than one OR the single one differs from the bucket label)
|
||||
const subs = subAccountTotals[b];
|
||||
const subKeys = Object.keys(subs);
|
||||
if (subKeys.length > 0) {
|
||||
subKeys
|
||||
.sort((a, z) => subs[z] - subs[a])
|
||||
.forEach((sk) => {
|
||||
breakdownBody.push([
|
||||
{ content: ` ${sk}`, styles: { textColor: 90 } },
|
||||
{ content: formatCurrency(subs[sk]), styles: { halign: "right", textColor: 90 } },
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: 150,
|
||||
margin: { left: margin, right: pageWidth / 2 + 20 },
|
||||
head: [["Amounts Due Breakdown", "Amount"]],
|
||||
body: breakdownBody,
|
||||
styles: { fontSize: 9, cellPadding: 4 },
|
||||
headStyles: { fillColor: [20, 20, 20], textColor: 255, fontStyle: "bold" },
|
||||
columnStyles: {
|
||||
0: { cellWidth: "auto" },
|
||||
1: { halign: "right", cellWidth: 90 },
|
||||
},
|
||||
theme: "grid",
|
||||
});
|
||||
|
||||
const breakdownEndY = (doc as any).lastAutoTable?.finalY ?? 150;
|
||||
|
||||
// ── Transaction ledger table (full width below) ──────────────
|
||||
const txStartY = breakdownEndY + 24;
|
||||
const body: any[] = [];
|
||||
if (opening !== 0) {
|
||||
body.push([
|
||||
formatDate(collection.opened_at),
|
||||
"Opening balance",
|
||||
"opening",
|
||||
"0.00", "0.00", "0.00", "0.00", "0.00", "0.00",
|
||||
"", "", "", "", "", "",
|
||||
opening > 0 ? opening.toFixed(2) : "0.00",
|
||||
"0.00",
|
||||
formatCurrency(opening),
|
||||
]);
|
||||
}
|
||||
withRunning.forEach((e: any) => {
|
||||
const isPayment = num(e.payment) > 0;
|
||||
body.push([
|
||||
formatDate(e.entry_date),
|
||||
e.description || "—",
|
||||
e.account || e.transaction_type || "",
|
||||
num(e.bank) ? num(e.bank).toFixed(2) : "",
|
||||
num(e.interest) ? num(e.interest).toFixed(2) : "",
|
||||
e.entry_date ? formatDate(e.entry_date) : "",
|
||||
e.description || (e.account || "—"),
|
||||
num(e.assess) ? num(e.assess).toFixed(2) : "",
|
||||
num(e.late) ? num(e.late).toFixed(2) : "",
|
||||
num(e.admin) ? num(e.admin).toFixed(2) : "",
|
||||
num(e.legal) ? num(e.legal).toFixed(2) : "",
|
||||
num(e.viol) ? num(e.viol).toFixed(2) : "",
|
||||
num(e.assess) ? num(e.assess).toFixed(2) : "",
|
||||
num(e.payment) ? num(e.payment).toFixed(2) : "",
|
||||
num(e.interest) ? num(e.interest).toFixed(2) : "",
|
||||
num(e.bank) ? num(e.bank).toFixed(2) : "",
|
||||
isPayment
|
||||
? { content: num(e.payment).toFixed(2), styles: { textColor: [0, 130, 0] as any, fontStyle: "bold" } }
|
||||
: "",
|
||||
formatCurrency(e.runningBalance ?? 0),
|
||||
]);
|
||||
});
|
||||
body.push([
|
||||
{ content: "Totals", colSpan: 3, styles: { halign: "right", fontStyle: "bold" } },
|
||||
totals.bank.toFixed(2),
|
||||
totals.interest.toFixed(2),
|
||||
totals.late.toFixed(2),
|
||||
totals.admin.toFixed(2),
|
||||
totals.legal.toFixed(2),
|
||||
totals.viol.toFixed(2),
|
||||
totals.assess.toFixed(2),
|
||||
totals.payment.toFixed(2),
|
||||
formatCurrency(computed.total),
|
||||
{ content: "TOTALS", colSpan: 2, styles: { halign: "left", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.assess.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.late.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.admin.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.legal.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.viol.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.interest.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.bank.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
{ content: totals.payment.toFixed(2), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240], textColor: [0, 130, 0] as any } },
|
||||
{ content: formatCurrency(computed.total), styles: { halign: "right", fontStyle: "bold", fillColor: [240, 240, 240] } },
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: startY + 36,
|
||||
head: [["Date", "Description", "Account", "Bank", "Int", "Late", "Admin", "Legal", "Viol", "Assess", "Payment", "Balance"]],
|
||||
startY: txStartY,
|
||||
margin: { left: margin, right: margin },
|
||||
head: [["Date", "Description", "Assess", "Late", "Admin", "Legal", "Viol", "Int", "Bank", "Pay (AR)", "Balance"]],
|
||||
body,
|
||||
styles: { fontSize: 8, cellPadding: 3 },
|
||||
headStyles: { fillColor: [40, 40, 40], textColor: 255 },
|
||||
styles: { fontSize: 8, cellPadding: 4 },
|
||||
headStyles: { fillColor: [20, 20, 20], textColor: 255, fontStyle: "bold" },
|
||||
theme: "grid",
|
||||
columnStyles: {
|
||||
0: { cellWidth: 60 },
|
||||
0: { cellWidth: 65 },
|
||||
1: { cellWidth: "auto" },
|
||||
2: { cellWidth: 55 },
|
||||
2: { halign: "right", cellWidth: 55 },
|
||||
3: { halign: "right", cellWidth: 50 },
|
||||
4: { halign: "right", cellWidth: 45 },
|
||||
5: { halign: "right", cellWidth: 50 },
|
||||
4: { halign: "right", cellWidth: 55 },
|
||||
5: { halign: "right", cellWidth: 55 },
|
||||
6: { halign: "right", cellWidth: 50 },
|
||||
7: { halign: "right", cellWidth: 45 },
|
||||
8: { halign: "right", cellWidth: 45 },
|
||||
9: { halign: "right", cellWidth: 50 },
|
||||
10: { halign: "right", cellWidth: 55 },
|
||||
11: { halign: "right", cellWidth: 65, fontStyle: "bold" },
|
||||
7: { halign: "right", cellWidth: 50 },
|
||||
8: { halign: "right", cellWidth: 50 },
|
||||
9: { halign: "right", cellWidth: 60 },
|
||||
10: { halign: "right", cellWidth: 70, fontStyle: "bold" },
|
||||
},
|
||||
didDrawPage: (data) => {
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(120);
|
||||
doc.text(
|
||||
doc.setTextColor(140);
|
||||
const footerParts = [
|
||||
firmName ? `Generated by ${firmName}` : null,
|
||||
`Page ${data.pageNumber} of ${pageCount}`,
|
||||
pageWidth - 40,
|
||||
doc.internal.pageSize.getHeight() - 20,
|
||||
{ align: "right" },
|
||||
);
|
||||
`Printed on ${stmtDate}`,
|
||||
].filter(Boolean) as string[];
|
||||
doc.text(footerParts.join(" • "), pageWidth / 2, pageHeight - 20, { align: "center" });
|
||||
doc.setTextColor(0);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user