Simpl-Resultat/src/components/categories/HierarchyCheckModal.tsx
le king fu db5ee65720 fix: refuse a looping or too deep parent, and reattach orphan categories (#393)
updateCategory checks a new parent before writing: the category itself,
one of its descendants or a category caught in a cycle (cycle), a fourth
level counting its sub-tree (too_deep), a parent holding transactions of
its own (parent_has_transactions). The refusal is a CategoryHierarchyError
that the Categories page shows by the translation of its code. The reads
and the writes, the new parent's is_inputable included, run in one
transaction through its handle; an unchanged parent is not checked, so a
category stored before these rules can still be renamed.

getCategoryDepth keeps the ids it has seen and ends on a cycle.

A "Check hierarchy" button, between "All keywords" and "Re-initialize",
opens a window that lists the orphan categories (caught in a parent loop,
parent deleted, parent gone), one per group, with a new parent for each:
the root by default, or a parent of the category form's list.
reattachCategory applies the same rules; after it, the list and the tree
reload and the Journal gets one line with the reason and the number of
categories put back, no name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:16:21 -04:00

161 lines
6.5 KiB
TypeScript

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<Record<number, number>>({});
const optionsByOrphan = useMemo(() => {
const byOrphan = new Map<number, CategoryTreeNode[]>();
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-label={t("categories.hierarchy.check")}
className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col"
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-full bg-[var(--primary)]/10">
<ListTree size={20} className="text-[var(--primary)]" />
</div>
<h2 className="text-lg font-semibold">{t("categories.hierarchy.check")}</h2>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-4">{t("categories.hierarchy.intro")}</p>
{error && (
<div role="alert" className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{error}
</div>
)}
{orphans === null ? (
error ? null : (
<p className="text-sm text-[var(--muted-foreground)]">{t("categories.hierarchy.checking")}</p>
)
) : orphans.length === 0 ? (
<p className="text-sm font-medium">{t("categories.hierarchy.none")}</p>
) : (
<>
<p className="text-sm font-medium mb-2">{t("categories.hierarchy.count", { count: orphans.length })}</p>
<ul className="flex-1 min-h-0 overflow-y-auto divide-y divide-[var(--border)]">
{orphans.map((orphan) => {
const name = nameOf(orphan.id);
const choice = choiceOf(orphan.id);
return (
<li key={orphan.id} className="py-3 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[10rem]">
<p className="text-sm font-medium">{name}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{t(`categories.hierarchy.reasons.${orphan.reason}`)}
</p>
</div>
<div className="w-56">
<CategoryCombobox
categories={optionsByOrphan.get(orphan.id) ?? []}
value={choice}
onChange={(id) => choose(orphan.id, id)}
extraOptions={rootOption}
activeExtra={choice === null ? ROOT : null}
onExtraSelect={() => choose(orphan.id, null)}
ariaLabel={t("categories.hierarchy.newParentFor", { name })}
compact
/>
</div>
<button
type="button"
onClick={() => onReattach(orphan, choice)}
disabled={isSaving}
className="px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("categories.hierarchy.reattach")}
</button>
</li>
);
})}
</ul>
</>
)}
<div className="flex justify-end mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.close")}
</button>
</div>
</div>
</div>
);
}