This commit is contained in:
commit
9124c888de
8 changed files with 378 additions and 39 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
### Modifié
|
### 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).
|
- 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é
|
### Sécurité
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
### Changed
|
### 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).
|
- 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
|
### Security
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
import type {
|
||||||
MappingRow as MappingRowType,
|
MappingRow as MappingRowType,
|
||||||
ConfidenceBadge,
|
ConfidenceBadge,
|
||||||
|
|
@ -13,13 +15,27 @@ interface MappingRowProps {
|
||||||
/** Callback fired when the row is clicked — opens the preview panel. */
|
/** Callback fired when the row is clicked — opens the preview panel. */
|
||||||
onSelect: (v2CategoryId: number) => void;
|
onSelect: (v2CategoryId: number) => void;
|
||||||
/**
|
/**
|
||||||
* Called with the new v1 target id + name when the user resolves the row
|
* Called with the new v1 target id + name when the user picks a target via
|
||||||
* via the inline dropdown. The dropdown is only rendered for unresolved
|
* the inline type-ahead combobox. Editable on EVERY row (resolved or not),
|
||||||
* ("🟠 needs review") rows — resolved rows just show the target name.
|
* 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;
|
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
|
||||||
/** Number of transactions currently attached to this v2 category. */
|
/** Number of transactions currently attached to this v2 category. */
|
||||||
transactionCount: number;
|
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 {
|
function badgeClass(confidence: ConfidenceBadge): string {
|
||||||
|
|
@ -41,14 +57,21 @@ export default function MappingRow({
|
||||||
onSelect,
|
onSelect,
|
||||||
onResolve,
|
onResolve,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
|
targetCategories,
|
||||||
|
resolveTarget,
|
||||||
}: MappingRowProps) {
|
}: MappingRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getLeaves } = useCategoryTaxonomy();
|
|
||||||
|
|
||||||
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
|
// The picker lists leaves only. If this row's current target is a non-leaf
|
||||||
// list flat because the simulate row is narrow; the search box in step 2
|
// parent (a low-confidence default like "Divertissement" #1710), it is absent
|
||||||
// already helps users find a target by keyword.
|
// from that list and would blank the combobox input, so re-inject it for this
|
||||||
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
|
// 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(
|
const badgeLabel = t(
|
||||||
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
||||||
|
|
@ -59,12 +82,18 @@ export default function MappingRow({
|
||||||
|
|
||||||
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
||||||
|
|
||||||
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
|
// Fired when the user picks a v1 leaf in the type-ahead combobox. The
|
||||||
const v1TargetId = Number(ev.target.value);
|
// combobox only emits ids that exist in `targetCategories` (never null with
|
||||||
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
|
// our config), but we guard defensively. Resolving a "none" row bumps its
|
||||||
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
|
// confidence to "medium" via the reducer; editing an already-resolved row
|
||||||
if (!leaf) return;
|
// keeps its confidence unchanged (see RESOLVE_ROW).
|
||||||
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
|
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);
|
onResolve(row.v2CategoryId, v1TargetId, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -119,32 +148,26 @@ export default function MappingRow({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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. */}
|
||||||
<div
|
<div
|
||||||
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
|
className="col-span-5 flex items-center justify-end min-w-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{isUnresolved ? (
|
<div className="w-full max-w-[16rem]">
|
||||||
<select
|
<CategoryCombobox
|
||||||
value=""
|
categories={comboboxCategories}
|
||||||
onChange={handleResolveChange}
|
value={row.v1TargetId ?? null}
|
||||||
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
|
onChange={handleTargetChange}
|
||||||
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
compact
|
||||||
>
|
placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||||
<option value="" disabled>
|
ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
|
||||||
{t("categoriesSeed.migration.simulate.chooseTarget")}
|
category: row.v2CategoryName,
|
||||||
</option>
|
})}
|
||||||
{v1Leaves.map((leaf) => (
|
/>
|
||||||
<option key={leaf.id} value={leaf.id}>
|
</div>
|
||||||
{t(leaf.i18n_key, { defaultValue: leaf.name })}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-[var(--foreground)]">
|
|
||||||
{targetDisplayName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
|
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
|
||||||
import MappingRow from "./MappingRow";
|
import MappingRow from "./MappingRow";
|
||||||
import TransactionPreviewPanel from "./TransactionPreviewPanel";
|
import TransactionPreviewPanel from "./TransactionPreviewPanel";
|
||||||
|
import {
|
||||||
|
taxonomyToComboboxCategories,
|
||||||
|
findTaxonomyCategory,
|
||||||
|
} from "./migrationTargets";
|
||||||
|
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||||
import type {
|
import type {
|
||||||
MigrationPlan,
|
MigrationPlan,
|
||||||
MappingRow as MappingRowType,
|
MappingRow as MappingRowType,
|
||||||
|
|
@ -36,6 +41,24 @@ export default function StepSimulate({
|
||||||
onBack,
|
onBack,
|
||||||
}: StepSimulateProps) {
|
}: StepSimulateProps) {
|
||||||
const { t } = useTranslation();
|
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<MappingRowType | null>(() => {
|
const selectedRow = useMemo<MappingRowType | null>(() => {
|
||||||
if (selectedRowV2Id === null) return null;
|
if (selectedRowV2Id === null) return null;
|
||||||
|
|
@ -149,6 +172,8 @@ export default function StepSimulate({
|
||||||
transactionCount={
|
transactionCount={
|
||||||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||||
}
|
}
|
||||||
|
targetCategories={targetCategories}
|
||||||
|
resolveTarget={resolveTarget}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
177
src/components/categories-migration/migrationTargets.test.ts
Normal file
177
src/components/categories-migration/migrationTargets.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
src/components/categories-migration/migrationTargets.ts
Normal file
110
src/components/categories-migration/migrationTargets.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1443,6 +1443,7 @@
|
||||||
"loadError": "Failed to load profile data: {{error}}",
|
"loadError": "Failed to load profile data: {{error}}",
|
||||||
"needsReview": "Needs review",
|
"needsReview": "Needs review",
|
||||||
"chooseTarget": "Choose a target...",
|
"chooseTarget": "Choose a target...",
|
||||||
|
"editTargetAria": "Change the target for {{category}}",
|
||||||
"txCount_one": "{{count}} transaction",
|
"txCount_one": "{{count}} transaction",
|
||||||
"txCount_other": "{{count}} transactions",
|
"txCount_other": "{{count}} transactions",
|
||||||
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
|
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
|
||||||
|
|
|
||||||
|
|
@ -1443,6 +1443,7 @@
|
||||||
"loadError": "Impossible de charger les données du profil : {{error}}",
|
"loadError": "Impossible de charger les données du profil : {{error}}",
|
||||||
"needsReview": "À réviser",
|
"needsReview": "À réviser",
|
||||||
"chooseTarget": "Choisir une cible...",
|
"chooseTarget": "Choisir une cible...",
|
||||||
|
"editTargetAria": "Modifier la cible pour {{category}}",
|
||||||
"txCount_one": "{{count}} transaction",
|
"txCount_one": "{{count}} transaction",
|
||||||
"txCount_other": "{{count}} transactions",
|
"txCount_other": "{{count}} transactions",
|
||||||
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
|
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue