mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user