Files
acmcc/src/pages/accounting/AccountingReportsPage.tsx
T
admin f74c61c9df Budget vs Actuals: custom actuals comparison date range
Add From/To date inputs to the Budget vs Actuals report (Accounting section
Reports) so actuals can be compared to the budget over any custom range,
independent of the page period preset. Defaults to the report period; drives
the actuals queries and CSV/PDF export labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:54:54 -04:00

1892 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "./lib/useCompanyId";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
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 } from "lucide-react";
import { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import {
renderReportPdf, fmtAmount,
type StructuredReport, type StructuredRow,
} from "./lib/reportPdf";
import { calcNetIncome, isRetainedEarnings, isCurrentYearEarnings, isSystemEquityAccount } from "./lib/earnings";
import { reconcile, type RecAccount, type RecLine } from "./lib/reconcile";
import {
computePnL, computeMargins, toMinor, fromMinor, PnlValidationError,
type PnlAccount, type PnlClassification, type Posting as PnlPosting, type PnlResult,
} from "./lib/pnl";
import { Lock } from "lucide-react";
import { TrialBalanceReport } from "./components/TrialBalanceReport";
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
type ReportId =
| "pnl" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
| "trial-balance" | "general-ledger"
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency"
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation";
const APP_NAME = "Cozy Books";
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"];
const GROUPS = [
{ name: "Business Overview", reports: [
{ id: "pnl" as ReportId, name: "Profit & Loss" },
{ id: "balance-sheet" as ReportId, name: "Balance Sheet" },
{ id: "cash-flow" as ReportId, name: "Cash Flow Statement" },
{ id: "movement-of-equity" as ReportId, name: "Movement of Equity" },
{ id: "trial-balance" as ReportId, name: "Trial Balance" },
{ id: "general-ledger" as ReportId, name: "General Ledger" },
{ id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" },
]},
{ name: "Receivables", reports: [
{ id: "ar-aging" as ReportId, name: "AR Aging Details" },
{ id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" },
{ id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" },
{ id: "invoice-summary" as ReportId, name: "Invoice Summary" },
{ id: "delinquency" as ReportId, name: "Delinquency Report" },
]},
{ name: "Payables", reports: [
{ id: "ap-aging" as ReportId, name: "AP Aging Details" },
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
]},
{ name: "Audit", reports: [
{ id: "reconciliation" as ReportId, name: "Reconciliation Checks" },
]},
];
const TZ_ET = "America/New_York";
function etDateStr(d = new Date()) { return d.toLocaleDateString("en-CA", { timeZone: TZ_ET }); } // YYYY-MM-DD in ET
function startOfYear() { return `${new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric" })}-01-01`; }
function today() { return etDateStr(); }
function shiftBack(from: string, to: string) {
const d1 = new Date(from), d2 = new Date(to);
const span = d2.getTime() - d1.getTime();
const pTo = new Date(d1.getTime() - 86400000);
const pFrom = new Date(pTo.getTime() - span);
return { from: pFrom.toISOString().slice(0, 10), to: pTo.toISOString().slice(0, 10) };
}
function useReportData(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 [inv, bills, accs, exp, custs, vends, ob, ytdInv, ytdExp, ytdBills, allBills, glRes, glCumRes, allInvRes] = 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("bills").select("number,total,paid_amount,status,issue_date,due_date,vendors(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to),
accounting.from("accounts").select("id,name,code,type,subtype,balance,is_bank").eq("company_id", cid),
accounting.from("expenses").select("date,category,amount,vendor_name,vendors(name)").eq("company_id", cid).gte("date", from).lte("date", to),
accounting.from("customers").select("id,name,balance,email,phone,property_address,lot_number").eq("company_id", cid).order("name"),
accounting.from("vendors").select("id,name").eq("company_id", cid),
accounting.from("opening_balances").select("account_id,debit,credit").eq("company_id", cid),
accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to),
accounting.from("expenses").select("amount,date").eq("company_id", cid).gte("date", ytdStart).lte("date", to),
// YTD bills (accrual — for net income in Balance Sheet / Movement of Equity)
accounting.from("bills").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to),
// All bills (not date-filtered) for AP aging
accounting.from("bills").select("id,vendor_id,total,paid_amount,status,due_date,issue_date,vendors(id,name)").eq("company_id", cid),
// General-ledger lines in period — P&L is built from these, grouped by account
accounting.from("journal_entry_lines")
.select("debit,credit,accounts!inner(id,name,code,type,parent_account_id),journal_entries!inner(company_id,date)")
.eq("journal_entries.company_id", cid)
.gte("journal_entries.date", from)
.lte("journal_entries.date", to),
// Cumulative GL through `to` — Balance Sheet is built from these (as-of balances)
accounting.from("journal_entry_lines")
.select("debit,credit,account_id,accounts!inner(type),journal_entries!inner(company_id,date)")
.eq("journal_entries.company_id", cid)
.lte("journal_entries.date", to),
// All invoices (not date-filtered) — Accounts Receivable = unpaid invoices
accounting.from("invoices").select("total,paid_amount,status").eq("company_id", cid),
]);
return {
invoices: inv.data ?? [], bills: bills.data ?? [], accounts: accs.data ?? [],
expenses: exp.data ?? [], customers: custs.data ?? [], vendors: vends.data ?? [],
openingBalances: ob.data ?? [],
ytdInvoices: ytdInv.data ?? [], ytdExpenses: ytdExp.data ?? [], ytdBills: ytdBills.data ?? [],
allBills: allBills.data ?? [],
glLines: glRes.data ?? [],
glCumulative: glCumRes.data ?? [],
allInvoices: allInvRes.data ?? [],
from, asOf: to,
};
},
});
}
// ── Period preset helpers ────────────────────────────────────────────────────
function etNow() {
// Current date/time as a Date object normalised to ET by parsing the ET date string
const etStr = new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric", month: "2-digit", day: "2-digit" });
return new Date(etStr); // local Date at ET midnight
}
function startOfMonth(d = etNow()) {
return new Date(d.getFullYear(), d.getMonth(), 1).toLocaleDateString("en-CA");
}
function endOfMonth(d = etNow()) {
return new Date(d.getFullYear(), d.getMonth() + 1, 0).toLocaleDateString("en-CA");
}
function startOfLastMonth() {
const d = etNow(); d.setMonth(d.getMonth() - 1);
return startOfMonth(d);
}
function endOfLastMonth() {
const d = etNow(); d.setMonth(d.getMonth() - 1);
return endOfMonth(d);
}
function startOfLastYear() {
return `${etNow().getFullYear() - 1}-01-01`;
}
function endOfLastYear() {
return `${etNow().getFullYear() - 1}-12-31`;
}
type Preset = "this-month" | "last-month" | "this-quarter" | "ytd" | "last-year" | "custom";
type CompareMode = "none" | "prior-period" | "prior-year" | "custom";
function presetDates(p: Preset): { from: string; to: string } {
const now = new Date();
const q = Math.floor(now.getMonth() / 3);
switch (p) {
case "this-month": return { from: startOfMonth(), to: endOfMonth() };
case "last-month": return { from: startOfLastMonth(), to: endOfLastMonth() };
case "this-quarter": return { from: new Date(now.getFullYear(), q * 3, 1).toISOString().slice(0, 10), to: today() };
case "ytd": return { from: startOfYear(), to: today() };
case "last-year": return { from: startOfLastYear(), to: endOfLastYear() };
default: return { from: startOfYear(), to: today() };
}
}
function compareDates(mode: CompareMode, from: string, to: string, customFrom: string, customTo: string) {
if (mode === "none") return null;
if (mode === "prior-period") return shiftBack(from, to);
if (mode === "prior-year") {
const py = (d: string) => `${parseInt(d.slice(0, 4)) - 1}${d.slice(4)}`;
return { from: py(from), to: py(to) };
}
if (mode === "custom") return { from: customFrom, to: customTo };
return null;
}
export default function AccountingReportsPage() {
const { companyId, associationName } = useCompanyId();
const cid = companyId ?? "";
const cur = "USD";
const [active, setActive] = useState<ReportId>("pnl");
// Period
const [preset, setPreset] = useState<Preset>("ytd");
const [from, setFrom] = useState(startOfYear());
const [to, setTo] = useState(today());
const applyPreset = (p: Preset) => {
setPreset(p);
if (p !== "custom") {
const d = presetDates(p);
setFrom(d.from);
setTo(d.to);
}
};
// Comparison
const [compareMode, setCompareMode] = useState<CompareMode>("none");
const [compareFrom, setCompareFrom] = useState(startOfLastYear());
const [compareTo, setCompareTo] = useState(endOfLastYear());
const showCompare = compareMode !== "none";
const comparePeriod = compareDates(compareMode, from, to, compareFrom, compareTo);
// Toggles
const [showCodes, setShowCodes] = useState(false);
const [showZero, setShowZero] = useState(false);
const [preview, setPreview] = useState(false);
const { data } = useReportData(cid, from, to);
const { data: prevData } = useReportData(
showCompare && comparePeriod ? cid : "",
comparePeriod?.from ?? from,
comparePeriod?.to ?? to
);
// AR open invoices — used by AR aging, homeowner summary, delinquency
const arReports: ReportId[] = ["customer-balances", "ar-aging", "homeowner-summary", "delinquency"];
const { data: arOpen = [] } = useQuery({
queryKey: ["ar-aging", cid],
enabled: !!cid && arReports.includes(active),
queryFn: async () => {
const { data } = await accounting
.from("invoices")
.select("id,customer_id,total,paid_amount,due_date,issue_date,status,number,customers(id,name)")
.eq("company_id", cid);
return data ?? [];
},
});
const isFinancial = FINANCIAL.includes(active);
const activeMeta = GROUPS.flatMap(g => g.reports).find(r => r.id === active)!;
const rangeLabel = active === "balance-sheet"
? `As of ${fmtDate(to)}`
: `${fmtDate(from)} ${fmtDate(to)}`;
const structured = useMemo<StructuredReport | null>(() => {
if (!data || !isFinancial) return null;
return buildFinancial(active, data, prevData, showCompare);
}, [active, data, prevData, showCompare, isFinancial]);
const flat = useMemo(() => (data && !isFinancial) ? buildFlat(active, data, cur) : null, [active, data, cur, isFinancial]);
// Build exportable flat data for custom-rendered reports (aging, delinquency, etc.)
const exportFlat = useMemo((): Flat | null => {
if (structured || flat) return null; // already have a better source
const m = (n: number) => money(n, cur);
const now = new Date();
if (active === "ar-aging" || active === "customer-balances") {
type AR = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
const byC = new Map<string, AR>();
for (const inv of arOpen as any[]) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
if (open <= 0) continue;
const cid2 = inv.customer_id; if (!cid2) continue;
const name = inv.customers?.name ?? "—";
const days = Math.floor((now.getTime() - new Date(inv.due_date ?? inv.issue_date).getTime()) / 86400000);
const r = byC.get(cid2) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open;
r.total += open; byC.set(cid2, r);
}
const list = [...byC.values()].sort((a, b) => b.total - a.total);
const tot = list.reduce((s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }), { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 });
return {
title: activeMeta.name, columns: ["Homeowner", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"],
rows: [...list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]),
["TOTAL", m(tot.current), m(tot.d30), m(tot.d60), m(tot.d90), m(tot.d90p), m(tot.total)]],
boldRows: [list.length],
};
}
if (active === "ap-aging") {
type AP = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
const byV = new Map<string, AP>();
for (const b of ((data as any)?.allBills ?? []) as any[]) {
const open = Number(b.total ?? 0) - Number(b.paid_amount ?? 0);
if (open <= 0) continue;
const vid = b.vendor_id ?? b.id; const name = b.vendors?.name ?? "Unknown";
const days = Math.floor((now.getTime() - new Date(b.due_date ?? b.issue_date ?? Date.now()).getTime()) / 86400000);
const r = byV.get(vid) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open;
r.total += open; byV.set(vid, r);
}
const list = [...byV.values()].sort((a, b) => b.total - a.total);
return {
title: "AP Aging Details", columns: ["Vendor", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"],
rows: list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]),
};
}
if (active === "homeowner-summary") {
const customers = (data as any)?.customers ?? [];
const openByC = new Map<string, { open: number; count: number }>();
for (const inv of arOpen as any[]) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
if (open > 0) { const e = openByC.get(inv.customer_id) ?? { open: 0, count: 0 }; e.open += open; e.count++; openByC.set(inv.customer_id, e); }
}
const rows = (customers as any[])
.map((c: any) => [c.name, c.property_address ?? "—", c.lot_number ? `Lot ${c.lot_number}` : "—", String(openByC.get(c.id)?.count ?? 0), m(openByC.get(c.id)?.open ?? 0)])
.sort((a, b) => parseFloat(String(b[4]).replace(/[^0-9.]/g, "")) - parseFloat(String(a[4]).replace(/[^0-9.]/g, "")));
return { title: "Homeowner Balance Summary", columns: ["Homeowner", "Property", "Lot", "Open Invoices", "Outstanding"] , rows };
}
if (active === "delinquency") {
const customers = (data as any)?.customers ?? [];
const overdue = new Map<string, { name: string; property: string; email: string; phone: string; amount: number; oldest: number }>();
for (const inv of arOpen as any[]) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
if (open <= 0) continue;
const due = new Date(inv.due_date ?? inv.issue_date);
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
if (days <= 0) continue;
const cust = (customers as any[]).find((c: any) => c.id === inv.customer_id);
const r = overdue.get(inv.customer_id) ?? { name: cust?.name ?? "—", property: cust?.property_address ?? "—", email: cust?.email ?? "—", phone: cust?.phone ?? "—", amount: 0, oldest: 0 };
r.amount += open; r.oldest = Math.max(r.oldest, days); overdue.set(inv.customer_id, r);
}
const list = [...overdue.values()].sort((a, b) => b.amount - a.amount);
return {
title: "Delinquency Report", columns: ["Homeowner", "Property", "Email", "Phone", "Days Overdue", "Amount Overdue"],
rows: list.map(r => [r.name, r.property, r.email, r.phone, String(r.oldest), m(r.amount)]),
};
}
return null;
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
// Reports whose export is handled internally (own PDF/CSV buttons inside the component)
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals";
const anyExportable = !!(structured || flat || exportFlat);
const doExportPDF = () => {
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
const src = flat ?? exportFlat;
if (structured) {
const doc = renderReportPdf(
structured,
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero },
);
doc.save(`${fileBase}.pdf`);
} else if (src) {
const doc = new jsPDF({ orientation: src.columns.length > 6 ? "landscape" : "portrait" });
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
doc.text(src.title, 14, 16);
doc.setFont("helvetica", "bold"); doc.setFontSize(9);
doc.text("Properties:", 14, 24);
const lw = doc.getTextWidth("Properties:");
doc.setFont("helvetica", "normal");
doc.text(` ${associationName ?? ""}`, 14 + lw, 24);
doc.setFont("helvetica", "bold"); doc.text("Period:", 14, 30);
const lw2 = doc.getTextWidth("Period:");
doc.setFont("helvetica", "normal"); doc.text(` ${rangeLabel}`, 14 + lw2, 30);
const lastCol = src.columns.length - 1;
autoTable(doc, {
head: [src.columns],
body: src.rows.map(r => r.map(String)),
startY: 36,
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 },
alternateRowStyles: { fillColor: [247, 248, 250] },
columnStyles: { [lastCol]: { halign: "right" } },
didParseCell: ({ row, cell }) => { if (src.boldRows?.includes(row.index)) cell.styles.fontStyle = "bold"; },
didDrawPage: () => {
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(110, 116, 122);
doc.text(`Created on ${new Date().toLocaleDateString("en-US")}`, 14, pageH - 8);
doc.text(`Page ${doc.getNumberOfPages()}`, pageW - 14, pageH - 8, { align: "right" });
},
});
doc.save(`${fileBase}.pdf`);
} else {
toast.error("No data to export for this report");
}
};
const doExportCSV = () => {
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
let lines: string[] = [];
if (structured) {
lines.push(["Account", "Amount", showCompare ? "Previous" : ""].filter(Boolean).join(","));
for (const r of structured.rows) {
if (r.kind === "spacer") continue;
if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) continue;
const label = (r.kind === "sub" ? " " : "") + (showCodes && r.code ? `${r.code} ${r.label}` : r.label);
const cells = [`"${label}"`, fmtAmount(r.amount)];
if (showCompare) cells.push(fmtAmount(r.compare));
lines.push(cells.join(","));
}
} else {
const src = flat ?? exportFlat;
if (src) {
lines.push(src.columns.map(c => `"${c}"`).join(","));
for (const r of src.rows) lines.push(r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(","));
}
}
if (!lines.length) { toast.error("No data to export"); return; }
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = `${fileBase}.csv`; a.click();
};
// Keep old names for any remaining references
const exportPDF = doExportPDF;
const exportCSV = doExportCSV;
return (
<div className="flex gap-6">
<aside className="w-60 shrink-0">
<Card>
<CardContent className="p-3 space-y-4">
{GROUPS.map((g) => (
<div key={g.name}>
<div className="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{g.name}</div>
<ul className="space-y-0.5">
{g.reports.map((r) => (
<li key={r.id}>
<button
onClick={() => setActive(r.id)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${active === r.id ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{r.name}</span>
</button>
</li>
))}
</ul>
</div>
))}
</CardContent>
</Card>
</aside>
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">{activeMeta.name}</h1>
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
</div>
<div className="flex gap-2">
{hasOwnExport ? (
<span className="text-xs text-muted-foreground self-center">Export available inside the report </span>
) : (
<>
{isFinancial && <Button variant="outline" onClick={() => setPreview(true)}><Eye className="mr-1 h-4 w-4" /> Preview</Button>}
<Button variant="outline" onClick={exportCSV} disabled={!anyExportable}><Download className="mr-1 h-4 w-4" /> CSV</Button>
<Button onClick={exportPDF} disabled={!anyExportable}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
</>
)}
</div>
</div>
{/* Period presets */}
<Card>
<CardContent className="py-3 px-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground w-14">Period</span>
{([
{ v: "this-month", l: "This Month" },
{ v: "last-month", l: "Last Month" },
{ v: "this-quarter", l: "This Quarter" },
{ v: "ytd", l: "YTD" },
{ v: "last-year", l: "Last Year" },
{ v: "custom", l: "Custom" },
] as { v: Preset; l: string }[]).map(({ v, l }) => (
<button key={v} onClick={() => applyPreset(v)}
className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${preset === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
{l}
</button>
))}
{preset === "custom" && (
<div className="flex items-center gap-2 ml-2">
<Input type="date" value={from} onChange={e => setFrom(e.target.value)} className="h-7 w-36 text-xs" />
<span className="text-muted-foreground text-xs">to</span>
<Input type="date" value={to} onChange={e => setTo(e.target.value)} className="h-7 w-36 text-xs" />
</div>
)}
</div>
{/* Comparison period — only for financial reports */}
{isFinancial && active !== "balance-sheet" && (
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-xs font-medium text-muted-foreground w-14">Compare</span>
{([
{ v: "none", l: "None" },
{ v: "prior-period", l: "Prior Period" },
{ v: "prior-year", l: "Prior Year" },
{ v: "custom", l: "Custom" },
] as { v: CompareMode; l: string }[]).map(({ v, l }) => (
<button key={v} onClick={() => setCompareMode(v)}
className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${compareMode === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
{l}
</button>
))}
{compareMode === "custom" && (
<div className="flex items-center gap-2 ml-2">
<Input type="date" value={compareFrom} onChange={e => setCompareFrom(e.target.value)} className="h-7 w-36 text-xs" />
<span className="text-muted-foreground text-xs">to</span>
<Input type="date" value={compareTo} onChange={e => setCompareTo(e.target.value)} className="h-7 w-36 text-xs" />
</div>
)}
</div>
)}
{/* Display toggles */}
<div className="flex flex-wrap gap-4 border-t pt-3">
<Toggle id="t-codes" checked={showCodes} onChange={setShowCodes} label="Account codes" />
<Toggle id="t-zero" checked={showZero} onChange={setShowZero} label="Zero-balance accounts" />
</div>
</CardContent>
</Card>
</div>
{active === "budget-vs-actuals" && (
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
)}
{active === "trial-balance" && (
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} />
)}
{active === "general-ledger" && (
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} />
)}
{active === "reconciliation" && (
<ReconciliationReport d={data} currency={cur} />
)}
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reconciliation" && (
<Card>
<CardContent>
{!data ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : structured ? (
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} />
) : active === "customer-balances" ? (
<ARAgingTable rows={arOpen as any[]} currency={cur} />
) : active === "ar-aging" ? (
<ARAgingTable rows={arOpen as any[]} currency={cur} detailed />
) : active === "ap-aging" ? (
<APAgingTable rows={(data as any)?.allBills ?? []} currency={cur} />
) : active === "homeowner-summary" ? (
<HomeownerSummaryTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
) : active === "delinquency" ? (
<DelinquencyTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
) : flat && flat.rows.length > 0 ? (
<Table>
<TableHeader>
<TableRow>{flat.columns.map((c, i) => (
<TableHead key={c} className={i === flat.columns.length - 1 ? "text-right" : ""}>{c}</TableHead>
))}</TableRow>
</TableHeader>
<TableBody>
{flat.rows.map((row, i) => (
<TableRow key={i} className={flat.boldRows?.includes(i) ? "font-semibold bg-muted/30" : ""}>
{row.map((cell, j) => (
<TableCell key={j} className={j === row.length - 1 ? "text-right" : ""}>{cell}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div>
)}
</CardContent>
</Card>
)}
</div>
{/* Print Preview Modal */}
<Dialog open={preview} onOpenChange={setPreview}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Print preview · {activeMeta.name}</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap items-center gap-5 border-y bg-muted/40 px-4 py-3 text-sm">
<Toggle id="t-codes" checked={showCodes} onChange={setShowCodes} label="Show account codes" />
<Toggle id="t-compare" checked={showCompare} onChange={(v) => setCompareMode(v ? "prior-year" : "none")} label="Show comparative period" disabled={active === "balance-sheet"} />
<Toggle id="t-zero" checked={showZero} onChange={setShowZero} label="Show zero-balance accounts" />
</div>
<div className="overflow-auto flex-1 bg-muted/30 p-6">
<PreviewSheet
report={structured ?? (flat ? flatToStructured(flat, activeMeta.name) : null)}
companyName={associationName ?? "Company"}
rangeLabel={rangeLabel}
showCodes={showCodes}
showCompare={showCompare && isFinancial}
showZero={showZero}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPreview(false)}>Close</Button>
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> Download PDF</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Toggle({ id, checked, onChange, label, disabled }: { id: string; checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
return (
<div className="flex items-center gap-2">
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
<Label htmlFor={id} className={disabled ? "text-muted-foreground" : ""}>{label}</Label>
</div>
);
}
function StructuredTable({ report, showCodes, showCompare, showZero, currency }: {
report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
}) {
let alt = false;
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
<th className="py-2 text-left font-semibold">Account</th>
{showCompare && <th className="py-2 text-right font-semibold">Previous</th>}
<th className="py-2 text-right font-semibold">Amount</th>
</tr>
</thead>
<tbody>
{report.rows.map((r, i) => {
if (r.kind === "spacer") { alt = false; return <tr key={i}><td colSpan={3} className="h-3" /></tr>; }
if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) return null;
if (r.kind === "section") {
alt = false;
return (
<tr key={i} className="bg-[hsl(174_47%_94%)]">
<td colSpan={showCompare ? 3 : 2} className="py-2 px-2 font-semibold text-[hsl(174_70%_25%)] text-sm">{r.label}</td>
</tr>
);
}
if (r.kind === "group") {
alt = false;
return (
<tr key={i}>
<td colSpan={showCompare ? 3 : 2} className="pt-2.5 pb-1 pl-4 font-semibold text-sm">{r.label}</td>
</tr>
);
}
const bold = r.kind === "total" || r.kind === "grand";
const shaded = r.kind === "sub" && alt;
if (r.kind === "sub") alt = !alt;
return (
<tr key={i} className={[
shaded ? "bg-muted/40" : "",
bold ? "border-t font-semibold" : "",
].join(" ")}>
<td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}>
{showCodes && r.code && <span className="text-xs text-muted-foreground mr-2 font-mono">{r.code}</span>}
{r.label}
</td>
{showCompare && <AmountCell n={r.compare} />}
<AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
</tr>
);
})}
</tbody>
{report.balanced !== undefined && (
<tfoot>
<tr>
<td colSpan={3} className="pt-3">
<div className={`rounded-md px-4 py-2 text-sm font-semibold text-white ${report.balanced ? "bg-primary" : "bg-red-600"}`}>
{report.balanced ? "Balance Sheet is balanced ✓" : `Balance Sheet is OUT OF BALANCE by ${money(report.outOfBalanceAmount ?? 0, currency)} (Assets Liabilities Equity)`}
</div>
</td>
</tr>
</tfoot>
)}
{report.cashHighlight && (
<tfoot>
<tr><td colSpan={3} className="pt-3">
<div className="flex items-center justify-between rounded-md border border-primary bg-[hsl(174_47%_94%)] px-4 py-2.5 text-sm font-semibold text-[hsl(174_70%_25%)]">
<span>{report.cashHighlight.label}</span>
<span className={report.cashHighlight.amount < 0 ? "text-red-600" : ""}>{fmtAmount(report.cashHighlight.amount)}</span>
</div>
</td></tr>
</tfoot>
)}
</table>
);
}
function AmountCell({ n, bold, doubleUnderline }: { n?: number; bold?: boolean; doubleUnderline?: boolean }) {
if (n === undefined) return <td className="py-1.5 px-2" />;
const cls = [
"py-1.5 px-2 text-right tabular-nums",
n < 0 ? "text-red-600" : "",
bold ? "font-semibold" : "",
doubleUnderline ? "border-b-4 border-double border-primary" : "",
].join(" ");
return <td className={cls}>{fmtAmount(n)}</td>;
}
function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare, showZero }: {
report: StructuredReport | null;
companyName: string; rangeLabel: string; showCodes: boolean; showCompare: boolean; showZero: boolean;
}) {
if (!report) return <div className="text-center text-muted-foreground py-12">No data.</div>;
return (
<div className="mx-auto bg-white shadow-md" style={{ width: 816, minHeight: 1056, fontFamily: "Georgia, 'Times New Roman', serif" }}>
<div className="px-12 pt-10 pb-6">
<div className="flex items-start gap-4">
<div className="h-11 w-11 rounded bg-primary flex items-center justify-center text-white font-bold text-lg">
{(companyName[0] ?? "?").toUpperCase()}
</div>
<div className="text-xl font-bold text-gray-900 mt-1">{companyName}</div>
</div>
<h1 className="text-2xl font-bold text-center mt-4">{report.title}</h1>
<div className="text-center text-sm text-gray-500 mt-1">{rangeLabel}</div>
<div className="mt-3 h-[1.5px] bg-primary" />
</div>
<div className="px-12 pb-12">
<StructuredTable report={report} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency="" />
</div>
<div className="px-12 pb-6">
<div className="h-[1px] bg-primary mb-3" />
<div className="text-center text-xs text-gray-500">Page 1 of 1</div>
<div className="text-center text-xs text-gray-500">Generated by {APP_NAME} on {new Date().toLocaleDateString()}</div>
</div>
</div>
);
}
// ---------- Financial report builders (structured) ----------
// Reconciliation matrix (§9) surfaced as visible residuals — never plug a residual.
function ReconciliationReport({ d, currency }: { d: any; currency: string }) {
if (!d) return <div className="text-sm text-muted-foreground">Loading</div>;
const accounts: RecAccount[] = ((d.accounts ?? []) as any[]).map((a) => ({
id: a.id, type: a.type, name: a.name,
is_cash: !!a.is_bank || /cash|undeposited/i.test(String(a.name || "")),
}));
const lines: RecLine[] = ((d.glCumulative ?? []) as any[]).map((l) => ({
account_id: l.account_id, date: String(l.journal_entries?.date ?? ""),
debit: Number(l.debit || 0), credit: Number(l.credit || 0),
}));
const openInv = ((d.allInvoices ?? []) as any[]).filter((i) => i.status !== "void").reduce((s, i) => s + (Number(i.total || 0) - Number(i.paid_amount || 0)), 0);
const openBill = ((d.allBills ?? []) as any[]).filter((b) => b.status !== "void").reduce((s, b) => s + (Number(b.total || 0) - Number(b.paid_amount || 0)), 0);
const checks = reconcile({ accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill });
const ok = (r: number) => Math.abs(r) < 0.005;
const allPass = checks.every((c) => c.pass);
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Reconciliation Checks
<span className={`text-xs rounded px-2 py-0.5 ${allPass ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"}`}>
{allPass ? "All passing" : "Residuals present"}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Check</TableHead>
<TableHead>Assertion</TableHead>
<TableHead className="text-right">Residual</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{checks.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-mono">{c.id}</TableCell>
<TableCell>{c.label}</TableCell>
<TableCell className={`text-right tabular-nums ${ok(c.residual) ? "" : "text-red-600 font-semibold"}`}>{money(c.residual, currency)}</TableCell>
<TableCell className="text-right">{ok(c.residual) ? "✓" : "✗"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-xs text-muted-foreground p-4">
A non-zero residual is a bug signal (§9), not to be plugged. R1/R2 failing means the ledger is
unbalanced (often an imported single-sided entry). R7/R8 failing means A/R or A/P is summing gross
billings instead of open balances, or a sub-ledger doesn't tie to the GL control account.
</p>
</CardContent>
</Card>
);
}
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);
if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare);
return buildCashFlow(d, p, useCompare);
}
function buildMovementOfEquity(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
const byType = (t: string) => d.accounts.filter((a: any) => a.type === t);
const equityAccs = byType("equity");
const reAccount = equityAccs.find((a: any) => isRetainedEarnings(a));
const draws = equityAccs.filter((a: any) => /draw|dividend/i.test(a.name));
const capital = equityAccs.filter((a: any) => !isSystemEquityAccount(a) && !/draw|dividend/i.test(a.name));
const reOB = (d.openingBalances ?? []).find((b: any) => b.account_id === reAccount?.id);
const openingRE = reOB ? Number(reOB.credit || 0) - Number(reOB.debit || 0) : (reAccount ? Number(reAccount.balance) : 0);
const capitalTotal = capital.reduce((s: number, a: any) => s + Number(a.balance), 0);
const drawsTotal = draws.reduce((s: number, a: any) => s + Number(a.balance), 0);
const netIncome = calcNetIncome({ invoices: d.ytdInvoices ?? d.invoices, expenses: d.ytdExpenses ?? d.expenses, bills: d.ytdBills ?? d.bills });
const prevNetIncome = useCompare && p ? calcNetIncome({ invoices: p.ytdInvoices ?? p.invoices, expenses: p.ytdExpenses ?? p.expenses, bills: p.ytdBills ?? p.bills }) : undefined;
const openingEquity = capitalTotal + openingRE;
const closingEquity = openingEquity + netIncome - drawsTotal;
const prevClosing = useCompare && prevNetIncome !== undefined ? openingEquity + prevNetIncome - drawsTotal : undefined;
const rows: StructuredRow[] = [
{ kind: "section", label: "Opening Equity" },
...capital.map((a: any) => ({ kind: "sub" as const, label: a.name, code: a.code ?? undefined, amount: Number(a.balance) })),
{ kind: "sub", label: "Retained Earnings (prior periods)", amount: openingRE },
{ kind: "total", label: "Total Opening Equity", amount: openingEquity },
{ kind: "spacer", label: "" },
{ kind: "section", label: "Period Activity" },
{ kind: "sub", label: "Net Income", amount: netIncome, compare: prevNetIncome },
...draws.map((a: any) => ({ kind: "sub" as const, label: `Less: ${a.name}`, amount: -Math.abs(Number(a.balance)) })),
{ kind: "total", label: "Net Change in Equity", amount: netIncome - drawsTotal, compare: prevNetIncome !== undefined ? prevNetIncome - drawsTotal : undefined },
{ kind: "spacer", label: "" },
{ kind: "grand", label: "Closing Equity", amount: closingEquity, compare: prevClosing },
];
return { title: "Movement of Equity", rows };
}
// ── P&L from the general ledger (grouped by parent account) ───────────────────
type GLAccountTotal = { name: string; code: string | null; amount: number; parentId: string | null };
/**
* Group journal-entry lines by GL account, split into income/expense.
* Income accounts net to creditdebit; expense accounts to debitcredit.
*/
function groupGLByAccount(lines: any[]) {
const income = new Map<string, GLAccountTotal>();
const expense = new Map<string, GLAccountTotal>();
for (const l of lines ?? []) {
const acc = l.accounts;
if (!acc) continue;
const debit = Number(l.debit ?? 0);
const credit = Number(l.credit ?? 0);
const parentId = acc.parent_account_id ?? null;
if (acc.type === "income") {
const e = income.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId };
e.amount += credit - debit;
income.set(acc.id, e);
} else if (acc.type === "expense") {
const e = expense.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId };
e.amount += debit - credit;
expense.set(acc.id, e);
}
}
return { income, expense };
}
/**
* Emit a P&L section, grouping accounts under their parent account. Top-level
* accounts (no parent) are listed directly; child accounts are nested under a
* parent group header with a "Total for <parent>" subtotal. Returns section totals.
*/
function pushPnLSection(
rows: StructuredRow[], label: string,
accMap: Map<string, GLAccountTotal>, prevMap: Map<string, GLAccountTotal>,
parentName: Map<string, string>, useCompare: boolean
): { total: number; prevTotal: number } {
rows.push({ kind: "section", label });
const ungrouped: [string, GLAccountTotal][] = [];
const groups = new Map<string, [string, GLAccountTotal][]>();
for (const entry of accMap.entries()) {
const pid = entry[1].parentId;
if (pid && parentName.has(pid)) {
const g = groups.get(pid) ?? [];
g.push(entry); groups.set(pid, g);
} else ungrouped.push(entry);
}
let total = 0, prevTotal = 0;
const byName = (a: [string, GLAccountTotal], b: [string, GLAccountTotal]) => a[1].name.localeCompare(b[1].name);
const emit = (id: string, v: GLAccountTotal) => {
total += v.amount; prevTotal += prevMap.get(id)?.amount ?? 0;
rows.push({ kind: "sub", label: v.name, code: v.code ?? undefined, amount: v.amount, compare: useCompare ? prevMap.get(id)?.amount : undefined });
};
for (const [id, v] of ungrouped.sort(byName)) emit(id, v);
const sortedGroups = [...groups.entries()].sort((a, b) => (parentName.get(a[0]) ?? "").localeCompare(parentName.get(b[0]) ?? ""));
for (const [pid, entries] of sortedGroups) {
const gName = parentName.get(pid) ?? "Group";
rows.push({ kind: "group", label: gName });
let gTotal = 0, gPrev = 0;
for (const [id, v] of entries.sort(byName)) {
gTotal += v.amount; gPrev += prevMap.get(id)?.amount ?? 0;
emit(id, v);
}
rows.push({ kind: "total", label: `Total for ${gName}`, amount: gTotal, compare: useCompare ? gPrev : undefined });
}
return { total, prevTotal };
}
// P&L sub-classification is read from the account's explicit `subtype` field
// (never inferred from names — see the P&L spec §4). Unclassified nominal
// accounts default to plain revenue / operating expense, which is correct for
// HOA charts that have no COGS, tax, or contra accounts.
const PNL_SUBTYPE_MAP: Record<string, PnlClassification> = {
revenue: "REVENUE", sales: "REVENUE", income: "REVENUE", operating_income: "REVENUE",
contra_revenue: "CONTRA_REVENUE", sales_returns: "CONTRA_REVENUE",
sales_allowances: "CONTRA_REVENUE", sales_discounts: "CONTRA_REVENUE",
cogs: "COGS", cost_of_goods_sold: "COGS", cost_of_sales: "COGS",
contra_cogs: "CONTRA_COGS", purchase_discounts: "CONTRA_COGS", purchase_returns: "CONTRA_COGS",
operating_expense: "OPERATING_EXPENSE", opex: "OPERATING_EXPENSE", sga: "OPERATING_EXPENSE",
selling_general_admin: "OPERATING_EXPENSE", depreciation: "OPERATING_EXPENSE",
other_income: "OTHER_INCOME", non_operating_income: "OTHER_INCOME", interest_income: "OTHER_INCOME",
other_expense: "OTHER_EXPENSE", non_operating_expense: "OTHER_EXPENSE", interest_expense: "OTHER_EXPENSE",
tax: "TAX_EXPENSE", income_tax: "TAX_EXPENSE", tax_expense: "TAX_EXPENSE",
};
function classifyAccount(a: any): PnlClassification {
const key = String(a.subtype ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_");
if (key && PNL_SUBTYPE_MAP[key]) return PNL_SUBTYPE_MAP[key];
return a.type === "income" ? "REVENUE" : "OPERATING_EXPENSE";
}
function runPnLEngine(d: any): PnlResult | { error: string } {
const nominal = ((d.accounts ?? []) as any[]).filter((a) => a.type === "income" || a.type === "expense");
const accts: PnlAccount[] = nominal.map((a) => ({ id: a.id, name: a.name, type: a.type, classification: classifyAccount(a) }));
const allow = new Set(nominal.map((a) => a.id));
const postings: PnlPosting[] = ((d.glLines ?? []) as any[])
.filter((l) => l.accounts && allow.has(l.accounts.id))
.map((l) => ({
accountId: l.accounts.id,
debitMinor: toMinor(l.debit ?? 0),
creditMinor: toMinor(l.credit ?? 0),
date: l.journal_entries?.date ?? d.from ?? d.asOf,
}));
try {
return computePnL(postings, accts, { periodStart: d.from ?? d.asOf, periodEnd: d.asOf });
} catch (e) {
if (e instanceof PnlValidationError) return { error: e.message };
return { error: e instanceof Error ? e.message : String(e) };
}
}
function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
const cur = runPnLEngine(d);
if ("error" in cur) {
return {
title: "Profit & Loss",
rows: [
{ kind: "section", label: "Profit & Loss could not be computed (validation failed)" },
{ kind: "section", label: cur.error },
],
};
}
const prevRes = useCompare && p ? runPnLEngine(p) : undefined;
const prevOk = prevRes && !("error" in prevRes) ? (prevRes as PnlResult) : undefined;
const s = cur.subtotals;
const ps = prevOk?.subtotals;
const D = fromMinor;
const cmp = (v?: number) => (useCompare && v !== undefined ? D(v) : undefined);
const rows: StructuredRow[] = [];
const prevByAcct = new Map((prevOk?.lines ?? []).map((l) => [l.accountId, l.amountMinor]));
// Emit per-account line items for the given classifications. `displaySign`
// shows contra/expense reductions as negative figures in the column.
const lineRows = (classes: PnlClassification[], displaySign: 1 | -1) => {
for (const l of cur.lines.filter((x) => classes.includes(x.classification)).sort((a, b) => a.name.localeCompare(b.name))) {
rows.push({
kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined,
});
}
};
const has = (cs: PnlClassification[]) => cur.lines.some((l) => cs.includes(l.classification));
const hasContra = has(["CONTRA_REVENUE"]);
const hasCogs = has(["COGS", "CONTRA_COGS"]) || s.costOfGoodsSold !== 0;
const hasOther = has(["OTHER_INCOME", "OTHER_EXPENSE"]) || s.otherNet !== 0;
const hasTax = has(["TAX_EXPENSE"]) || s.incomeTaxExpense !== 0;
// Revenue → Net Revenue
rows.push({ kind: "section", label: "Revenue" });
lineRows(["REVENUE"], 1);
if (hasContra) lineRows(["CONTRA_REVENUE"], -1);
rows.push({ kind: "total", label: "Net Revenue", amount: D(s.netRevenue), compare: cmp(ps?.netRevenue) });
rows.push({ kind: "spacer", label: "" });
// Cost of Goods Sold → Gross Profit (only when present)
if (hasCogs) {
rows.push({ kind: "section", label: "Cost of Goods Sold" });
lineRows(["COGS"], 1);
if (has(["CONTRA_COGS"])) lineRows(["CONTRA_COGS"], -1);
rows.push({ kind: "total", label: "Total Cost of Goods Sold", amount: D(s.costOfGoodsSold), compare: cmp(ps?.costOfGoodsSold) });
rows.push({ kind: "total", label: "Gross Profit", amount: D(s.grossProfit), compare: cmp(ps?.grossProfit) });
rows.push({ kind: "spacer", label: "" });
}
// Operating Expenses → Operating Income (EBIT)
rows.push({ kind: "section", label: "Operating Expenses" });
lineRows(["OPERATING_EXPENSE"], 1);
rows.push({ kind: "total", label: "Total Operating Expenses", amount: D(s.operatingExpenses), compare: cmp(ps?.operatingExpenses) });
rows.push({ kind: "total", label: "Operating Income (EBIT)", amount: D(s.operatingIncome), compare: cmp(ps?.operatingIncome) });
// Other Income / (Expense)
if (hasOther) {
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "section", label: "Other Income / (Expense)" });
lineRows(["OTHER_INCOME"], 1);
lineRows(["OTHER_EXPENSE"], -1);
rows.push({ kind: "total", label: "Total Other Income / (Expense)", amount: D(s.otherNet), compare: cmp(ps?.otherNet) });
}
// Pre-Tax Income (EBT) — shown when it differs from Operating Income
if (hasOther || hasTax) {
rows.push({ kind: "total", label: "Pre-Tax Income (EBT)", amount: D(s.preTaxIncome), compare: cmp(ps?.preTaxIncome) });
}
// Income Tax
if (hasTax) {
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "section", label: "Income Tax" });
lineRows(["TAX_EXPENSE"], 1);
rows.push({ kind: "total", label: "Income Tax Expense", amount: D(s.incomeTaxExpense), compare: cmp(ps?.incomeTaxExpense) });
}
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "grand", label: "Net Income", amount: D(s.netIncome), compare: cmp(ps?.netIncome) });
// Margins (presentation-only, §5)
const m = computeMargins(cur);
if (m.netMargin !== null) {
const pct = (x: number | null) => (x === null ? "—" : `${(x * 100).toFixed(1)}%`);
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "section", label: `Margins — Gross ${pct(m.grossMargin)} · Operating ${pct(m.operatingMargin)} · Net ${pct(m.netMargin)}` });
}
return { title: "Profit & Loss", rows };
}
function buildBalanceSheet(d: any): StructuredReport {
// Balance Sheet is computed from the general ledger (journal entries) +
// opening balances, so every account with GL activity appears with its real
// as-of balance. Sign convention (natural balance, positive):
// asset & expense → debit credit
// liability/equity/income → credit debit
const accounts = (d.accounts ?? []) as any[];
const fyStart = `${String(d.asOf ?? "").slice(0, 4)}-01-01`;
const isDebitNormal = (t: string) => t === "asset" || t === "expense";
// Opening balances are posted to the GL (acmacc_opening) for managed companies
// and are already inside the imported GL for the rest — so the Balance Sheet
// reads balances from the GL alone (no separate opening-balance add).
// Cumulative GL (natural) per account, plus income/expense net split by FY
const glByAcct = new Map<string, number>();
let incomeAll = 0, expenseAll = 0, incomePrior = 0, expensePrior = 0;
for (const l of (d.glCumulative ?? []) as any[]) {
const t = l.accounts?.type;
if (!t) continue;
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
const nat = isDebitNormal(t) ? debit - credit : credit - debit;
glByAcct.set(l.account_id, (glByAcct.get(l.account_id) ?? 0) + nat);
const isPrior = String(l.journal_entries?.date ?? "") < fyStart;
if (t === "income") { incomeAll += credit - debit; if (isPrior) incomePrior += credit - debit; }
else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; }
}
const balOf = (a: any) => (glByAcct.get(a.id) ?? 0);
const byType = (t: string) => accounts.filter((a) => a.type === t);
const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0);
const rePrior = incomePrior - expensePrior; // prior-year retained earnings (from GL)
const cye = (incomeAll - expenseAll) - rePrior; // current-year earnings
// A/P and A/R come from the general ledger (native bills/invoices are posted
// to the GL by syncBillsInvoicesToLedger), so they reflect the outstanding
// balances AND keep the sheet in balance — no out-of-GL override.
const rows: StructuredRow[] = [];
// Assets
rows.push({ kind: "section", label: "Assets" });
const assets = byType("asset");
for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a) });
const totalA = sumBal(assets);
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA });
rows.push({ kind: "spacer", label: "" });
// Liabilities (Accounts Payable = unpaid bills)
rows.push({ kind: "section", label: "Liabilities" });
const liabs = byType("liability");
for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a) });
const totalL = sumBal(liabs);
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL });
rows.push({ kind: "spacer", label: "" });
// Equity — equity accounts (opening + GL) plus calculated RE / CYE from the ledger
rows.push({ kind: "section", label: "Equity" });
const equityAccs = byType("equity");
for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a) });
rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: rePrior });
rows.push({ kind: "sub", label: "Current Year Earnings", amount: cye });
const totalE = sumBal(equityAccs) + rePrior + cye;
rows.push({ kind: "total", label: "Total Equity", amount: totalE });
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE });
// Invariant check in integer cents — exact, no binary-float drift (spec §6/§8).
// The residual is surfaced (never plugged): residual = Assets (Liab + Equity).
const cents = (n: number) => Math.round(n * 100);
const residualCents = cents(totalA) - cents(totalL + totalE);
const balanced = residualCents === 0;
return {
title: "Balance Sheet",
rows,
balanced,
outOfBalanceAmount: balanced ? undefined : residualCents / 100,
};
}
// Indirect-method cash flow built from the GL (§5). It ties to the change in the
// Balance Sheet cash accounts by construction (R4): because every entry balances,
// the cash impact of net income + all non-cash balance movements equals ΔCash.
function buildCashFlow(d: any, _p: any | undefined, _useCompare: boolean): StructuredReport {
const from: string = d.from;
const acctById = new Map<string, any>((d.accounts ?? []).map((a: any) => [a.id, a]));
const isCash = (a: any) => !!a && (a.is_bank || /cash|undeposited/i.test(String(a.name || "")));
// Beginning (date < from) and ending (<= asOf) raw balances (debit credit).
const beginRaw = new Map<string, number>();
const endRaw = new Map<string, number>();
for (const l of (d.glCumulative ?? []) as any[]) {
const raw = Number(l.debit || 0) - Number(l.credit || 0);
endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw);
if (String(l.journal_entries?.date ?? "") < from) {
beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw);
}
}
const ids = new Set<string>([...endRaw.keys(), ...beginRaw.keys()]);
const deltaRaw = (id: string) => (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0);
let beginCash = 0, endCash = 0, revenue = 0, expense = 0;
const operating: { label: string; amount: number }[] = [];
let cfi = 0, cff = 0;
for (const id of ids) {
const a = acctById.get(id);
if (!a) continue;
if (isCash(a)) { beginCash += beginRaw.get(id) ?? 0; endCash += endRaw.get(id) ?? 0; continue; }
if (a.type === "income") { revenue += -deltaRaw(id); continue; } // natural = raw
if (a.type === "expense") { expense += deltaRaw(id); continue; } // natural = raw
// Non-cash balance-sheet account: cash impact of its movement = −Δraw.
const impact = -deltaRaw(id);
if (Math.abs(impact) < 0.005) continue;
const name = String(a.name || "").toLowerCase();
if (a.type === "asset") {
const naturalUp = deltaRaw(id) > 0; // asset natural = raw
if (/investment|property|equipment|fixed|capital asset/.test(name)) cfi += impact;
else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
} else if (a.type === "liability") {
const naturalUp = -deltaRaw(id) > 0; // liability natural = raw
if (/loan|note|mortgage|debt|bond/.test(name)) cff += impact;
else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
} else if (a.type === "equity") {
cff += impact; // contributions / distributions / opening equity
}
}
const netIncome = revenue - expense;
const cfo = netIncome + operating.reduce((s, r) => s + r.amount, 0);
const netChange = cfo + cfi + cff;
const deltaCash = endCash - beginCash;
const residual = netChange - deltaCash;
const rows: StructuredRow[] = [];
rows.push({ kind: "section", label: "Operating Activities" });
rows.push({ kind: "sub", label: "Net Income", amount: netIncome });
for (const r of operating) rows.push({ kind: "sub", label: r.label, amount: r.amount });
rows.push({ kind: "total", label: "Net Cash from Operating Activities", amount: cfo });
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "section", label: "Investing Activities" });
rows.push({ kind: "total", label: "Net Cash from Investing Activities", amount: cfi });
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "section", label: "Financing Activities" });
rows.push({ kind: "total", label: "Net Cash from Financing Activities", amount: cff });
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "sub", label: "Beginning Cash", amount: beginCash });
rows.push({ kind: "sub", label: "Ending Cash", amount: endCash });
if (Math.abs(residual) >= 0.005) {
rows.push({ kind: "total", label: "⚠ Out of balance — R4 residual (CFO+CFI+CFF ΔCash)", amount: residual });
}
return {
title: "Cash Flow Statement",
rows,
cashHighlight: { label: "Net Change in Cash", amount: netChange },
};
}
// ---------- Flat (non-financial) report builders ----------
type Flat = { title: string; columns: string[]; rows: (string | number)[][]; boldRows?: number[] };
function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
const m = (n: number) => money(n, cur);
switch (id) {
case "invoice-summary":
return {
title: "Invoice Summary", columns: ["Invoice #", "Customer", "Date", "Status", "Amount"],
rows: d.invoices.map((i: any) => [i.number, i.customers?.name ?? "—", fmtDate(i.issue_date), i.status, m(Number(i.total))]),
};
case "customer-balances":
return {
title: "Customer Balances", columns: ["Customer", "Balance"],
rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
};
case "expense-summary": {
const byCat: Record<string, number> = {};
// Direct expenses from expenses table
for (const e of d.expenses) byCat[e.category] = (byCat[e.category] ?? 0) + Number(e.amount);
// Bill expenses (accrual — total billed, not just paid)
for (const b of d.bills) {
if (b.status === "void" || b.status === "draft") continue;
const cat = b.vendors?.name ?? "Vendor Expenses";
byCat[cat] = (byCat[cat] ?? 0) + Number(b.total);
}
const rows = Object.entries(byCat).sort((a, b) => b[1] - a[1]).map(([cat, amt]) => [cat, m(amt)]);
const total = Object.values(byCat).reduce((s, v) => s + v, 0);
return { title: "Expense Summary (Accrual)", columns: ["Category / Vendor", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
}
case "vendor-balances": {
const byVendor: Record<string, number> = {};
for (const b of d.bills) {
const name = b.vendors?.name ?? "—";
const bal = Number(b.total) - Number(b.paid_amount ?? 0);
if (bal > 0) byVendor[name] = (byVendor[name] ?? 0) + bal;
}
return {
title: "Vendor Balances (open bills)", columns: ["Vendor", "Outstanding"],
rows: Object.entries(byVendor).map(([n, v]) => [n, m(v)]),
};
}
default: return null;
}
}
function flatToStructured(flat: Flat, title: string): StructuredReport {
const rows: StructuredRow[] = [{ kind: "section", label: flat.columns.join(" · ") }];
for (const r of flat.rows) {
const label = r.slice(0, r.length - 1).map(String).join(" · ");
const last = r[r.length - 1];
const num = typeof last === "number" ? last : parseFloat(String(last).replace(/[^0-9.\-]/g, "")) || 0;
rows.push({ kind: "sub", label, amount: num });
}
return { title, rows };
}
// ---------- Budget vs Actuals ----------
/** Order accounts as a tree (parents first, children indented) instead of by number. */
function orderAccountsHierarchically(accs: any[]): any[] {
const byId = new Map(accs.map((a) => [a.id, a]));
const childrenByParent = new Map<string, any[]>();
const roots: any[] = [];
for (const a of accs) {
if (a.parent_account_id && byId.has(a.parent_account_id)) {
const arr = childrenByParent.get(a.parent_account_id) ?? [];
arr.push(a); childrenByParent.set(a.parent_account_id, arr);
} else {
roots.push(a);
}
}
const byCode = (a: any, b: any) => String(a.code ?? "").localeCompare(String(b.code ?? ""));
roots.sort(byCode);
const out: any[] = [];
const visit = (node: any, depth: number) => {
out.push({ ...node, _depth: depth });
for (const k of (childrenByParent.get(node.id) ?? []).sort(byCode)) visit(k, depth + 1);
};
for (const r of roots) visit(r, 0);
return out;
}
function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string }) {
const { data: budgets = [] } = useQuery({
queryKey: ["budgets-active", companyId],
enabled: !!companyId,
queryFn: async () => (await accounting.from("budgets").select("*").eq("company_id", companyId).eq("status", "active").order("created_at", { ascending: false })).data ?? [],
});
const [budgetId, setBudgetId] = useState<string>("");
useEffect(() => { if (!budgetId && budgets.length) setBudgetId(budgets[0].id); }, [budgets, budgetId]);
// Actuals comparison window — defaults to the report period, but the user can
// choose any custom date range to compare against the budget.
const [actFrom, setActFrom] = useState(from);
const [actTo, setActTo] = useState(to);
useEffect(() => { setActFrom(from); setActTo(to); }, [from, to]);
const actualsLabel = `${actFrom} to ${actTo}`;
const { data: accounts = [] } = useQuery({
queryKey: ["accounts", companyId],
enabled: !!companyId,
queryFn: async () => (await accounting.from("accounts").select("*").eq("company_id", companyId).order("code")).data ?? [],
});
const { data: entries = [] } = useQuery({
queryKey: ["budget-entries", budgetId],
enabled: !!budgetId,
queryFn: async () => (await accounting.from("budget_entries").select("*").eq("budget_id", budgetId)).data ?? [],
});
const { data: actualsData } = useQuery({
queryKey: ["bva-actuals", companyId, actFrom, actTo],
enabled: !!companyId,
queryFn: async () => {
const [inv, exp, txns, billItemsRes] = await Promise.all([
// All issued invoices (accrual — not filtered by payment status)
accounting.from("invoices").select("total,status,issue_date").eq("company_id", companyId).gte("issue_date", actFrom).lte("issue_date", actTo).not("status", "in", '("void","draft")'),
// Expenses table (tertiary for expense matching)
accounting.from("expenses").select("amount,category,date").eq("company_id", companyId).gte("date", actFrom).lte("date", actTo),
// Transactions with coa_account_id (primary — covers banking + receive-payments flow)
accounting.from("transactions").select("coa_account_id,amount,type").eq("company_id", companyId).gte("date", actFrom).lte("date", actTo).not("coa_account_id", "is", null),
// Bill items joined to bills in range (secondary — covers bills paid via Bills page)
accounting.from("bill_items").select("account_id,amount,bills!inner(issue_date,company_id)").eq("bills.company_id", companyId).gte("bills.issue_date", actFrom).lte("bills.issue_date", actTo).not("account_id", "is", null),
]);
return {
invoices: inv.data ?? [],
expenses: exp.data ?? [],
transactions: txns.data ?? [],
billItems: billItemsRes.data ?? [],
};
},
});
const TYPES_LOCAL = [
{ value: "income", label: "Income", favorableWhen: "over" as const },
{ value: "expense", label: "Expenses", favorableWhen: "under" as const },
];
const grouped = useMemo(() => {
const out: Record<string, any[]> = { income: [], expense: [] };
for (const a of accounts) if (a.type === "income" || a.type === "expense") out[a.type].push(a);
return out;
}, [accounts]);
// Same accounts, ordered as a parent→child tree (each carries `_depth`).
const groupedOrdered = useMemo(() => ({
income: orderAccountsHierarchically(grouped.income ?? []),
expense: orderAccountsHierarchically(grouped.expense ?? []),
} as Record<string, any[]>), [grouped]);
const budgetByAcct = useMemo(() => {
const m: Record<string, number> = {};
for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
return m;
}, [entries]);
const actualByAcct = useMemo(() => {
const m: Record<string, number> = {};
if (!actualsData) return m;
const incomeAccIds = new Set((grouped.income ?? []).map((a) => a.id));
const expAccs = grouped.expense ?? [];
// ── 1. Transactions with coa_account_id (highest accuracy) ──────────────
for (const tx of actualsData.transactions as any[]) {
if (!tx.coa_account_id) continue;
m[tx.coa_account_id] = (m[tx.coa_account_id] ?? 0) + Number(tx.amount);
}
// ── 2. Bill items with account_id (expense accounts via Bills page) ──────
for (const bi of actualsData.billItems as any[]) {
if (!bi.account_id) continue;
// Only add if not already counted via a transaction (avoid double-counting)
// Bill items are supplemental — add to total
m[bi.account_id] = (m[bi.account_id] ?? 0) + Number(bi.amount);
}
// ── 3. Expenses table — match by account name or code (tertiary) ─────────
for (const e of actualsData.expenses as any[]) {
const cat = String(e.category ?? "").toLowerCase().trim();
const match = expAccs.find((a) =>
a.name.toLowerCase().trim() === cat ||
(a.code && a.code.toLowerCase().trim() === cat)
);
if (match) m[match.id] = (m[match.id] ?? 0) + Number(e.amount);
}
// ── 4. Income: all issued invoices distributed proportionally by budget weight (accrual) ──
const totalPaidInvoices = actualsData.invoices
.filter((i: any) => i.status !== "void" && i.status !== "draft")
.reduce((s: number, i: any) => s + Number(i.total), 0);
const alreadyCountedIncome = (grouped.income ?? []).reduce((s: number, a: any) => s + (m[a.id] ?? 0), 0);
const remainingInvoiceIncome = Math.max(0, totalPaidInvoices - alreadyCountedIncome);
if (remainingInvoiceIncome > 0 && (grouped.income ?? []).length > 0) {
const incomeAccs = grouped.income ?? [];
const totalBudgeted = incomeAccs.reduce((s: number, a: any) => s + (budgetByAcct[a.id] ?? 0), 0);
for (const a of incomeAccs) {
const weight = totalBudgeted > 0
? (budgetByAcct[a.id] ?? 0) / totalBudgeted // proportional to budget
: 1 / incomeAccs.length; // equal if no budget
m[a.id] = (m[a.id] ?? 0) + remainingInvoiceIncome * weight;
}
}
return m;
}, [actualsData, grouped, budgetByAcct]);
const chartData = useMemo(() => {
const sumGroup = (type: "income" | "expense") => {
const accs = grouped[type] ?? [];
const b = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
const ac = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
return { b, ac };
};
const inc = sumGroup("income"); const exp = sumGroup("expense");
return [
{ name: "Income", Budget: inc.b, Actual: inc.ac },
{ name: "Expenses", Budget: exp.b, Actual: exp.ac },
];
}, [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 groupedOrdered[t.value] ?? []) {
const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; const v = ac - b;
const indent = " ".repeat(a._depth ?? 0);
rows.push({ label: `${indent}${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, groupedOrdered, budgetByAcct, actualByAcct]);
const fileBase = `budget-vs-actuals-${actFrom}-to-${actTo}`;
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 = () => {
const doc = new jsPDF({ unit: "pt", format: "letter" });
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} · ${actualsLabel}`, 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) {
return (
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground">
No active budgets yet. <Link to="/dashboard/accounting/budgets" className="text-primary underline">Create one</Link> to compare against actuals.
</CardContent></Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-wrap items-center gap-3 py-4">
<Label className="text-sm">Budget:</Label>
<Select value={budgetId} onValueChange={setBudgetId}>
<SelectTrigger className="h-9 w-[280px]"><SelectValue /></SelectTrigger>
<SelectContent>
{budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Label className="text-sm">Actuals from</Label>
<Input type="date" value={actFrom} onChange={(e) => setActFrom(e.target.value)} className="h-9 w-40" />
<Label className="text-sm">to</Label>
<Input type="date" value={actTo} onChange={(e) => setActTo(e.target.value)} className="h-9 w-40" />
</div>
<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>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Income vs Expenses</CardTitle></CardHeader>
<CardContent className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="name" />
<YAxis tickFormatter={(v) => money(v, currency)} width={90} />
<RTooltip formatter={(v: any) => money(Number(v), currency)} />
<Legend />
<Bar dataKey="Budget" fill="hsl(174 70% 45%)" />
<Bar dataKey="Actual" fill="hsl(214 80% 55%)" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead className="text-right">Budget</TableHead>
<TableHead className="text-right">Actual</TableHead>
<TableHead className="text-right">Variance</TableHead>
<TableHead className="text-right">Variance %</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{TYPES_LOCAL.map((t) => {
const accs = grouped[t.value] ?? [];
if (!accs.length) return null;
const totalB = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
const totalA = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
const totalVar = totalA - totalB;
const totalPct = totalB ? (totalVar / totalB) * 100 : 0;
const totalFavorable = t.favorableWhen === "over" ? totalVar >= 0 : totalVar <= 0;
return (
<Fragment key={t.value}>
<TableRow className="bg-muted/60 font-semibold">
<TableCell>{t.label}</TableCell>
<TableCell className="text-right tabular-nums">{money(totalB, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totalA, currency)}</TableCell>
<TableCell className={`text-right tabular-nums ${totalFavorable ? "text-emerald-600" : "text-red-600"}`}>{money(totalVar, currency)}</TableCell>
<TableCell className={`text-right tabular-nums ${totalFavorable ? "text-emerald-600" : "text-red-600"}`}>{totalB ? `${totalPct.toFixed(1)}%` : "—"}</TableCell>
</TableRow>
{(groupedOrdered[t.value] ?? []).map((a: any) => {
const b = budgetByAcct[a.id] ?? 0;
const ac = actualByAcct[a.id] ?? 0;
const v = ac - b;
const pct = b ? (v / b) * 100 : 0;
const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0;
const depth = a._depth ?? 0;
return (
<TableRow key={a.id} className="hover:bg-muted/20">
<TableCell style={{ paddingLeft: `${16 + depth * 20}px` }}>
<span className={depth === 0 ? "font-semibold" : "font-medium"}>{a.name}</span>
{a.code && <span className="text-xs text-muted-foreground font-mono ml-2">{a.code}</span>}
</TableCell>
<TableCell className="text-right tabular-nums">{money(b, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(ac, currency)}</TableCell>
<TableCell className={`text-right tabular-nums ${fav ? "text-emerald-600" : "text-red-600"}`}>{money(v, currency)}</TableCell>
<TableCell className={`text-right tabular-nums ${fav ? "text-emerald-600" : "text-red-600"}`}>{b ? `${pct.toFixed(1)}%` : "—"}</TableCell>
</TableRow>
);
})}
</Fragment>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
// ── AP Aging ─────────────────────────────────────────────────────────────────
function APAgingTable({ rows, currency }: { rows: any[]; currency: string }) {
const now = new Date();
type APRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
const byVendor = new Map<string, APRow>();
for (const bill of rows) {
const open = Number(bill.total ?? 0) - Number(bill.paid_amount ?? 0);
if (open <= 0) continue;
const vid = bill.vendor_id ?? bill.vendors?.id ?? bill.id;
const name = bill.vendors?.name ?? "Unknown Vendor";
const due = bill.due_date ? new Date(bill.due_date) : new Date(bill.issue_date ?? Date.now());
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
const r = byVendor.get(vid) ?? { id: vid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
if (days <= 0) r.current += open;
else if (days <= 30) r.d30 += open;
else if (days <= 60) r.d60 += open;
else if (days <= 90) r.d90 += open;
else r.d90p += open;
r.total += open;
byVendor.set(vid, r);
}
const list = Array.from(byVendor.values()).sort((a, b) => b.total - a.total);
const totals = list.reduce(
(s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }),
{ current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }
);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Vendor</TableHead>
<TableHead className="text-right">Current</TableHead>
<TableHead className="text-right">130 days</TableHead>
<TableHead className="text-right">3160 days</TableHead>
<TableHead className="text-right">6190 days</TableHead>
<TableHead className="text-right">90+ days</TableHead>
<TableHead className="text-right font-semibold">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<TableRow key={r.id} className="hover:bg-muted/40">
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="text-right tabular-nums">{money(r.current, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-amber-600">{money(r.d30, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-orange-600">{money(r.d60, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-red-500">{money(r.d90, currency)}</TableCell>
<TableCell className="text-right tabular-nums text-red-700 font-medium">{money(r.d90p, currency)}</TableCell>
<TableCell className="text-right tabular-nums font-semibold">{money(r.total, currency)}</TableCell>
</TableRow>
))}
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding payables.</TableCell></TableRow>}
{list.length > 0 && (
<TableRow className="font-semibold bg-muted/30">
<TableCell>Total</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.current, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d30, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d60, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d90, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.d90p, currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(totals.total, currency)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}
// ── Homeowner Balance Summary ─────────────────────────────────────────────────
function HomeownerSummaryTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) {
const byCustomer = useMemo(() => {
const m = new Map<string, { open: number; count: number; lastPaid: string | null }>();
for (const inv of invoices) {
const cid = inv.customer_id;
if (!cid) continue;
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
const isPaid = inv.status === "paid";
const cur = m.get(cid) ?? { open: 0, count: 0, lastPaid: null };
if (open > 0) { cur.open += open; cur.count++; }
if (isPaid && (!cur.lastPaid || inv.issue_date > cur.lastPaid)) cur.lastPaid = inv.issue_date;
m.set(cid, cur);
}
return m;
}, [invoices]);
const rows = customers
.map((c: any) => ({ ...c, ...(byCustomer.get(c.id) ?? { open: 0, count: 0, lastPaid: null }) }))
.sort((a, b) => b.open - a.open);
const totalBalance = rows.reduce((s, r) => s + Number(r.balance ?? 0), 0);
const totalOpen = rows.reduce((s, r) => s + r.open, 0);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Homeowner</TableHead>
<TableHead>Property</TableHead>
<TableHead className="text-right">Open Invoices</TableHead>
<TableHead className="text-right">Outstanding Balance</TableHead>
<TableHead className="text-right">Last Payment</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r: any) => (
<TableRow key={r.id} className="hover:bg-muted/40">
<TableCell>
<Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.property_address ?? "—"}{r.lot_number ? ` · Lot ${r.lot_number}` : ""}</TableCell>
<TableCell className="text-right tabular-nums">{r.count || "—"}</TableCell>
<TableCell className={`text-right tabular-nums font-medium ${r.open > 0 ? "text-red-600" : ""}`}>{money(r.open, currency)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">{r.lastPaid ? fmtDate(r.lastPaid) : "—"}</TableCell>
</TableRow>
))}
{rows.length === 0 && <TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No homeowners.</TableCell></TableRow>}
<TableRow className="font-semibold bg-muted/30">
<TableCell colSpan={3}>Total ({rows.length} homeowners)</TableCell>
<TableCell className="text-right tabular-nums">{money(totalOpen, currency)}</TableCell>
<TableCell />
</TableRow>
</TableBody>
</Table>
);
}
// ── Delinquency Report ────────────────────────────────────────────────────────
function DelinquencyTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) {
const now = new Date();
type DelRow = { id: string; name: string; email: string | null; phone: string | null; property: string | null; overdue: number; oldest: number; invoiceCount: number };
const byCustomer = new Map<string, DelRow>();
for (const inv of invoices) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
if (open <= 0) continue;
const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date ?? Date.now());
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
if (days <= 0) continue; // not overdue yet
const cid = inv.customer_id;
if (!cid) continue;
const cust = customers.find((c: any) => c.id === cid);
const r = byCustomer.get(cid) ?? {
id: cid, name: cust?.name ?? inv.customers?.name ?? "—",
email: cust?.email ?? null, phone: cust?.phone ?? null,
property: cust?.property_address ?? null,
overdue: 0, oldest: 0, invoiceCount: 0,
};
r.overdue += open;
r.oldest = Math.max(r.oldest, days);
r.invoiceCount++;
byCustomer.set(cid, r);
}
const list = Array.from(byCustomer.values()).sort((a, b) => b.overdue - a.overdue);
const totalOverdue = list.reduce((s, r) => s + r.overdue, 0);
const ageBucket = (days: number) => {
if (days <= 30) return { label: "130 days", cls: "bg-amber-100 text-amber-800" };
if (days <= 60) return { label: "3160 days", cls: "bg-orange-100 text-orange-800" };
if (days <= 90) return { label: "6190 days", cls: "bg-red-100 text-red-800" };
return { label: "90+ days", cls: "bg-red-200 text-red-900 font-semibold" };
};
return (
<div className="space-y-3">
{totalOverdue > 0 && (
<div className="rounded-md bg-red-50 border border-red-200 px-4 py-2 text-sm flex items-center justify-between">
<span className="font-medium text-red-800">{list.length} homeowners with overdue balances</span>
<span className="font-bold text-red-900">{money(totalOverdue, currency)} total overdue</span>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Homeowner</TableHead>
<TableHead>Property</TableHead>
<TableHead>Contact</TableHead>
<TableHead className="text-right">Invoices</TableHead>
<TableHead>Age</TableHead>
<TableHead className="text-right">Amount Overdue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => {
const { label, cls } = ageBucket(r.oldest);
return (
<TableRow key={r.id} className="hover:bg-muted/40">
<TableCell>
<Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.property ?? "—"}</TableCell>
<TableCell className="text-sm">
{r.email && <div><a href={`mailto:${r.email}`} className="text-primary hover:underline">{r.email}</a></div>}
{r.phone && <div className="text-muted-foreground">{r.phone}</div>}
</TableCell>
<TableCell className="text-right tabular-nums">{r.invoiceCount}</TableCell>
<TableCell><span className={`rounded-full px-2 py-0.5 text-xs ${cls}`}>{label}</span></TableCell>
<TableCell className="text-right tabular-nums font-semibold text-red-700">{money(r.overdue, currency)}</TableCell>
</TableRow>
);
})}
{list.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No overdue accounts. All homeowners are current.</TableCell></TableRow>}
{list.length > 0 && (
<TableRow className="font-semibold bg-muted/30">
<TableCell colSpan={5}>Total overdue</TableCell>
<TableCell className="text-right tabular-nums text-red-700">{money(totalOverdue, currency)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
// ── AR Aging ──────────────────────────────────────────────────────────────────
function ARAgingTable({ rows, currency, detailed = false }: { rows: any[]; currency: string; detailed?: boolean }) {
const now = new Date();
type AgingRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
const byCustomer = new Map<string, AgingRow>();
for (const inv of rows) {
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
if (open <= 0) continue;
const cid = inv.customer_id ?? inv.customers?.id;
if (!cid) continue;
const name = inv.customers?.name ?? "—";
const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date);
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
const r = byCustomer.get(cid) ?? { id: cid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
if (days <= 0) r.current += open;
else if (days <= 30) r.d30 += open;
else if (days <= 60) r.d60 += open;
else if (days <= 90) r.d90 += open;
else r.d90p += open;
r.total += open;
byCustomer.set(cid, r);
}
const list = Array.from(byCustomer.values()).sort((a, b) => b.total - a.total);
const totals = list.reduce(
(s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }),
{ current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }
);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Homeowner</TableHead>
<TableHead className="text-right">Current</TableHead>
<TableHead className="text-right">130 days</TableHead>
<TableHead className="text-right">3160 days</TableHead>
<TableHead className="text-right">6190 days</TableHead>
<TableHead className="text-right">90+ days</TableHead>
<TableHead className="text-right font-semibold">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/40">
<TableCell>
<Link to={`/dashboard/accounting/customers/${r.id}`} className="text-primary hover:underline">{r.name}</Link>
</TableCell>
<TableCell className="text-right">{money(r.current, currency)}</TableCell>
<TableCell className="text-right">{money(r.d30, currency)}</TableCell>
<TableCell className="text-right">{money(r.d60, currency)}</TableCell>
<TableCell className="text-right">{money(r.d90, currency)}</TableCell>
<TableCell className="text-right">{money(r.d90p, currency)}</TableCell>
<TableCell className="text-right font-medium">{money(r.total, currency)}</TableCell>
</TableRow>
))}
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding receivables.</TableCell></TableRow>}
{list.length > 0 && (
<TableRow className="font-semibold bg-muted/30">
<TableCell>Total</TableCell>
<TableCell className="text-right">{money(totals.current, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d30, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d60, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d90, currency)}</TableCell>
<TableCell className="text-right">{money(totals.d90p, currency)}</TableCell>
<TableCell className="text-right">{money(totals.total, currency)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}