Simpl-Resultat/src/components/categories-migration/StepConsent.tsx
le king fu 0646875327
All checks were successful
PR Check / rust (push) Successful in 21m39s
PR Check / frontend (push) Successful in 2m21s
PR Check / rust (pull_request) Successful in 21m49s
PR Check / frontend (pull_request) Successful in 2m15s
feat(categories): add 3-step migration page + categoryMigrationService (#121)
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>
2026-04-20 21:31:21 -04:00

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>
);
}