Simpl-Resultat/src/components/categories-migration/StepSimulate.tsx
le king fu ce2793fc2c
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m30s
refactor(categories): migration target picker lists leaves only
The v1 target combobox in the migration preview now offers only leaf
categories (the inputable end-categories), not intermediate parents, so a
transaction is never mapped to a grouping bucket. Leaves render as a flat,
un-indented list in taxonomy order.

A low-confidence default can still point a v2 category at a non-leaf parent
(e.g. 'Divertissement' #1710); MappingRow re-injects that current target for
its single row via findTaxonomyCategory so the input stays populated, while
new picks remain leaves-only. Adds a findTaxonomyCategory pure helper + tests
(adapter now returns 112 leaves, not the 150-node full tree).
2026-07-04 20:40:29 -04:00

250 lines
9 KiB
TypeScript

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,
} from "../../services/categoryMappingService";
interface StepSimulateProps {
plan: MigrationPlan;
unresolved: number;
selectedRowV2Id: number | null;
transactionCountByV2Id: Map<number, number>;
onResolveRow: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
onSelectRow: (v2CategoryId: number | null) => void;
onNext: () => void;
onBack: () => void;
}
/**
* Step 2 — Simulate (dry-run): a 3-column table (v2 | confidence | v1 target),
* a preview side panel per selected row, and a blocking guard on the "next"
* button until every row is resolved. No DB writes happen here — the plan is
* mutated in memory via the reducer.
*/
export default function StepSimulate({
plan,
unresolved,
selectedRowV2Id,
transactionCountByV2Id,
onResolveRow,
onSelectRow,
onNext,
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<MappingRowType | null>(() => {
if (selectedRowV2Id === null) return null;
return (
plan.rows.find((r) => r.v2CategoryId === selectedRowV2Id) ??
plan.preserved.find((r) => r.v2CategoryId === selectedRowV2Id) ??
null
);
}, [plan.rows, plan.preserved, selectedRowV2Id]);
const canContinue = unresolved === 0;
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.simulate.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.subtitle")}
</p>
</header>
{/* Stats summary */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
<dl className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-sm">
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.stats.total")}
</dt>
<dd className="text-lg font-semibold">{plan.stats.total}</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.high")}
</dt>
<dd className="text-lg font-semibold text-green-600 dark:text-green-400">
{plan.stats.high}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.medium")}
</dt>
<dd className="text-lg font-semibold text-blue-600 dark:text-blue-400">
{plan.stats.medium}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.low")}
</dt>
<dd className="text-lg font-semibold text-orange-600 dark:text-orange-400">
{plan.stats.low}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.none")}
</dt>
<dd className="text-lg font-semibold text-red-600 dark:text-red-400">
{plan.stats.none}
</dd>
</div>
</dl>
</div>
{/* Unresolved warning banner */}
{unresolved > 0 && (
<div
role="status"
className="flex items-start gap-3 rounded-xl border border-orange-300 bg-orange-50 p-4 dark:bg-orange-900/10 dark:border-orange-700"
>
<AlertTriangle
size={18}
className="mt-0.5 shrink-0 text-orange-600 dark:text-orange-400"
/>
<p className="text-sm text-orange-900 dark:text-orange-200">
{t("categoriesSeed.migration.simulate.unresolvedWarning", {
count: unresolved,
})}
</p>
</div>
)}
{/* Header row */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<div
className="grid grid-cols-12 gap-2 px-3 py-2 text-xs font-semibold uppercase text-[var(--muted-foreground)] border-b border-[var(--border)]"
aria-hidden="true"
>
<div className="col-span-4">
{t("categoriesSeed.migration.simulate.header.current")}
</div>
<div className="col-span-3">
{t("categoriesSeed.migration.simulate.header.match")}
</div>
<div className="col-span-5 text-right">
{t("categoriesSeed.migration.simulate.header.target")}
</div>
</div>
<ul className="mt-2 space-y-1">
{plan.rows.map((row) => (
<li key={row.v2CategoryId}>
<MappingRow
row={row}
isSelected={selectedRowV2Id === row.v2CategoryId}
onSelect={onSelectRow}
onResolve={onResolveRow}
transactionCount={
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
}
targetCategories={targetCategories}
resolveTarget={resolveTarget}
/>
</li>
))}
</ul>
</div>
{/* Preserved categories block */}
{plan.preserved.length > 0 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<div className="flex items-start gap-3">
<FolderHeart
size={18}
className="mt-0.5 shrink-0 text-[var(--primary)]"
/>
<div>
<h3 className="font-semibold">
{t("categoriesSeed.migration.simulate.preserved.title", {
count: plan.preserved.length,
})}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.preserved.body")}
</p>
</div>
</div>
<ul className="text-sm space-y-1">
{plan.preserved.map((row) => (
<li
key={row.v2CategoryId}
className="px-3 py-1.5 rounded-md bg-[var(--muted)] text-[var(--foreground)]"
>
<span className="font-medium">{row.v2CategoryName}</span>
<span className="text-xs text-[var(--muted-foreground)] ml-2">
{t("categoriesSeed.migration.simulate.preserved.txCount", {
count: transactionCountByV2Id.get(row.v2CategoryId) ?? 0,
})}
</span>
</li>
))}
</ul>
</div>
)}
{/* Nav */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] text-sm"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.simulate.back")}
</button>
<button
type="button"
onClick={onNext}
disabled={!canContinue}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
>
{t("categoriesSeed.migration.simulate.next")}
<ArrowRight size={16} />
</button>
</div>
{/* Side panel with tx preview */}
{selectedRow !== null && (
<TransactionPreviewPanel
row={selectedRow}
onClose={() => onSelectRow(null)}
/>
)}
</section>
);
}