mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -13,7 +13,9 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RTooltip, Legend, ResponsiveContainer } from "recharts";
|
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 { toast } from "sonner";
|
||||||
import { money, fmtDate } from "./lib/format";
|
import { money, fmtDate } from "./lib/format";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
@@ -121,12 +123,8 @@ async function fetchAllGLLines(cid: string, to: string, select: string, from?: s
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useReportData(cid: string, from: string, to: string) {
|
// Shared fetch for the financial reports (also used by the report-batch engine).
|
||||||
|
export async function fetchReportData(cid: string, from: string, to: string) {
|
||||||
return useQuery({
|
|
||||||
queryKey: ["reports-data", cid, from, to],
|
|
||||||
enabled: !!cid,
|
|
||||||
queryFn: async () => {
|
|
||||||
const ytdStart = new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0,10);
|
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([
|
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),
|
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,
|
glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true,
|
||||||
from, asOf: to,
|
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
|
// Period
|
||||||
const [preset, setPreset] = useState<Preset>("ytd");
|
const [preset, setPreset] = useState<Preset>("ytd");
|
||||||
const [from, setFrom] = useState(startOfYear());
|
const [from, setFrom] = useState(startOfMonth());
|
||||||
const [to, setTo] = useState(today());
|
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) => {
|
const applyPreset = (p: Preset) => {
|
||||||
setPreset(p);
|
setPreset(p);
|
||||||
if (p !== "custom") {
|
if (p !== "custom") {
|
||||||
@@ -509,6 +573,9 @@ export default function AccountingReportsPage({ association }: { association?: {
|
|||||||
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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}>
|
<Button variant="outline" onClick={refreshReport} disabled={refreshing}>
|
||||||
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} /> Refresh
|
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} /> Refresh
|
||||||
</Button>
|
</Button>
|
||||||
@@ -663,6 +730,64 @@ export default function AccountingReportsPage({ association }: { association?: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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 === "pnl") return buildPnL(d, p, useCompare);
|
||||||
if (id === "balance-sheet") return buildBalanceSheet(d, p, useCompare);
|
if (id === "balance-sheet") return buildBalanceSheet(d, p, useCompare);
|
||||||
if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare);
|
if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user