diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 595fff3..a613523 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -10,6 +10,7 @@ ### Modifié - Bilan : la page d'accueil du bilan est désormais un hub de tuiles. Avoir des comptes mais aucun snapshot ne vous bloque plus sur une invite « créer un snapshot » — la page affiche maintenant des tuiles de navigation, et **Gérer les comptes** est accessible en tout temps, y compris depuis le tableau de bord peuplé. La page de gestion des comptes (créer/modifier/archiver les comptes et les types d'actif) n'était auparavant accessible qu'en tapant son URL (#244). +- Migration des catégories (Paramètres → Catégories → migrer vers le jeu standard) : la catégorie cible de l'étape d'aperçu est désormais modifiable sur **chaque** ligne, et non plus seulement celles « à réviser ». Chaque ligne offre un champ de recherche avec autocomplétion (insensible aux accents), pour corriger une cible détectée automatiquement aussi facilement que résoudre une ligne sans correspondance (#246). ### Sécurité diff --git a/CHANGELOG.md b/CHANGELOG.md index a58a903..fe7b1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Changed - Balance: the balance-sheet landing page is now a tile-based hub. Having accounts but no snapshot yet no longer strands you on a "create a snapshot" prompt — the page now shows navigation tiles, and **Manage accounts** is reachable at all times, including from the populated dashboard. The account-management page (create/edit/archive accounts and asset types) was previously only reachable by typing its URL (#244). +- Categories migration (Settings → Categories → migrate to the standard set): the target category on the preview step is now editable on **every** row, not only the "needs review" ones. Each row carries a type-ahead picker (accent-insensitive search) so you can override an auto-detected target as easily as resolving an unmatched one (#246). ### Security diff --git a/src/components/categories-migration/MappingRow.tsx b/src/components/categories-migration/MappingRow.tsx index 0a0550a..79d2580 100644 --- a/src/components/categories-migration/MappingRow.tsx +++ b/src/components/categories-migration/MappingRow.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; +import CategoryCombobox from "../shared/CategoryCombobox"; +import { comboboxCategoriesForTarget } from "./migrationTargets"; +import type { Category } from "../../shared/types"; import type { MappingRow as MappingRowType, ConfidenceBadge, @@ -13,13 +15,27 @@ interface MappingRowProps { /** Callback fired when the row is clicked — opens the preview panel. */ onSelect: (v2CategoryId: number) => void; /** - * Called with the new v1 target id + name when the user resolves the row - * via the inline dropdown. The dropdown is only rendered for unresolved - * ("🟠 needs review") rows — resolved rows just show the target name. + * Called with the new v1 target id + name when the user picks a target via + * the inline type-ahead combobox. Editable on EVERY row (resolved or not), + * so a user can override an auto-detected target as well as resolve a + * "needs review" one. */ onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void; /** Number of transactions currently attached to this v2 category. */ transactionCount: number; + /** + * v1 LEAF catalogue adapted to the `Category` shape the combobox expects. + * Computed ONCE by StepSimulate and shared across rows (perf). Leaves only — + * a transaction is never mapped to a grouping bucket. + */ + targetCategories: Category[]; + /** + * Resolves a target id (possibly a non-leaf parent absent from the leaves-only + * `targetCategories`) to a display `Category`. Used to keep a low-confidence + * parent default like "Divertissement" (#1710) visible on its row without + * offering parents as new picks. + */ + resolveTarget: (id: number) => Category | null; } function badgeClass(confidence: ConfidenceBadge): string { @@ -41,14 +57,21 @@ export default function MappingRow({ onSelect, onResolve, transactionCount, + targetCategories, + resolveTarget, }: MappingRowProps) { const { t } = useTranslation(); - const { getLeaves } = useCategoryTaxonomy(); - // For the resolve dropdown: all v1 leaves (terminal categories). We keep the - // list flat because the simulate row is narrow; the search box in step 2 - // already helps users find a target by keyword. - const v1Leaves = useMemo(() => getLeaves(), [getLeaves]); + // The picker lists leaves only. If this row's current target is a non-leaf + // parent (a low-confidence default like "Divertissement" #1710), it is absent + // from that list and would blank the combobox input, so re-inject it for this + // row so it stays visible. It remains clickable, but selecting it is a no-op — + // handleTargetChange only resolves ids present in `targetCategories` (leaves) — + // so any pick that takes effect is a leaf. See comboboxCategoriesForTarget. + const comboboxCategories = useMemo( + () => comboboxCategoriesForTarget(targetCategories, row.v1TargetId ?? null, resolveTarget), + [targetCategories, row.v1TargetId, resolveTarget], + ); const badgeLabel = t( `categoriesSeed.migration.simulate.confidence.${row.confidence}`, @@ -59,12 +82,18 @@ export default function MappingRow({ const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined; - const handleResolveChange = (ev: React.ChangeEvent) => { - const v1TargetId = Number(ev.target.value); - if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return; - const leaf = v1Leaves.find((l) => l.id === v1TargetId); - if (!leaf) return; - const name = t(leaf.i18n_key, { defaultValue: leaf.name }); + // Fired when the user picks a v1 leaf in the type-ahead combobox. The + // combobox only emits ids that exist in `targetCategories` (never null with + // our config), but we guard defensively. Resolving a "none" row bumps its + // confidence to "medium" via the reducer; editing an already-resolved row + // keeps its confidence unchanged (see RESOLVE_ROW). + const handleTargetChange = (v1TargetId: number | null) => { + if (v1TargetId === null) return; + const target = targetCategories.find((c) => c.id === v1TargetId); + if (!target) return; + const name = target.i18n_key + ? t(target.i18n_key, { defaultValue: target.name }) + : target.name; onResolve(row.v2CategoryId, v1TargetId, name); }; @@ -119,32 +148,26 @@ export default function MappingRow({ - {/* v1 target (or picker) */} + {/* v1 target — editable type-ahead combobox on EVERY row. Stop click + + keydown from bubbling so interacting with the picker (typing spaces, + Enter to select) never triggers the row's select/preview handler. */}
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} > - {isUnresolved ? ( - - ) : ( - - {targetDisplayName} - - )} +
+ +
); diff --git a/src/components/categories-migration/StepSimulate.tsx b/src/components/categories-migration/StepSimulate.tsx index 64f7d54..e86c8d4 100644 --- a/src/components/categories-migration/StepSimulate.tsx +++ b/src/components/categories-migration/StepSimulate.tsx @@ -1,8 +1,13 @@ -import { useMemo } from "react"; +import { useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react"; import MappingRow from "./MappingRow"; import TransactionPreviewPanel from "./TransactionPreviewPanel"; +import { + taxonomyToComboboxCategories, + findTaxonomyCategory, +} from "./migrationTargets"; +import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; import type { MigrationPlan, MappingRow as MappingRowType, @@ -36,6 +41,24 @@ export default function StepSimulate({ onBack, }: StepSimulateProps) { const { t } = useTranslation(); + const { taxonomy } = useCategoryTaxonomy(); + + // v1 LEAF catalogue adapted to the combobox `Category` shape. Computed once + // here (not per row) and shared across every MappingRow's target picker — + // only leaves are offered, so a transaction is never mapped to a grouping + // bucket. + const targetCategories = useMemo( + () => taxonomyToComboboxCategories(taxonomy.roots), + [taxonomy], + ); + + // Resolve a target id (possibly a non-leaf parent absent from the leaves-only + // list) to a display Category, so a row keeps showing a low-confidence parent + // default like "Divertissement" (#1710) without offering parents as picks. + const resolveTarget = useCallback( + (id: number) => findTaxonomyCategory(taxonomy.roots, id), + [taxonomy], + ); const selectedRow = useMemo(() => { if (selectedRowV2Id === null) return null; @@ -149,6 +172,8 @@ export default function StepSimulate({ transactionCount={ transactionCountByV2Id.get(row.v2CategoryId) ?? 0 } + targetCategories={targetCategories} + resolveTarget={resolveTarget} /> ))} diff --git a/src/components/categories-migration/migrationTargets.test.ts b/src/components/categories-migration/migrationTargets.test.ts new file mode 100644 index 0000000..2f2c2da --- /dev/null +++ b/src/components/categories-migration/migrationTargets.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "vitest"; +import { + taxonomyToComboboxCategories, + findTaxonomyCategory, + comboboxCategoriesForTarget, +} from "./migrationTargets"; +import type { Category } from "../../shared/types"; +import { + getTaxonomyV1, + type TaxonomyNode, +} from "../../services/categoryTaxonomyService"; + +function node( + id: number, + name: string, + children: TaxonomyNode[] = [], + type: "expense" | "income" | "transfer" = "expense", +): TaxonomyNode { + return { + id, + name, + i18n_key: `categoriesSeed.test.${id}`, + type, + color: "#123456", + sort_order: 1, + children, + }; +} + +describe("taxonomyToComboboxCategories (leaves only)", () => { + it("returns [] for empty input", () => { + expect(taxonomyToComboboxCategories([])).toEqual([]); + }); + + it("keeps only leaves, dropping every intermediate parent, in DFS order", () => { + const roots = [ + node(1000, "Revenus", [ + node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]), + ]), + node(1100, "Alimentation", [node(1111, "Épicerie")]), + ]; + const out = taxonomyToComboboxCategories(roots); + // parents 1000/1010/1100 dropped; leaves kept in depth-first reading order + expect(out.map((c) => c.id)).toEqual([1011, 1012, 1111]); + }); + + it("flattens leaves: parent_id undefined + running sort_order (flat list)", () => { + const roots = [ + node(1000, "Revenus", [ + node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]), + ]), + node(1100, "Alimentation", [node(1111, "Épicerie")]), + ]; + const out = taxonomyToComboboxCategories(roots); + expect(out.every((c) => c.parent_id === undefined)).toBe(true); + expect(out.map((c) => c.sort_order)).toEqual([0, 1, 2]); + }); + + it("marks every returned row inputable (all leaves)", () => { + const roots = [ + node(1700, "Loisirs", [ + node(1710, "Divertissement", [node(1711, "Cinéma")]), + ]), + ]; + const out = taxonomyToComboboxCategories(roots); + expect(out.map((c) => c.id)).toEqual([1711]); // 1700 + 1710 parents dropped + expect(out.every((c) => c.is_inputable)).toBe(true); + }); + + it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => { + const [c] = taxonomyToComboboxCategories([ + node(1111, "Épicerie", [], "expense"), + ]); + expect(c).toMatchObject({ + id: 1111, + name: "Épicerie", + type: "expense", + color: "#123456", + i18n_key: "categoriesSeed.test.1111", + is_active: true, + is_inputable: true, + }); + expect(typeof c.created_at).toBe("string"); + }); + + it("adapts the real v1 taxonomy: 112 leaves, ids unique, NO non-leaf parent offered", () => { + const out = taxonomyToComboboxCategories(getTaxonomyV1().roots); + expect(out.length).toBe(112); // leaves only (was 150 incl. 38 parents) + const ids = new Set(out.map((c) => c.id)); + expect(ids.size).toBe(out.length); + // The non-leaf parent 1710 "Divertissement" must NOT be a selectable option. + expect(out.find((c) => c.id === 1710)).toBeUndefined(); + // A known leaf is present and inputable. + expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true); + expect(out.every((c) => c.is_inputable)).toBe(true); + }); +}); + +describe("findTaxonomyCategory", () => { + const roots = [ + node(1700, "Loisirs", [ + node(1710, "Divertissement", [node(1711, "Cinéma")]), + ]), + ]; + + it("resolves a non-leaf parent by id (to display a current target)", () => { + const c = findTaxonomyCategory(roots, 1710); + expect(c).toMatchObject({ + id: 1710, + name: "Divertissement", + is_inputable: false, + }); + expect(c!.parent_id).toBeUndefined(); + }); + + it("resolves a leaf too", () => { + expect(findTaxonomyCategory(roots, 1711)).toMatchObject({ + id: 1711, + is_inputable: true, + }); + }); + + it("returns null for an unknown id", () => { + expect(findTaxonomyCategory(roots, 9999)).toBeNull(); + }); + + it("finds the real low-confidence default target 1710 in the bundled taxonomy", () => { + const c = findTaxonomyCategory(getTaxonomyV1().roots, 1710); + expect(c).toMatchObject({ id: 1710, is_inputable: false }); + }); +}); + +describe("comboboxCategoriesForTarget", () => { + const leaf = (id: number): Category => ({ + id, + name: `Leaf ${id}`, + parent_id: undefined, + type: "expense", + is_active: true, + is_inputable: true, + sort_order: id, + created_at: "", + }); + const leaves = [leaf(1111), leaf(1121), leaf(1131)]; + const parent1710: Category = { + id: 1710, + name: "Divertissement", + parent_id: undefined, + type: "expense", + is_active: true, + is_inputable: false, + sort_order: Number.MAX_SAFE_INTEGER, + created_at: "", + }; + const resolveTarget = (id: number) => (id === 1710 ? parent1710 : null); + + it("returns the leaves list unchanged (same reference) when target is null", () => { + expect(comboboxCategoriesForTarget(leaves, null, resolveTarget)).toBe(leaves); + }); + + it("returns the leaves list unchanged (same reference) when target is already a leaf", () => { + expect(comboboxCategoriesForTarget(leaves, 1121, resolveTarget)).toBe(leaves); + }); + + it("appends the resolved non-leaf parent when the target is a parent absent from leaves", () => { + const out = comboboxCategoriesForTarget(leaves, 1710, resolveTarget); + expect(out).toHaveLength(leaves.length + 1); + expect(out[out.length - 1]).toMatchObject({ id: 1710, is_inputable: false }); + // originals preserved, in order + expect(out.slice(0, 3).map((c) => c.id)).toEqual([1111, 1121, 1131]); + }); + + it("returns the leaves list unchanged when the non-leaf target cannot be resolved", () => { + // targetId absent from leaves AND resolver returns null (stale id) -> no injection + expect(comboboxCategoriesForTarget(leaves, 9999, resolveTarget)).toBe(leaves); + }); +}); diff --git a/src/components/categories-migration/migrationTargets.ts b/src/components/categories-migration/migrationTargets.ts new file mode 100644 index 0000000..31fe9d3 --- /dev/null +++ b/src/components/categories-migration/migrationTargets.ts @@ -0,0 +1,110 @@ +import type { Category } from "../../shared/types"; +import type { TaxonomyNode } from "../../services/categoryTaxonomyService"; + +/** + * Flatten the v1 taxonomy into `Category`-shaped rows for the migration target + * picker, keeping ONLY the leaves — the inputable end-categories a transaction + * can actually be filed under. Intermediate parents (grouping buckets) are + * dropped so the type-ahead offers real targets only, never a bucket. + * + * Leaves are emitted in depth-first reading order and carry a running + * `sort_order` with `parent_id` undefined, so `CategoryCombobox` renders them as + * one flat, un-indented list in taxonomy order (leaves stay grouped under their + * original parent) instead of re-scrambling them by the per-parent sort_order. + * + * Every leaf id is `is_inputable` in the DB after the migration, so any pick is + * FK-safe. + * + * Edge case: a low-confidence default can point a v2 category at a NON-leaf + * parent (e.g. v2 "Jeux, Films & Livres" -> v1 "Divertissement" #1710). Such a + * value is absent from this leaves-only list; `MappingRow` re-injects it for that + * single row (via `findTaxonomyCategory`) so the input still shows the current + * suggestion, while new picks stay leaves-only. + * + * Pure helper — no i18n, no DB, no React. + */ +export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] { + const out: Category[] = []; + let order = 0; + const walk = (node: TaxonomyNode): void => { + if (node.children.length === 0) { + out.push({ + id: node.id, + name: node.name, + parent_id: undefined, + color: node.color, + type: node.type, + is_active: true, + is_inputable: true, + sort_order: order++, + i18n_key: node.i18n_key, + created_at: "", + }); + return; + } + for (const child of node.children) walk(child); + }; + for (const root of roots) walk(root); + return out; +} + +/** + * Resolve any taxonomy node (leaf OR parent) by id into the `Category` shape, + * for DISPLAY of a current non-leaf target that the leaves-only picker omits. + * `sort_order` is pushed to the end so, when injected, the node sorts last. + * Returns null when the id is absent from the taxonomy. Pure helper. + */ +export function findTaxonomyCategory( + roots: TaxonomyNode[], + id: number, +): Category | null { + const stack: TaxonomyNode[] = [...roots]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.id === id) { + return { + id: node.id, + name: node.name, + parent_id: undefined, + color: node.color, + type: node.type, + is_active: true, + is_inputable: node.children.length === 0, + sort_order: Number.MAX_SAFE_INTEGER, + i18n_key: node.i18n_key, + created_at: "", + }; + } + for (const child of node.children) stack.push(child); + } + return null; +} + +/** + * Build the target-picker option list for one migration row. + * + * Returns the leaves-only `leafOptions` unchanged when the row's current target + * is null or already a leaf (the common case — same array reference, so the + * combobox never re-renders needlessly). + * + * When the target is a NON-leaf parent absent from the leaves list (a + * low-confidence default like "Divertissement" #1710), the resolved parent is + * appended so the combobox can DISPLAY it as the current value. It stays visible + * (and, since `CategoryCombobox` renders every option, clickable) but selecting + * it is a no-op: the row's resolve handler only accepts ids present in the + * leaves list, so re-picking the parent changes nothing and the user is steered + * to a leaf. New picks that take effect are therefore always leaves. + * + * Pure helper — no React, no i18n, no DB. + */ +export function comboboxCategoriesForTarget( + leafOptions: Category[], + targetId: number | null, + resolveTarget: (id: number) => Category | null, +): Category[] { + if (targetId == null || leafOptions.some((c) => c.id === targetId)) { + return leafOptions; + } + const current = resolveTarget(targetId); + return current ? [...leafOptions, current] : leafOptions; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5cd083e..4ed8412 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1443,6 +1443,7 @@ "loadError": "Failed to load profile data: {{error}}", "needsReview": "Needs review", "chooseTarget": "Choose a target...", + "editTargetAria": "Change the target for {{category}}", "txCount_one": "{{count}} transaction", "txCount_other": "{{count}} transactions", "unresolvedWarning_one": "You have {{count}} decision to make before you can continue.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3a54a52..792aca3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1443,6 +1443,7 @@ "loadError": "Impossible de charger les données du profil : {{error}}", "needsReview": "À réviser", "chooseTarget": "Choisir une cible...", + "editTargetAria": "Modifier la cible pour {{category}}", "txCount_one": "{{count}} transaction", "txCount_other": "{{count}} transactions", "unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",