Accounting report batches: saved per-association packets → one combined PDF

- accounting.report_batches (name + ordered report_ids per company, member RLS)
- batchReports.ts engine: cover page + each report on a fresh page + one global
  Page X/Y footer. Financial four reuse the page's fetchReportData/buildFinancial
  (injected to avoid an import cycle); Trial Balance, General Ledger, Cash
  Disbursement, AR Aging, Pre-Paid, Reserve Fund built from the same source data
- Reports page: Report Batches dialog (name, ordered report checklist, saved
  batches load/delete, Save, Generate PDF); page now defaults to This Month
- reportPdf.appendStructuredReportPdf used for the financial sections

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 22:57:12 -04:00
parent b9235f644f
commit 8a57f53317
2 changed files with 517 additions and 10 deletions
+135 -10
View File
@@ -13,7 +13,9 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RTooltip, Legend, ResponsiveContainer } from "recharts";
import { FileText, Download, FileDown, Eye, RefreshCw } from "lucide-react";
import { FileText, Download, FileDown, Eye, RefreshCw, Layers, Trash2, Loader2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { generateBatchPdf, BATCHABLE_REPORTS } from "./lib/batchReports";
import { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
import jsPDF from "jspdf";
@@ -121,12 +123,8 @@ async function fetchAllGLLines(cid: string, to: string, select: string, from?: s
return out;
}
function useReportData(cid: string, from: string, to: string) {
return useQuery({
queryKey: ["reports-data", cid, from, to],
enabled: !!cid,
queryFn: async () => {
// Shared fetch for the financial reports (also used by the report-batch engine).
export async function fetchReportData(cid: string, from: string, to: string) {
const ytdStart = new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0,10);
const [inv, bills, accs, exp, custs, vends, ob, ytdInv, ytdExp, ytdBills, allBills, glRes, glCumRes, allInvRes, companyRes] = await Promise.all([
accounting.from("invoices").select("number,total,paid_amount,status,issue_date,customers(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to),
@@ -165,7 +163,13 @@ function useReportData(cid: string, from: string, to: string) {
glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true,
from, asOf: to,
};
},
}
function useReportData(cid: string, from: string, to: string) {
return useQuery({
queryKey: ["reports-data", cid, from, to],
enabled: !!cid,
queryFn: () => fetchReportData(cid, from, to),
});
}
@@ -243,9 +247,69 @@ export default function AccountingReportsPage({ association }: { association?: {
// Period
const [preset, setPreset] = useState<Preset>("ytd");
const [from, setFrom] = useState(startOfYear());
const [from, setFrom] = useState(startOfMonth());
const [to, setTo] = useState(today());
// ── Report batches (saved per-company report packets) ──
const [batchOpen, setBatchOpen] = useState(false);
const [batchName, setBatchName] = useState("");
const [batchReportIds, setBatchReportIds] = useState<string[]>(["balance-sheet", "pnl", "trial-balance", "general-ledger"]);
const [batchLoadedId, setBatchLoadedId] = useState<string | null>(null);
const [generatingBatch, setGeneratingBatch] = useState(false);
const { data: savedBatches = [], refetch: refetchBatches } = useQuery({
queryKey: ["report-batches", cid],
enabled: !!cid,
queryFn: async () => (await accounting.from("report_batches").select("*").eq("company_id", cid).order("name")).data ?? [],
});
const toggleBatchReport = (id: string) =>
setBatchReportIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
const saveBatch = async () => {
const name = batchName.trim();
if (!name) { toast.error("Name the batch first"); return; }
if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; }
if (batchLoadedId) {
const { error } = await accounting.from("report_batches").update({ name, report_ids: batchReportIds, updated_at: new Date().toISOString() }).eq("id", batchLoadedId);
if (error) { toast.error(error.message); return; }
} else {
const { data, error } = await accounting.from("report_batches").insert({ company_id: cid, name, report_ids: batchReportIds }).select("id").single();
if (error) { toast.error(error.message); return; }
setBatchLoadedId(data.id);
}
toast.success(`Batch "${name}" saved`);
refetchBatches();
};
const loadBatch = (b: any) => {
setBatchLoadedId(b.id); setBatchName(b.name);
setBatchReportIds(Array.isArray(b.report_ids) ? b.report_ids : []);
};
const deleteBatch = async (id: string) => {
await accounting.from("report_batches").delete().eq("id", id);
if (batchLoadedId === id) { setBatchLoadedId(null); setBatchName(""); }
refetchBatches();
toast.success("Batch deleted");
};
const generateBatch = async () => {
if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; }
setGeneratingBatch(true);
try {
const doc = await generateBatchPdf({
companyId: cid, companyName: associationName ?? "Company",
from, to, currency: cur, fetchReportData, buildFinancial,
}, batchReportIds);
const slug = (associationName ?? "report").replace(/[^a-z0-9]+/gi, "-").toLowerCase().slice(0, 40);
doc.save(`${slug}-report-packet-${from}-to-${to}.pdf`);
toast.success("Report package generated");
} catch (e: any) {
toast.error(e?.message || "Could not generate package");
} finally {
setGeneratingBatch(false);
}
};
const applyPreset = (p: Preset) => {
setPreset(p);
if (p !== "custom") {
@@ -509,6 +573,9 @@ export default function AccountingReportsPage({ association }: { association?: {
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setBatchOpen(true)}>
<Layers className="mr-1 h-4 w-4" /> Report Batches
</Button>
<Button variant="outline" onClick={refreshReport} disabled={refreshing}>
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} /> Refresh
</Button>
@@ -663,6 +730,64 @@ export default function AccountingReportsPage({ association }: { association?: {
)}
</div>
{/* ── Report Batches dialog ── */}
<Dialog open={batchOpen} onOpenChange={setBatchOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Report Batches</DialogTitle></DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Pick a set of reports to combine into one PDF for <strong>{associationName ?? "this association"}</strong>.
The package uses the period selected on this page (<span className="font-medium">{rangeLabel}</span>).
</p>
{savedBatches.length > 0 && (
<div>
<Label className="text-xs uppercase text-muted-foreground">Saved batches</Label>
<div className="mt-1 space-y-1">
{(savedBatches as any[]).map((b) => (
<div key={b.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${batchLoadedId === b.id ? "border-primary bg-primary/5" : ""}`}>
<button className="flex-1 text-left truncate" onClick={() => loadBatch(b)}>
{b.name} <span className="text-xs text-muted-foreground">· {(b.report_ids ?? []).length} reports</span>
</button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => deleteBatch(b.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
)}
<div>
<Label className="text-xs">Batch name</Label>
<Input value={batchName} onChange={(e) => setBatchName(e.target.value)} placeholder="e.g. Monthly Board Package" className="mt-1" />
</div>
<div>
<Label className="text-xs uppercase text-muted-foreground">Reports (in packet order)</Label>
<div className="mt-1 space-y-1 max-h-64 overflow-y-auto rounded border p-2">
{BATCHABLE_REPORTS.map((r) => (
<label key={r.id} className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-accent cursor-pointer text-sm">
<Checkbox checked={batchReportIds.includes(r.id)} onCheckedChange={() => toggleBatchReport(r.id)} />
{r.name}
</label>
))}
</div>
</div>
</div>
<DialogFooter className="flex-wrap gap-2">
{batchLoadedId && (
<Button variant="ghost" onClick={() => { setBatchLoadedId(null); setBatchName(""); }}>New batch</Button>
)}
<Button variant="outline" onClick={saveBatch}>{batchLoadedId ? "Update" : "Save"} batch</Button>
<Button onClick={generateBatch} disabled={generatingBatch || batchReportIds.length === 0}>
{generatingBatch ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <FileDown className="mr-1 h-4 w-4" />}
Generate PDF
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1203,7 +1328,7 @@ function ReconciliationReport({ d, currency }: { d: any; currency: string }) {
);
}
function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: boolean): StructuredReport {
export function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: boolean): StructuredReport {
if (id === "pnl") return buildPnL(d, p, useCompare);
if (id === "balance-sheet") return buildBalanceSheet(d, p, useCompare);
if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare);
+382
View File
@@ -0,0 +1,382 @@
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { accounting } from "@/lib/accountingClient";
import { fmtDate } from "./format";
import { drawBrandedFooter } from "./reportHeader";
import { appendStructuredReportPdf, type StructuredReport } from "./reportPdf";
import {
fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners,
unitLabel, money, num,
type OwnerLedgerEntry, type UnitInfo, type OwnerInfo,
} from "./ownerLedger";
const TEAL: [number, number, number] = [0, 137, 123];
const DEBIT_NATURAL = ["asset", "expense"];
/** Reports that can be added to a saved batch, in packet order. */
export const BATCHABLE_REPORTS: { id: string; name: string }[] = [
{ id: "balance-sheet", name: "Balance Sheet" },
{ id: "pnl", name: "Profit & Loss" },
{ id: "cash-flow", name: "Cash Flow Statement" },
{ id: "movement-of-equity", name: "Movement of Equity" },
{ id: "trial-balance", name: "Trial Balance" },
{ id: "general-ledger", name: "General Ledger" },
{ id: "cash-disbursement", name: "Cash Disbursement" },
{ id: "ar-aging-property", name: "AR Aging (Property)" },
{ id: "prepaid-homeowners", name: "Pre-Paid Homeowners" },
{ id: "reserve-fund", name: "Reserve Fund Schedule" },
];
const FINANCIAL_IDS = new Set(["balance-sheet", "pnl", "cash-flow", "movement-of-equity"]);
export type BatchCtx = {
companyId: string;
companyName: string;
from: string;
to: string;
currency: string;
// Injected from the reports page to avoid an import cycle.
fetchReportData: (cid: string, from: string, to: string) => Promise<any>;
buildFinancial: (id: any, d: any, p: any, useCompare: boolean) => StructuredReport;
};
const PAGE = 1000;
async function fetchAllGL(cid: string, to: string, select: string, from?: string): Promise<any[]> {
const out: any[] = [];
for (let offset = 0; ; offset += PAGE) {
let q = accounting.from("journal_entry_lines").select(select)
.eq("journal_entries.company_id", cid).lte("journal_entries.date", to);
if (from) q = q.gte("journal_entries.date", from);
const { data, error } = await q.order("id", { ascending: true }).range(offset, offset + PAGE - 1);
if (error) throw error;
const rows = (data ?? []) as any[];
out.push(...rows);
if (rows.length < PAGE) break;
}
return out;
}
const numAcc = (n: number) => {
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return v < 0 ? `(${abs})` : abs;
};
// ── Per-report appenders (each starts on the current page) ────────────────────
async function appendFinancial(doc: jsPDF, id: string, ctx: BatchCtx) {
const data = await ctx.fetchReportData(ctx.companyId, ctx.from, ctx.to);
const report = ctx.buildFinancial(id, data, undefined, false);
appendStructuredReportPdf(doc, report, {
companyName: ctx.companyName, appName: "Cozy Books",
rangeLabel: id === "balance-sheet" ? `As of ${fmtDate(ctx.to)}` : `${fmtDate(ctx.from)} ${fmtDate(ctx.to)}`,
currency: ctx.currency, showCodes: true, showCompare: false, showZero: false, skipFooter: true,
});
}
async function appendTrialBalance(doc: jsPDF, ctx: BatchCtx) {
const [{ data: accts }, glLines] = await Promise.all([
accounting.from("accounts").select("id,name,code,type").eq("company_id", ctx.companyId).eq("is_archived", false).order("code"),
fetchAllGL(ctx.companyId, ctx.to, "debit,credit,account_id,accounts!inner(type)"),
]);
const net = new Map<string, number>();
for (const l of glLines) net.set(l.account_id, (net.get(l.account_id) ?? 0) + Number(l.debit || 0) - Number(l.credit || 0));
let td = 0, tc = 0;
const body = (accts ?? []).map((a: any) => {
const n = net.get(a.id) ?? 0;
const debit = n > 0.004 ? n : 0;
const credit = n < -0.004 ? -n : 0;
td += debit; tc += credit;
return [`${a.code ? a.code + " " : ""}${a.name}`, debit ? numAcc(debit) : "", credit ? numAcc(credit) : ""];
}).filter((r) => r[1] || r[2]);
body.push([{ content: "TOTAL", styles: { fontStyle: "bold" } } as any,
{ content: numAcc(td), styles: { fontStyle: "bold", halign: "right" } } as any,
{ content: numAcc(tc), styles: { fontStyle: "bold", halign: "right" } } as any]);
sectionTitle(doc, "Trial Balance", `As of ${fmtDate(ctx.to)}`, ctx);
autoTable(doc, {
startY: (doc as any).__y, head: [["Account", "Debit", "Credit"]], body,
styles: { fontSize: 8.5, cellPadding: 3 }, headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 1: { halign: "right" }, 2: { halign: "right" } }, margin: { left: 40, right: 40 },
});
}
async function appendGeneralLedger(doc: jsPDF, ctx: BatchCtx) {
const [{ data: accts }, glLines] = await Promise.all([
accounting.from("accounts").select("id,name,code,type").eq("company_id", ctx.companyId).eq("is_archived", false).order("code"),
fetchAllGL(ctx.companyId, ctx.to, "id,debit,credit,description,account_id,journal_entries!inner(company_id,date,reference)"),
]);
const acctById = new Map((accts ?? []).map((a: any) => [a.id, a]));
const groups = new Map<string, { account: any; opening: number; entries: any[]; closing: number }>();
const rows = glLines.map((l: any) => ({
date: String(l.journal_entries?.date ?? "").slice(0, 10),
description: l.description ?? null, reference: l.journal_entries?.reference ?? null,
account_id: l.account_id, debit: Number(l.debit || 0), credit: Number(l.credit || 0),
})).sort((a, b) => a.date.localeCompare(b.date));
for (const t of rows) {
const a = acctById.get(t.account_id); if (!a) continue;
const g = groups.get(t.account_id) ?? { account: a, opening: 0, entries: [], closing: 0 };
const nat = DEBIT_NATURAL.includes(a.type) ? t.debit - t.credit : t.credit - t.debit;
if (t.date < ctx.from) g.opening += nat; else g.entries.push(t);
groups.set(t.account_id, g);
}
const list = [...groups.values()].filter((g) => g.entries.length > 0 || Math.abs(g.opening) > 0.004)
.sort((a, b) => (a.account.code ?? "").localeCompare(b.account.code ?? ""));
sectionTitle(doc, "General Ledger", `${fmtDate(ctx.from)} ${fmtDate(ctx.to)}`, ctx);
let y = (doc as any).__y;
for (const g of list) {
const nat = DEBIT_NATURAL.includes(g.account.type);
let bal = g.opening;
const body: any[] = [[
{ content: fmtDate(ctx.from), styles: { fontStyle: "bold", fillColor: [245, 245, 245] } },
{ content: "Opening Balance", colSpan: 3, styles: { fontStyle: "bold", fillColor: [245, 245, 245] } },
{ content: numAcc(g.opening), styles: { fontStyle: "bold", halign: "right", fillColor: [245, 245, 245] } },
]];
for (const t of g.entries) {
bal += nat ? t.debit - t.credit : t.credit - t.debit;
body.push([fmtDate(t.date), t.description ?? "", t.debit ? numAcc(t.debit) : "", t.credit ? numAcc(t.credit) : "", numAcc(bal)]);
}
body.push([{ content: "Closing Balance", colSpan: 4, styles: { fontStyle: "bold", halign: "right" } } as any,
{ content: numAcc(bal), styles: { fontStyle: "bold", halign: "right" } } as any]);
autoTable(doc, {
startY: y,
head: [[{ content: `${g.account.code ? g.account.code + " · " : ""}${g.account.name}`, colSpan: 5, styles: { halign: "left", fillColor: TEAL, textColor: 255 } }],
["Date", "Description", "Debit", "Credit", "Balance"]],
body, styles: { fontSize: 8, cellPadding: 2.5 },
headStyles: { fillColor: [232, 240, 240], textColor: 20 },
columnStyles: { 0: { cellWidth: 64 }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" } },
margin: { left: 40, right: 40 },
});
y = (doc as any).lastAutoTable.finalY + 14;
if (y > doc.internal.pageSize.getHeight() - 80) { doc.addPage(); y = 54; }
}
}
async function appendCashDisbursement(doc: jsPDF, ctx: BatchCtx) {
const [{ data: accts }, lines] = await Promise.all([
accounting.from("accounts").select("id,code,name,is_bank").eq("company_id", ctx.companyId),
accounting.from("journal_entry_lines")
.select("debit,credit,description,account_id,journal_entries!inner(id,company_id,date,description,reference,external_source)")
.eq("journal_entries.company_id", ctx.companyId).gte("journal_entries.date", ctx.from).lte("journal_entries.date", ctx.to).limit(50000),
]);
const acctById = new Map((accts ?? []).map((a: any) => [a.id, a]));
const byJe = new Map<string, { je: any; lines: any[] }>();
for (const l of (lines ?? []) as any[]) {
const je = l.journal_entries; if (!je?.id) continue;
const g = byJe.get(je.id) ?? { je, lines: [] }; g.lines.push(l); byJe.set(je.id, g);
}
const groups = new Map<string, { label: string; entries: any[]; subtotal: number }>();
let grand = 0;
for (const { je, lines: ls } of byJe.values()) {
if (je.external_source === "acmacc_xfer") continue;
const bankCredits = ls.filter((l) => Number(l.credit || 0) > 0 && acctById.get(l.account_id)?.is_bank);
const nonBankDebits = ls.filter((l) => Number(l.debit || 0) > 0 && !acctById.get(l.account_id)?.is_bank);
if (!bankCredits.length || !nonBankDebits.length) continue;
const amount = bankCredits.reduce((s, l) => s + Number(l.credit || 0), 0);
const mainBank = bankCredits.reduce((a, b) => (Number(b.credit) > Number(a.credit) ? b : a));
const ba = acctById.get(mainBank.account_id);
const label = ba ? `${ba.code ? ba.code + " - " : ""}${ba.name}` : "Unknown account";
const g = groups.get(label) ?? { label, entries: [], subtotal: 0 };
g.entries.push({ date: String(je.date).slice(0, 10), ref: je.reference || "—", desc: je.description || "—", amount });
g.subtotal += amount; grand += amount; groups.set(label, g);
}
const list = [...groups.values()].sort((a, b) => a.label.localeCompare(b.label));
for (const g of list) g.entries.sort((a, b) => a.date.localeCompare(b.date));
sectionTitle(doc, "Cash Disbursement", `${fmtDate(ctx.from)} ${fmtDate(ctx.to)}`, ctx);
const body: any[] = [];
for (const g of list) {
body.push([{ content: g.label, colSpan: 4, styles: { fontStyle: "bold", textColor: TEAL, fontSize: 10 } }]);
for (const e of g.entries) body.push([fmtDate(e.date), e.ref, e.desc, { content: numAcc(e.amount), styles: { halign: "right" } }]);
body.push([{ content: `Total ${g.label}`, colSpan: 3, styles: { fontStyle: "bold", halign: "right" } },
{ content: numAcc(g.subtotal), styles: { fontStyle: "bold", halign: "right" } }]);
}
body.push([{ content: "Total Disbursements", colSpan: 3, styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
{ content: numAcc(grand), styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } }]);
autoTable(doc, {
startY: (doc as any).__y, head: [["Paid Date", "CheckNo", "Description", "Amount"]], body,
styles: { fontSize: 8, cellPadding: 3 }, headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 0: { cellWidth: 70 }, 1: { cellWidth: 70 }, 3: { halign: "right", cellWidth: 80 } },
margin: { left: 40, right: 40 },
});
}
// AR Aging (Property) — FIFO-aged open balances per unit
async function appendArAging(doc: jsPDF, ctx: BatchCtx) {
const assocId = await fetchAssociationId(ctx.companyId);
if (!assocId) return sectionNote(doc, "AR Aging", "No association linked.", ctx);
const [entries, { units, owners }] = await Promise.all([
fetchOwnerLedger(assocId, ctx.to), fetchUnitsAndOwners(assocId),
]);
const unitById = new Map<string, UnitInfo>(units.map((u) => [u.id, u]));
const ownerByUnit = new Map<string, OwnerInfo>();
for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o);
const byUnit = new Map<string, OwnerLedgerEntry[]>();
for (const e of entries) {
const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null; if (!key) continue;
(byUnit.get(key) ?? byUnit.set(key, []).get(key)!).push(e);
}
const bIdx = (chargeDate: string) => {
const days = Math.floor((new Date(ctx.to + "T00:00:00").getTime() - new Date(chargeDate + "T00:00:00").getTime()) / 86400000);
return days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3;
};
const rows: { label: string; buckets: number[]; total: number }[] = [];
const totals = [0, 0, 0, 0];
for (const [key, list] of byUnit) {
list.sort((a, b) => a.date.localeCompare(b.date));
let pool = list.reduce((s, e) => s + e.credit, 0);
const buckets = [0, 0, 0, 0]; let total = 0;
for (const e of list) {
if (e.debit <= 0) continue;
let open = e.debit;
if (pool > 0) { const ap = Math.min(pool, open); pool -= ap; open -= ap; }
if (open <= 0.004) continue;
const i = bIdx(e.date); buckets[i] += open; total += open; totals[i] += open;
}
if (total <= 0.004) continue;
const unitId = key.startsWith("u:") ? key.slice(2) : null;
const unit = unitId ? unitById.get(unitId) : undefined;
const owner = unitId ? ownerByUnit.get(unitId) : null;
rows.push({ label: unitLabel(unit, owner?.last_name ?? null), buckets, total });
}
rows.sort((a, b) => a.label.localeCompare(b.label));
const grand = totals.reduce((s, n) => s + n, 0);
sectionTitle(doc, "AR Aging", `As of ${fmtDate(ctx.to)}`, ctx);
const dash = (n: number) => (n ? money(n) : "-");
const body = rows.map((r) => [r.label, dash(r.buckets[0]), dash(r.buckets[1]), dash(r.buckets[2]), dash(r.buckets[3]), money(r.total)]);
body.push([{ content: "Total", styles: { fontStyle: "bold" } } as any,
...totals.map((n) => ({ content: money(n), styles: { fontStyle: "bold", halign: "right" } })),
{ content: money(grand), styles: { fontStyle: "bold", halign: "right" } } as any]);
autoTable(doc, {
startY: (doc as any).__y, head: [["Property", "0-30", "Over 30", "Over 60", "Over 90", "Balance"]], body,
styles: { fontSize: 8, cellPadding: 3 }, headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 0: { cellWidth: 200 }, 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" }, 5: { halign: "right" } },
margin: { left: 40, right: 40 },
});
}
async function appendPrepaid(doc: jsPDF, ctx: BatchCtx) {
const assocId = await fetchAssociationId(ctx.companyId);
if (!assocId) return sectionNote(doc, "Pre Paid Homeowners", "No association linked.", ctx);
const [entries, { units, owners }] = await Promise.all([fetchOwnerLedger(assocId, ctx.to), fetchUnitsAndOwners(assocId)]);
const unitById = new Map<string, UnitInfo>(units.map((u) => [u.id, u]));
const ownerByUnit = new Map<string, OwnerInfo>();
for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o);
const bal = new Map<string, number>();
for (const e of entries) {
const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null; if (!key) continue;
bal.set(key, (bal.get(key) ?? 0) + e.debit - e.credit);
}
const rows: { account: string; property: string; owner: string; credit: number }[] = [];
for (const [key, b] of bal) {
if (b >= -0.004) continue;
const unitId = key.startsWith("u:") ? key.slice(2) : null;
const unit = unitId ? unitById.get(unitId) : undefined;
const owner = unitId ? ownerByUnit.get(unitId) : null;
rows.push({
account: unit?.account_number || unit?.unit_number || "—",
property: [unit?.address, unit?.unit_number].filter(Boolean).join(" ") || "—",
owner: owner ? `${owner.first_name ?? ""} ${owner.last_name ?? ""}`.trim() || "—" : "—",
credit: -b,
});
}
rows.sort((a, b) => b.credit - a.credit);
const total = rows.reduce((s, r) => s + r.credit, 0);
sectionTitle(doc, "Pre Paid Homeowners", `For ${fmtDate(ctx.to)}`, ctx);
const body = rows.map((r) => [r.account, r.property, r.owner, num(r.credit)]);
body.push([{ content: "", styles: {} } as any, { content: "", styles: {} } as any,
{ content: "Total", styles: { fontStyle: "bold", halign: "right" } } as any,
{ content: num(total), styles: { fontStyle: "bold", halign: "right" } } as any]);
autoTable(doc, {
startY: (doc as any).__y, head: [["Account", "Property", "Owner Name", "Credit Amount"]], body,
styles: { fontSize: 8.5, cellPadding: 4 }, headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 3: { halign: "right" } }, margin: { left: 40, right: 40 },
});
}
async function appendReserveFund(doc: jsPDF, ctx: BatchCtx) {
const { data: reserveAccts } = await accounting.from("accounts")
.select("id,name,code,type,is_reserve").eq("company_id", ctx.companyId).eq("is_reserve", true).eq("is_archived", false).order("code");
const ids = (reserveAccts ?? []).map((a: any) => a.id);
if (!ids.length) return sectionNote(doc, "Reserve Fund Schedule", "No reserve accounts flagged.", ctx);
const { data: glLines } = await accounting.from("journal_entry_lines")
.select("debit,credit,account_id,journal_entries!inner(company_id,date)")
.eq("journal_entries.company_id", ctx.companyId).in("account_id", ids).lte("journal_entries.date", ctx.to).limit(50000);
const bal = new Map<string, number>();
for (const l of (glLines ?? []) as any[]) {
const a = (reserveAccts ?? []).find((x: any) => x.id === l.account_id); if (!a) continue;
const nat = DEBIT_NATURAL.includes(a.type) ? Number(l.debit || 0) - Number(l.credit || 0) : Number(l.credit || 0) - Number(l.debit || 0);
bal.set(l.account_id, (bal.get(l.account_id) ?? 0) + nat);
}
let total = 0;
const body = (reserveAccts ?? []).map((a: any) => {
const b = bal.get(a.id) ?? 0; total += b;
return [`${a.code ? a.code + " " : ""}${a.name}`, numAcc(b)];
});
body.push([{ content: "TOTAL RESERVE FUNDS", styles: { fontStyle: "bold" } } as any,
{ content: numAcc(total), styles: { fontStyle: "bold", halign: "right" } } as any]);
sectionTitle(doc, "Reserve Fund Schedule", `As of ${fmtDate(ctx.to)}`, ctx);
autoTable(doc, {
startY: (doc as any).__y, head: [["Account", "Current Balance"]], body,
styles: { fontSize: 9, cellPadding: 4 }, headStyles: { fillColor: TEAL, textColor: 255 },
columnStyles: { 1: { halign: "right" } }, margin: { left: 40, right: 40 },
});
}
// ── Shared section heading + cover ────────────────────────────────────────────
function sectionTitle(doc: jsPDF, title: string, subtitle: string, ctx: BatchCtx) {
const W = doc.internal.pageSize.getWidth();
doc.setFont("helvetica", "bold"); doc.setFontSize(16); doc.setTextColor(20);
doc.text(title, W / 2, 48, { align: "center" });
doc.setFont("helvetica", "normal"); doc.setFontSize(10); doc.setTextColor(90);
doc.text(subtitle, W / 2, 64, { align: "center" });
doc.setFontSize(9); doc.text(ctx.companyName, W / 2, 78, { align: "center" });
(doc as any).__y = 96;
}
function sectionNote(doc: jsPDF, title: string, note: string, ctx: BatchCtx) {
sectionTitle(doc, title, "", ctx);
doc.setFontSize(10); doc.setTextColor(120);
doc.text(note, 40, (doc as any).__y + 10);
}
function drawCover(doc: jsPDF, ctx: BatchCtx, reportIds: string[]) {
const W = doc.internal.pageSize.getWidth();
let y = 160;
doc.setFont("helvetica", "bold"); doc.setFontSize(24); doc.setTextColor(20);
doc.text(ctx.companyName, W / 2, y, { align: "center" }); y += 32;
doc.setFont("helvetica", "normal"); doc.setFontSize(14); doc.setTextColor(90);
doc.text("Financial Report Package", W / 2, y, { align: "center" }); y += 24;
doc.setFontSize(11); doc.text(`${fmtDate(ctx.from)} ${fmtDate(ctx.to)}`, W / 2, y, { align: "center" }); y += 50;
doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(20);
doc.text("Contents", W / 2, y, { align: "center" }); y += 18;
doc.setFont("helvetica", "normal"); doc.setFontSize(10); doc.setTextColor(70);
const labels = new Map(BATCHABLE_REPORTS.map((r) => [r.id, r.name]));
for (const id of reportIds) { doc.text(`${labels.get(id) ?? id}`, W / 2, y, { align: "center" }); y += 16; }
}
/** Build one combined PDF for a saved batch. Each report starts on a fresh page. */
export async function generateBatchPdf(ctx: BatchCtx, reportIds: string[]): Promise<jsPDF> {
const ordered = BATCHABLE_REPORTS.map((r) => r.id).filter((id) => reportIds.includes(id));
const doc = new jsPDF({ unit: "pt", format: "letter" });
drawCover(doc, ctx, ordered);
for (const id of ordered) {
doc.addPage();
try {
if (FINANCIAL_IDS.has(id)) await appendFinancial(doc, id, ctx);
else if (id === "trial-balance") await appendTrialBalance(doc, ctx);
else if (id === "general-ledger") await appendGeneralLedger(doc, ctx);
else if (id === "cash-disbursement") await appendCashDisbursement(doc, ctx);
else if (id === "ar-aging-property") await appendArAging(doc, ctx);
else if (id === "prepaid-homeowners") await appendPrepaid(doc, ctx);
else if (id === "reserve-fund") await appendReserveFund(doc, ctx);
} catch (e) {
sectionNote(doc, BATCHABLE_REPORTS.find((r) => r.id === id)?.name ?? id,
`Could not generate: ${e instanceof Error ? e.message : String(e)}`, ctx);
}
}
drawBrandedFooter(doc); // one global "Page X of Y" across the whole packet
return doc;
}