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
+60 -3
View File
@@ -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() {
</Card>
</div>
{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" && (
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} />
@@ -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 (
<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>)}
</SelectContent>
</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>
</Card>