mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Fix report exports: Budget vs Actuals + branded cover everywhere
- Budget vs Actuals now exports (CSV + branded PDF); previously the page's export buttons were disabled for it. Wired into hasOwnExport with its own buttons in the report. - Apply the shared branded cover page to the remaining PDF exports so they match the main scheme: homeowner account Statement (AccountingCustomer DetailPage), Trial Balance, and General Ledger. Note: payments already render on the accounting customer ledger/statement via payments_received (phase 3+4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { toast } from "sonner";
|
|||||||
import { money, fmtDate } from "./lib/format";
|
import { money, fmtDate } from "./lib/format";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
import autoTable from "jspdf-autotable";
|
import autoTable from "jspdf-autotable";
|
||||||
|
import { drawReportCoverPage } from "@/lib/reportCover";
|
||||||
|
|
||||||
function startOfFY() {
|
function startOfFY() {
|
||||||
return new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0, 10);
|
return new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0, 10);
|
||||||
@@ -234,9 +235,17 @@ export default function AccountingCustomerDetailPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["customers", cid] });
|
qc.invalidateQueries({ queryKey: ["customers", cid] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportStatement = () => {
|
const exportStatement = async () => {
|
||||||
if (!homeowner) return;
|
if (!homeowner) return;
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
|
// Shared branded cover page (matches all other report exports), then detail.
|
||||||
|
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), {
|
||||||
|
title: "Homeowner Statement",
|
||||||
|
date: `${fmtDate(from)} – ${fmtDate(to)}`,
|
||||||
|
companyName: associationName ?? "Association",
|
||||||
|
preparedBy: "Avria Community Management, LLC",
|
||||||
|
});
|
||||||
|
doc.addPage();
|
||||||
doc.setFontSize(16);
|
doc.setFontSize(16);
|
||||||
doc.text(associationName ?? "Association", 40, 50);
|
doc.text(associationName ?? "Association", 40, 50);
|
||||||
doc.setFontSize(14);
|
doc.setFontSize(14);
|
||||||
@@ -271,9 +280,9 @@ export default function AccountingCustomerDetailPage() {
|
|||||||
doc.save(`statement-${homeowner.name.replace(/\s+/g, "_")}.pdf`);
|
doc.save(`statement-${homeowner.name.replace(/\s+/g, "_")}.pdf`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const emailStatement = () => {
|
const emailStatement = async () => {
|
||||||
if (!homeowner) return;
|
if (!homeowner) return;
|
||||||
exportStatement();
|
await exportStatement();
|
||||||
const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`);
|
const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`);
|
||||||
const body = encodeURIComponent(
|
const body = encodeURIComponent(
|
||||||
`Hello ${homeowner.name},\n\nPlease find attached your homeowner statement for the period ${fmtDate(from)} – ${fmtDate(to)}.\nCurrent balance due: ${money(currentBalance, cur)}.\n\nThank you.`
|
`Hello ${homeowner.name},\n\nPlease find attached your homeowner statement for the period ${fmtDate(from)} – ${fmtDate(to)}.\nCurrent balance due: ${money(currentBalance, cur)}.\n\nThank you.`
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export default function AccountingReportsPage() {
|
|||||||
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
|
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
|
||||||
|
|
||||||
// Reports whose export is handled internally (own PDF/CSV buttons inside the component)
|
// Reports whose export is handled internally (own PDF/CSV buttons inside the component)
|
||||||
const hasOwnExport = active === "trial-balance" || active === "general-ledger";
|
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals";
|
||||||
const anyExportable = !!(structured || flat || exportFlat);
|
const anyExportable = !!(structured || flat || exportFlat);
|
||||||
|
|
||||||
const doExportPDF = async () => {
|
const doExportPDF = async () => {
|
||||||
@@ -528,7 +528,7 @@ export default function AccountingReportsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
{active === "budget-vs-actuals" && (
|
{active === "budget-vs-actuals" && (
|
||||||
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} />
|
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
|
||||||
)}
|
)}
|
||||||
{active === "trial-balance" && (
|
{active === "trial-balance" && (
|
||||||
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} />
|
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} />
|
||||||
@@ -1184,7 +1184,7 @@ function flatToStructured(flat: Flat, title: string): StructuredReport {
|
|||||||
|
|
||||||
// ---------- Budget vs Actuals ----------
|
// ---------- Budget vs Actuals ----------
|
||||||
|
|
||||||
function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string; from: string; to: string; currency: string }) {
|
function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string }) {
|
||||||
const { data: budgets = [] } = useQuery({
|
const { data: budgets = [] } = useQuery({
|
||||||
queryKey: ["budgets-active", companyId],
|
queryKey: ["budgets-active", companyId],
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
@@ -1311,6 +1311,59 @@ function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string;
|
|||||||
];
|
];
|
||||||
}, [grouped, budgetByAcct, actualByAcct]);
|
}, [grouped, budgetByAcct, actualByAcct]);
|
||||||
|
|
||||||
|
// Flattened rows (group totals + accounts) shared by the CSV and PDF exports.
|
||||||
|
const exportRows = useMemo(() => {
|
||||||
|
const rows: { label: string; budget: number; actual: number; variance: number; pct: string; group: boolean }[] = [];
|
||||||
|
for (const t of TYPES_LOCAL) {
|
||||||
|
const accs = grouped[t.value] ?? [];
|
||||||
|
if (!accs.length) continue;
|
||||||
|
const tb = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
|
||||||
|
const ta = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
|
||||||
|
const tv = ta - tb;
|
||||||
|
rows.push({ label: t.label, budget: tb, actual: ta, variance: tv, pct: tb ? `${((tv / tb) * 100).toFixed(1)}%` : "—", group: true });
|
||||||
|
for (const a of accs) {
|
||||||
|
const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; const v = ac - b;
|
||||||
|
rows.push({ label: a.code ? `${a.name} (${a.code})` : a.name, budget: b, actual: ac, variance: v, pct: b ? `${((v / b) * 100).toFixed(1)}%` : "—", group: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [grouped, budgetByAcct, actualByAcct]);
|
||||||
|
|
||||||
|
const fileBase = `budget-vs-actuals-${from}-to-${to}`;
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
const esc = (s: any) => `"${String(s).replace(/"/g, '""')}"`;
|
||||||
|
const lines = [["Account", "Budget", "Actual", "Variance", "Variance %"].join(",")];
|
||||||
|
for (const r of exportRows) lines.push([esc(r.label), r.budget.toFixed(2), r.actual.toFixed(2), r.variance.toFixed(2), esc(r.pct)].join(","));
|
||||||
|
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url; a.download = `${fileBase}.csv`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportPDF = async () => {
|
||||||
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
|
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), {
|
||||||
|
title: "Budget vs Actuals", date: rangeLabel, companyName, preparedBy: "Avria Community Management, LLC",
|
||||||
|
});
|
||||||
|
doc.addPage();
|
||||||
|
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
|
||||||
|
doc.text("Budget vs Actuals", 40, 50);
|
||||||
|
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110, 116, 122);
|
||||||
|
doc.text(`${companyName} · ${rangeLabel}`, 40, 66);
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: 80,
|
||||||
|
head: [["Account", "Budget", "Actual", "Variance", "Variance %"]],
|
||||||
|
body: exportRows.map((r) => [r.label, money(r.budget, currency), money(r.actual, currency), money(r.variance, currency), r.pct]),
|
||||||
|
styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
|
||||||
|
headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
|
||||||
|
columnStyles: { 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" } },
|
||||||
|
didParseCell: ({ row, cell, section }) => { if (section === "body" && exportRows[row.index]?.group) cell.styles.fontStyle = "bold"; },
|
||||||
|
});
|
||||||
|
doc.save(`${fileBase}.pdf`);
|
||||||
|
};
|
||||||
|
|
||||||
if (!budgets.length) {
|
if (!budgets.length) {
|
||||||
return (
|
return (
|
||||||
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground">
|
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||||
@@ -1330,6 +1383,10 @@ function BudgetVsActuals({ companyId, from, to, currency }: { companyId: string;
|
|||||||
{budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)}
|
{budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||||||
|
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { FileDown, Download, ChevronDown, ChevronsDownUp, ChevronsUpDown, AlertT
|
|||||||
import { fmtDate } from "../lib/format";
|
import { fmtDate } from "../lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
|
import { drawReportCoverPage } from "@/lib/reportCover";
|
||||||
import autoTable from "jspdf-autotable";
|
import autoTable from "jspdf-autotable";
|
||||||
|
|
||||||
const TEAL: [number, number, number] = [0, 137, 123];
|
const TEAL: [number, number, number] = [0, 137, 123];
|
||||||
@@ -142,12 +143,21 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
|
|
||||||
const toggleAccount = (id: string) => setSelectedAccounts((s) => s.includes(id) ? s.filter((x) => x !== id) : [...s, id]);
|
const toggleAccount = (id: string) => setSelectedAccounts((s) => s.includes(id) ? s.filter((x) => x !== id) : [...s, id]);
|
||||||
|
|
||||||
const exportPDF = () => {
|
const exportPDF = async () => {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
const W = doc.internal.pageSize.getWidth();
|
const W = doc.internal.pageSize.getWidth();
|
||||||
const H = doc.internal.pageSize.getHeight();
|
const H = doc.internal.pageSize.getHeight();
|
||||||
const ML = 54;
|
const ML = 54;
|
||||||
|
|
||||||
|
// Shared branded cover page, then the report on the next page.
|
||||||
|
await drawReportCoverPage(doc, W, H, {
|
||||||
|
title: "General Ledger",
|
||||||
|
date: `${fmtDate(from)} – ${fmtDate(to)}`,
|
||||||
|
companyName: companyName || "Association",
|
||||||
|
preparedBy: "Avria Community Management, LLC",
|
||||||
|
});
|
||||||
|
doc.addPage();
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
|
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
|
||||||
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);
|
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { CheckCircle2, AlertTriangle, FileDown, Download } from "lucide-react";
|
import { CheckCircle2, AlertTriangle, FileDown, Download } from "lucide-react";
|
||||||
import { fmtDate } from "../lib/format";
|
import { fmtDate } from "../lib/format";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
|
import { drawReportCoverPage } from "@/lib/reportCover";
|
||||||
import autoTable from "jspdf-autotable";
|
import autoTable from "jspdf-autotable";
|
||||||
|
|
||||||
type Account = {
|
type Account = {
|
||||||
@@ -91,11 +92,20 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
|
|||||||
const diff = totals.debit - totals.credit;
|
const diff = totals.debit - totals.credit;
|
||||||
const inBalance = Math.abs(diff) < 0.005;
|
const inBalance = Math.abs(diff) < 0.005;
|
||||||
|
|
||||||
const exportPDF = () => {
|
const exportPDF = async () => {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
const W = doc.internal.pageSize.getWidth();
|
const W = doc.internal.pageSize.getWidth();
|
||||||
const ML = 54;
|
const ML = 54;
|
||||||
|
|
||||||
|
// Shared branded cover page, then the report on the next page.
|
||||||
|
await drawReportCoverPage(doc, W, doc.internal.pageSize.getHeight(), {
|
||||||
|
title: "Trial Balance",
|
||||||
|
date: `As of ${fmtDate(asOf)}`,
|
||||||
|
companyName: companyName || "Association",
|
||||||
|
preparedBy: "Avria Community Management, LLC",
|
||||||
|
});
|
||||||
|
doc.addPage();
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
|
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
|
||||||
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);
|
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);
|
||||||
|
|||||||
Reference in New Issue
Block a user