# Spec Decisions — Repli/dépliage à chaque niveau de la hiérarchie

> Date: 2026-07-13
> Projet: simpl-resultat
> Statut: Draft
> Slug: collapse-multi-niveaux

## Contexte

Le repli des catégories a été livré en deux temps — #254 (les deux rapports comparables) puis #265 (la tendance par catégorie) — mais avec une limite qui n'avait pas été explicitée : **on ne peut replier que les catégories de premier niveau**.

La taxonomie standard compte pourtant **trois niveaux** : 11 racines, **41 catégories intermédiaires**, 98 feuilles. Ces 41 catégories intermédiaires (par exemple `Alimentation > Épicerie`) sont aujourd'hui des culs-de-sac visuels : elles portent un sous-total, un style dédié et une indentation, mais **aucun chevron**. Déplier une racine révèle d'un coup tout son sous-arbre, feuilles comprises — impossible de s'arrêter au palier intermédiaire.

La restriction n'est pas structurelle. Elle tient dans deux fonctions de `src/utils/collapsibleRows.ts` : `visibleRows` ne consulte l'état de repli qu'à `depth === 0`, et `collapsibleKeys` ne liste que les parents de premier niveau. Tout le reste de la chaîne est déjà prêt : le builder d'arbre (`buildLeafDrivenTree`) est récursif à profondeur arbitraire, chaque ligne porte sa propre `depth`, les sous-totaux sont exacts à chaque palier, et l'indentation gère déjà quatre niveaux.

En parallèle, la **grille Budget** est hiérarchique (elle lit `depth` / `is_parent`, elle indente) mais a été **délibérément exclue du repli** par #278 : « elle demeure une surface d'édition, sans ligne masquée ni repliable ». Avec ~150 lignes affichées d'un bloc, cette décision est revue ici.

## Objectif

Rendre chaque catégorie parente repliable **à n'importe quel niveau de la hiérarchie**, sur les trois rapports hiérarchiques et sur la grille Budget. Tous les niveaux s'ouvrent **repliés** : déplier un parent révèle uniquement le palier suivant, lui-même replié, ce qui permet de forer par étapes au lieu de subir un mur de lignes.

## Scope

### IN

