From 0537a2b53f6b8b7b9fc2d050401747e79a0b4d4a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 15 Aug 2026 23:56:18 +0200 Subject: [PATCH] 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 --- web/src/components/GroupParentSelect.jsx | 146 +++++++ web/src/components/LocationMinStock.jsx | 34 +- web/src/dashboard/cards.jsx | 5 +- web/src/groupGraph.js | 77 ++++ web/src/pages/Groups.jsx | 81 +++- web/src/pages/MinStock.jsx | 526 ++++++++++------------- web/src/pages/ProductForm.jsx | 63 +-- web/src/pages/ShoppingList.jsx | 20 +- web/src/units.js | 8 + 9 files changed, 603 insertions(+), 357 deletions(-) create mode 100644 web/src/components/GroupParentSelect.jsx create mode 100644 web/src/groupGraph.js diff --git a/web/src/components/GroupParentSelect.jsx b/web/src/components/GroupParentSelect.jsx new file mode 100644 index 0000000..9b79900 --- /dev/null +++ b/web/src/components/GroupParentSelect.jsx @@ -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 , 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 ( + <> + + + {open && pos && createPortal( +
+ {groups.length > 8 && ( +
+ setFilter(e.target.value)} + /> +
+ )} + {sichtbar.map((g) => { + const aus = gesperrt.has(g.id); + const an = gewaehlt.has(g.id); + return ( +
+ +
+ ); + })} + {sichtbar.length === 0 && ( +
Keine Gruppe gefunden.
+ )} +
, + document.body, + )} + + ); +} diff --git a/web/src/components/LocationMinStock.jsx b/web/src/components/LocationMinStock.jsx index fef1657..784fb6b 100644 --- a/web/src/components/LocationMinStock.jsx +++ b/web/src/components/LocationMinStock.jsx @@ -2,20 +2,36 @@ import { useState } from "react"; import Icon from "./Icon"; import { locationOptions } from "../locationPath"; +// „Überall" ist ein Ort wie jeder andere – nur eben der oberste. Im Datenmodell +// ist er location_id = null; im setRow(i, { location_id: e.target.value })}> - {locationOptions(locations).map((o) => )} + {alleOrte.map((o) => )}