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>
167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react";
|
|
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
|
import CategoryTaxonomyTree from "../categories/CategoryTaxonomyTree";
|
|
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
|
|
|
|
interface StepDiscoverProps {
|
|
onNext: () => void;
|
|
}
|
|
|
|
function collectAllIds(nodes: TaxonomyNode[]): number[] {
|
|
const ids: number[] = [];
|
|
const walk = (n: TaxonomyNode) => {
|
|
ids.push(n.id);
|
|
n.children.forEach(walk);
|
|
};
|
|
nodes.forEach(walk);
|
|
return ids;
|
|
}
|
|
|
|
function countNodes(nodes: TaxonomyNode[]): {
|
|
roots: number;
|
|
subcategories: number;
|
|
leaves: number;
|
|
} {
|
|
let roots = 0;
|
|
let subcategories = 0;
|
|
let leaves = 0;
|
|
for (const root of nodes) {
|
|
roots += 1;
|
|
for (const child of root.children) {
|
|
if (child.children.length === 0) leaves += 1;
|
|
else {
|
|
subcategories += 1;
|
|
for (const leaf of child.children) {
|
|
if (leaf.children.length === 0) leaves += 1;
|
|
else subcategories += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { roots, subcategories, leaves };
|
|
}
|
|
|
|
/**
|
|
* Step 1 — Discover: read-only navigation of the v1 taxonomy. Reuses the same
|
|
* CategoryTaxonomyTree component as the standalone guide page (#117) so the
|
|
* two surfaces stay visually consistent.
|
|
*/
|
|
export default function StepDiscover({ onNext }: StepDiscoverProps) {
|
|
const { t } = useTranslation();
|
|
const { taxonomy } = useCategoryTaxonomy();
|
|
const [search, setSearch] = useState("");
|
|
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
|
|
|
|
const counts = countNodes(taxonomy.roots);
|
|
const total = counts.roots + counts.subcategories + counts.leaves;
|
|
|
|
const toggleNode = (id: number) => {
|
|
setExpanded((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleExpandAll = () => {
|
|
setExpanded(new Set(collectAllIds(taxonomy.roots)));
|
|
};
|
|
const handleCollapseAll = () => setExpanded(new Set());
|
|
const allExpanded = expanded.size > 0;
|
|
|
|
return (
|
|
<section className="space-y-6">
|
|
<header className="space-y-1">
|
|
<h2 className="text-xl font-semibold">
|
|
{t("categoriesSeed.migration.discover.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.discover.subtitle")}
|
|
</p>
|
|
</header>
|
|
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
|
|
<h3 className="font-semibold">
|
|
{t("categoriesSeed.migration.discover.intro.title")}
|
|
</h3>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.discover.intro.body")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-4">
|
|
<p
|
|
className="text-sm text-[var(--muted-foreground)]"
|
|
aria-live="polite"
|
|
>
|
|
{t("categoriesSeed.guidePage.counter", {
|
|
roots: counts.roots,
|
|
subcategories: counts.subcategories,
|
|
leaves: counts.leaves,
|
|
total,
|
|
})}
|
|
</p>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<Search
|
|
size={16}
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] pointer-events-none"
|
|
aria-hidden="true"
|
|
/>
|
|
<input
|
|
type="search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("categoriesSeed.guidePage.searchPlaceholder")}
|
|
aria-label={t("categoriesSeed.guidePage.searchPlaceholder")}
|
|
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={allExpanded ? handleCollapseAll : handleExpandAll}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
|
>
|
|
{allExpanded ? (
|
|
<>
|
|
<ChevronsDownUp size={16} />
|
|
{t("categoriesSeed.guidePage.collapseAll")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronsUpDown size={16} />
|
|
{t("categoriesSeed.guidePage.expandAll")}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
|
|
<CategoryTaxonomyTree
|
|
nodes={taxonomy.roots}
|
|
expanded={expanded}
|
|
onToggle={toggleNode}
|
|
searchQuery={search}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onNext}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
|
>
|
|
{t("categoriesSeed.migration.discover.next")}
|
|
<ArrowRight size={16} />
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|