- **Généralisation du socle** — `collapsibleRows.ts` : le repli devient valable à toute profondeur (aujourd'hui `depth === 0` en dur).
- **Sémantique en cascade** — déplier un parent révèle ses enfants directs, eux-mêmes repliés. Un descendant n'est visible que si **tous** ses ancêtres sont dépliés.
- **Les 3 rapports hiérarchiques** — `ComparePeriodTable` (réel-vs-réel), `BudgetVsActualTable` (réel-vs-budget), `CategoryOverTimeTable` (tendances par catégorie) : chevron sur les parents intermédiaires, qui n'ont aujourd'hui qu'un `<span>` muet.
- **La grille Budget** (`BudgetTable`) — nouveau consommateur du repli : elle ne l'a jamais eu. Revient sur la décision de #278.
- **Le bouton « Tout déplier / Tout replier »** — agit désormais sur tous les niveaux d'un coup.
- **Correctifs d'hygiène** dans les fichiers touchés :
  - collision de clé `localStorage` `subtotals-position`, partagée entre `BudgetVsActualTable.tsx:30` et `BudgetTable.tsx:21` ;
  - état de repli non scopé par profil (l'app est multi-profils, les ids de catégories divergent d'un profil à l'autre) ;
  - suppression de `src/components/reports/CategoryTable.tsx` (74 lignes, importé nulle part).
- **`aria-level`** sur les lignes parentes : avec plusieurs niveaux imbriqués, la profondeur n'est plus déductible pour un lecteur d'écran (l'indentation est du padding CSS, invisible pour lui).

### OUT (explicitement exclu)

- **Dashboard, Cartes, Faits saillants, Par-catégorie** — aucun tableau hiérarchique de catégories à replier : ce sont des donuts, des top-N plats et des cartes de KPI. « Tous les rapports » ne s'y applique pas.
- **Page Catégories (`/categories`) et guide de la taxonomie standard** — les deux ont déjà leur propre repli à N niveaux, avec leur code, non persisté, et des défauts opposés (Catégories = tout déplié ; guide = tout replié). Les unifier sur le hook partagé est un chantier distinct : la page Catégories est une surface CRUD avec drag-and-drop, où « tout replié par défaut » nuirait à l'usage.
- **Page Bilan** — sa hiérarchie porte sur des **comptes**, pas des catégories, et son mécanisme d'expansion est entièrement distinct (drill-down des titres, colonnes de rendement persistées en DB).
- **Migration vers `role="treegrid"`** — le pattern ARIA complet exigerait toute la navigation clavier bidirectionnelle (flèches, Home/End, Ctrl+Home…). Hors sujet ici ; `aria-expanded` sur le bouton de repli reste valide et conforme. À noter comme dette.
- **Niveau 4 et au-delà** — l'UI l'interdit déjà à la création (`CategoryForm.tsx:44-63`) comme au drag-and-drop (`CategoryTree.tsx:283-287`). Le socle reste néanmoins agnostique de la profondeur (le garde anti-cycle `MAX_TREE_DEPTH = 5` est conservé).

## Decisions prises

| Question | Decision | Raison |
|----------|----------|--------|
| Quelles surfaces reçoivent le repli multi-niveaux ? | Les 3 rapports hiérarchiques **+ la grille Budget** | Ce sont les seules surfaces réellement hiérarchiques. Dashboard/Cartes/Faits saillants n'ont aucun tableau de catégories imbriqué — il n'y a rien à y replier. |
| Que révèle le dépliage d'un parent ? | **Le palier suivant, replié** (cascade) | Fidèle à « chaque niveau est replié par défaut ». Permet de forer par étapes. Assume un changement de comportement : aujourd'hui, déplier une racine révèle tout son sous-arbre d'un coup. |
| État par défaut de la grille Budget ? | **Replié, comme partout** | Uniformité. La page ouvre sur 11 lignes compactes au lieu de ~150. Coût assumé : à la première visite, aucun champ de saisie n'est visible (seules les feuilles sont éditables) — mais l'état étant persisté, ce coût n'est payé qu'une fois, et « Tout déplier » est à un clic. |
| Portée du bouton « Tout déplier / Tout replier » ? | **Tous les niveaux d'un coup** | C'est l'échappatoire qui rend le défaut replié vivable, en particulier sur la grille de saisie. Sans lui, il faudrait ouvrir les 41 intermédiaires un par un. |
| Corriger la collision `subtotals-position` ? | **Oui** | Bug préexistant : déplacer les sous-totaux dans la grille Budget les déplace aussi dans le rapport réel-vs-budget. Une ligne, dans un fichier qu'on ouvre de toute façon. |
| Scoper l'état de repli par profil ? | **Oui** | Le repli multi-niveaux aggrave le défaut : on mémorise désormais des ids de catégories à tous les niveaux, et deux profils aux taxonomies divergentes se marchent dessus. |
| Supprimer `reports/CategoryTable.tsx` ? | **Oui** | Code mort (0 import), vestige plat d'avant les tables hiérarchiques. Évite qu'on le confonde un jour avec une surface à replier. |
| Découpage du travail ? | **Deux issues stackées** — A : socle + les 3 rapports ; B (dépend de A) : adoption sur la grille Budget | Sépare un changement de ~15 lignes bien testé de l'ajout du repli sur une surface d'édition, où la régression est plus plausible. A peut être mergée sans porter le risque de B. |

## Contraintes techniques établies (vérifiées dans le code)

- **La couche données est déjà prête.** `buildLeafDrivenTree` (`reportService.ts:561-686`) et `buildSubGroup` (`budgetService.ts:351-374`) sont récursifs à profondeur arbitraire ; chaque ligne porte `depth` / `is_parent` / `parent_id` ; les sous-totaux sont exacts à chaque palier. Le rendu indente déjà quatre niveaux (`px-3` / `pl-8` / `pl-14` / `pl-20`) et style déjà les parents intermédiaires. **Il ne leur manque que le chevron.**
- **Le bouton bulk fonctionnera sans toucher aux appelants.** `expandAll` / `allExpanded` / `groupCount` reçoivent les lignes **complètes** (`rows` / `data.tree`), le filtre `visible()` étant appliqué séparément au rendu. Généraliser `collapsibleKeys` pour qu'il retourne tous les parents suffit donc à couvrir tous les niveaux.
- **Le repli reste purement visuel.** Sous-totaux, « Résultat avant transferts » et « Résultat net » sont calculés sur les lignes brutes, jamais sur les lignes visibles — invariant vérifié en revue de #254 et couvert par les tests. Il doit le rester.
- **Seules les feuilles sont éditables dans la grille Budget** (`BudgetTable.tsx:105`, `:175`) : les parents sont des sous-totaux en lecture seule. C'est ce qui fonde le compromis sur le défaut replié.
- **Les tests actuels verrouillent la sémantique mono-niveau** (`collapsibleRows.test.ts:53-63` et `:82-84` affirment explicitement que déplier révèle tout le sous-arbre et que seuls les parents de niveau 1 sont repliables). **Ils devront être réécrits** — ce n'est pas une régression, c'est le changement demandé.
- **Pas de harnais de rendu React** dans le repo (ni jsdom ni testing-library). Toute la logique doit rester dans des modules purs testables (`collapsibleRows.ts`, `overTimeTableModel.ts`), les composants restant de simples afficheurs.

## References

| Source | Pertinence |
|--------|------------|
| [Treegrid Pattern — WAI-ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/) | Confirme que les lignes parentes portent `aria-expanded`, et que les lignes **sans** enfants ne doivent **pas** porter l'attribut (l'inclure les définirait comme parentes). Notre gate actuel — bouton présent uniquement sur les parents — est conforme et le reste après généralisation. |
| [ARIA: treegrid role — MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/treegrid_role) | Un vrai `treegrid` impose la navigation clavier bidirectionnelle complète. Trop coûteux pour ce chantier → on conserve le disclosure par bouton et on ajoute `aria-level`, qui porte l'information de profondeur qu'un lecteur d'écran ne peut pas tirer du padding CSS. |
