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 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:00:57 -04:00
parent 8360363a15
commit b77860772e
4 changed files with 94 additions and 8 deletions
@@ -16,6 +16,7 @@ import { toast } from "sonner";
import { money, fmtDate } from "./lib/format"; import { money, fmtDate } from "./lib/format";
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import autoTable from "jspdf-autotable"; import autoTable from "jspdf-autotable";
import { drawReportCoverPage } from "@/lib/reportCover";
function startOfFY() { function startOfFY() {
return new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0, 10); 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] }); qc.invalidateQueries({ queryKey: ["customers", cid] });
}; };
const exportStatement = () => { const exportStatement = async () => {
if (!homeowner) return; if (!homeowner) return;
const doc = new jsPDF({ unit: "pt", format: "letter" }); 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.setFontSize(16);
doc.text(associationName ?? "Association", 40, 50); doc.text(associationName ?? "Association", 40, 50);
doc.setFontSize(14); doc.setFontSize(14);
@@ -271,9 +280,9 @@ export default function AccountingCustomerDetailPage() {
doc.save(`statement-${homeowner.name.replace(/\s+/g, "_")}.pdf`); doc.save(`statement-${homeowner.name.replace(/\s+/g, "_")}.pdf`);
}; };
const emailStatement = () => { const emailStatement = async () => {
if (!homeowner) return; if (!homeowner) return;
exportStatement(); await exportStatement();
const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`); const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`);
const body = encodeURIComponent( 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.` `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.`
+60 -3
View File
@@ -332,7 +332,7 @@ export default function AccountingReportsPage() {
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]); }, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
// Reports whose export is handled internally (own PDF/CSV buttons inside the component) // 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 anyExportable = !!(structured || flat || exportFlat);
const doExportPDF = async () => { const doExportPDF = async () => {
@@ -528,7 +528,7 @@ export default function AccountingReportsPage() {
</Card> </Card>
</div> </div>
{active === "budget-vs-actuals" && ( {active === "budget-vs-actuals" && (
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} /> <BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
)} )}
{active === "trial-balance" && ( {active === "trial-balance" && (
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} /> <TrialBalanceReport companyId={cid} companyName={associationName ?? ""} />
@@ -1184,7 +1184,7 @@ function flatToStructured(flat: Flat, title: string): StructuredReport {
// ---------- Budget vs Actuals ---------- // ---------- 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({ const { data: budgets = [] } = useQuery({
queryKey: ["budgets-active", companyId], queryKey: ["budgets-active", companyId],
enabled: !!companyId, enabled: !!companyId,
@@ -1311,6 +1311,59 @@ function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string;
]; ];
}, [grouped, budgetByAcct, actualByAcct]); }, [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) { if (!budgets.length) {
return ( return (
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground"> <Card><CardContent className="py-12 text-center text-sm text-muted-foreground">
@@ -1330,6 +1383,10 @@ function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string;
{budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)} {budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -15,6 +15,7 @@ import { FileDown, Download, ChevronDown, ChevronsDownUp, ChevronsUpDown, AlertT
import { fmtDate } from "../lib/format"; import { fmtDate } from "../lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import { drawReportCoverPage } from "@/lib/reportCover";
import autoTable from "jspdf-autotable"; import autoTable from "jspdf-autotable";
const TEAL: [number, number, number] = [0, 137, 123]; 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 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 doc = new jsPDF({ unit: "pt", format: "letter" });
const W = doc.internal.pageSize.getWidth(); const W = doc.internal.pageSize.getWidth();
const H = doc.internal.pageSize.getHeight(); const H = doc.internal.pageSize.getHeight();
const ML = 54; 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 // Header
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F"); doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255); doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { CheckCircle2, AlertTriangle, FileDown, Download } from "lucide-react"; import { CheckCircle2, AlertTriangle, FileDown, Download } from "lucide-react";
import { fmtDate } from "../lib/format"; import { fmtDate } from "../lib/format";
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import { drawReportCoverPage } from "@/lib/reportCover";
import autoTable from "jspdf-autotable"; import autoTable from "jspdf-autotable";
type Account = { type Account = {
@@ -91,11 +92,20 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
const diff = totals.debit - totals.credit; const diff = totals.debit - totals.credit;
const inBalance = Math.abs(diff) < 0.005; const inBalance = Math.abs(diff) < 0.005;
const exportPDF = () => { const exportPDF = async () => {
const doc = new jsPDF({ unit: "pt", format: "letter" }); const doc = new jsPDF({ unit: "pt", format: "letter" });
const W = doc.internal.pageSize.getWidth(); const W = doc.internal.pageSize.getWidth();
const ML = 54; 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 // Header
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F"); doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255); doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);