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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 10:58:59 -04:00
parent 6fe1e3943c
commit 217e511792
+100 -1
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { toast } from "sonner";
import { useAssociation } from "@/contexts/AssociationContext"; import { useAssociation } from "@/contexts/AssociationContext";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
@@ -236,6 +238,102 @@ export default function BudgetWorkbookPage() {
URL.revokeObjectURL(url); 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[]) => ( const renderSection = (title: string, rows: any[]) => (
<Card> <Card>
<CardHeader className="pb-2"><CardTitle className="text-base">{title}</CardTitle></CardHeader> <CardHeader className="pb-2"><CardTitle className="text-base">{title}</CardTitle></CardHeader>
@@ -353,6 +451,7 @@ export default function BudgetWorkbookPage() {
<RefreshCw className={`h-4 w-4 mr-1 ${glFetching ? "animate-spin" : ""}`} /> Refresh <RefreshCw className={`h-4 w-4 mr-1 ${glFetching ? "animate-spin" : ""}`} /> Refresh
</Button> </Button>
<Button variant="outline" size="sm" onClick={exportCsv}><Download className="h-4 w-4 mr-1" /> CSV</Button> <Button variant="outline" size="sm" onClick={exportCsv}><Download className="h-4 w-4 mr-1" /> CSV</Button>
<Button variant="outline" size="sm" onClick={exportPdf}><FileText className="h-4 w-4 mr-1" /> PDF</Button>
<Button variant="outline" size="sm" onClick={saveWorkbook} disabled={saving}><Save className="h-4 w-4 mr-1" /> {saving ? "Saving…" : "Save"}</Button> <Button variant="outline" size="sm" onClick={saveWorkbook} disabled={saving}><Save className="h-4 w-4 mr-1" /> {saving ? "Saving…" : "Save"}</Button>
<Button size="sm" onClick={pushToBudget} disabled={pushing}><Upload className="h-4 w-4 mr-1" /> {pushing ? "Pushing…" : "Push to Budget"}</Button> <Button size="sm" onClick={pushToBudget} disabled={pushing}><Upload className="h-4 w-4 mr-1" /> {pushing ? "Pushing…" : "Push to Budget"}</Button>
</div> </div>