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>
151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
|
import type {
|
|
MappingRow as MappingRowType,
|
|
ConfidenceBadge,
|
|
} from "../../services/categoryMappingService";
|
|
|
|
interface MappingRowProps {
|
|
row: MappingRowType;
|
|
/** When true, the row is highlighted (its preview panel is open). */
|
|
isSelected: boolean;
|
|
/** Callback fired when the row is clicked — opens the preview panel. */
|
|
onSelect: (v2CategoryId: number) => void;
|
|
/**
|
|
* Called with the new v1 target id + name when the user resolves the row
|
|
* via the inline dropdown. The dropdown is only rendered for unresolved
|
|
* ("🟠 needs review") rows — resolved rows just show the target name.
|
|
*/
|
|
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
|
|
/** Number of transactions currently attached to this v2 category. */
|
|
transactionCount: number;
|
|
}
|
|
|
|
function badgeClass(confidence: ConfidenceBadge): string {
|
|
switch (confidence) {
|
|
case "high":
|
|
return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300";
|
|
case "medium":
|
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
|
|
case "low":
|
|
return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300";
|
|
case "none":
|
|
return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300";
|
|
}
|
|
}
|
|
|
|
export default function MappingRow({
|
|
row,
|
|
isSelected,
|
|
onSelect,
|
|
onResolve,
|
|
transactionCount,
|
|
}: MappingRowProps) {
|
|
const { t } = useTranslation();
|
|
const { getLeaves } = useCategoryTaxonomy();
|
|
|
|
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
|
|
// list flat because the simulate row is narrow; the search box in step 2
|
|
// already helps users find a target by keyword.
|
|
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
|
|
|
|
const badgeLabel = t(
|
|
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
|
);
|
|
const reasonLabel = t(
|
|
`categoriesSeed.migration.simulate.reason.${row.reason}`,
|
|
);
|
|
|
|
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
|
|
|
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const v1TargetId = Number(ev.target.value);
|
|
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
|
|
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
|
|
if (!leaf) return;
|
|
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
|
|
onResolve(row.v2CategoryId, v1TargetId, name);
|
|
};
|
|
|
|
const rowClass =
|
|
"grid grid-cols-12 gap-2 items-center px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors " +
|
|
(isSelected
|
|
? "bg-[var(--primary)]/10 border-[var(--primary)]/40"
|
|
: "bg-[var(--card)] border-[var(--border)] hover:border-[var(--primary)]/30 hover:bg-[var(--muted)]");
|
|
|
|
const targetDisplayName = isUnresolved
|
|
? null
|
|
: row.v1TargetName;
|
|
|
|
return (
|
|
<div
|
|
className={rowClass}
|
|
onClick={() => onSelect(row.v2CategoryId)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onSelect(row.v2CategoryId);
|
|
}
|
|
}}
|
|
aria-label={`${row.v2CategoryName} → ${targetDisplayName ?? t("categoriesSeed.migration.simulate.needsReview")}`}
|
|
>
|
|
{/* v2 category name + tx count */}
|
|
<div className="col-span-4 flex items-center gap-2 min-w-0">
|
|
<span className="truncate font-medium text-[var(--foreground)]">
|
|
{row.v2CategoryName}
|
|
</span>
|
|
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
|
|
{t("categoriesSeed.migration.simulate.txCount", {
|
|
count: transactionCount,
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Confidence badge + reason */}
|
|
<div className="col-span-3 flex items-center gap-2">
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeClass(
|
|
row.confidence,
|
|
)}`}
|
|
title={row.notes ?? undefined}
|
|
>
|
|
{badgeLabel}
|
|
</span>
|
|
<span className="text-xs text-[var(--muted-foreground)] truncate">
|
|
{reasonLabel}
|
|
</span>
|
|
</div>
|
|
|
|
{/* v1 target (or picker) */}
|
|
<div
|
|
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{isUnresolved ? (
|
|
<select
|
|
value=""
|
|
onChange={handleResolveChange}
|
|
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
|
|
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
|
>
|
|
<option value="" disabled>
|
|
{t("categoriesSeed.migration.simulate.chooseTarget")}
|
|
</option>
|
|
{v1Leaves.map((leaf) => (
|
|
<option key={leaf.id} value={leaf.id}>
|
|
{t(leaf.i18n_key, { defaultValue: leaf.name })}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<span className="truncate text-[var(--foreground)]">
|
|
{targetDisplayName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|