diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7c0ae1c..6b86224 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -5,6 +5,7 @@ ### Modifié - Rapports : les trois tableaux de rapport hiérarchiques (Comparaison réel-vs-réel, réel-vs-budget, et Tendances par catégorie) permettent désormais de **replier ou déplier chaque niveau de la hiérarchie de catégories**, plus seulement le premier. Chaque catégorie parente — y compris les intermédiaires — porte son propre chevron ; en déplier une révèle ses enfants directs (eux-mêmes repliés), pour descendre un niveau à la fois. Les tableaux s'ouvrent entièrement repliés (seules les catégories de premier niveau visibles), « Tout déplier » ouvre tous les niveaux d'un coup, et vos choix de repli sont désormais enregistrés **par profil** — dans la base de données du profil, donc détruits avec lui — au lieu du navigateur. Les sous-totaux et les lignes de résultat restent rigoureusement inchangés quel que soit ce que vous repliez (#288). +- Budget : la grille budget se **replie et se déplie elle aussi à chaque niveau de catégorie**, à l'image des rapports. Chaque catégorie parente porte un chevron, la grille **s'ouvre entièrement repliée** (seules les catégories de premier niveau visibles) pour un aperçu compact, et « Tout déplier » ouvre tous les niveaux en un clic. Replier une catégorie est purement visuel — les totaux de section et les lignes de résultat sont inchangés, et aucune valeur de budget que vous avez saisie n'est jamais perdue. Vos choix de repli sont enregistrés par profil (#289). ## [0.13.0] - 2026-07-12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9131ae0..c66b933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Reports: the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, and Trends by category) now let you **collapse or expand every level of the category hierarchy**, not just the top level. Every parent category — including the intermediate ones — carries its own chevron; expanding one reveals its direct children (themselves collapsed), so you drill down one level at a time. The tables open fully collapsed (only the top-level categories visible), "Expand all" opens every level at once, and your collapse choices are now saved **per profile** — in the profile's own database, so they are destroyed with the profile — instead of in the browser. Subtotals and result lines stay exactly the same whatever you fold away (#288). +- Budget: the budget grid now **collapses and expands at every category level** too, matching the reports. Each parent category carries a chevron, the grid **opens fully collapsed** (only the top-level categories visible) for a compact overview, and "Expand all" opens every level in one click. Folding a category is purely visual — section totals and the result lines are unchanged, and no budget figure you have typed is ever lost. Your collapse choices are saved per profile (#289). ## [0.13.0] - 2026-07-12 diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index c902a4c..5040da9 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -1,9 +1,11 @@ import { useState, useRef, useEffect, Fragment } from "react"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, ArrowUpDown } from "lucide-react"; +import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import type { BudgetYearRow } from "../../shared/types"; import { reorderRows } from "../../utils/reorderRows"; -import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults"; +import type { CollapseAccessors } from "../../utils/collapsibleRows"; +import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups"; +import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults"; const fmt = new Intl.NumberFormat("en-CA", { style: "currency", @@ -18,7 +20,22 @@ const MONTH_KEYS = [ "months.sep", "months.oct", "months.nov", "months.dec", ] as const; -const STORAGE_KEY = "subtotals-position"; +// Prefixed so the "subtotals on top/bottom" preference is INDEPENDENT of the +// real-vs-budget report's (BudgetVsActualTable still uses "subtotals-position") — +// they collided before (issue #289). +const STORAGE_KEY = "budget-subtotals-position"; + +// Collapse groups keyed by category id; a row is hidden when any ancestor +// (walking parent_id) is collapsed, so every parent level is collapsible. +// Declared inline, mirroring ComparePeriodTable / BudgetVsActualTable (issue #289). +const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors = { + keyOf: (row) => `p:${row.category_id}`, + parentKeyOf: (row) => (row.parent_id != null ? `p:${row.parent_id}` : null), + depthOf: (row) => row.depth ?? 0, + isParent: (row) => row.is_parent, +}; + +const BUDGET_EXPANDED_KEY = "budget-grid-expanded"; interface BudgetTableProps { rows: BudgetYearRow[]; @@ -43,6 +60,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu return next; }); }; + + // Collapse/expand at every hierarchy level — collapsed by default (issue #289), + // matching the hierarchical reports. Persisted per profile in user_preferences. + const groups = useCollapsibleGroups( + BUDGET_EXPANDED_KEY, + BUDGET_COLLAPSE_ACCESSORS, + { defaultExpanded: false }, + ); + const inputRef = useRef(null); const annualInputRef = useRef(null); @@ -173,24 +199,37 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu const rowKey = row.is_parent ? `parent-${row.category_id}` : `leaf-${row.category_id}-${row.category_name}`; if (row.is_parent) { - // Parent subtotal row: read-only, bold, distinct background + // Parent subtotal row: read-only, bold, distinct background. Collapsible + // at every level (issue #289) — a chevron toggles its subtree. const parentDepth = row.depth ?? 0; const isTopParent = parentDepth === 0; const isIntermediateParent = parentDepth >= 1; + const collapsed = groups.isCollapsed(row); const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3"; return ( -
+
+ {formatSigned(row.previousYearTotal)} @@ -211,6 +250,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu return ( {/* Category name - sticky */} @@ -297,18 +337,11 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu const renderTypeSection = (type: (typeof typeOrder)[number]) => { const group = grouped[type]; if (!group || group.length === 0) return null; - const sign = signFor(type); - const leaves = group.filter((r) => !r.is_parent); - const sectionMonthTotals: number[] = Array(12).fill(0); - let sectionAnnualTotal = 0; - let sectionPrevYearTotal = 0; - for (const row of leaves) { - for (let m = 0; m < 12; m++) { - sectionMonthTotals[m] += row.months[m] * sign; - } - sectionAnnualTotal += row.annual * sign; - sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB - } + // Section subtotal is summed from the RAW group via the tested + // `sumLeavesForType` (leaves only, sign applied to budgeted figures), never + // from the collapse-filtered rows — folding a parent stays purely visual and + // never moves a total (issue #289). + const sectionTotals = sumLeavesForType(group, type); return ( @@ -319,14 +352,14 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {t(typeLabelKeys[type])} - {reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))} + {reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))} {t(typeTotalKeys[type])} - {formatSigned(sectionPrevYearTotal)} - {formatSigned(sectionAnnualTotal)} - {sectionMonthTotals.map((total, mIdx) => ( + {formatSigned(sectionTotals.previousYearTotal)} + {formatSigned(sectionTotals.annual)} + {sectionTotals.months.map((total, mIdx) => ( {formatSigned(total)} @@ -362,9 +395,22 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu ); }; + const hasGroups = groups.groupCount(rows) > 0; + const allExpanded = groups.allExpanded(rows); + return (
-
+
+ {hasGroups && ( + + )}