Simpl-Resultat/src/components/categories-migration/MappingRow.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

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