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).
175 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|