Part B: Δ/Δ% columns, Balance Sheet comparison, Budget vs Actuals comparison

- StructuredTable now renders Comparative + Change + Change % columns when a
  comparison period is on (P&L, Cash Flow, Movement of Equity, Balance Sheet).
- Balance Sheet supports an as-of comparison period (prior as-of balances per
  account + totals); comparison toggle enabled for it (buildBalanceSheet takes
  prior dataset). Current Year Earnings stays independently computed.
- Budget vs Actuals: optional "Compare to" date range adds Compare + Δ-vs-Compare
  columns; actuals computation factored into a shared helper for both windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 01:49:47 -04:00
parent 5383404bb0
commit 3d11980b8c
+129 -115
View File
@@ -488,7 +488,7 @@ export default function AccountingReportsPage() {
</div>
{/* Comparison period — only for financial reports */}
{isFinancial && active !== "balance-sheet" && (
{isFinancial && (
<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>
{([
@@ -582,7 +582,7 @@ export default function AccountingReportsPage() {
</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-compare" checked={showCompare} onChange={(v) => setCompareMode(v ? "prior-year" : "none")} label="Show comparative period" />
<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">
@@ -618,24 +618,31 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
}) {
let alt = false;
const span = showCompare ? 5 : 2;
const pctStr = (amount?: number, compare?: number) => {
if (amount === undefined || compare === undefined || Math.abs(compare) < 0.005) return "—";
return `${(((amount - compare) / Math.abs(compare)) * 100).toFixed(1)}%`;
};
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>
{showCompare && <th className="py-2 text-right font-semibold">Comparative</th>}
{showCompare && <th className="py-2 text-right font-semibold">Change</th>}
{showCompare && <th className="py-2 text-right font-semibold">Change %</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 === "spacer") { alt = false; return <tr key={i}><td colSpan={span} 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>
<td colSpan={span} className="py-2 px-2 font-semibold text-[hsl(174_70%_25%)] text-sm">{r.label}</td>
</tr>
);
}
@@ -643,13 +650,14 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
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>
<td colSpan={span} 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;
const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
return (
<tr key={i} className={[
shaded ? "bg-muted/40" : "",
@@ -659,8 +667,10 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
{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"} />
{showCompare && <AmountCell n={r.compare} />}
{showCompare && <AmountCell n={delta} bold={bold} />}
{showCompare && <td className={`py-1.5 px-2 text-right tabular-nums text-muted-foreground ${bold ? "font-semibold" : ""}`}>{r.amount !== undefined ? pctStr(r.amount, r.compare) : ""}</td>}
</tr>
);
})}
@@ -668,7 +678,7 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
{report.balanced !== undefined && (
<tfoot>
<tr>
<td colSpan={3} className="pt-3">
<td colSpan={span} 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>
@@ -678,7 +688,7 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
)}
{report.cashHighlight && (
<tfoot>
<tr><td colSpan={3} className="pt-3">
<tr><td colSpan={span} 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>
@@ -794,7 +804,7 @@ function ReconciliationReport({ d, currency }: { d: any; currency: string }) {
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 === "balance-sheet") return buildBalanceSheet(d, p, useCompare);
if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare);
return buildCashFlow(d, p, useCompare);
}
@@ -1053,24 +1063,13 @@ function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredRe
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`;
// Per-account natural balances + retained-earnings split from a dataset's GL.
function bsBalances(ds: any) {
const fyStart = `${String(ds?.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[]) {
for (const l of (ds?.glCumulative ?? []) as any[]) {
const t = l.accounts?.type;
if (!t) continue;
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
@@ -1080,50 +1079,56 @@ function buildBalanceSheet(d: any): StructuredReport {
if (t === "income") { incomeAll += credit - debit; if (isPrior) incomePrior += credit - debit; }
else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; }
}
const rePrior = incomePrior - expensePrior;
const cye = (incomeAll - expenseAll) - rePrior;
return { glByAcct, rePrior, cye };
}
const balOf = (a: any) => (glByAcct.get(a.id) ?? 0);
function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredReport {
// GL-derived (opening balances posted to the GL). Current-Year Earnings is
// computed independently from actuals (income expense), never plugged.
const accounts = (d.accounts ?? []) as any[];
const cur = bsBalances(d);
const prev = useCompare && p ? bsBalances(p) : undefined;
const balOf = (a: any) => (cur.glByAcct.get(a.id) ?? 0);
const balOfP = (a: any) => (prev ? (prev.glByAcct.get(a.id) ?? 0) : undefined);
const cmp = (v: number | undefined) => (prev ? v : undefined);
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 sumBalP = (rows: any[]) => (prev ? rows.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined);
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) });
for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
const totalA = sumBal(assets);
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA });
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
rows.push({ kind: "spacer", label: "" });
// Liabilities (Accounts Payable = unpaid bills)
// Liabilities
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) });
for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
const totalL = sumBal(liabs);
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL });
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
rows.push({ kind: "spacer", label: "" });
// Equity — equity accounts (opening + GL) plus calculated RE / CYE from the ledger
// Equity — equity accounts + calculated RE / current-year earnings
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 });
for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) });
rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) });
rows.push({ kind: "sub", label: "Current Year Earnings", amount: cur.cye, compare: cmp(prev?.cye) });
const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye;
const totalEP = prev ? (sumBalP(equityAccs)! + prev.rePrior + prev.cye) : undefined;
rows.push({ kind: "total", label: "Total Equity", amount: totalE, compare: cmp(totalEP) });
rows.push({ kind: "spacer", label: "" });
rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE });
rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE, compare: cmp(prev ? (sumBalP(liabs)! + totalEP!) : undefined) });
// Invariant check in integer cents — exact, no binary-float drift (spec §6/§8).
// The residual is surfaced (never plugged): residual = Assets (Liab + Equity).
// Residual surfaced (never plugged): Assets (Liabilities + Equity).
const cents = (n: number) => Math.round(n * 100);
const residualCents = cents(totalA) - cents(totalL + totalE);
const balanced = residualCents === 0;
@@ -1302,6 +1307,51 @@ function orderAccountsHierarchically(accs: any[]): any[] {
return out;
}
async function fetchBvaActuals(companyId: string, f: string, t: string) {
const [inv, exp, txns, billItemsRes] = await Promise.all([
accounting.from("invoices").select("total,status,issue_date").eq("company_id", companyId).gte("issue_date", f).lte("issue_date", t).not("status", "in", '("void","draft")'),
accounting.from("expenses").select("amount,category,date").eq("company_id", companyId).gte("date", f).lte("date", t),
accounting.from("transactions").select("coa_account_id,amount,type").eq("company_id", companyId).gte("date", f).lte("date", t).not("coa_account_id", "is", null),
accounting.from("bill_items").select("account_id,amount,bills!inner(issue_date,company_id)").eq("bills.company_id", companyId).gte("bills.issue_date", f).lte("bills.issue_date", t).not("account_id", "is", null),
]);
return { invoices: inv.data ?? [], expenses: exp.data ?? [], transactions: txns.data ?? [], billItems: billItemsRes.data ?? [] };
}
// Actuals per account for a given actualsData window (transactions, bill items,
// expenses, and accrual invoice income distributed by budget weight).
function computeBvaActuals(actualsData: any, grouped: Record<string, any[]>, budgetByAcct: Record<string, number>): Record<string, number> {
const m: Record<string, number> = {};
if (!actualsData) return m;
const expAccs = grouped.expense ?? [];
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);
}
for (const bi of actualsData.billItems as any[]) {
if (!bi.account_id) continue;
m[bi.account_id] = (m[bi.account_id] ?? 0) + Number(bi.amount);
}
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);
}
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 : 1 / incomeAccs.length;
m[a.id] = (m[a.id] ?? 0) + remainingInvoiceIncome * weight;
}
}
return m;
}
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],
@@ -1331,27 +1381,21 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
queryFn: async () => (await accounting.from("budget_entries").select("*").eq("budget_id", budgetId)).data ?? [],
});
// Optional comparison window (B3): compare actuals to another date range.
const [cmpOn, setCmpOn] = useState(false);
const [cmpFrom, setCmpFrom] = useState("");
const [cmpTo, setCmpTo] = useState("");
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 ?? [],
};
},
queryFn: () => fetchBvaActuals(companyId, actFrom, actTo),
});
const { data: cmpActualsData } = useQuery({
queryKey: ["bva-actuals-cmp", companyId, cmpFrom, cmpTo],
enabled: !!companyId && cmpOn && !!cmpFrom && !!cmpTo,
queryFn: () => fetchBvaActuals(companyId, cmpFrom, cmpTo),
});
const TYPES_LOCAL = [
@@ -1399,56 +1443,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
return m;
}, [entries, selectedBudget, actFrom, actTo]);
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 actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]);
const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]);
const chartData = useMemo(() => {
const sumGroup = (type: "income" | "expense") => {
@@ -1539,6 +1535,16 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
<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="flex items-center gap-2">
<Label className="text-sm flex items-center gap-1">
<input type="checkbox" checked={cmpOn} onChange={(e) => setCmpOn(e.target.checked)} /> Compare to
</Label>
{cmpOn && (<>
<Input type="date" value={cmpFrom} onChange={(e) => setCmpFrom(e.target.value)} className="h-9 w-40" />
<Label className="text-sm">to</Label>
<Input type="date" value={cmpTo} onChange={(e) => setCmpTo(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>
@@ -1576,6 +1582,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
<TableHead className="text-right">Actual</TableHead>
<TableHead className="text-right">Variance</TableHead>
<TableHead className="text-right">Variance %</TableHead>
{cmpOn && <TableHead className="text-right">Compare</TableHead>}
{cmpOn && <TableHead className="text-right">Δ vs Compare</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@@ -1584,6 +1592,7 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
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 totalC = accs.reduce((s, a) => s + (cmpActualByAcct[a.id] ?? 0), 0);
const totalVar = totalA - totalB;
const totalPct = totalB ? (totalVar / totalB) * 100 : 0;
const totalFavorable = t.favorableWhen === "over" ? totalVar >= 0 : totalVar <= 0;
@@ -1595,10 +1604,13 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
<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>
{cmpOn && <TableCell className="text-right tabular-nums">{money(totalC, currency)}</TableCell>}
{cmpOn && <TableCell className="text-right tabular-nums">{money(totalA - totalC, currency)}</TableCell>}
</TableRow>
{(groupedOrdered[t.value] ?? []).map((a: any) => {
const b = budgetByAcct[a.id] ?? 0;
const ac = actualByAcct[a.id] ?? 0;
const c = cmpActualByAcct[a.id] ?? 0;
const v = ac - b;
const pct = b ? (v / b) * 100 : 0;
const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0;
@@ -1613,6 +1625,8 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
<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>
{cmpOn && <TableCell className="text-right tabular-nums">{money(c, currency)}</TableCell>}
{cmpOn && <TableCell className="text-right tabular-nums">{money(ac - c, currency)}</TableCell>}
</TableRow>
);
})}