import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ListTree } from "lucide-react"; import type { CategoryTreeNode } from "../../shared/types"; import type { OrphanCategory } from "../../utils/categoryHierarchy"; import CategoryCombobox from "../shared/CategoryCombobox"; import { buildParentOptions } from "./parentOptions"; interface Props { /** The orphan categories, one per group to reattach; `null` while the check runs. */ orphans: OrphanCategory[] | null; /** The translated text of a refused reattachment, or of a check that failed. */ error: string | null; /** The active categories, as the tree has them: names and parent choices. */ categories: CategoryTreeNode[]; isSaving: boolean; onReattach: (orphan: OrphanCategory, parentId: number | null) => void; onClose: () => void; } /** The value of the « Racine » option of each row's combobox. */ const ROOT = ""; /** * « Vérifier la hiérarchie » (#393): the orphan categories of the profile, one * row per group to reattach, each with its reason and a new parent. The root * is preselected; the other choices are those of the category form * (`buildParentOptions`, the row's category treated as the one being edited). * The service applies the rules again and may refuse (a parent that holds * transactions, for one): the translated refusal shows above the list. * * The comboboxes open their list in a portal (#429), so the scrolling list of * rows never cuts it. */ export default function HierarchyCheckModal({ orphans, error, categories, isSaving, onReattach, onClose, }: Props) { const { t } = useTranslation(); /** The parent picked on each row, by orphan id; no entry means the root. */ const [choices, setChoices] = useState>({}); const optionsByOrphan = useMemo(() => { const byOrphan = new Map(); for (const orphan of orphans ?? []) { byOrphan.set(orphan.id, buildParentOptions(categories, { editingId: orphan.id })); } return byOrphan; }, [orphans, categories]); const rootOption = useMemo(() => [{ value: ROOT, label: t("categories.hierarchy.root") }], [t]); /** The row's parent: the one picked if it is still offered, else the root. */ const choiceOf = (orphanId: number): number | null => { const picked = choices[orphanId]; if (picked === undefined) return null; return optionsByOrphan.get(orphanId)?.some((c) => c.id === picked) ? picked : null; }; const choose = (orphanId: number, parentId: number | null) => { setChoices((previous) => { const next = { ...previous }; if (parentId === null) delete next[orphanId]; else next[orphanId] = parentId; return next; }); }; const nameOf = (id: number): string => { const category = categories.find((c) => c.id === id); if (!category) return "—"; return category.i18n_key ? t(category.i18n_key, { defaultValue: category.name }) : category.name; }; return (

{t("categories.hierarchy.check")}

{t("categories.hierarchy.intro")}

{error && (
{error}
)} {orphans === null ? ( error ? null : (

{t("categories.hierarchy.checking")}

) ) : orphans.length === 0 ? (

{t("categories.hierarchy.none")}

) : ( <>

{t("categories.hierarchy.count", { count: orphans.length })}

    {orphans.map((orphan) => { const name = nameOf(orphan.id); const choice = choiceOf(orphan.id); return (
  • {name}

    {t(`categories.hierarchy.reasons.${orphan.reason}`)}

    choose(orphan.id, id)} extraOptions={rootOption} activeExtra={choice === null ? ROOT : null} onExtraSelect={() => choose(orphan.id, null)} ariaLabel={t("categories.hierarchy.newParentFor", { name })} compact />
  • ); })}
)}
); }