From 613694ac49b42de907da056ff2819d2dae437162 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:48:57 +0000 Subject: [PATCH 1/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index 4faef35..1e60b82 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -224,6 +224,7 @@ export function CaseCollectionsTab({ setActiveId(null)} onChange={load} From cc996601d004c7a5883f49a8da2407f069d2d82c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:49:05 +0000 Subject: [PATCH 2/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index 1e60b82..07d5a4f 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -414,12 +414,14 @@ export function CaseCollectionsTab({ export function CollectionDetail({ collection, annualRate, + client, currentBalance: _currentBalance, onBack, onChange, }: { collection: any; annualRate: number | null; + client?: any; currentBalance: number; onBack: () => void; onChange: () => void; From 18f829e54067b4721845658ce69374ac63862405 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:50:10 +0000 Subject: [PATCH 3/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 224 ++++++++++++++++------- 1 file changed, 154 insertions(+), 70 deletions(-) diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index 07d5a4f..400b0c5 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -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> = {}; + 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 = [ + "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); }, });