mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user