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>
538 lines
18 KiB
TypeScript
538 lines
18 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import {
|
|
ArrowLeft,
|
|
CheckCircle2,
|
|
AlertOctagon,
|
|
FolderOpen,
|
|
RotateCcw,
|
|
Home as HomeIcon,
|
|
} from "lucide-react";
|
|
import { useProfile } from "../contexts/ProfileContext";
|
|
import { useCategoryMigration } from "../hooks/useCategoryMigration";
|
|
import {
|
|
computeMigrationPlan,
|
|
type ProfileData,
|
|
} from "../services/categoryMappingService";
|
|
import {
|
|
createPreMigrationBackup,
|
|
BackupError,
|
|
} from "../services/categoryBackupService";
|
|
import { applyMigration } from "../services/categoryMigrationService";
|
|
import { getDb } from "../services/db";
|
|
import { getPreference } from "../services/userPreferenceService";
|
|
import StepDiscover from "../components/categories-migration/StepDiscover";
|
|
import StepSimulate from "../components/categories-migration/StepSimulate";
|
|
import StepConsent from "../components/categories-migration/StepConsent";
|
|
|
|
/**
|
|
* 3-step category migration page — route `/settings/categories/migrate`.
|
|
*
|
|
* Wraps:
|
|
* - StepDiscover (read-only taxonomy preview, reused from #117)
|
|
* - StepSimulate (dry-run table + transaction preview side panel)
|
|
* - StepConsent (checklist + loader + success/error screens)
|
|
*
|
|
* The page owns the data-fetching pipeline:
|
|
* 1. Load profile data (categories, keywords, transactions, suppliers) from
|
|
* SQLite as soon as we enter step 2 the first time, then compute the
|
|
* MigrationPlan via the pure function from categoryMappingService.
|
|
* 2. On consent-confirm: createPreMigrationBackup → applyMigration → outcome.
|
|
*
|
|
* State transitions are delegated to useCategoryMigration (useReducer).
|
|
*/
|
|
|
|
const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
|
|
|
|
type AlreadyMigratedState = "loading" | "needs_migration" | "already_v1";
|
|
|
|
export default function CategoriesMigrationPage() {
|
|
const { t } = useTranslation();
|
|
const { activeProfile } = useProfile();
|
|
const {
|
|
state,
|
|
loadPlan,
|
|
resolveRow,
|
|
selectRow,
|
|
goNext,
|
|
goBack,
|
|
startRun,
|
|
setBackup,
|
|
setOutcome,
|
|
fail,
|
|
} = useCategoryMigration();
|
|
|
|
// Gate the page: profiles already on v1 should see a friendly "already done"
|
|
// screen instead of re-migrating (re-running the writer would be idempotent
|
|
// thanks to OR IGNORE, but it would surprise the user).
|
|
const [schemaCheck, setSchemaCheck] = useState<AlreadyMigratedState>(
|
|
"loading",
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const version = await getPreference(CATEGORIES_SCHEMA_VERSION_KEY);
|
|
if (cancelled) return;
|
|
setSchemaCheck(version === "v1" ? "already_v1" : "needs_migration");
|
|
} catch {
|
|
if (!cancelled) setSchemaCheck("needs_migration");
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Lazy-load the profile data + plan the first time we enter the simulate step.
|
|
const [txCountByV2Id, setTxCountByV2Id] = useState<Map<number, number>>(
|
|
() => new Map(),
|
|
);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const planLoadedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (state.step !== "simulate" || planLoadedRef.current) return;
|
|
planLoadedRef.current = true;
|
|
|
|
(async () => {
|
|
try {
|
|
const db = await getDb();
|
|
|
|
const [v2Cats, kws, txs, sups, txCounts] = await Promise.all([
|
|
db.select<Array<{ id: number; name: string; parent_id: number | null }>>(
|
|
"SELECT id, name, parent_id FROM categories WHERE is_active = 1",
|
|
),
|
|
db.select<Array<{ id: number; keyword: string; category_id: number }>>(
|
|
"SELECT id, keyword, category_id FROM keywords WHERE is_active = 1",
|
|
),
|
|
db.select<
|
|
Array<{
|
|
id: number;
|
|
description: string;
|
|
category_id: number | null;
|
|
supplier_id: number | null;
|
|
}>
|
|
>(
|
|
"SELECT id, description, category_id, supplier_id FROM transactions",
|
|
),
|
|
db.select<Array<{ id: number; name: string }>>(
|
|
"SELECT id, name FROM suppliers WHERE is_active = 1",
|
|
),
|
|
db.select<Array<{ category_id: number; cnt: number }>>(
|
|
"SELECT category_id, COUNT(*) AS cnt FROM transactions WHERE category_id IS NOT NULL GROUP BY category_id",
|
|
),
|
|
]);
|
|
|
|
const profileData: ProfileData = {
|
|
v2Categories: v2Cats,
|
|
keywords: kws,
|
|
transactions: txs,
|
|
suppliers: sups,
|
|
};
|
|
const plan = computeMigrationPlan(profileData);
|
|
loadPlan(plan);
|
|
|
|
const counts = new Map<number, number>();
|
|
for (const row of txCounts) {
|
|
counts.set(row.category_id, Number(row.cnt));
|
|
}
|
|
setTxCountByV2Id(counts);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
setLoadError(msg);
|
|
planLoadedRef.current = false;
|
|
}
|
|
})();
|
|
}, [state.step, loadPlan]);
|
|
|
|
// Password for PIN-protected backups.
|
|
const [password, setPassword] = useState("");
|
|
const requiresPassword =
|
|
!!activeProfile?.pin_hash && activeProfile.pin_hash.length > 0;
|
|
|
|
// Loader stage for the 4 sub-steps (see StepConsent RunningLoader).
|
|
const [runningStage, setRunningStage] = useState<0 | 1 | 2 | 3>(0);
|
|
|
|
const handleConfirm = async () => {
|
|
if (!activeProfile || !state.plan) return;
|
|
startRun();
|
|
setRunningStage(0);
|
|
try {
|
|
// 1. Create + verify the backup.
|
|
const backup = await createPreMigrationBackup({
|
|
profile: activeProfile,
|
|
password: requiresPassword ? password : undefined,
|
|
});
|
|
setBackup(backup);
|
|
setRunningStage(1);
|
|
// 2. Tiny pause for the UI to breathe (checksum already verified inside
|
|
// createPreMigrationBackup, but we bump the stage for the loader).
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
setRunningStage(2);
|
|
// 3. Run the migration.
|
|
const outcome = await applyMigration(state.plan, backup);
|
|
setRunningStage(3);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
setOutcome(outcome);
|
|
} catch (e) {
|
|
if (e instanceof BackupError) {
|
|
const label = t(
|
|
`categoriesSeed.migration.error.backup.${e.code}`,
|
|
{ defaultValue: e.message },
|
|
);
|
|
fail(label);
|
|
} else {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
fail(msg);
|
|
}
|
|
}
|
|
};
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Renders
|
|
// --------------------------------------------------------------------------
|
|
|
|
if (schemaCheck === "loading") {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (schemaCheck === "already_v1") {
|
|
return (
|
|
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
|
<Link
|
|
to="/settings"
|
|
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
|
>
|
|
<ArrowLeft size={16} />
|
|
{t("categoriesSeed.migration.backToSettings")}
|
|
</Link>
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3">
|
|
<h1 className="text-xl font-semibold">
|
|
{t("categoriesSeed.migration.alreadyMigrated.title")}
|
|
</h1>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.alreadyMigrated.body")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
|
<div>
|
|
<Link
|
|
to="/settings"
|
|
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
|
>
|
|
<ArrowLeft size={16} />
|
|
{t("categoriesSeed.migration.backToSettings")}
|
|
</Link>
|
|
</div>
|
|
|
|
<header className="space-y-1">
|
|
<h1 className="text-2xl font-bold">
|
|
{t("categoriesSeed.migration.pageTitle")}
|
|
</h1>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.pageSubtitle")}
|
|
</p>
|
|
</header>
|
|
|
|
<Stepper step={state.step} />
|
|
|
|
{loadError !== null && state.step === "simulate" && (
|
|
<div className="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-900 dark:bg-red-900/10 dark:border-red-700 dark:text-red-200">
|
|
{t("categoriesSeed.migration.simulate.loadError", {
|
|
error: loadError,
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{state.step === "discover" && <StepDiscover onNext={goNext} />}
|
|
|
|
{state.step === "simulate" && state.plan !== null && (
|
|
<StepSimulate
|
|
plan={state.plan}
|
|
unresolved={state.unresolved}
|
|
selectedRowV2Id={state.selectedRowV2Id}
|
|
transactionCountByV2Id={txCountByV2Id}
|
|
onResolveRow={resolveRow}
|
|
onSelectRow={selectRow}
|
|
onNext={goNext}
|
|
onBack={goBack}
|
|
/>
|
|
)}
|
|
|
|
{state.step === "simulate" && state.plan === null && loadError === null && (
|
|
<div className="flex items-center justify-center p-10">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
|
</div>
|
|
)}
|
|
|
|
{(state.step === "consent" || state.step === "running") && (
|
|
<StepConsent
|
|
password={password}
|
|
onPasswordChange={setPassword}
|
|
requiresPassword={requiresPassword}
|
|
isRunning={state.step === "running"}
|
|
runningStage={runningStage}
|
|
onBack={goBack}
|
|
onConfirm={handleConfirm}
|
|
/>
|
|
)}
|
|
|
|
{state.step === "success" && state.outcome && (
|
|
<SuccessScreen
|
|
backupPath={state.outcome.backupPath}
|
|
insertedV1={state.outcome.insertedV1Count}
|
|
updatedTx={state.outcome.updatedTransactionsCount}
|
|
updatedKw={state.outcome.updatedKeywordsCount}
|
|
updatedBg={state.outcome.updatedBudgetsCount}
|
|
/>
|
|
)}
|
|
|
|
{state.step === "error" && (
|
|
<ErrorScreen
|
|
errors={state.errors}
|
|
onRetry={goBack}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface StepperProps {
|
|
step: string;
|
|
}
|
|
function Stepper({ step }: StepperProps) {
|
|
const { t } = useTranslation();
|
|
const steps: Array<{
|
|
key: string;
|
|
label: string;
|
|
active: boolean;
|
|
done: boolean;
|
|
}> = useMemo(
|
|
() => [
|
|
{
|
|
key: "discover",
|
|
label: t("categoriesSeed.migration.stepper.discover"),
|
|
active: step === "discover",
|
|
done: ["simulate", "consent", "running", "success"].includes(step),
|
|
},
|
|
{
|
|
key: "simulate",
|
|
label: t("categoriesSeed.migration.stepper.simulate"),
|
|
active: step === "simulate",
|
|
done: ["consent", "running", "success"].includes(step),
|
|
},
|
|
{
|
|
key: "consent",
|
|
label: t("categoriesSeed.migration.stepper.consent"),
|
|
active: step === "consent" || step === "running",
|
|
done: step === "success",
|
|
},
|
|
],
|
|
[step, t],
|
|
);
|
|
return (
|
|
<ol
|
|
className="flex items-center gap-3 text-sm"
|
|
aria-label={t("categoriesSeed.migration.stepper.ariaLabel")}
|
|
>
|
|
{steps.map((s, i) => (
|
|
<li key={s.key} className="flex items-center gap-3">
|
|
<span
|
|
className={
|
|
"inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-semibold border " +
|
|
(s.done
|
|
? "bg-[var(--primary)] text-white border-[var(--primary)]"
|
|
: s.active
|
|
? "bg-[var(--primary)]/10 text-[var(--primary)] border-[var(--primary)]"
|
|
: "bg-[var(--muted)] text-[var(--muted-foreground)] border-[var(--border)]")
|
|
}
|
|
>
|
|
{i + 1}
|
|
</span>
|
|
<span
|
|
className={
|
|
s.active
|
|
? "font-medium text-[var(--foreground)]"
|
|
: s.done
|
|
? "text-[var(--foreground)]"
|
|
: "text-[var(--muted-foreground)]"
|
|
}
|
|
>
|
|
{s.label}
|
|
</span>
|
|
{i < steps.length - 1 && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="h-px w-8 bg-[var(--border)]"
|
|
/>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
interface SuccessScreenProps {
|
|
backupPath: string;
|
|
insertedV1: number;
|
|
updatedTx: number;
|
|
updatedKw: number;
|
|
updatedBg: number;
|
|
}
|
|
function SuccessScreen({
|
|
backupPath,
|
|
insertedV1,
|
|
updatedTx,
|
|
updatedKw,
|
|
updatedBg,
|
|
}: SuccessScreenProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<section className="space-y-6">
|
|
<div className="bg-[var(--card)] border border-green-300 dark:border-green-700 rounded-xl p-6 space-y-4">
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle2
|
|
size={28}
|
|
className="shrink-0 text-green-600 dark:text-green-400"
|
|
/>
|
|
<div>
|
|
<h2 className="text-xl font-semibold">
|
|
{t("categoriesSeed.migration.success.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.success.subtitle")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-[var(--muted)] rounded-lg p-4 space-y-2">
|
|
<p className="text-sm font-medium text-[var(--foreground)]">
|
|
<FolderOpen size={16} className="inline mr-1" />
|
|
{t("categoriesSeed.migration.success.backupLabel")}
|
|
</p>
|
|
<code className="block text-xs break-all p-2 bg-[var(--background)] rounded border border-[var(--border)]">
|
|
{backupPath}
|
|
</code>
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.success.restoreHint")}
|
|
</p>
|
|
</div>
|
|
|
|
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
|
<div>
|
|
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.success.stats.inserted")}
|
|
</dt>
|
|
<dd className="text-lg font-semibold">{insertedV1}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.success.stats.transactions")}
|
|
</dt>
|
|
<dd className="text-lg font-semibold">{updatedTx}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.success.stats.keywords")}
|
|
</dt>
|
|
<dd className="text-lg font-semibold">{updatedKw}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.success.stats.budgets")}
|
|
</dt>
|
|
<dd className="text-lg font-semibold">{updatedBg}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center gap-3">
|
|
<Link
|
|
to="/"
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90"
|
|
>
|
|
<HomeIcon size={16} />
|
|
{t("categoriesSeed.migration.success.backToDashboard")}
|
|
</Link>
|
|
<Link
|
|
to="/categories"
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
|
|
>
|
|
{t("categoriesSeed.migration.success.viewCategories")}
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
interface ErrorScreenProps {
|
|
errors: string[];
|
|
onRetry: () => void;
|
|
}
|
|
function ErrorScreen({ errors, onRetry }: ErrorScreenProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<section className="space-y-6">
|
|
<div className="bg-[var(--card)] border border-red-300 dark:border-red-700 rounded-xl p-6 space-y-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertOctagon
|
|
size={28}
|
|
className="shrink-0 text-red-600 dark:text-red-400"
|
|
/>
|
|
<div>
|
|
<h2 className="text-xl font-semibold">
|
|
{t("categoriesSeed.migration.error.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.error.subtitle")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{errors.length > 0 && (
|
|
<ul className="space-y-1 text-sm text-red-700 dark:text-red-400">
|
|
{errors.map((e, i) => (
|
|
<li
|
|
key={i}
|
|
className="p-2 rounded bg-red-50 dark:bg-red-900/10 break-words"
|
|
>
|
|
{e}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.error.rollbackNote")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90"
|
|
>
|
|
<RotateCcw size={16} />
|
|
{t("categoriesSeed.migration.error.retry")}
|
|
</button>
|
|
<Link
|
|
to="/settings"
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
|
|
>
|
|
{t("categoriesSeed.migration.error.backToSettings")}
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|