New user-facing 3-step migration flow at /settings/categories/migrate that allows legacy v2 profiles to opt in to the v1 IPC taxonomy. Step 1 Discover — read-only taxonomy tree (reuses CategoryTaxonomyTree from Livraison 1, #117). Step 2 Simulate — 3-column dry-run table with confidence badges (high / medium / low / needs-review), transaction preview side panel, inline target picker for unresolved rows. The "next" button is blocked until every row is resolved. Step 3 Consent — checklist + optional PIN field for PIN-protected profiles + 4-step loader (backup created / verified / SQL applied / committed). Success and error screens surface the SREF backup path and the counts of rows migrated. Errors never leave the profile in a partial state — the new categoryMigrationService wraps the entire SQL writeover in a BEGIN/COMMIT/ROLLBACK atomic transaction and aborts up-front if the backup is not present / verified. New code: - src/services/categoryMigrationService.ts — applyMigration(plan, backup) atomic writer (INSERT v1 → UPDATE transactions/budgets/budget_templates/ keywords/suppliers → reparent preserved customs → deactivate v2 seed → bump categories_schema_version=v1 → journal last_categories_migration). - src/hooks/useCategoryMigration.ts — useReducer state machine (discover → simulate → consent → running → success | error). - src/hooks/useCategoryMigration.test.ts — 13 pure reducer tests. - src/components/categories-migration/{StepDiscover,StepSimulate,StepConsent, MappingRow,TransactionPreviewPanel}.tsx — UI per the mockup. - src/pages/CategoriesMigrationPage.tsx — wrapper with internal router, stepper, backup/migrate orchestration, success/error screens. Tweaks: - src/App.tsx — new /settings/categories/migrate route. - src/components/settings/CategoriesCard.tsx — additional card surfacing the migrate entry for v2 profiles only. - src/i18n/locales/{fr,en}.json — categoriesSeed.migration.* namespace (page / stepper / 3 steps / running / success / error / backup error codes). - CHANGELOG.{md,fr.md} — [Unreleased] / Added entry. Scope limits respected: no SQL migration modified, no new migration added, no unit tests of the applyMigration writer (covered by #123 in wave 4), no restore-backup button (#122 in wave 4), no post-migration banner (#122). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
7.9 KiB
TypeScript
225 lines
7.9 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
|
|
import MappingRow from "./MappingRow";
|
|
import TransactionPreviewPanel from "./TransactionPreviewPanel";
|
|
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 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
|
|
}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|