Remove cover sheet from financial reports; nest Budget vs Actuals accounts

- Drop the branded cover page from all financial/accounting report exports
  (P&L, Balance Sheet, Cash Flow, Movement of Equity, AR/AP flats, Trial
  Balance, General Ledger, Budget vs Actuals, and the Zoho/Board financial
  reports). The general Report Generator's own cover is unchanged.
- Budget vs Actuals now orders accounts as a parent→child tree with
  indentation (on screen and in CSV/PDF) instead of flat by account number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:26:05 -04:00
parent 39829b7e1b
commit 57a9a1022e
4 changed files with 44 additions and 63 deletions
+42 -25
View File
@@ -18,10 +18,9 @@ import { money, fmtDate } from "./lib/format";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import {
renderReportPdfWithCover, fmtAmount,
renderReportPdf, fmtAmount,
type StructuredReport, type StructuredRow,
} from "./lib/reportPdf";
import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover";
import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings";
import {
computePnL, computeMargins, toMinor, fromMinor, PnlValidationError,
@@ -335,27 +334,17 @@ export default function AccountingReportsPage() {
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals";
const anyExportable = !!(structured || flat || exportFlat);
const doExportPDF = async () => {
const doExportPDF = () => {
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
const src = flat ?? exportFlat;
const cover: ReportCoverData = {
title: activeMeta.name,
date: rangeLabel,
companyName: associationName ?? "Company",
preparedBy: "Avria Community Management, LLC",
};
if (structured) {
const doc = await renderReportPdfWithCover(
const doc = renderReportPdf(
structured,
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero },
cover,
);
doc.save(`${fileBase}.pdf`);
} else if (src) {
const doc = new jsPDF({ orientation: src.columns.length > 6 ? "landscape" : "portrait" });
// Shared branded cover page, then the tabular report on the next page.
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), cover);
doc.addPage();
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
doc.text(src.title, 14, 16);
doc.setFont("helvetica", "bold"); doc.setFontSize(9);
@@ -1184,6 +1173,30 @@ function flatToStructured(flat: Flat, title: string): StructuredReport {
// ---------- Budget vs Actuals ----------
/** Order accounts as a tree (parents first, children indented) instead of by number. */
function orderAccountsHierarchically(accs: any[]): any[] {
const byId = new Map(accs.map((a) => [a.id, a]));
const childrenByParent = new Map<string, any[]>();
const roots: any[] = [];
for (const a of accs) {
if (a.parent_account_id && byId.has(a.parent_account_id)) {
const arr = childrenByParent.get(a.parent_account_id) ?? [];
arr.push(a); childrenByParent.set(a.parent_account_id, arr);
} else {
roots.push(a);
}
}
const byCode = (a: any, b: any) => String(a.code ?? "").localeCompare(String(b.code ?? ""));
roots.sort(byCode);
const out: any[] = [];
const visit = (node: any, depth: number) => {
out.push({ ...node, _depth: depth });
for (const k of (childrenByParent.get(node.id) ?? []).sort(byCode)) visit(k, depth + 1);
};
for (const r of roots) visit(r, 0);
return out;
}
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],
@@ -1240,6 +1253,12 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
return out;
}, [accounts]);
// Same accounts, ordered as a parent→child tree (each carries `_depth`).
const groupedOrdered = useMemo(() => ({
income: orderAccountsHierarchically(grouped.income ?? []),
expense: orderAccountsHierarchically(grouped.expense ?? []),
} as Record<string, any[]>), [grouped]);
const budgetByAcct = useMemo(() => {
const m: Record<string, number> = {};
for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
@@ -1321,13 +1340,14 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
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) {
for (const a of groupedOrdered[t.value] ?? []) {
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 });
const indent = " ".repeat(a._depth ?? 0);
rows.push({ label: `${indent}${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]);
}, [grouped, groupedOrdered, budgetByAcct, actualByAcct]);
const fileBase = `budget-vs-actuals-${from}-to-${to}`;
@@ -1342,12 +1362,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
URL.revokeObjectURL(url);
};
const exportPDF = async () => {
const exportPDF = () => {
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);
@@ -1437,16 +1453,17 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
<TableCell className={`text-right tabular-nums ${totalFavorable ? "text-emerald-600" : "text-red-600"}`}>{money(totalVar, currency)}</TableCell>
<TableCell className={`text-right tabular-nums ${totalFavorable ? "text-emerald-600" : "text-red-600"}`}>{totalB ? `${totalPct.toFixed(1)}%` : "—"}</TableCell>
</TableRow>
{accs.map((a: any) => {
{(groupedOrdered[t.value] ?? []).map((a: any) => {
const b = budgetByAcct[a.id] ?? 0;
const ac = actualByAcct[a.id] ?? 0;
const v = ac - b;
const pct = b ? (v / b) * 100 : 0;
const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0;
const depth = a._depth ?? 0;
return (
<TableRow key={a.id} className="hover:bg-muted/20">
<TableCell className="pl-8">
<span className="font-medium">{a.name}</span>
<TableCell style={{ paddingLeft: `${16 + depth * 20}px` }}>
<span className={depth === 0 ? "font-semibold" : "font-medium"}>{a.name}</span>
{a.code && <span className="text-xs text-muted-foreground font-mono ml-2">{a.code}</span>}
</TableCell>
<TableCell className="text-right tabular-nums">{money(b, currency)}</TableCell>