refactor(categories) : unifier la machine a etats des 2 arbres de categories sur useCollapsibleGroups #294

Open
maximus wants to merge 1 commit from issue-290-categories-unify into issue-289-budget-collapse
6 changed files with 118 additions and 88 deletions
Showing only changes of commit 9f628aa9f4 - Show all commits

View file

@ -7,6 +7,10 @@
- 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). - 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). - 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).
### Corrigé
- Arbre des catégories standard (le guide autonome comme l'étape d'aperçu de l'assistant de migration des catégories) : le bouton « Tout déplier / Tout replier » reflète désormais l'état réel de l'arbre — il reste sur « Tout déplier » tant que tous les groupes ne sont pas ouverts, au lieu de basculer sur « Tout replier » dès qu'une seule catégorie était dépliée (#290).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12
### Ajouté ### Ajouté

View file

@ -7,6 +7,10 @@
- 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). - 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). - 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).
### Fixed
- Standard categories tree (both the standalone guide and the category-migration wizard's overview step): the "Expand all / Collapse all" button now reflects the tree's real state — it stays on "Expand all" until every group is open, instead of flipping to "Collapse all" as soon as a single category was expanded (#290).
## [0.13.0] - 2026-07-12 ## [0.13.0] - 2026-07-12
### Added ### Added

View file

@ -1,22 +1,27 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react"; import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree from "../categories/CategoryTaxonomyTree"; import CategoryTaxonomyTree, {
TAXONOMY_COLLAPSE_ACCESSORS,
} from "../categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService"; import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
interface StepDiscoverProps { interface StepDiscoverProps {
onNext: () => void; onNext: () => void;
} }
function collectAllIds(nodes: TaxonomyNode[]): number[] { // Flattens the taxonomy so the hook's bulk ops (expand/collapse all, allExpanded)
const ids: number[] = []; // can walk every parent at any depth.
function flattenNodes(nodes: TaxonomyNode[]): TaxonomyNode[] {
const flat: TaxonomyNode[] = [];
const walk = (n: TaxonomyNode) => { const walk = (n: TaxonomyNode) => {
ids.push(n.id); flat.push(n);
n.children.forEach(walk); n.children.forEach(walk);
}; };
nodes.forEach(walk); nodes.forEach(walk);
return ids; return flat;
} }
function countNodes(nodes: TaxonomyNode[]): { function countNodes(nodes: TaxonomyNode[]): {
@ -52,25 +57,23 @@ export default function StepDiscover({ onNext }: StepDiscoverProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy(); const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
// State machine only (issue #290): in-memory (storageKey null), collapsed by
// default — same as the standalone guide page it shares CategoryTaxonomyTree with.
const groups = useCollapsibleGroups<TaxonomyNode>(null, TAXONOMY_COLLAPSE_ACCESSORS, {
defaultExpanded: false,
});
const counts = countNodes(taxonomy.roots); const counts = countNodes(taxonomy.roots);
const total = counts.roots + counts.subcategories + counts.leaves; const total = counts.roots + counts.subcategories + counts.leaves;
const toggleNode = (id: number) => { const flatNodes = useMemo(() => flattenNodes(taxonomy.roots), [taxonomy.roots]);
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => { const handleExpandAll = () => groups.expandAll(flatNodes);
setExpanded(new Set(collectAllIds(taxonomy.roots))); const handleCollapseAll = () => groups.collapseAll(flatNodes);
}; // Correct "all expanded" test (issue #290): every group must be open, fixing the
const handleCollapseAll = () => setExpanded(new Set()); // old expanded.size > 0 bug that flipped the button after a single expand.
const allExpanded = expanded.size > 0; const allExpanded = groups.allExpanded(flatNodes);
return ( return (
<section className="space-y-6"> <section className="space-y-6">
@ -146,8 +149,8 @@ export default function StepDiscover({ onNext }: StepDiscoverProps) {
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<CategoryTaxonomyTree <CategoryTaxonomyTree
nodes={taxonomy.roots} nodes={taxonomy.roots}
expanded={expanded} isCollapsed={groups.isCollapsed}
onToggle={toggleNode} onToggle={groups.toggle}
searchQuery={search} searchQuery={search}
/> />
</div> </div>

View file

@ -2,33 +2,49 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react"; import { ChevronRight, ChevronDown } from "lucide-react";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService"; import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
/**
* Collapse-state accessors for the taxonomy tree, shared by every consumer that
* drives it through `useCollapsibleGroups` (the guide page and the migration
* wizard's Discover step) issue #290. Only keyOf/isParent are consulted: the
* tree renders recursively and gates each node on `isCollapsed(node)`, so it never
* calls `visibleRows`; parentKeyOf/depthOf are unused stubs (TaxonomyNode carries
* no parent_id).
*/
export const TAXONOMY_COLLAPSE_ACCESSORS: CollapseAccessors<TaxonomyNode> = {
keyOf: (node) => `p:${node.id}`,
parentKeyOf: () => null,
isParent: (node) => node.children.length > 0,
depthOf: () => 0,
};
interface CategoryTaxonomyTreeProps { interface CategoryTaxonomyTreeProps {
nodes: TaxonomyNode[]; nodes: TaxonomyNode[];
expanded: Set<number>; isCollapsed: (node: TaxonomyNode) => boolean;
onToggle: (id: number) => void; onToggle: (node: TaxonomyNode) => void;
searchQuery: string; searchQuery: string;
} }
interface NodeRowProps { interface NodeRowProps {
node: TaxonomyNode; node: TaxonomyNode;
depth: number; depth: number;
expanded: Set<number>; isCollapsed: (node: TaxonomyNode) => boolean;
onToggle: (id: number) => void; onToggle: (node: TaxonomyNode) => void;
visibleIds: Set<number> | null; visibleIds: Set<number> | null;
} }
function NodeRow({ function NodeRow({
node, node,
depth, depth,
expanded, isCollapsed,
onToggle, onToggle,
visibleIds, visibleIds,
}: NodeRowProps) { }: NodeRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const label = t(node.i18n_key, { defaultValue: node.name }); const label = t(node.i18n_key, { defaultValue: node.name });
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = expanded.has(node.id); const isExpanded = !isCollapsed(node);
// Filter children by visibility set (search mode) if provided. // Filter children by visibility set (search mode) if provided.
const visibleChildren = useMemo(() => { const visibleChildren = useMemo(() => {
@ -63,7 +79,7 @@ function NodeRow({
{hasChildren ? ( {hasChildren ? (
<button <button
type="button" type="button"
onClick={() => onToggle(node.id)} onClick={() => onToggle(node)}
aria-label={ aria-label={
isExpanded isExpanded
? t("categoriesSeed.guidePage.collapseAll") ? t("categoriesSeed.guidePage.collapseAll")
@ -114,7 +130,7 @@ function NodeRow({
key={child.id} key={child.id}
node={child} node={child}
depth={depth + 1} depth={depth + 1}
expanded={expanded} isCollapsed={isCollapsed}
onToggle={onToggle} onToggle={onToggle}
visibleIds={visibleIds} visibleIds={visibleIds}
/> />
@ -172,7 +188,7 @@ export function normalize(s: string): string {
export default function CategoryTaxonomyTree({ export default function CategoryTaxonomyTree({
nodes, nodes,
expanded, isCollapsed,
onToggle, onToggle,
searchQuery, searchQuery,
}: CategoryTaxonomyTreeProps) { }: CategoryTaxonomyTreeProps) {
@ -204,7 +220,7 @@ export default function CategoryTaxonomyTree({
key={root.id} key={root.id}
node={root} node={root}
depth={0} depth={0}
expanded={expanded} isCollapsed={isCollapsed}
onToggle={onToggle} onToggle={onToggle}
visibleIds={visibleIds} visibleIds={visibleIds}
/> />

View file

@ -18,6 +18,8 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { CategoryTreeNode } from "../../shared/types"; import type { CategoryTreeNode } from "../../shared/types";
import type { CollapseAccessors } from "../../utils/collapsibleRows";
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
interface FlatItem { interface FlatItem {
id: number; id: number;
@ -40,14 +42,28 @@ function getSubtreeDepth(node: CategoryTreeNode): number {
return 1 + Math.max(...node.children.map(getSubtreeDepth)); return 1 + Math.max(...node.children.map(getSubtreeDepth));
} }
function flattenTree(tree: CategoryTreeNode[], expandedSet: Set<number>): FlatItem[] { // State machine shared with useCollapsibleGroups (issue #290). Only keyOf/isParent
// are consulted here: this tree renders recursively and gates each node on
// isCollapsed(node), so it never calls visibleRows — parentKeyOf/depthOf exist to
// satisfy the accessor contract but are not read (depth is computed in flattenTree).
const CATEGORY_TREE_ACCESSORS: CollapseAccessors<CategoryTreeNode> = {
keyOf: (node) => `p:${node.id}`,
parentKeyOf: (node) => (node.parent_id != null ? `p:${node.parent_id}` : null),
isParent: (node) => node.children.length > 0,
depthOf: () => 0,
};
function flattenTree(
tree: CategoryTreeNode[],
isExpanded: (node: CategoryTreeNode) => boolean,
): FlatItem[] {
const items: FlatItem[] = []; const items: FlatItem[] = [];
function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) { function recurse(nodes: CategoryTreeNode[], depth: number, parentId: number | null) {
for (const node of nodes) { for (const node of nodes) {
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = expandedSet.has(node.id); const expanded = hasChildren && isExpanded(node);
items.push({ id: node.id, node, depth, parentId, isExpanded, hasChildren }); items.push({ id: node.id, node, depth, parentId, isExpanded: expanded, hasChildren });
if (isExpanded && hasChildren) { if (expanded) {
recurse(node.children, depth + 1, node.id); recurse(node.children, depth + 1, node.id);
} }
} }
@ -153,7 +169,7 @@ function SortableTreeRow({
item: FlatItem; item: FlatItem;
selectedId: number | null; selectedId: number | null;
onSelect: (id: number) => void; onSelect: (id: number) => void;
onToggle: (id: number) => void; onToggle: (node: CategoryTreeNode) => void;
isDragActive: boolean; isDragActive: boolean;
}) { }) {
const { const {
@ -180,7 +196,7 @@ function SortableTreeRow({
selectedId={isDragActive ? null : selectedId} selectedId={isDragActive ? null : selectedId}
onSelect={onSelect} onSelect={onSelect}
expanded={item.isExpanded} expanded={item.isExpanded}
onToggle={() => onToggle(item.id)} onToggle={() => onToggle(item.node)}
hasChildren={item.hasChildren} hasChildren={item.hasChildren}
dragHandleProps={listeners} dragHandleProps={listeners}
isDragging={isDragging} isDragging={isDragging}
@ -190,23 +206,20 @@ function SortableTreeRow({
} }
export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) { export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) {
const [expanded, setExpanded] = useState<Set<number>>(() => { // State machine only (issue #290): in-memory (storageKey null), expanded by
const ids = new Set<number>(); // default (defaultExpanded true) so every parent opens with no seeding — the
function collectExpandable(nodes: CategoryTreeNode[]) { // previous collectExpandable + local Set behaviour. Render + DnD stay untouched.
for (const node of nodes) { const { isCollapsed, toggle } = useCollapsibleGroups<CategoryTreeNode>(
if (node.children.length > 0) { null,
ids.add(node.id); CATEGORY_TREE_ACCESSORS,
collectExpandable(node.children); { defaultExpanded: true },
} );
}
}
collectExpandable(tree);
return ids;
});
const [activeId, setActiveId] = useState<number | null>(null); const [activeId, setActiveId] = useState<number | null>(null);
// Update expanded set when tree changes (new parents appear) const flatItems = useMemo(
const flatItems = useMemo(() => flattenTree(tree, expanded), [tree, expanded]); () => flattenTree(tree, (node) => !isCollapsed(node)),
[tree, isCollapsed],
);
const activeItem = useMemo( const activeItem = useMemo(
() => (activeId !== null ? flatItems.find((i) => i.id === activeId) ?? null : null), () => (activeId !== null ? flatItems.find((i) => i.id === activeId) ?? null : null),
@ -219,15 +232,6 @@ export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategor
}) })
); );
const toggle = useCallback((id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as number); setActiveId(event.active.id as number);
}, []); }, []);

View file

@ -3,8 +3,11 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft, Search, Printer, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import { ArrowLeft, Search, Printer, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { useCategoryTaxonomy } from "../hooks/useCategoryTaxonomy"; import { useCategoryTaxonomy } from "../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree from "../components/categories/CategoryTaxonomyTree"; import CategoryTaxonomyTree, {
TAXONOMY_COLLAPSE_ACCESSORS,
} from "../components/categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../services/categoryTaxonomyService"; import type { TaxonomyNode } from "../services/categoryTaxonomyService";
import { useCollapsibleGroups } from "../hooks/useCollapsibleGroups";
function countNodes(nodes: TaxonomyNode[]): { function countNodes(nodes: TaxonomyNode[]): {
roots: number; roots: number;
@ -32,51 +35,47 @@ function countNodes(nodes: TaxonomyNode[]): {
return { roots, subcategories, leaves }; return { roots, subcategories, leaves };
} }
function collectAllIds(nodes: TaxonomyNode[]): number[] { // Flattens the taxonomy to a single array so the hook's bulk ops
const ids: number[] = []; // (expandAll/collapseAll/allExpanded/groupCount) can walk every parent at any depth.
function flattenNodes(nodes: TaxonomyNode[]): TaxonomyNode[] {
const flat: TaxonomyNode[] = [];
const walk = (n: TaxonomyNode) => { const walk = (n: TaxonomyNode) => {
ids.push(n.id); flat.push(n);
n.children.forEach(walk); n.children.forEach(walk);
}; };
nodes.forEach(walk); nodes.forEach(walk);
return ids; return flat;
} }
export default function CategoriesStandardGuidePage() { export default function CategoriesStandardGuidePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy(); const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => {
// Start with roots collapsed (user can expand as needed); counter and search still work. // State machine only (issue #290): in-memory (storageKey null), collapsed by
return new Set<number>(); // default so the guide opens on roots — the previous local Set behaviour.
const groups = useCollapsibleGroups<TaxonomyNode>(null, TAXONOMY_COLLAPSE_ACCESSORS, {
defaultExpanded: false,
}); });
const counts = useMemo(() => countNodes(taxonomy.roots), [taxonomy.roots]); const counts = useMemo(() => countNodes(taxonomy.roots), [taxonomy.roots]);
const total = counts.roots + counts.subcategories + counts.leaves; const total = counts.roots + counts.subcategories + counts.leaves;
const toggleNode = (id: number) => { // Flattened nodes for the bulk ops (expand/collapse all, allExpanded).
setExpanded((prev) => { const flatNodes = useMemo(() => flattenNodes(taxonomy.roots), [taxonomy.roots]);
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => { const handleExpandAll = () => groups.expandAll(flatNodes);
setExpanded(new Set(collectAllIds(taxonomy.roots))); const handleCollapseAll = () => groups.collapseAll(flatNodes);
};
const handleCollapseAll = () => {
setExpanded(new Set());
};
const handlePrint = () => { const handlePrint = () => {
// window.print() opens the browser print dialog; @media print rules strip chrome. // window.print() opens the browser print dialog; @media print rules strip chrome.
window.print(); window.print();
}; };
const allExpanded = expanded.size > 0; // Correct "all expanded" test (issue #290): every collapsible group must be open,
// not merely "at least one node open" (the old expanded.size > 0 bug, which
// flipped the button to "Collapse all" after a single node was expanded).
const allExpanded = groups.allExpanded(flatNodes);
return ( return (
<div className="p-6 max-w-4xl mx-auto space-y-6"> <div className="p-6 max-w-4xl mx-auto space-y-6">
@ -177,8 +176,8 @@ export default function CategoriesStandardGuidePage() {
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 taxonomy-tree-print"> <section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 taxonomy-tree-print">
<CategoryTaxonomyTree <CategoryTaxonomyTree
nodes={taxonomy.roots} nodes={taxonomy.roots}
expanded={expanded} isCollapsed={groups.isCollapsed}
onToggle={toggleNode} onToggle={groups.toggle}
searchQuery={search} searchQuery={search}
/> />
</section> </section>