mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user