Web: Obergruppen zuordnen, Mindestbestaende nach Lagerort

Mindestbestaende-Seite ist jetzt nach Lagerort gegliedert statt nach Artikel -
"Ueberall (egal wo)" zuerst, danach die Lagerorte. Es gibt keine automatische
Leerzeile je Artikel mehr; angelegt wird ueber "+ hinzufuegen" im jeweiligen
Abschnitt, dann steht der Ort schon fest und es fehlen nur Ziel und Menge.
Das Zeilenmodell faellt damit von vier Faellen auf zwei, und weil der Server
einheitlich Basiseinheiten liefert, bleibt genau eine Umrechnung uebrig statt
der drei gegenlaeufigen von vorher.

Gruppen: neue Spalte "Obergruppen" mit Mehrfachauswahl (GroupParentSelect).
Bewusst kein Baum - CategorySelect und die tree-Prop der DataTable koennen nur
einen Elternteil und wuerden eine Gruppe mit zwei Obergruppen doppelt anzeigen.
Die eigene Gruppe und ihre Untergruppen sind in der Auswahl gesperrt, damit die
Ringregel sichtbar wird, bevor der Server sie ablehnt. Produktzahl zeigt
transitiv und direkt ("12 (3 direkt)"), dazu Badges fuer Untergruppen und eine
Warnung, wenn eine Gruppe mit Untergruppen keine Einheit hat - sonst summiert
sie Gramm und Stueck zu einer sinnlosen Zahl.

Einkaufsliste: "Gesamt" heisst "Ueberall", Gruppen zeigen ihre mitgezaehlten
Untergruppen, und eine Fussnote benennt die eine Stelle, die sich nicht
verrechnen laesst (zwei Obergruppen mit gemeinsamer Untergruppe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-15 23:56:18 +02:00
parent 54261b98c0
commit 0537a2b53f
9 changed files with 603 additions and 357 deletions

View File

@@ -0,0 +1,146 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { 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]));
const sichtbar = groups
.filter((g) => !filter || g.name.toLowerCase().includes(filter.toLowerCase()))
.slice()
.sort((a, b) => a.name.localeCompare(b.name, "de"));
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((g) => {
const aus = gesperrt.has(g.id);
const an = gewaehlt.has(g.id);
return (
<div key={g.id} className="tree-pop-line">
<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, g.id)}
onClick={() => umschalten(g.id)}
>
<span style={{ display: "inline-block", width: 18 }}>{an ? "✓" : ""}</span>
{g.name}
</button>
</div>
);
})}
{sichtbar.length === 0 && (
<div className="tree-pop-line muted" style={{ padding: 6 }}>Keine Gruppe gefunden.</div>
)}
</div>,
document.body,
)}
</>
);
}