feat(budget): flip grid to income-first + add Résultat rows
All checks were successful
PR Check / rust (pull_request) Successful in 21m4s
PR Check / frontend (pull_request) Successful in 2m20s

Align BudgetTable on the income-statement standard shipped for the
compare/trend reports: sections now read Income -> Expense -> Transfer
(useBudget's TYPE_ORDER and BudgetTable's typeOrder both flipped), and
the previously unlabeled grand-total row is replaced by two interleaved
result rows (Result before transfers / Net result), computed by a new
pure, tested module (budgetTableResults.ts) mirroring compareResults.ts's
shape. Roll-up covers the previous-year-actual, budgeted-annual, and
budgeted-monthly columns alike.

All categories remain displayed (the grid stays a full edit surface) --
no collapse, no empty-row toggle, matching this issue's frozen decisions.

Resolves #278
This commit is contained in:
le king fu 2026-07-11 16:59:01 -04:00
parent 26896bbf03
commit ab67c68605
6 changed files with 285 additions and 67 deletions

View file

@ -19,6 +19,7 @@
### 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).
- 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

View file

@ -19,6 +19,7 @@
### 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).
- 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

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
const fmt = new Intl.NumberFormat("en-CA", {
style: "currency",
@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
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> = {
expense: "budget.expenses",
income: "budget.income",
@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
transfer: "budget.totalTransfers",
};
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0;
let prevYearTotal = 0;
for (const row of rows) {
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
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
}
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
// computed on the budgeted + previous-year-actual leaves. Mathematically
// equivalent to the old plain grand-total (income expense + transfer over
// every leaf), now surfaced as the two labeled result rows below instead of
// a single unlabeled "Total".
const results = computeBudgetResults(rows);
const totalCols = 15; // category + prev year + annual + 12 months
@ -295,39 +291,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
);
};
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>
{typeOrder.map((type) => {
// One type's section: header, its (reorderable) rows, and a leaf-summed
// subtotal row. Extracted so it can be called for income/expense, then
// again for transfer once the Résultat rows are interleaved between them.
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
const group = grouped[type];
if (!group || group.length === 0) return null;
const sign = signFor(type);
@ -367,18 +334,76 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</tr>
</Fragment>
);
})}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-3 px-2 text-right text-sm">
{formatSigned(total)}
};
// A Résultat row (avant-transferts subtotal, or the net bottom line).
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
// the softer variant mirrors the per-type section-subtotal row above.
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
const rowClass = strong
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
: "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>
))}
</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>
</table>
</div>

View 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);
});
});

View 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 };
}

View file

@ -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() {
const { accountIds } = useReportsPeriod();