Files
Vorrania/web/src/components/CategorySelect.jsx
Scarriffle 4607e3a6a8 Lagerort-Auswahl zeigt gewaehlten uebergeordneten Ort wieder an
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>
2026-07-27 12:22:57 +02:00

175 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)}
</>
);
}