import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import CategoryCombobox from "../shared/CategoryCombobox"; import type { Category } from "../../shared/types"; 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 picks a target via * the inline type-ahead combobox. Editable on EVERY row (resolved or not), * so a user can override an auto-detected target as well as resolve a * "needs review" one. */ onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void; /** Number of transactions currently attached to this v2 category. */ transactionCount: number; /** * v1 LEAF catalogue adapted to the `Category` shape the combobox expects. * Computed ONCE by StepSimulate and shared across rows (perf). Leaves only — * a transaction is never mapped to a grouping bucket. */ targetCategories: Category[]; /** * Resolves a target id (possibly a non-leaf parent absent from the leaves-only * `targetCategories`) to a display `Category`. Used to keep a low-confidence * parent default like "Divertissement" (#1710) visible on its row without * offering parents as new picks. */ resolveTarget: (id: number) => Category | null; } 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, targetCategories, resolveTarget, }: MappingRowProps) { const { t } = useTranslation(); // The picker lists leaves only. If this row's current target is a non-leaf // parent (a low-confidence default like "Divertissement" #1710), it is absent // from that list and would blank the combobox input, so re-inject it for this // row only — shown as the current value, never offered as a new pick. const comboboxCategories = useMemo(() => { const id = row.v1TargetId; if (id == null || targetCategories.some((c) => c.id === id)) { return targetCategories; } const current = resolveTarget(id); return current ? [...targetCategories, current] : targetCategories; }, [targetCategories, row.v1TargetId, resolveTarget]); 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; // Fired when the user picks a v1 leaf in the type-ahead combobox. The // combobox only emits ids that exist in `targetCategories` (never null with // our config), but we guard defensively. Resolving a "none" row bumps its // confidence to "medium" via the reducer; editing an already-resolved row // keeps its confidence unchanged (see RESOLVE_ROW). const handleTargetChange = (v1TargetId: number | null) => { if (v1TargetId === null) return; const target = targetCategories.find((c) => c.id === v1TargetId); if (!target) return; const name = target.i18n_key ? t(target.i18n_key, { defaultValue: target.name }) : target.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 (