From 3e10e254c803df1ae5b9c822bbdece859060fd75 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 30 Jul 2026 14:29:09 +0200 Subject: [PATCH] Web: Dashboard-Karten wie HomeAssistant konfigurieren + Einkaufsliste je Lagerort Karten-Optionen (Artikel, Lagerort, Zeitraum) sassen als einzelne Dropdowns im Kartenkopf. Jetzt: deklaratives config-Schema je Kartenart und ein Zahnrad-Dialog (KartenConfig) im Anordnen-Modus - der Kopf zeigt sonst nur noch Titel/Papierkorb. Felder: product, location, zeitraum (erweiterbar: select, boolean). Werte bleiben in props/tage, keine Migration. Einkaufsliste-Karte laedt zusaetzlich shoppingByLocation und zeigt Gesamt + Bedarfe je Lagerort. Ueber die Karten-Konfig waehlbarer Lagerort: leer = alle Orte, gesetzt = Gesamt + nur dieser Ort. Co-Authored-By: Claude Opus 4.8 --- web/src/dashboard/KartenConfig.jsx | 93 ++++++++++++++++++++++++++++++ web/src/dashboard/cards.jsx | 69 +++++++++++++++++----- web/src/pages/Dashboard.jsx | 91 +++++++++++++++-------------- 3 files changed, 191 insertions(+), 62 deletions(-) create mode 100644 web/src/dashboard/KartenConfig.jsx diff --git a/web/src/dashboard/KartenConfig.jsx b/web/src/dashboard/KartenConfig.jsx new file mode 100644 index 0000000..8088c78 --- /dev/null +++ b/web/src/dashboard/KartenConfig.jsx @@ -0,0 +1,93 @@ +import Icon from "../components/Icon"; +import { ZEITRAEUME } from "./cards"; +import { locationOptions } from "../locationPath"; + +/** + * Konfig-Dialog einer Karte – wie in HomeAssistant: ein eigener Dialog mit den + * Optionen der Karte, statt Dropdowns im Kartenkopf. Welche Felder erscheinen, + * bestimmt `karte.config`. Die Werte landen in den Karten-Props (bzw. `tage` + * beim Zeitraum) und werden mit dem Layout gespeichert. + */ +export default function KartenConfig({ + karte, props: karteProps = {}, tage, + produkte = [], locations = [], onProps, onTage, onClose, +}) { + const felder = karte?.config || []; + + function feld(f) { + if (f.type === "product") { + return ( + + ); + } + if (f.type === "location") { + return ( + + ); + } + if (f.type === "zeitraum") { + return ( + + ); + } + if (f.type === "select") { + return ( + + ); + } + if (f.type === "boolean") { + return ( + + ); + } + return null; + } + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+ +

{karte?.titel} einstellen

+
+
+ {felder.length === 0 + ?

Diese Karte hat keine Einstellungen.

+ : felder.map(feld)} +
+
+ +
+
+
+ ); +} diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 5226356..a04a78f 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -306,43 +306,74 @@ function KarteAblauf({ modus = "alle" }) { ); } -function KarteEinkaufsliste() { +/** + * Einkaufsliste mit Gesamt-Bedarf UND Bedarf je Lagerort. Über die Karten-Konfig + * lässt sich ein Lagerort festlegen: leer = alle Orte, sonst nur dieser. + */ +function KarteEinkaufsliste({ props: karteProps }) { + const ortId = karteProps?.location_id || ""; const { daten, fehler } = useDaten(async () => { - const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]); - return { produkte, gruppen }; + const [produkte, gruppen, orte] = await Promise.all([ + api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation(), + ]); + return { produkte, gruppen, orte }; }); if (fehler) return
{fehler}
; if (!daten) return
Lädt…
; - if (!daten.produkte.length && !daten.gruppen.length) { - return
Alle Mindestbestände erreicht.
; - } - return ( + const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte; + const gesamtLeer = !daten.produkte.length && !daten.gruppen.length; + const orteLeer = orte.every((o) => !o.products.length && !o.groups.length); + if (gesamtLeer && orteLeer) return
Alle Mindestbestände erreicht.
; + + // Ein Abschnitt (Gesamt oder ein Ort). Die Produkt-Formatierung kommt herein, + // weil Gesamt-Produkte Gebinde/Basiseinheit tragen, je-Ort-Bedarfe aber schon + // eine fertige Einheit (unit_label). + const abschnitt = (gruppen, produkte, prodWert) => (
- - - + - {daten.gruppen.map((it) => ( + {gruppen.map((it) => ( ))} - {daten.produkte.map((it) => ( + {produkte.map((it) => ( - - + + ))}
BedarfBestandFehlt
BedarfBestandFehlt
Gruppe {it.name} {fmt(it.stock)} {it.unit_name} {fmt(it.deficit)} {it.unit_name}
{it.name}{amountText(it.stock, it.package_size, it.base_unit)}{amountText(it.deficit, it.package_size, it.base_unit)}{prodWert(it, "stock")}{prodWert(it, "deficit")}
); + const gesamtProd = (it, feld) => amountText(it[feld], it.package_size, it.base_unit); + const ortProd = (it, feld) => `${fmt(it[feld])} ${it.unit_label}`; + + return ( +
+ {!gesamtLeer && ( +
+
Gesamt
+ {abschnitt(daten.gruppen, daten.produkte, gesamtProd)} +
+ )} + {orte.map((o) => ((o.products.length || o.groups.length) ? ( +
+
+ {o.location_name} +
+ {abschnitt(o.groups, o.products, ortProd)} +
+ ) : null))} +
+ ); } const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" }; @@ -575,7 +606,7 @@ export const KARTEN = { titel: "Artikel im Blick", icon: "package", vorschau: "kennzahl", beschreibung: "Bestand eines festen Artikels – mehrfach verwendbar", standard: { w: 3, h: 3 }, min: { w: 2, h: 3 }, - artikel: true, + config: [{ key: "product_id", type: "product", label: "Artikel" }], komponente: KarteArtikel, }, status: { @@ -618,6 +649,7 @@ export const KARTEN = { titel: "Einkaufsliste", icon: "cart", vorschau: "tabelle", beschreibung: "Produkte und Gruppen unter Mindestbestand", standard: { w: 6, h: 6 }, min: { w: 4, h: 4 }, + config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }], komponente: KarteEinkaufsliste, }, movements: { @@ -648,19 +680,24 @@ export const KARTEN = { titel: "Bestandsverlauf", icon: "history", vorschau: "linie", beschreibung: "Artikeleinheiten im Lager über 90 Tage", zeitraum: 90, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, + config: [{ type: "zeitraum", label: "Zeitraum" }], komponente: KarteBestandsverlauf, }, "product-timeline": { titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie", beschreibung: "Ein fester Artikel über die Zeit – mehrfach verwendbar", zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 }, - artikel: true, + config: [ + { key: "product_id", type: "product", label: "Artikel" }, + { type: "zeitraum", label: "Zeitraum" }, + ], komponente: KarteArtikelverlauf, }, "activity-timeline": { titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall", beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung", zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 }, + config: [{ type: "zeitraum", label: "Zeitraum" }], komponente: KarteBewegungsverlauf, }, }; diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 6f6f373..33eb1c9 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -6,7 +6,8 @@ import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; -import { KARTEN, KARTEN_IDS, ZEITRAEUME } from "../dashboard/cards"; +import { KARTEN, KARTEN_IDS } from "../dashboard/cards"; +import KartenConfig from "../dashboard/KartenConfig"; import Vorschau from "../dashboard/Vorschau"; const Raster = WidthProvider(GridLayout); @@ -54,6 +55,8 @@ export default function Dashboard() { const [fehler, setFehler] = useState(null); const [menuOffen, setMenuOffen] = useState(false); const [produkte, setProdukte] = useState([]); + const [locations, setLocations] = useState([]); + const [konfigKarte, setKonfigKarte] = useState(null); // Kennung der Karte im Konfig-Dialog const aktivesId = routeId ? Number(routeId) : dashboards[0]?.id; const aktives = dashboards.find((d) => d.id === aktivesId) ?? dashboards[0]; @@ -80,14 +83,15 @@ export default function Dashboard() { useEffect(() => { laden(); }, []); - // Artikelliste nur einmal für die Einstellung der artikelbezogenen Karten. + // Artikel- und Lagerortliste einmal für die Karten-Konfiguration laden. useEffect(() => { api.listProducts().then(setProdukte).catch(() => {}); + api.listLocations().then(setLocations).catch(() => {}); }, []); // Beim Wechsel des Dashboards einen offenen Entwurf verwerfen – sonst // landete er beim Speichern auf dem falschen. - useEffect(() => { setEntwurf(null); setMenuOffen(false); }, [aktivesId]); + useEffect(() => { setEntwurf(null); setMenuOffen(false); setKonfigKarte(null); }, [aktivesId]); /** * Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt. @@ -149,6 +153,7 @@ export default function Dashboard() { const gespeichert = await api.updateDashboard(aktives.id, { layout: schlank(entwurf) }); setDashboards((alt) => alt.map((d) => (d.id === gespeichert.id ? gespeichert : d))); setEntwurf(null); + setKonfigKarte(null); toast("Anordnung gespeichert."); } catch (err) { setFehler(err.message); @@ -271,26 +276,10 @@ export default function Dashboard() { }); } - /** - * Zeitraum einer Verlaufskarte ändern. Außerhalb des Bearbeitungsmodus wird - * die Wahl still gespeichert – sonst wäre sie nach dem nächsten Laden wieder - * weg, ohne dass es einen sichtbaren Speichern-Knopf gäbe. - */ - async function setzeZeitraum(kennung, tage) { - const geaendert = aktuell.map((k) => (k.i === kennung ? { ...k, tage } : k)); - if (bearbeiten) { - setEntwurf(geaendert); - return; - } - setDashboards((alt) => alt.map((d) => ( - d.id === aktives.id ? { ...d, layout: geaendert } : d - ))); - if (erzwungen || !aktives?.id) return; - try { - await api.updateDashboard(aktives.id, { layout: geaendert }); - } catch { - /* Nicht speichern zu können ist hier kein Grund, die Ansicht zu stören. */ - } + /** Zeitraum einer Karte ändern. Passiert nur noch im Konfig-Dialog, also im + * Bearbeitungsmodus – gespeichert wird mit „Speichern" wie jede andere Änderung. */ + function setzeZeitraum(kennung, tage) { + setEntwurf((alt) => (alt ?? []).map((k) => (k.i === kennung ? { ...k, tage } : k))); } const eigenes = herkunft === "user" && aktives?.id; @@ -348,7 +337,8 @@ export default function Dashboard() { )} - @@ -387,33 +377,24 @@ export default function Dashboard() { if (!karte) return null; const Inhalt = karte.komponente; const artikel = produkte.find((p) => p.id === eintrag.props?.product_id); + const ort = eintrag.props?.location_id + ? locations.find((l) => l.id === eintrag.props.location_id) + : null; + const zusatz = artikel?.name || ort?.name; // im Titel gezeigt return (
-

{artikel ? `${karte.titel}: ${artikel.name}` : karte.titel}

- {/* Zeitraum gehört in den Kopf: So bleibt die volle Kartenhöhe - dem Diagramm. onMouseDown stoppt das Ziehen der Karte. */} - {karte.zeitraum && ( - - )} - {/* Artikelbezogene Karten werden beim Anordnen eingestellt - - so kann dieselbe Art mehrfach auf verschiedene Artikel zeigen. */} - {bearbeiten && karte.artikel && ( - + onClick={() => setKonfigKarte(eintrag.i)}> + + )} {bearbeiten && (
+ + {konfigKarte && (() => { + const inst = aktuell.find((k) => k.i === konfigKarte); + if (!inst) return null; + const karte = KARTEN[inst.type]; + return ( + setzeProps(konfigKarte, partial)} + onTage={(n) => setzeZeitraum(konfigKarte, n)} + onClose={() => setKonfigKarte(null)} + /> + ); + })()} ); }