Merge issue-278 (budget grid income-first) into main
This commit is contained in:
commit
e55c3bd250
6 changed files with 285 additions and 67 deletions
|
|
@ -19,6 +19,7 @@
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
- Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262).
|
- Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262).
|
||||||
|
- Budget : la grille budget se lit désormais comme une analyse de résultat — les catégories sont regroupées **Revenus → Dépenses → Transferts** (auparavant dépenses en premier), et l'ancienne ligne « Total » brute est remplacée par deux lignes de résultat : **Résultat avant transferts** (revenus − dépenses) et **Résultat net** (après transferts), calculées sur les colonnes année précédente, annuelle et mensuelles. Toutes les catégories restent affichées sur la grille — elle demeure une surface d'édition, sans ligne masquée ni repliable (#278).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262).
|
- Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262).
|
||||||
|
- Budget: the budget grid now reads as an income statement — categories are grouped **Income → Expenses → Transfers** (previously expenses first), and the old plain "Total" row is replaced by two result lines: **Result before transfers** (income − expenses) and **Net result** (after transfers), computed across the previous-year, annual, and monthly columns alike. Every category still shows on the grid — it stays an editable surface, with no rows hidden or collapsed (#278).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||||
import type { BudgetYearRow } from "../../shared/types";
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
|
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", {
|
const fmt = new Intl.NumberFormat("en-CA", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
|
@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
grouped[key].push(row);
|
grouped[key].push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeOrder = ["expense", "income", "transfer"] as const;
|
// Income-statement reading order: revenue first, then expenses, then
|
||||||
|
// transfers (Issue #278) — matches the compare/trend reports' ordering.
|
||||||
|
const typeOrder = ["income", "expense", "transfer"] as const;
|
||||||
const typeLabelKeys: Record<string, string> = {
|
const typeLabelKeys: Record<string, string> = {
|
||||||
expense: "budget.expenses",
|
expense: "budget.expenses",
|
||||||
income: "budget.income",
|
income: "budget.income",
|
||||||
|
|
@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
transfer: "budget.totalTransfers",
|
transfer: "budget.totalTransfers",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
|
||||||
const monthTotals: number[] = Array(12).fill(0);
|
// computed on the budgeted + previous-year-actual leaves. Mathematically
|
||||||
let annualTotal = 0;
|
// equivalent to the old plain grand-total (income − expense + transfer over
|
||||||
let prevYearTotal = 0;
|
// every leaf), now surfaced as the two labeled result rows below instead of
|
||||||
for (const row of rows) {
|
// a single unlabeled "Total".
|
||||||
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
const results = computeBudgetResults(rows);
|
||||||
const sign = signFor(row.category_type);
|
|
||||||
for (let m = 0; m < 12; m++) {
|
|
||||||
monthTotals[m] += row.months[m] * sign;
|
|
||||||
}
|
|
||||||
annualTotal += row.annual * sign;
|
|
||||||
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCols = 15; // category + prev year + annual + 12 months
|
const totalCols = 15; // category + prev year + annual + 12 months
|
||||||
|
|
||||||
|
|
@ -295,39 +291,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// One type's section: header, its (reorderable) rows, and a leaf-summed
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
// subtotal row. Extracted so it can be called for income/expense, then
|
||||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
// again for transfer once the Résultat rows are interleaved between them.
|
||||||
<button
|
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
|
||||||
onClick={toggleSubtotals}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={13} />
|
|
||||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
|
||||||
<table className="w-full text-sm whitespace-nowrap">
|
|
||||||
<thead className="sticky top-0 z-20">
|
|
||||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
|
||||||
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
|
||||||
{t("budget.category")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
|
||||||
{t("budget.previousYear")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
|
||||||
{t("budget.annual")}
|
|
||||||
</th>
|
|
||||||
{MONTH_KEYS.map((key) => (
|
|
||||||
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
|
|
||||||
{t(key)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{typeOrder.map((type) => {
|
|
||||||
const group = grouped[type];
|
const group = grouped[type];
|
||||||
if (!group || group.length === 0) return null;
|
if (!group || group.length === 0) return null;
|
||||||
const sign = signFor(type);
|
const sign = signFor(type);
|
||||||
|
|
@ -367,18 +334,76 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
||||||
</tr>
|
</tr>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
{/* Totals row */}
|
|
||||||
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
// A Résultat row (avant-transferts subtotal, or the net bottom line).
|
||||||
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
|
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
|
||||||
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
|
// the softer variant mirrors the per-type section-subtotal row above.
|
||||||
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
|
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
|
||||||
{monthTotals.map((total, mIdx) => (
|
const rowClass = strong
|
||||||
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
|
||||||
{formatSigned(total)}
|
: "bg-[var(--muted)]/40 border-b border-[var(--border)]";
|
||||||
|
const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40";
|
||||||
|
const cellWeight = strong ? "" : "font-semibold";
|
||||||
|
const pad = strong ? "py-3" : "py-2.5";
|
||||||
|
return (
|
||||||
|
<tr key={labelKey} className={rowClass}>
|
||||||
|
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
|
||||||
|
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
|
||||||
|
{formatSigned(tot.previousYearTotal)}
|
||||||
|
</td>
|
||||||
|
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
|
||||||
|
{tot.months.map((val, mIdx) => (
|
||||||
|
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
|
||||||
|
{formatSigned(val)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||||
|
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
onClick={toggleSubtotals}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
||||||
|
{t("budget.category")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||||
|
{t("budget.previousYear")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||||
|
{t("budget.annual")}
|
||||||
|
</th>
|
||||||
|
{MONTH_KEYS.map((key) => (
|
||||||
|
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
|
||||||
|
{t(key)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{renderTypeSection("income")}
|
||||||
|
{renderTypeSection("expense")}
|
||||||
|
{/* Operating result (revenues − expenses), interleaved before the
|
||||||
|
transfers section only when transfers exist — otherwise it
|
||||||
|
equals the net result below and would just be noise. */}
|
||||||
|
{results.hasTransfers &&
|
||||||
|
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
||||||
|
{renderTypeSection("transfer")}
|
||||||
|
{/* Bottom line: result after netting transfers. */}
|
||||||
|
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
94
src/components/budget/budgetTableResults.test.ts
Normal file
94
src/components/budget/budgetTableResults.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
|
import { computeBudgetResults, sumLeavesForType, signForBudgetType } from "./budgetTableResults";
|
||||||
|
|
||||||
|
// Minimal leaf factory. `months` arrives as the positive-magnitude budgeted
|
||||||
|
// figure BudgetTable itself flips by type via `signFor` — same convention
|
||||||
|
// `sumLeavesForType` re-implements. `annual` always equals `sum(months)`
|
||||||
|
// (useBudget derives it that way; see useBudget.ts's `buildMonths`).
|
||||||
|
function leaf(
|
||||||
|
type: BudgetYearRow["category_type"],
|
||||||
|
months: number[],
|
||||||
|
previousYearTotal = 0,
|
||||||
|
overrides: Partial<BudgetYearRow> = {},
|
||||||
|
): BudgetYearRow {
|
||||||
|
return {
|
||||||
|
category_id: 1,
|
||||||
|
category_name: type,
|
||||||
|
category_color: "#000",
|
||||||
|
category_type: type,
|
||||||
|
parent_id: null,
|
||||||
|
is_parent: false,
|
||||||
|
depth: 0,
|
||||||
|
months,
|
||||||
|
annual: months.reduce((s, v) => s + v, 0),
|
||||||
|
previousYearTotal,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAT12 = (v: number) => Array(12).fill(v) as number[];
|
||||||
|
|
||||||
|
describe("computeBudgetResults — income-statement roll-up (Issue #278)", () => {
|
||||||
|
it("resultBefore = budgeted income − expense; resultNet adds transfers", () => {
|
||||||
|
const r = computeBudgetResults([
|
||||||
|
leaf("income", FLAT12(400)), // annual 4800
|
||||||
|
leaf("expense", FLAT12(300)), // annual 3600, stored positive, flipped to -300/mo
|
||||||
|
leaf("transfer", FLAT12(0)),
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.months[0]).toBe(100); // 400 − 300
|
||||||
|
expect(r.resultBefore.annual).toBe(1200); // 4800 − 3600
|
||||||
|
expect(r.resultNet.annual).toBe(1200); // balanced (0) transfer → unchanged
|
||||||
|
expect(r.hasTransfers).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
|
||||||
|
const r = computeBudgetResults([
|
||||||
|
leaf("income", FLAT12(1000)),
|
||||||
|
leaf("expense", FLAT12(700)),
|
||||||
|
leaf("transfer", FLAT12(50)), // uncategorized single leg, signed pass-through
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.annual).toBe(3600); // (1000 − 700) × 12
|
||||||
|
expect(r.resultNet.annual).toBe(4200); // resultBefore + transfer(50×12)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a deficit yields a negative net result and no transfer line when absent", () => {
|
||||||
|
const r = computeBudgetResults([leaf("income", FLAT12(200)), leaf("expense", FLAT12(250))]);
|
||||||
|
expect(r.resultNet.months[0]).toBe(-50);
|
||||||
|
expect(r.hasTransfers).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
||||||
|
const parent = leaf("expense", FLAT12(999), 0, { is_parent: true });
|
||||||
|
const r = computeBudgetResults([parent, leaf("expense", FLAT12(100))]);
|
||||||
|
expect(r.expense.annual).toBe(-1200); // only the leaf's 100 × 12 (flipped), not 999
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the previous-year actual through unsigned (already signed in the DB)", () => {
|
||||||
|
// Actual totals arrive pre-signed: expense negative, income positive.
|
||||||
|
const r = computeBudgetResults([
|
||||||
|
leaf("income", FLAT12(100), 1150),
|
||||||
|
leaf("expense", FLAT12(60), -650),
|
||||||
|
]);
|
||||||
|
expect(r.resultBefore.previousYearTotal).toBe(500); // 1150 + (−650), no re-sign
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sumLeavesForType filters by type and excludes parents", () => {
|
||||||
|
const t = sumLeavesForType(
|
||||||
|
[
|
||||||
|
leaf("expense", FLAT12(50)),
|
||||||
|
leaf("expense", FLAT12(999), 0, { is_parent: true }),
|
||||||
|
leaf("income", FLAT12(10)),
|
||||||
|
],
|
||||||
|
"expense",
|
||||||
|
);
|
||||||
|
expect(t.annual).toBe(-600); // 50 × 12, flipped negative
|
||||||
|
expect(t.months[0]).toBe(-50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signForBudgetType flips expense only", () => {
|
||||||
|
expect(signForBudgetType("expense")).toBe(-1);
|
||||||
|
expect(signForBudgetType("income")).toBe(1);
|
||||||
|
expect(signForBudgetType("transfer")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
src/components/budget/budgetTableResults.ts
Normal file
94
src/components/budget/budgetTableResults.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type { BudgetYearRow } from "../../shared/types";
|
||||||
|
|
||||||
|
export type BudgetSectionType = "income" | "expense" | "transfer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign multiplier applied to budgeted (months/annual) magnitudes. Budgeted
|
||||||
|
* amounts are entered/stored as positive magnitudes regardless of type and
|
||||||
|
* flipped for display by category type — mirrors `BudgetTable`'s `signFor`.
|
||||||
|
*/
|
||||||
|
export function signForBudgetType(type: BudgetSectionType): 1 | -1 {
|
||||||
|
return type === "expense" ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate of the budget grid's 3 comparable column groups, already signed:
|
||||||
|
* 12 monthly (budgeted) totals, the annual (budgeted) total, and the
|
||||||
|
* previous-year (actual) total.
|
||||||
|
*/
|
||||||
|
export interface BudgetTotals {
|
||||||
|
/** index 0-11 = Jan-Dec, signed budgeted total */
|
||||||
|
months: number[];
|
||||||
|
/** signed budgeted annual total */
|
||||||
|
annual: number;
|
||||||
|
/** signed actual total from the previous year (already signed in the DB) */
|
||||||
|
previousYearTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeroTotals(): BudgetTotals {
|
||||||
|
return { months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum every non-subtotal (leaf) row of a single type into one `BudgetTotals`.
|
||||||
|
*
|
||||||
|
* The type's sign is applied to the budgeted figures (`months`/`annual`),
|
||||||
|
* matching how `BudgetTable` displays them. `previousYearTotal` is NOT
|
||||||
|
* re-signed: it comes from `getActualTotalsForYear` (a plain signed
|
||||||
|
* `SUM(amount)` over the transactions table), already negative for expenses
|
||||||
|
* and ~0-net for balanced transfers — the same convention `compareResults.ts`
|
||||||
|
* relies on for its own previous-period figures.
|
||||||
|
*/
|
||||||
|
export function sumLeavesForType(rows: BudgetYearRow[], type: BudgetSectionType): BudgetTotals {
|
||||||
|
const sign = signForBudgetType(type);
|
||||||
|
const totals = zeroTotals();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.is_parent || row.category_type !== type) continue;
|
||||||
|
for (let m = 0; m < 12; m++) totals.months[m] += row.months[m] * sign;
|
||||||
|
totals.annual += row.annual * sign;
|
||||||
|
totals.previousYearTotal += row.previousYearTotal;
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Component-wise sum of two BudgetTotals (both already signed). */
|
||||||
|
function combine(a: BudgetTotals, b: BudgetTotals): BudgetTotals {
|
||||||
|
return {
|
||||||
|
months: a.months.map((v, i) => v + b.months[i]),
|
||||||
|
annual: a.annual + b.annual,
|
||||||
|
previousYearTotal: a.previousYearTotal + b.previousYearTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetResults {
|
||||||
|
income: BudgetTotals;
|
||||||
|
expense: BudgetTotals;
|
||||||
|
transfer: BudgetTotals;
|
||||||
|
/** Budgeted/actual revenues − expenses: the operating result, before transfers. */
|
||||||
|
resultBefore: BudgetTotals;
|
||||||
|
/** resultBefore + transfers: the bottom-line total. */
|
||||||
|
resultNet: BudgetTotals;
|
||||||
|
hasTransfers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Income-statement roll-up for the budget grid (Issue #278), mirroring
|
||||||
|
* `compareResults.ts`'s shape (see that module's doc comment for the sign
|
||||||
|
* convention this relies on).
|
||||||
|
*
|
||||||
|
* Operates on the flat `BudgetYearRow[]` the grid already renders (produced
|
||||||
|
* by `useBudget`) — LEAVES only (`is_parent === false`), so parent subtotal
|
||||||
|
* rows are never double-counted. All three column groups (previous-year
|
||||||
|
* actual, budgeted annual, budgeted monthly) are folded into the same result
|
||||||
|
* rows so "Résultat avant transferts" / "Résultat net" read consistently
|
||||||
|
* across every column of the grid.
|
||||||
|
*/
|
||||||
|
export function computeBudgetResults(rows: BudgetYearRow[]): BudgetResults {
|
||||||
|
const income = sumLeavesForType(rows, "income");
|
||||||
|
const expense = sumLeavesForType(rows, "expense");
|
||||||
|
const transfer = sumLeavesForType(rows, "transfer");
|
||||||
|
const resultBefore = combine(income, expense);
|
||||||
|
const resultNet = combine(resultBefore, transfer);
|
||||||
|
const hasTransfers = rows.some((r) => !r.is_parent && r.category_type === "transfer");
|
||||||
|
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
||||||
|
}
|
||||||
|
|
@ -68,7 +68,10 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
// Income-statement reading order: revenue first, then expenses, then
|
||||||
|
// transfers (Issue #278) — matches the compare/trend reports' ordering
|
||||||
|
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
|
||||||
|
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
export function useBudget() {
|
export function useBudget() {
|
||||||
const { accountIds } = useReportsPeriod();
|
const { accountIds } = useReportsPeriod();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue