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( "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>( () => new Map(), ); const [loadError, setLoadError] = useState(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>( "SELECT id, name, parent_id FROM categories WHERE is_active = 1", ), db.select>( "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>( "SELECT id, name FROM suppliers WHERE is_active = 1", ), db.select>( "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(); 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 (
); } if (schemaCheck === "already_v1") { return (
{t("categoriesSeed.migration.backToSettings")}

{t("categoriesSeed.migration.alreadyMigrated.title")}

{t("categoriesSeed.migration.alreadyMigrated.body")}

); } return (
{t("categoriesSeed.migration.backToSettings")}

{t("categoriesSeed.migration.pageTitle")}

{t("categoriesSeed.migration.pageSubtitle")}

{loadError !== null && state.step === "simulate" && (
{t("categoriesSeed.migration.simulate.loadError", { error: loadError, })}
)} {state.step === "discover" && } {state.step === "simulate" && state.plan !== null && ( )} {state.step === "simulate" && state.plan === null && loadError === null && (
)} {(state.step === "consent" || state.step === "running") && ( )} {state.step === "success" && state.outcome && ( )} {state.step === "error" && ( )}
); } 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 (
    {steps.map((s, i) => (
  1. {i + 1} {s.label} {i < steps.length - 1 && (
  2. ))}
); } interface SuccessScreenProps { backupPath: string; insertedV1: number; updatedTx: number; updatedKw: number; updatedBg: number; } function SuccessScreen({ backupPath, insertedV1, updatedTx, updatedKw, updatedBg, }: SuccessScreenProps) { const { t } = useTranslation(); return (

{t("categoriesSeed.migration.success.title")}

{t("categoriesSeed.migration.success.subtitle")}

{t("categoriesSeed.migration.success.backupLabel")}

{backupPath}

{t("categoriesSeed.migration.success.restoreHint")}

{t("categoriesSeed.migration.success.stats.inserted")}
{insertedV1}
{t("categoriesSeed.migration.success.stats.transactions")}
{updatedTx}
{t("categoriesSeed.migration.success.stats.keywords")}
{updatedKw}
{t("categoriesSeed.migration.success.stats.budgets")}
{updatedBg}
{t("categoriesSeed.migration.success.backToDashboard")} {t("categoriesSeed.migration.success.viewCategories")}
); } interface ErrorScreenProps { errors: string[]; onRetry: () => void; } function ErrorScreen({ errors, onRetry }: ErrorScreenProps) { const { t } = useTranslation(); return (

{t("categoriesSeed.migration.error.title")}

{t("categoriesSeed.migration.error.subtitle")}

{errors.length > 0 && (
    {errors.map((e, i) => (
  • {e}
  • ))}
)}

{t("categoriesSeed.migration.error.rollbackNote")}

{t("categoriesSeed.migration.error.backToSettings")}
); }