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>
245 lines
8 KiB
TypeScript
245 lines
8 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ArrowLeft,
|
|
ShieldCheck,
|
|
FolderLock,
|
|
Loader2,
|
|
CheckCircle2,
|
|
Circle,
|
|
} from "lucide-react";
|
|
|
|
interface StepConsentProps {
|
|
/** PIN/password for PIN-protected profiles. Empty string if no PIN. */
|
|
password: string;
|
|
onPasswordChange: (value: string) => void;
|
|
/** True when the current profile is PIN-protected — hides the field otherwise. */
|
|
requiresPassword: boolean;
|
|
/** Transition indicator: the running loader reuses this file via a flag. */
|
|
isRunning: boolean;
|
|
/** Progress stage for the loader (0 = backup, 1 = verified, 2 = sql, 3 = done). */
|
|
runningStage: 0 | 1 | 2 | 3;
|
|
onBack: () => void;
|
|
onConfirm: () => void;
|
|
}
|
|
|
|
/**
|
|
* Step 3 — Consent: an explicit checklist + confirm button, plus a loader
|
|
* that takes over once the user clicks confirm. The loader shows the 4 sub-
|
|
* steps (backup created, backup verified, SQL running, commit) per the mockup.
|
|
*/
|
|
export default function StepConsent({
|
|
password,
|
|
onPasswordChange,
|
|
requiresPassword,
|
|
isRunning,
|
|
runningStage,
|
|
onBack,
|
|
onConfirm,
|
|
}: StepConsentProps) {
|
|
const { t } = useTranslation();
|
|
const [ack1, setAck1] = useState(false);
|
|
const [ack2, setAck2] = useState(false);
|
|
const [ack3, setAck3] = useState(false);
|
|
|
|
const allAck = ack1 && ack2 && ack3;
|
|
const canConfirm =
|
|
!isRunning && allAck && (!requiresPassword || password.trim().length > 0);
|
|
|
|
if (isRunning) {
|
|
return <RunningLoader stage={runningStage} />;
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-6">
|
|
<header className="space-y-1">
|
|
<h2 className="text-xl font-semibold">
|
|
{t("categoriesSeed.migration.consent.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.consent.subtitle")}
|
|
</p>
|
|
</header>
|
|
|
|
{/* Backup info card */}
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
|
|
<div className="flex items-start gap-3">
|
|
<ShieldCheck
|
|
size={18}
|
|
className="mt-0.5 shrink-0 text-[var(--primary)]"
|
|
/>
|
|
<div>
|
|
<h3 className="font-semibold">
|
|
{t("categoriesSeed.migration.consent.backup.title")}
|
|
</h3>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.consent.backup.body")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-[var(--muted-foreground)] pl-8">
|
|
<FolderLock size={12} className="inline mr-1" />
|
|
{t("categoriesSeed.migration.consent.backup.location")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Password field (only for PIN-protected profiles) */}
|
|
{requiresPassword && (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
|
|
<label
|
|
htmlFor="consent-password"
|
|
className="text-sm font-medium text-[var(--foreground)]"
|
|
>
|
|
{t("categoriesSeed.migration.consent.password.label")}
|
|
</label>
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.consent.password.help")}
|
|
</p>
|
|
<input
|
|
id="consent-password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => onPasswordChange(e.target.value)}
|
|
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Checklist */}
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
|
|
<p className="text-sm font-medium text-[var(--foreground)]">
|
|
{t("categoriesSeed.migration.consent.checklist.title")}
|
|
</p>
|
|
<ul className="space-y-2 text-sm text-[var(--foreground)]">
|
|
<li>
|
|
<label className="flex items-start gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={ack1}
|
|
onChange={(e) => setAck1(e.target.checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>{t("categoriesSeed.migration.consent.checklist.item1")}</span>
|
|
</label>
|
|
</li>
|
|
<li>
|
|
<label className="flex items-start gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={ack2}
|
|
onChange={(e) => setAck2(e.target.checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>{t("categoriesSeed.migration.consent.checklist.item2")}</span>
|
|
</label>
|
|
</li>
|
|
<li>
|
|
<label className="flex items-start gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={ack3}
|
|
onChange={(e) => setAck3(e.target.checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>{t("categoriesSeed.migration.consent.checklist.item3")}</span>
|
|
</label>
|
|
</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.consent.back")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
disabled={!canConfirm}
|
|
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"
|
|
>
|
|
<ShieldCheck size={16} />
|
|
{t("categoriesSeed.migration.consent.confirm")}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
interface StageLineProps {
|
|
done: boolean;
|
|
active: boolean;
|
|
label: string;
|
|
}
|
|
|
|
function StageLine({ done, active, label }: StageLineProps) {
|
|
const icon = done ? (
|
|
<CheckCircle2 size={16} className="text-green-600 dark:text-green-400" />
|
|
) : active ? (
|
|
<Loader2 size={16} className="animate-spin text-[var(--primary)]" />
|
|
) : (
|
|
<Circle size={16} className="text-[var(--muted-foreground)]" />
|
|
);
|
|
return (
|
|
<li className="flex items-center gap-3">
|
|
{icon}
|
|
<span
|
|
className={
|
|
done
|
|
? "text-sm text-[var(--foreground)]"
|
|
: active
|
|
? "text-sm font-medium text-[var(--foreground)]"
|
|
: "text-sm text-[var(--muted-foreground)]"
|
|
}
|
|
>
|
|
{label}
|
|
</span>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function RunningLoader({ stage }: { stage: 0 | 1 | 2 | 3 }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<section className="space-y-6">
|
|
<header className="space-y-1">
|
|
<h2 className="text-xl font-semibold">
|
|
{t("categoriesSeed.migration.running.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.running.subtitle")}
|
|
</p>
|
|
</header>
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
|
|
<ul className="space-y-3" aria-live="polite">
|
|
<StageLine
|
|
done={stage > 0}
|
|
active={stage === 0}
|
|
label={t("categoriesSeed.migration.running.step1")}
|
|
/>
|
|
<StageLine
|
|
done={stage > 1}
|
|
active={stage === 1}
|
|
label={t("categoriesSeed.migration.running.step2")}
|
|
/>
|
|
<StageLine
|
|
done={stage > 2}
|
|
active={stage === 2}
|
|
label={t("categoriesSeed.migration.running.step3")}
|
|
/>
|
|
<StageLine
|
|
done={stage > 3}
|
|
active={stage === 3}
|
|
label={t("categoriesSeed.migration.running.step4")}
|
|
/>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|