From 217e5117922be844f1fe8d037334274328bd3bcd Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 10 Jun 2026 10:58:59 -0400 Subject: [PATCH] Budget Workbook: export a Proposed Budget PDF Add a PDF button that generates a board-ready proposed budget: income and expense tables (monthly/annual per account with totals), a summary, and a headline banner for the per-unit monthly assessment. Uses the workbook's projected figures (overrides/inflation/unit override) via jsPDF + autotable. Co-Authored-By: Claude Opus 4.8 --- src/pages/BudgetWorkbookPage.tsx | 101 ++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/src/pages/BudgetWorkbookPage.tsx b/src/pages/BudgetWorkbookPage.tsx index 9d68cce..dd21afc 100644 --- a/src/pages/BudgetWorkbookPage.tsx +++ b/src/pages/BudgetWorkbookPage.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Building2, RefreshCw, Save, Upload, Download, Loader2, RotateCcw } from "lucide-react"; +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import { Building2, RefreshCw, Save, Upload, Download, FileText, Loader2, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useAssociation } from "@/contexts/AssociationContext"; import { supabase } from "@/integrations/supabase/client"; @@ -236,6 +238,102 @@ export default function BudgetWorkbookPage() { URL.revokeObjectURL(url); }; + const exportPdf = () => { + const doc = new jsPDF({ orientation: "portrait", unit: "pt", format: "letter" }); + const pageW = doc.internal.pageSize.getWidth(); + const pageH = doc.internal.pageSize.getHeight(); + const margin = 50; + const contentW = pageW - margin * 2; + const assocName = selectedAssociation?.name ?? "Association"; + + // Header bar + doc.setFillColor(30, 41, 59); + doc.rect(0, 0, pageW, 78, "F"); + doc.setTextColor(255, 255, 255); + doc.setFontSize(20); + doc.setFont("helvetica", "bold"); + doc.text("PROPOSED BUDGET", margin, 40); + doc.setFontSize(11); + doc.setFont("helvetica", "normal"); + doc.text(`${assocName} · Fiscal Year ${fy}`, margin, 60); + + let y = 100; + + const sectionTable = (title: string, rows: any[], total: number) => { + autoTable(doc, { + startY: y, + head: [[title, "Monthly", "Annual"]], + body: rows.map((a) => [ + `${a.code ? a.code + " " : ""}${a.name}`, money(projVal(a.id) / 12), money(projVal(a.id)), + ]), + foot: [[`Total ${title}`, money(total / 12), money(total)]], + theme: "grid", + styles: { font: "helvetica", fontSize: 9, cellPadding: 5, textColor: 30, lineColor: [220, 220, 220], lineWidth: 0.5 }, + headStyles: { fillColor: [30, 41, 59], textColor: 255, fontStyle: "bold", halign: "left" }, + footStyles: { fillColor: [241, 245, 249], textColor: 30, fontStyle: "bold" }, + alternateRowStyles: { fillColor: [248, 250, 252] }, + columnStyles: { 0: { cellWidth: "auto" }, 1: { cellWidth: 95, halign: "right" }, 2: { cellWidth: 95, halign: "right" } }, + margin: { left: margin, right: margin }, + }); + y = (doc as any).lastAutoTable.finalY + 22; + }; + + if (sections.inc.length) sectionTable("Income", sections.inc, incomeAnnual); + if (sections.exp.length) sectionTable("Expenses", sections.exp, expenseAnnual); + + // Keep the summary + assessment banner together on one page. + if (y > pageH - 220) { doc.addPage(); y = margin; } + + const surplus = incomeAnnual - expenseAnnual; + doc.setTextColor(100, 116, 139); + doc.setFontSize(10); + doc.setFont("helvetica", "bold"); + doc.text("SUMMARY", margin, y); + y += 10; + autoTable(doc, { + startY: y, + body: [ + ["Total Projected Income (annual)", money(incomeAnnual)], + ["Total Projected Expenses (annual)", money(expenseAnnual)], + ["Projected Surplus / (Deficit)", money(surplus)], + ["Monthly Budget (expenses ÷ 12)", money(monthly)], + [`Number of Units${unitOv.trim() && Number(unitOv) !== unitCount ? " (override)" : ""}`, String(effUnits)], + ], + theme: "plain", + styles: { font: "helvetica", fontSize: 10, cellPadding: 3 }, + columnStyles: { 0: { cellWidth: contentW - 150 }, 1: { cellWidth: 150, halign: "right", fontStyle: "bold" } }, + margin: { left: margin, right: margin }, + }); + y = (doc as any).lastAutoTable.finalY + 16; + + // Headline: the proposed per-unit monthly assessment. + doc.setFillColor(22, 101, 52); + doc.roundedRect(margin, y, contentW, 44, 5, 5, "F"); + doc.setTextColor(255, 255, 255); + doc.setFontSize(11); + doc.setFont("helvetica", "normal"); + doc.text("Proposed Assessment — per unit / month", margin + 16, y + 19); + doc.setFontSize(18); + doc.setFont("helvetica", "bold"); + doc.text(effUnits > 0 ? money(perUnit) : "—", margin + contentW - 16, y + 28, { align: "right" }); + + // Footer on every page + const totalPages = (doc as any).internal.getNumberOfPages(); + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + const footerY = pageH - 30; + doc.setDrawColor(226, 232, 240); + doc.setLineWidth(0.5); + doc.line(margin, footerY, margin + contentW, footerY); + doc.setTextColor(148, 163, 184); + doc.setFontSize(8); + doc.text(`Proposed budget — generated ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`, margin, footerY + 14); + doc.text(`Page ${i} of ${totalPages}`, margin + contentW - 60, footerY + 14); + } + + doc.save(`Proposed-Budget-${assocName.replace(/[^a-z0-9]/gi, "_")}-FY${fy}.pdf`); + }; + const renderSection = (title: string, rows: any[]) => ( {title} @@ -353,6 +451,7 @@ export default function BudgetWorkbookPage() { Refresh +