From b77860772ee3694dccdd2f4d7af0a864f77792ec Mon Sep 17 00:00:00 2001 From: renee-png Date: Mon, 1 Jun 2026 22:00:57 -0400 Subject: [PATCH] Fix report exports: Budget vs Actuals + branded cover everywhere - Budget vs Actuals now exports (CSV + branded PDF); previously the page's export buttons were disabled for it. Wired into hasOwnExport with its own buttons in the report. - Apply the shared branded cover page to the remaining PDF exports so they match the main scheme: homeowner account Statement (AccountingCustomer DetailPage), Trial Balance, and General Ledger. Note: payments already render on the accounting customer ledger/statement via payments_received (phase 3+4). Co-Authored-By: Claude Opus 4.8 --- .../AccountingCustomerDetailPage.tsx | 15 ++++- .../accounting/AccountingReportsPage.tsx | 63 ++++++++++++++++++- .../components/GeneralLedgerReport.tsx | 12 +++- .../components/TrialBalanceReport.tsx | 12 +++- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/pages/accounting/AccountingCustomerDetailPage.tsx b/src/pages/accounting/AccountingCustomerDetailPage.tsx index 99f3e7c..a9fd6ce 100644 --- a/src/pages/accounting/AccountingCustomerDetailPage.tsx +++ b/src/pages/accounting/AccountingCustomerDetailPage.tsx @@ -16,6 +16,7 @@ import { toast } from "sonner"; import { money, fmtDate } from "./lib/format"; import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; +import { drawReportCoverPage } from "@/lib/reportCover"; function startOfFY() { return new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0, 10); @@ -234,9 +235,17 @@ export default function AccountingCustomerDetailPage() { qc.invalidateQueries({ queryKey: ["customers", cid] }); }; - const exportStatement = () => { + const exportStatement = async () => { if (!homeowner) return; const doc = new jsPDF({ unit: "pt", format: "letter" }); + // Shared branded cover page (matches all other report exports), then detail. + await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), { + title: "Homeowner Statement", + date: `${fmtDate(from)} – ${fmtDate(to)}`, + companyName: associationName ?? "Association", + preparedBy: "Avria Community Management, LLC", + }); + doc.addPage(); doc.setFontSize(16); doc.text(associationName ?? "Association", 40, 50); doc.setFontSize(14); @@ -271,9 +280,9 @@ export default function AccountingCustomerDetailPage() { doc.save(`statement-${homeowner.name.replace(/\s+/g, "_")}.pdf`); }; - const emailStatement = () => { + const emailStatement = async () => { if (!homeowner) return; - exportStatement(); + await exportStatement(); const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`); const body = encodeURIComponent( `Hello ${homeowner.name},\n\nPlease find attached your homeowner statement for the period ${fmtDate(from)} – ${fmtDate(to)}.\nCurrent balance due: ${money(currentBalance, cur)}.\n\nThank you.` diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 3895e26..969ab65 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -332,7 +332,7 @@ export default function AccountingReportsPage() { }, [active, arOpen, data, flat, structured, cur, activeMeta.name]); // Reports whose export is handled internally (own PDF/CSV buttons inside the component) - const hasOwnExport = active === "trial-balance" || active === "general-ledger"; + const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals"; const anyExportable = !!(structured || flat || exportFlat); const doExportPDF = async () => { @@ -528,7 +528,7 @@ export default function AccountingReportsPage() { {active === "budget-vs-actuals" && ( - + )} {active === "trial-balance" && ( @@ -1184,7 +1184,7 @@ function flatToStructured(flat: Flat, title: string): StructuredReport { // ---------- Budget vs Actuals ---------- -function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string; from: string; to: string; currency: string }) { +function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string }) { const { data: budgets = [] } = useQuery({ queryKey: ["budgets-active", companyId], enabled: !!companyId, @@ -1311,6 +1311,59 @@ function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string; ]; }, [grouped, budgetByAcct, actualByAcct]); + // Flattened rows (group totals + accounts) shared by the CSV and PDF exports. + const exportRows = useMemo(() => { + const rows: { label: string; budget: number; actual: number; variance: number; pct: string; group: boolean }[] = []; + for (const t of TYPES_LOCAL) { + const accs = grouped[t.value] ?? []; + if (!accs.length) continue; + const tb = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0); + const ta = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0); + const tv = ta - tb; + rows.push({ label: t.label, budget: tb, actual: ta, variance: tv, pct: tb ? `${((tv / tb) * 100).toFixed(1)}%` : "—", group: true }); + for (const a of accs) { + const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; const v = ac - b; + rows.push({ label: a.code ? `${a.name} (${a.code})` : a.name, budget: b, actual: ac, variance: v, pct: b ? `${((v / b) * 100).toFixed(1)}%` : "—", group: false }); + } + } + return rows; + }, [grouped, budgetByAcct, actualByAcct]); + + const fileBase = `budget-vs-actuals-${from}-to-${to}`; + + const exportCSV = () => { + const esc = (s: any) => `"${String(s).replace(/"/g, '""')}"`; + const lines = [["Account", "Budget", "Actual", "Variance", "Variance %"].join(",")]; + for (const r of exportRows) lines.push([esc(r.label), r.budget.toFixed(2), r.actual.toFixed(2), r.variance.toFixed(2), esc(r.pct)].join(",")); + const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = `${fileBase}.csv`; a.click(); + URL.revokeObjectURL(url); + }; + + const exportPDF = async () => { + const doc = new jsPDF({ unit: "pt", format: "letter" }); + await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), { + title: "Budget vs Actuals", date: rangeLabel, companyName, preparedBy: "Avria Community Management, LLC", + }); + doc.addPage(); + doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41); + doc.text("Budget vs Actuals", 40, 50); + doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110, 116, 122); + doc.text(`${companyName} · ${rangeLabel}`, 40, 66); + autoTable(doc, { + startY: 80, + head: [["Account", "Budget", "Actual", "Variance", "Variance %"]], + body: exportRows.map((r) => [r.label, money(r.budget, currency), money(r.actual, currency), money(r.variance, currency), r.pct]), + styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 }, + headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 }, + columnStyles: { 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" } }, + didParseCell: ({ row, cell, section }) => { if (section === "body" && exportRows[row.index]?.group) cell.styles.fontStyle = "bold"; }, + }); + doc.save(`${fileBase}.pdf`); + }; + if (!budgets.length) { return ( @@ -1330,6 +1383,10 @@ function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string; {budgets.map((b: any) => {b.name} (FY {b.fiscal_year}))} +
+ + +
diff --git a/src/pages/accounting/components/GeneralLedgerReport.tsx b/src/pages/accounting/components/GeneralLedgerReport.tsx index 2850b3c..bc8c86d 100644 --- a/src/pages/accounting/components/GeneralLedgerReport.tsx +++ b/src/pages/accounting/components/GeneralLedgerReport.tsx @@ -15,6 +15,7 @@ import { FileDown, Download, ChevronDown, ChevronsDownUp, ChevronsUpDown, AlertT import { fmtDate } from "../lib/format"; import { cn } from "@/lib/utils"; import jsPDF from "jspdf"; +import { drawReportCoverPage } from "@/lib/reportCover"; import autoTable from "jspdf-autotable"; const TEAL: [number, number, number] = [0, 137, 123]; @@ -142,12 +143,21 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str const toggleAccount = (id: string) => setSelectedAccounts((s) => s.includes(id) ? s.filter((x) => x !== id) : [...s, id]); - const exportPDF = () => { + const exportPDF = async () => { const doc = new jsPDF({ unit: "pt", format: "letter" }); const W = doc.internal.pageSize.getWidth(); const H = doc.internal.pageSize.getHeight(); const ML = 54; + // Shared branded cover page, then the report on the next page. + await drawReportCoverPage(doc, W, H, { + title: "General Ledger", + date: `${fmtDate(from)} – ${fmtDate(to)}`, + companyName: companyName || "Association", + preparedBy: "Avria Community Management, LLC", + }); + doc.addPage(); + // Header doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F"); doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255); diff --git a/src/pages/accounting/components/TrialBalanceReport.tsx b/src/pages/accounting/components/TrialBalanceReport.tsx index 44a62e2..042ccb3 100644 --- a/src/pages/accounting/components/TrialBalanceReport.tsx +++ b/src/pages/accounting/components/TrialBalanceReport.tsx @@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge"; import { CheckCircle2, AlertTriangle, FileDown, Download } from "lucide-react"; import { fmtDate } from "../lib/format"; import jsPDF from "jspdf"; +import { drawReportCoverPage } from "@/lib/reportCover"; import autoTable from "jspdf-autotable"; type Account = { @@ -91,11 +92,20 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri const diff = totals.debit - totals.credit; const inBalance = Math.abs(diff) < 0.005; - const exportPDF = () => { + const exportPDF = async () => { const doc = new jsPDF({ unit: "pt", format: "letter" }); const W = doc.internal.pageSize.getWidth(); const ML = 54; + // Shared branded cover page, then the report on the next page. + await drawReportCoverPage(doc, W, doc.internal.pageSize.getHeight(), { + title: "Trial Balance", + date: `As of ${fmtDate(asOf)}`, + companyName: companyName || "Association", + preparedBy: "Avria Community Management, LLC", + }); + doc.addPage(); + // Header doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F"); doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);