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 jsPDF from "jspdf";
|
||||||
import autoTable, { RowInput } from "jspdf-autotable";
|
import autoTable, { RowInput } from "jspdf-autotable";
|
||||||
import { drawReportCoverPage } from "@/lib/reportCover";
|
|
||||||
|
|
||||||
type Association = { id?: string; name: string; logo_url?: string | null };
|
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 fmt = (n: number | string | undefined | null) => {
|
||||||
const v = typeof n === "string" ? parseFloat(n) : n;
|
const v = typeof n === "string" ? parseFloat(n) : n;
|
||||||
if (v == null || isNaN(v as number)) return "$0.00";
|
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 { association, reportLabel, subTitle, rows } = opts;
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
await coverPage(doc, association, reportLabel, subTitle);
|
|
||||||
await drawHeader(doc, association, reportLabel, subTitle);
|
await drawHeader(doc, association, reportLabel, subTitle);
|
||||||
|
|
||||||
const body: RowInput[] = rows.map((r) => {
|
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" });
|
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}`);
|
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 };
|
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}`;
|
: `${fromDate} to ${toDate}`;
|
||||||
|
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
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);
|
await drawHeader(doc, association, "Budget vs Actual", subTitle);
|
||||||
|
|
||||||
if (budgets.length === 0) {
|
if (budgets.length === 0) {
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ 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 {
|
import {
|
||||||
renderReportPdfWithCover, fmtAmount,
|
renderReportPdf, fmtAmount,
|
||||||
type StructuredReport, type StructuredRow,
|
type StructuredReport, type StructuredRow,
|
||||||
} from "./lib/reportPdf";
|
} from "./lib/reportPdf";
|
||||||
import { drawReportCoverPage, type ReportCoverData } from "@/lib/reportCover";
|
|
||||||
import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings";
|
import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings";
|
||||||
import {
|
import {
|
||||||
computePnL, computeMargins, toMinor, fromMinor, PnlValidationError,
|
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 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 = () => {
|
||||||
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
|
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
|
||||||
const src = flat ?? exportFlat;
|
const src = flat ?? exportFlat;
|
||||||
const cover: ReportCoverData = {
|
|
||||||
title: activeMeta.name,
|
|
||||||
date: rangeLabel,
|
|
||||||
companyName: associationName ?? "Company",
|
|
||||||
preparedBy: "Avria Community Management, LLC",
|
|
||||||
};
|
|
||||||
if (structured) {
|
if (structured) {
|
||||||
const doc = await renderReportPdfWithCover(
|
const doc = renderReportPdf(
|
||||||
structured,
|
structured,
|
||||||
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero },
|
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero },
|
||||||
cover,
|
|
||||||
);
|
);
|
||||||
doc.save(`${fileBase}.pdf`);
|
doc.save(`${fileBase}.pdf`);
|
||||||
} else if (src) {
|
} else if (src) {
|
||||||
const doc = new jsPDF({ orientation: src.columns.length > 6 ? "landscape" : "portrait" });
|
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.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
|
||||||
doc.text(src.title, 14, 16);
|
doc.text(src.title, 14, 16);
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(9);
|
doc.setFont("helvetica", "bold"); doc.setFontSize(9);
|
||||||
@@ -1184,6 +1173,30 @@ function flatToStructured(flat: Flat, title: string): StructuredReport {
|
|||||||
|
|
||||||
// ---------- Budget vs Actuals ----------
|
// ---------- 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 }) {
|
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],
|
||||||
@@ -1240,6 +1253,12 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
|
|||||||
return out;
|
return out;
|
||||||
}, [accounts]);
|
}, [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 budgetByAcct = useMemo(() => {
|
||||||
const m: Record<string, number> = {};
|
const m: Record<string, number> = {};
|
||||||
for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
|
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 ta = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
|
||||||
const tv = ta - tb;
|
const tv = ta - tb;
|
||||||
rows.push({ label: t.label, budget: tb, actual: ta, variance: tv, pct: tb ? `${((tv / tb) * 100).toFixed(1)}%` : "—", group: true });
|
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;
|
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;
|
return rows;
|
||||||
}, [grouped, budgetByAcct, actualByAcct]);
|
}, [grouped, groupedOrdered, budgetByAcct, actualByAcct]);
|
||||||
|
|
||||||
const fileBase = `budget-vs-actuals-${from}-to-${to}`;
|
const fileBase = `budget-vs-actuals-${from}-to-${to}`;
|
||||||
|
|
||||||
@@ -1342,12 +1362,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportPDF = async () => {
|
const exportPDF = () => {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
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.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
|
||||||
doc.text("Budget vs Actuals", 40, 50);
|
doc.text("Budget vs Actuals", 40, 50);
|
||||||
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110, 116, 122);
|
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"}`}>{money(totalVar, currency)}</TableCell>
|
||||||
<TableCell className={`text-right tabular-nums ${totalFavorable ? "text-emerald-600" : "text-red-600"}`}>{totalB ? `${totalPct.toFixed(1)}%` : "—"}</TableCell>
|
<TableCell className={`text-right tabular-nums ${totalFavorable ? "text-emerald-600" : "text-red-600"}`}>{totalB ? `${totalPct.toFixed(1)}%` : "—"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{accs.map((a: any) => {
|
{(groupedOrdered[t.value] ?? []).map((a: any) => {
|
||||||
const b = budgetByAcct[a.id] ?? 0;
|
const b = budgetByAcct[a.id] ?? 0;
|
||||||
const ac = actualByAcct[a.id] ?? 0;
|
const ac = actualByAcct[a.id] ?? 0;
|
||||||
const v = ac - b;
|
const v = ac - b;
|
||||||
const pct = b ? (v / b) * 100 : 0;
|
const pct = b ? (v / b) * 100 : 0;
|
||||||
const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0;
|
const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0;
|
||||||
|
const depth = a._depth ?? 0;
|
||||||
return (
|
return (
|
||||||
<TableRow key={a.id} className="hover:bg-muted/20">
|
<TableRow key={a.id} className="hover:bg-muted/20">
|
||||||
<TableCell className="pl-8">
|
<TableCell style={{ paddingLeft: `${16 + depth * 20}px` }}>
|
||||||
<span className="font-medium">{a.name}</span>
|
<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>}
|
{a.code && <span className="text-xs text-muted-foreground font-mono ml-2">{a.code}</span>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">{money(b, currency)}</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 { 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];
|
||||||
@@ -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 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 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,7 +11,6 @@ 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 = {
|
||||||
@@ -92,20 +91,11 @@ 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 = async () => {
|
const exportPDF = () => {
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user