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);