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é
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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() {
|
||||
const { accountIds } = useReportsPeriod();
|
||||
|
|
|
|||
Loading…
Reference in a new issue