Die Gruppen-Dropdowns waren flache, alphabetische Listen - man sah nicht, dass "Grillwurst" unter "Wurst" haengt. Ein gemeinsamer Helfer (gruppenOptionen) rollt den Graphen zum Baum aus und rueckt ein; eine Gruppe mit mehreren Obergruppen erscheint unter jeder, denn genau das ist ihr Sinn, und ausgewaehlt wird ohnehin dieselbe ID. Eingerueckt wird mit GESCHUETZTEN Leerzeichen - normale fasst der Browser in einem <option> zusammen, die Einrueckung waere sonst wirkungslos. Betroffen: Gruppen-Auswahl im Artikelformular (beide Zweige), beim Einlagern, die Ziel-Auswahl der Mindestbestaende und die Obergruppen-Mehrfachauswahl (die beim Suchen bewusst abflacht, weil eine Einrueckung ohne die Eltern in die Irre fuehrt). In der App dasselbe in beiden Gruppen-Pickern. Die Gruppen-Spalte der Artikelliste zeigt jetzt den Weg von oben statt eines Etiketts - "Wurst → Grillwurst", mit gedaempften Vorfahren wie bei der Kategorie. Gefiltert wird ueber ALLE Wege: wer auf "Wurst" filtert, sieht auch die Artikel aus den Untergruppen. Dafuer gibt es gruppenInfoMap als Gegenstueck zu categoryInfoMap, das mit mehreren Wegen je Gruppe umgehen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
5.5 KiB
JavaScript
153 lines
5.5 KiB
JavaScript
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import Icon from "./Icon";
|
||
import { gruppenOptionen, nachfahrenIds, pfadText } from "../groupGraph";
|
||
|
||
/**
|
||
* Mehrfachauswahl der Obergruppen einer Gruppe.
|
||
*
|
||
* Bewusst KEIN Baum wie CategorySelect: eine Gruppe darf unter mehreren
|
||
* Obergruppen hängen („Grillwurst" unter „Wurst" UND unter „Grillgut"). Ein
|
||
* Baum müsste sie mehrfach anzeigen; eine flache, alphabetische Liste mit
|
||
* Häkchen ist ehrlicher und deutlich weniger Code. Der volle Weg steht als
|
||
* Titel an jeder Zeile, damit gleichnamige Äste unterscheidbar bleiben.
|
||
*
|
||
* Die eigene Gruppe und ihre Untergruppen sind gesperrt – sie würden einen Ring
|
||
* erzeugen. Der Server lehnt das ohnehin ab; hier wird es vorher sichtbar.
|
||
*
|
||
* Das Menü hängt per Portal am <body>, damit es der horizontal scrollende
|
||
* Tabellenrahmen nicht abschneidet (wie bei CategorySelect).
|
||
*/
|
||
export default function GroupParentSelect({
|
||
value = [],
|
||
onChange,
|
||
groups = [],
|
||
selfId = null,
|
||
disabled = false,
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [pos, setPos] = useState(null);
|
||
const [filter, setFilter] = useState("");
|
||
const btnRef = useRef(null);
|
||
const popRef = useRef(null);
|
||
|
||
const gewaehlt = new Set(value || []);
|
||
const gesperrt = selfId == null ? new Set() : new Set([selfId, ...nachfahrenIds(groups, selfId)]);
|
||
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
|
||
|
||
// Als Baum, damit die Struktur beim Zuordnen sichtbar ist. Beim Suchen
|
||
// flacht die Liste ab – eine Einrückung ohne ihre Eltern wäre irreführend.
|
||
const sichtbar = filter
|
||
? groups
|
||
.filter((g) => g.name.toLowerCase().includes(filter.toLowerCase()))
|
||
.slice()
|
||
.sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||
.map((g) => ({ id: g.id, key: `f${g.id}`, gruppe: g, tiefe: 0, label: g.name }))
|
||
: gruppenOptionen(groups);
|
||
|
||
const label = value?.length
|
||
? value.map((id) => nameById[id]).filter(Boolean).join(", ")
|
||
: "– keine –";
|
||
|
||
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) {
|
||
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]);
|
||
|
||
// Bei Mehrfachauswahl bleibt das Menü offen – sonst müsste man es für jede
|
||
// weitere Obergruppe neu aufklappen.
|
||
function umschalten(id) {
|
||
const neu = new Set(gewaehlt);
|
||
if (neu.has(id)) neu.delete(id);
|
||
else neu.add(id);
|
||
onChange([...neu]);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
ref={btnRef}
|
||
className="tree-select-btn"
|
||
disabled={disabled}
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((o) => !o)}
|
||
>
|
||
<span className={value?.length ? "" : "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, 240) }}
|
||
>
|
||
{groups.length > 8 && (
|
||
<div className="tree-pop-line">
|
||
<input
|
||
autoFocus
|
||
placeholder="Suchen…"
|
||
value={filter}
|
||
style={{ marginTop: 0, width: "100%" }}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{sichtbar.map((o) => {
|
||
const aus = gesperrt.has(o.id);
|
||
const an = gewaehlt.has(o.id);
|
||
return (
|
||
<div key={o.key} className="tree-pop-line" style={{ paddingLeft: o.tiefe * 14 }}>
|
||
<button
|
||
type="button"
|
||
className={`tree-pop-opt ${an ? "sel" : ""}`}
|
||
disabled={aus}
|
||
title={aus
|
||
? "Würde einen Ring erzeugen (die Gruppe selbst oder eine ihrer Untergruppen)"
|
||
: pfadText(groups, o.id)}
|
||
onClick={() => umschalten(o.id)}
|
||
>
|
||
<span style={{ display: "inline-block", width: 18 }}>{an ? "✓" : ""}</span>
|
||
{o.tiefe > 0 && <span className="muted">↳ </span>}
|
||
{o.gruppe.name}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{sichtbar.length === 0 && (
|
||
<div className="tree-pop-line muted" style={{ padding: 6 }}>Keine Gruppe gefunden.</div>
|
||
)}
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</>
|
||
);
|
||
}
|