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 { 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);
|
||||
|
||||
Reference in New Issue
Block a user