From 8a57f533175c884edcc6ddd88e2e2f779aee522c Mon Sep 17 00:00:00 2001 From: renee-png Date: Fri, 12 Jun 2026 22:57:12 -0400 Subject: [PATCH] =?UTF-8?q?Accounting=20report=20batches:=20saved=20per-as?= =?UTF-8?q?sociation=20packets=20=E2=86=92=20one=20combined=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../accounting/AccountingReportsPage.tsx | 145 ++++++- src/pages/accounting/lib/batchReports.ts | 382 ++++++++++++++++++ 2 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 src/pages/accounting/lib/batchReports.ts diff --git a/src/pages/accounting/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index e50cc46..c171ce6 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -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("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(["balance-sheet", "pnl", "trial-balance", "general-ledger"]); + const [batchLoadedId, setBatchLoadedId] = useState(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?: {

{rangeLabel}

+ @@ -663,6 +730,64 @@ export default function AccountingReportsPage({ association }: { association?: { )}
+ {/* ── Report Batches dialog ── */} + + + Report Batches +
+

+ Pick a set of reports to combine into one PDF for {associationName ?? "this association"}. + The package uses the period selected on this page ({rangeLabel}). +

+ + {savedBatches.length > 0 && ( +
+ +
+ {(savedBatches as any[]).map((b) => ( +
+ + +
+ ))} +
+
+ )} + +
+ + setBatchName(e.target.value)} placeholder="e.g. Monthly Board Package" className="mt-1" /> +
+ +
+ +
+ {BATCHABLE_REPORTS.map((r) => ( + + ))} +
+
+
+ + {batchLoadedId && ( + + )} + + + +
+
+ ); } @@ -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); diff --git a/src/pages/accounting/lib/batchReports.ts b/src/pages/accounting/lib/batchReports.ts new file mode 100644 index 0000000..16ec317 --- /dev/null +++ b/src/pages/accounting/lib/batchReports.ts @@ -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; + 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 { + 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(); + 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(); + 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(); + 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(); + 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(units.map((u) => [u.id, u])); + const ownerByUnit = new Map(); + for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o); + const byUnit = new Map(); + 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(units.map((u) => [u.id, u])); + const ownerByUnit = new Map(); + for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o); + const bal = new Map(); + 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(); + 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 { + 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; +}