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
-16
View File
@@ -1,21 +1,8 @@
import jsPDF from "jspdf";
import autoTable, { RowInput } from "jspdf-autotable";
import { drawReportCoverPage } from "@/lib/reportCover";
type Association = { id?: string; name: string; logo_url?: string | null };
/** Prepend the shared branded cover page, then move to a fresh page for content. */
async function coverPage(doc: jsPDF, association: Association | null, title: string, subTitle: string) {
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), {
title,
date: subTitle,
companyName: association?.name || "All Associations",
preparedBy: "Avria Community Management, LLC",
logoUrl: association?.logo_url || undefined,
});
doc.addPage();
}
const fmt = (n: number | string | undefined | null) => {
const v = typeof n === "string" ? parseFloat(n) : n;
if (v == null || isNaN(v as number)) return "$0.00";
@@ -126,7 +113,6 @@ async function generateSectionedReportPdf(opts: {
}) {
const { association, reportLabel, subTitle, rows } = opts;
const doc = new jsPDF({ unit: "pt", format: "letter" });
await coverPage(doc, association, reportLabel, subTitle);
await drawHeader(doc, association, reportLabel, subTitle);
const body: RowInput[] = rows.map((r) => {
@@ -265,7 +251,6 @@ export async function generateARAgingPdf(opts: {
}
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
await coverPage(doc, association, "Accounts Receivable Aging", `As of ${asOfDate}`);
await drawHeader(doc, association, "Accounts Receivable Aging", `As of ${asOfDate}`);
const totals = { current: 0, d1_30: 0, d31_60: 0, d61_90: 0, over_90: 0, total: 0 };
@@ -392,7 +377,6 @@ export async function generateBudgetVsActualPdf(opts: {
: `${fromDate} to ${toDate}`;
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
await coverPage(doc, association, "Budget vs Actual", subTitle);
await drawHeader(doc, association, "Budget vs Actual", subTitle);
if (budgets.length === 0) {
+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>
@@ -15,7 +15,6 @@ 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];
@@ -143,21 +142,12 @@ 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 = async () => {
const exportPDF = () => {
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);
@@ -11,7 +11,6 @@ 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 = {
@@ -92,20 +91,11 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
const diff = totals.debit - totals.credit;
const inBalance = Math.abs(diff) < 0.005;
const exportPDF = async () => {
const exportPDF = () => {
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);