Simpl-Resultat/src/components/categories-migration/MappingRow.tsx
le king fu ce2793fc2c
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m30s
refactor(categories): migration target picker lists leaves only
The v1 target combobox in the migration preview now offers only leaf
categories (the inputable end-categories), not intermediate parents, so a
transaction is never mapped to a grouping bucket. Leaves render as a flat,
un-indented list in taxonomy order.

A low-confidence default can still point a v2 category at a non-leaf parent
(e.g. 'Divertissement' #1710); MappingRow re-injects that current target for
its single row via findTaxonomyCategory so the input stays populated, while
new picks remain leaves-only. Adds a findTaxonomyCategory pure helper + tests
(adapter now returns 112 leaves, not the 150-node full tree).
2026-07-04 20:40:29 -04:00

175 lines
6.5 KiB
TypeScript

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 (
<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 — editable type-ahead combobox on EVERY row. Stop click +
keydown from bubbling so interacting with the picker (typing spaces,
Enter to select) never triggers the row's select/preview handler. */}
<div
className="col-span-5 flex items-center justify-end min-w-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="w-full max-w-[16rem]">
<CategoryCombobox
categories={comboboxCategories}
value={row.v1TargetId ?? null}
onChange={handleTargetChange}
compact
placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
category: row.v2CategoryName,
})}
/>
</div>
</div>
</div>
);
}