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