feat(reports): collapse/expand sub-categories in comparable reports (#254) #261

Merged
maximus merged 1 commit from issue-254-collapse-expand into main 2026-07-07 11:45:45 +00:00
Owner

Resolves #254

Résumé

Ajoute le repli/dépli des sous-catégories dans les deux rapports comparables hiérarchiques de la page Compare : réel-vs-réel (ComparePeriodTable) et réel-vs-budget (BudgetVsActualTable).

  • Chevron cliquable sur chaque catégorie parente de premier niveau : replie/déplie son sous-arbre. Le sous-total de la parente reste visible quand replié.
  • Bouton « Tout déplier / Tout replier » dans la barre d'outils (à côté du toggle sous-totaux), affiché seulement s'il existe au moins un groupe.
  • Replié par défaut (#260) : le premier affichage montre l'aperçu compact au niveau des sous-totaux ; ce que l'utilisateur déplie est mémorisé par rapport (localStorage, une clé par rapport).

Décisions de design (à valider)

  1. Scope = 2 tableaux hiérarchiques. Le body de #254 nomme « réel-vs-budget ET réel-vs-réel » : ce sont exactement les deux tableaux avec hiérarchie parent/enfant (is_parent/depth), tous deux sous la page Compare. CategoryOverTimeTable (Tendances → par catégorie), évoqué ailleurs comme 2ᵉ rapport, est une liste plate top-N sans hiérarchie de sous-catégories → rien à replier, laissé hors scope.
  2. Persistance = localStorage, par rapport. Aligné sur le toggle sous-totaux voisin de ces deux tables (déjà en localStorage, clés distinctes), plutôt que le userPreferenceService du Bilan. Synchrone (pas de flash), pur UI. On persiste l'ensemble des groupes dépliés (exceptions au défaut replié) → ensemble vide = tout replié.
  3. Granularité = premier niveau. Le chevron n'est posé que sur les parents depth 0 ; les déplier révèle tout le sous-arbre. Pas de repli indépendant des parents intermédiaires (v1 simple, cohérent avec « replier les sous-catégories sous une catégorie »).

Implémentation

  • Module pur src/utils/collapsibleRows.ts (filtre de visibilité, extraction des clés de groupe, (dé)sérialisation de l'ensemble déplié) — 12 tests vitest.
  • Hook src/hooks/useCollapsibleGroups.ts : state + persistance localStorage, bâti sur le module pur.
  • Accessors par table (depthOf/isParent/keyOf) qui reflètent la logique de rendu existante de chaque tableau → les lignes masquées sont exactement les descendants indentés d'un groupe.
  • Sous-totaux de section et lignes de résultat inchangés (somme de toutes les feuilles) → replier ne modifie aucun total.
  • i18n reports.collapse.{expandAll,collapseAll} (FR « Tout déplier/replier » + EN), CHANGELOG FR/EN.

Validation locale

  • npm run build (tsc + vite) vert
  • npm test vert — 713 tests (701 baseline + 12 nouveaux)
  • Aucune migration DB.
Resolves #254 ## Résumé Ajoute le **repli/dépli des sous-catégories** dans les deux rapports comparables hiérarchiques de la page Compare : réel-vs-réel (`ComparePeriodTable`) et réel-vs-budget (`BudgetVsActualTable`). - Chevron cliquable sur chaque **catégorie parente de premier niveau** : replie/déplie son sous-arbre. Le **sous-total de la parente reste visible** quand replié. - Bouton **« Tout déplier / Tout replier »** dans la barre d'outils (à côté du toggle sous-totaux), affiché seulement s'il existe au moins un groupe. - **Replié par défaut** (#260) : le premier affichage montre l'aperçu compact au niveau des sous-totaux ; ce que l'utilisateur déplie est **mémorisé par rapport** (localStorage, une clé par rapport). ## Décisions de design (à valider) 1. **Scope = 2 tableaux hiérarchiques.** Le body de #254 nomme « réel-vs-budget ET réel-vs-réel » : ce sont exactement les deux tableaux avec hiérarchie parent/enfant (`is_parent`/`depth`), tous deux sous la page Compare. **`CategoryOverTimeTable`** (Tendances → par catégorie), évoqué ailleurs comme 2ᵉ rapport, est une **liste plate top-N sans hiérarchie de sous-catégories** → rien à replier, laissé hors scope. 2. **Persistance = localStorage, par rapport.** Aligné sur le toggle sous-totaux voisin de ces deux tables (déjà en localStorage, clés distinctes), plutôt que le `userPreferenceService` du Bilan. Synchrone (pas de flash), pur UI. On persiste l'ensemble des groupes **dépliés** (exceptions au défaut replié) → ensemble vide = tout replié. 3. **Granularité = premier niveau.** Le chevron n'est posé que sur les parents `depth 0` ; les déplier révèle tout le sous-arbre. Pas de repli indépendant des parents intermédiaires (v1 simple, cohérent avec « replier les sous-catégories sous une catégorie »). ## Implémentation - Module pur `src/utils/collapsibleRows.ts` (filtre de visibilité, extraction des clés de groupe, (dé)sérialisation de l'ensemble déplié) — **12 tests vitest**. - Hook `src/hooks/useCollapsibleGroups.ts` : state + persistance localStorage, bâti sur le module pur. - Accessors par table (`depthOf`/`isParent`/`keyOf`) qui **reflètent la logique de rendu existante** de chaque tableau → les lignes masquées sont exactement les descendants indentés d'un groupe. - Sous-totaux de section et lignes de résultat inchangés (somme de toutes les feuilles) → replier ne modifie **aucun total**. - i18n `reports.collapse.{expandAll,collapseAll}` (FR « Tout déplier/replier » + EN), CHANGELOG FR/EN. ## Validation locale - `npm run build` (tsc + vite) **vert** - `npm test` **vert — 713 tests** (701 baseline + 12 nouveaux) - Aucune migration DB.
maximus added 1 commit 2026-07-07 00:19:22 +00:00
feat(reports): collapse/expand sub-categories in comparable reports (#254)
All checks were successful
PR Check / rust (pull_request) Successful in 22m52s
PR Check / frontend (pull_request) Successful in 2m24s
616cccec37
The two hierarchical comparable reports — real-vs-real Compare and
real-vs-budget — now let the user collapse or expand each top-level
category's sub-categories. Groups start collapsed by default (#260): only
the parent's subtotal row shows until the user expands it, and each expanded
group is remembered per report.

- New pure module utils/collapsibleRows.ts (visibility filter, group-key
  extraction, expanded-set (de)serialization) + useCollapsibleGroups hook
  wrapping the localStorage-persisted expanded set. Persisting the *expanded*
  set (not the collapsed one) makes "all collapsed" the zero/default state.
- A chevron toggles each top-level parent; an "Expand all / Collapse all"
  button toggles them together. Section subtotals and result lines keep
  summing every leaf, so collapsing never changes any total.
- Accessors mirror each table's own depth/parent logic so hidden rows are
  exactly a group's indented descendants.
- i18n keys reports.collapse.{expandAll,collapseAll} (FR/EN); CHANGELOG.

CategoryOverTimeTable (Trends -> by category) is intentionally left out: its
rows are a flat top-N category list with no parent/child hierarchy to fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
maximus added the
type:feature
label 2026-07-07 00:19:37 +00:00
Author
Owner

/pr-review PR #261 — collapse/expand des sous-catégories

Verdict : APPROVE

Résumé

Feature propre et bien découpée : un module pur testé (collapsibleRows.ts, 12 tests), un hook mince de state + persistance (useCollapsibleGroups.ts), et des accessors par tableau branchés sur les deux tables hiérarchiques (ComparePeriodTable, BudgetVsActualTable). Le repli/dépli est purement visuel — vérifié : les sous-totaux de section, le total général et les lignes de résultat sont calculés sur les lignes brutes (sumLeaves/computeResults/section.rows.filter), jamais sur les lignes visibles → replier ne modifie aucun total.

Points vérifiés

  • Accessors ≡ logique de rendu. depthOf/isParent/keyOf reproduisent exactement les expressions de rendu de chaque table (depth ?? 0 côté Compare, depth ?? (parent_id !== null && !is_parent ? 1 : 0) côté BVA). Confirmé côté services : budgetService et reportService peuplent toujours depth explicitement à chaque niveau et émettent [subtotal, …enfants] (ordre parent-first) → les fallbacks ?? ne se déclenchent jamais en pratique et l'ordre depth-first attendu par visibleRows est garanti. Les lignes masquées sont donc exactement les descendants indentés du groupe.
  • Composition reorderRows(groups.visible(rows), subtotalsOnTop) saine. visible filtre des sous-arbres entiers en préservant l'ordre relatif, puis reorderRows réordonne le reste. Tracé sur les cas mixte / tout-replié / sous-totaux-en-bas → sortie correcte, pas de régression du toggle sous-totaux voisin.
  • Défaut = tout replié appuyé par #260 (« les regroupements devraient être collapsé par défaut »), sous-total du groupe visible quand replié (demandé par #254). Attribution exacte.
  • Scope. CategoryOverTimeTable (Tendances → par catégorie) est bien une liste plate (section.categories: string[], aucun is_parent/depth) → rien à replier, exclusion justifiée. Les deux tables retenues sont exactement les deux rapports hiérarchiques nommés dans #254.
  • Robustesse persistance. parseStoredExpanded gère null/""/JSON corrompu/non-array → set vide (défaut replié), et filtre les membres non-string. Try/catch autour du setItem. Clés localStorage distinctes (reports-bva-expanded, reports-compare-expanded) — pas de collision avec les clés *subtotals-position existantes.
  • i18n dans les 2 locales (reports.collapse.{expandAll,collapseAll}), aucune chaîne en dur. CHANGELOG FR + EN sous [Unreleased]. a11y : aria-expanded présent, le bouton porte le nom de catégorie comme nom accessible. Aucune migration DB. Commit conventional unique référençant #254.

Suggestions (non bloquantes)

  1. Duplication du bloc chevron+pastille+nom entre ComparePeriodTable et BudgetVsActualTable (~20 lignes identiques modulo categoryColor/categoryName vs category_color/category_name). Un petit composant partagé CollapsibleCategoryCell serait plus DRY — mais vu que les deux tables dupliquent déjà beaucoup de structure avec des noms de champs divergents, l'inlining reste un choix défendable.
  2. UX sous-totaux-en-bas + repli. Quand subtotalsOnBottom, le chevron du groupe est rendu là où se pose le sous-total (en bas du groupe une fois déplié) ; déplier fait apparaître les enfants au-dessus de la ligne cliquée. Fonctionnellement correct, légèrement inhabituel — à surveiller si un retour utilisateur remonte.
  3. Hook non testé unitairement. La logique vit dans le module pur (testé) ; le hook est de la glue localStorage/setState. Un test jsdom serait un plus, non requis.
  4. keyOf côté Compare = String(row.categoryId)"null" si categoryId était null ; en pratique les parents de premier niveau ont toujours un id réel, donc pas de collision atteignable. Note défensive seulement.

Build + 713 vitest annoncés verts ; PR ciblant main donc couverte par check.yml.

## `/pr-review` PR #261 — collapse/expand des sous-catégories **Verdict : APPROVE** ✅ ### Résumé Feature propre et bien découpée : un module pur testé (`collapsibleRows.ts`, 12 tests), un hook mince de state + persistance (`useCollapsibleGroups.ts`), et des accessors par tableau branchés sur les deux tables hiérarchiques (`ComparePeriodTable`, `BudgetVsActualTable`). Le repli/dépli est **purement visuel** — vérifié : les sous-totaux de section, le total général et les lignes de résultat sont calculés sur les lignes brutes (`sumLeaves`/`computeResults`/`section.rows.filter`), jamais sur les lignes visibles → replier ne modifie aucun total. ### Points vérifiés - **Accessors ≡ logique de rendu.** `depthOf`/`isParent`/`keyOf` reproduisent exactement les expressions de rendu de chaque table (`depth ?? 0` côté Compare, `depth ?? (parent_id !== null && !is_parent ? 1 : 0)` côté BVA). Confirmé côté services : `budgetService` et `reportService` peuplent **toujours** `depth` explicitement à chaque niveau et émettent `[subtotal, …enfants]` (ordre parent-first) → les fallbacks `??` ne se déclenchent jamais en pratique et l'ordre depth-first attendu par `visibleRows` est garanti. Les lignes masquées sont donc exactement les descendants indentés du groupe. - **Composition `reorderRows(groups.visible(rows), subtotalsOnTop)` saine.** `visible` filtre des sous-arbres entiers en préservant l'ordre relatif, puis `reorderRows` réordonne le reste. Tracé sur les cas mixte / tout-replié / sous-totaux-en-bas → sortie correcte, pas de régression du toggle sous-totaux voisin. - **Défaut = tout replié** appuyé par #260 (« les regroupements devraient être collapsé par défaut »), sous-total du groupe visible quand replié (demandé par #254). Attribution exacte. - **Scope.** `CategoryOverTimeTable` (Tendances → par catégorie) est bien une **liste plate** (`section.categories: string[]`, aucun `is_parent`/`depth`) → rien à replier, exclusion justifiée. Les deux tables retenues sont exactement les deux rapports hiérarchiques nommés dans #254. - **Robustesse persistance.** `parseStoredExpanded` gère `null`/`""`/JSON corrompu/non-array → set vide (défaut replié), et filtre les membres non-string. Try/catch autour du `setItem`. Clés localStorage distinctes (`reports-bva-expanded`, `reports-compare-expanded`) — pas de collision avec les clés `*subtotals-position` existantes. - **i18n** dans les 2 locales (`reports.collapse.{expandAll,collapseAll}`), aucune chaîne en dur. **CHANGELOG** FR + EN sous `[Unreleased]`. **a11y** : `aria-expanded` présent, le bouton porte le nom de catégorie comme nom accessible. Aucune migration DB. Commit conventional unique référençant #254. ### Suggestions (non bloquantes) 1. **Duplication du bloc chevron+pastille+nom** entre `ComparePeriodTable` et `BudgetVsActualTable` (~20 lignes identiques modulo `categoryColor`/`categoryName` vs `category_color`/`category_name`). Un petit composant partagé `CollapsibleCategoryCell` serait plus DRY — mais vu que les deux tables dupliquent déjà beaucoup de structure avec des noms de champs divergents, l'inlining reste un choix défendable. 2. **UX sous-totaux-en-bas + repli.** Quand `subtotalsOnBottom`, le chevron du groupe est rendu là où se pose le sous-total (en bas du groupe une fois déplié) ; déplier fait apparaître les enfants *au-dessus* de la ligne cliquée. Fonctionnellement correct, légèrement inhabituel — à surveiller si un retour utilisateur remonte. 3. **Hook non testé unitairement.** La logique vit dans le module pur (testé) ; le hook est de la glue localStorage/setState. Un test jsdom serait un plus, non requis. 4. `keyOf` côté Compare = `String(row.categoryId)` → `"null"` si `categoryId` était null ; en pratique les parents de premier niveau ont toujours un id réel, donc pas de collision atteignable. Note défensive seulement. Build + 713 vitest annoncés verts ; PR ciblant `main` donc couverte par `check.yml`.
maximus merged commit 0a55a47ab5 into main 2026-07-07 11:45:45 +00:00
maximus deleted branch issue-254-collapse-expand 2026-07-07 11:45:45 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#261
No description provided.