CategorySelect wandelte den Wert per Number() um - bei den neuen Lagerort-Codes (Zeichenkette) ergab das NaN, sodass die Anzeige auf 'keiner' zuruecksprang, obwohl korrekt gespeichert wurde. Wert jetzt unveraendert nehmen und typunabhaengig (String-Vergleich) markieren, damit Zahl-IDs (Kategorien) wie Code-IDs (Lagerorte) passen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
6.0 KiB
JavaScript
175 lines
6.0 KiB
JavaScript
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import Icon from "./Icon";
|
||
|
||
/**
|
||
* Aufklappbarer Kategorie-Baum als Auswahlfeld – Ersatz für die früheren, mit
|
||
* „— —“ eingerückten <select>. 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 <body> gehängt, damit es nicht vom
|
||
* horizontal scrollenden Tabellenrahmen (overflow) abgeschnitten wird.
|
||
*/
|
||
export default function CategorySelect({
|
||
value,
|
||
onChange,
|
||
nodes,
|
||
rootLabel = "– oberste Ebene –",
|
||
// Zusätzliche Einträge über dem Baum, z.B. { value: 0, label: "Ohne Kategorie" }
|
||
// für Filter. Ihr Wert wird unverändert an onChange gereicht.
|
||
extraOptions = [],
|
||
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 isRoot = value == null || value === "";
|
||
const extra = extraOptions.find((o) => String(o.value) === String(value));
|
||
// Wert unverändert übernehmen: IDs können Zahlen (Kategorien) ODER
|
||
// Zeichenketten (Lagerort-Codes) sein – Number() würde Codes zu NaN machen.
|
||
const selectedId = !isRoot && !extra ? value : null;
|
||
const label = isRoot ? rootLabel : extra ? extra.label : nameById[selectedId] ?? 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 onScroll(e) {
|
||
// Scrollen IM Menü (die Liste ist scrollbar) darf es nicht schließen –
|
||
// nur ein Scrollen der Seite dahinter, weil das fixierte Menü sonst
|
||
// verrutschen würde.
|
||
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||
setOpen(false);
|
||
}
|
||
function onResize() { setOpen(false); }
|
||
function onKey(e) { if (e.key === "Escape") setOpen(false); }
|
||
document.addEventListener("mousedown", onDoc);
|
||
window.addEventListener("scroll", onScroll, true);
|
||
window.addEventListener("resize", onResize);
|
||
document.addEventListener("keydown", onKey);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onDoc);
|
||
window.removeEventListener("scroll", onScroll, true);
|
||
window.removeEventListener("resize", onResize);
|
||
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 (
|
||
<>
|
||
<button
|
||
type="button"
|
||
ref={btnRef}
|
||
className="tree-select-btn"
|
||
disabled={disabled}
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((o) => !o)}
|
||
>
|
||
<span className={isRoot ? "muted" : ""}>{label}</span>
|
||
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
|
||
</button>
|
||
|
||
{open && pos && createPortal(
|
||
<div
|
||
ref={popRef}
|
||
className="tree-pop"
|
||
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 220) }}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
|
||
style={{ paddingLeft: 12 }}
|
||
onClick={() => pick(null)}
|
||
>
|
||
{rootLabel}
|
||
</button>
|
||
{extraOptions.map((o) => (
|
||
<button
|
||
key={String(o.value)}
|
||
type="button"
|
||
className={`tree-pop-opt ${extra && String(extra.value) === String(o.value) ? "sel" : ""}`}
|
||
style={{ paddingLeft: 12 }}
|
||
onClick={() => pick(o.value)}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
{visible.map((n) => {
|
||
const kids = hasKids(n.id);
|
||
const zu = collapsed.has(n.id);
|
||
return (
|
||
<div key={n.id} className="tree-pop-line" style={{ paddingLeft: 6 + n.depth * 16 }}>
|
||
{kids ? (
|
||
<span
|
||
className="tree-toggle"
|
||
role="button"
|
||
tabIndex={0}
|
||
title={zu ? "Unterkategorien einblenden" : "Unterkategorien ausblenden"}
|
||
onClick={(e) => { e.stopPropagation(); toggle(n.id); }}
|
||
>
|
||
<Icon name="chevronRight" size={12} className={zu ? "" : "rotated"} />
|
||
</span>
|
||
) : (
|
||
<span className="tree-spacer-sm" />
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={`tree-pop-opt ${String(selectedId) === String(n.id) ? "sel" : ""}`}
|
||
onClick={() => pick(n.id)}
|
||
>
|
||
{n.name}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{nodes.length === 0 && <div className="tree-pop-empty">Keine Auswahl möglich.</div>}
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</>
|
||
);
|
||
}
|