From f35126cb7b5de83b2f2898be15e95cbf4052fa40 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 11:44:03 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20Kategorie-Elternwahl=20als=20aufklappbar?= =?UTF-8?q?er=20Baum=20statt=20"=E2=80=94=20=E2=80=94"-Select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die uebergeordnete Kategorie wird nicht mehr ueber ein . Zeigt echte Einrückung und lässt Äste per Pfeil + * ein- und ausklappen (wie die Kategorienliste selbst). Gibt `null` für die + * oberste Ebene zurück, sonst die Kategorie-ID. + * + * Das Menü wird per Portal an den gehängt, damit es nicht vom + * horizontal scrollenden Tabellenrahmen (overflow) abgeschnitten wird. + */ +export default function CategorySelect({ + value, + onChange, + nodes, + rootLabel = "– oberste Ebene –", + disabled = false, +}) { + const [open, setOpen] = useState(false); + const [collapsed, setCollapsed] = useState(() => new Set()); + const [pos, setPos] = useState(null); + const btnRef = useRef(null); + const popRef = useRef(null); + + const nameById = Object.fromEntries(nodes.map((n) => [n.id, n.name])); + const parentById = Object.fromEntries(nodes.map((n) => [n.id, n.parent_id])); + const idset = new Set(nodes.map((n) => n.id)); + const hasKids = (id) => nodes.some((n) => n.parent_id === id); + + const selected = value == null || value === "" ? null : Number(value); + const label = selected == null ? rootLabel : nameById[selected] ?? rootLabel; + + function place() { + const r = btnRef.current?.getBoundingClientRect(); + if (r) setPos({ left: r.left, top: r.bottom + 4, width: r.width }); + } + useLayoutEffect(() => { if (open) place(); }, [open]); + + useEffect(() => { + if (!open) return; + function onDoc(e) { + if (btnRef.current?.contains(e.target)) return; + if (popRef.current?.contains(e.target)) return; + setOpen(false); + } + function onAway() { setOpen(false); } + function onKey(e) { if (e.key === "Escape") setOpen(false); } + document.addEventListener("mousedown", onDoc); + // Beim Scrollen/Größenwechsel würde das fixierte Menü verrutschen – lieber + // schließen. + window.addEventListener("scroll", onAway, true); + window.addEventListener("resize", onAway); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + window.removeEventListener("scroll", onAway, true); + window.removeEventListener("resize", onAway); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + function hidden(id) { + let p = parentById[id]; + while (p != null && idset.has(p)) { + if (collapsed.has(p)) return true; + p = parentById[p]; + } + return false; + } + + function toggle(id) { + setCollapsed((alt) => { + const neu = new Set(alt); + if (neu.has(id)) neu.delete(id); + else neu.add(id); + return neu; + }); + } + + function pick(id) { + onChange(id); + setOpen(false); + } + + const visible = nodes.filter((n) => !hidden(n.id)); + + return ( + <> + + + {open && pos && createPortal( +
+ + {visible.map((n) => { + const kids = hasKids(n.id); + const zu = collapsed.has(n.id); + return ( +
+ {kids ? ( + { e.stopPropagation(); toggle(n.id); }} + > + + + ) : ( + + )} + +
+ ); + })} + {nodes.length === 0 &&
Keine Auswahl möglich.
} +
, + document.body, + )} + + ); +} diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index 5b931de..5018532 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -5,6 +5,7 @@ import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { asTree } from "../categoryTree"; import { FIELD_TYPES, fieldTypeLabel } from "../fields"; +import CategorySelect from "../components/CategorySelect"; const MODUS = { food: "Lebensmittel", object: "Gegenstand" }; @@ -199,15 +200,11 @@ export default function Categories() { {isAdmin ? ( - + patch(c, { parent_id: pid })} + /> ) : ( {c.parent_id ? nameById[c.parent_id] : "–"} )} @@ -246,15 +243,11 @@ export default function Categories() {