Files
Vorrania/web/src/components/GroupParentSelect.jsx
Scarriffle e00eef275c Gruppen hierarchisch: in allen Auswahlfeldern und in der Artikelliste
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>
2026-08-16 09:59:32 +02:00

153 lines
5.5 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";
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,
)}
</>
);
}