From 3de168b598bbd1d7427fc1e432dfe42e0de060c4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 16 Aug 2026 09:40:09 +0200 Subject: [PATCH] Gruppen: Bestand in der richtigen Einheit, Baumansicht, Gruppen-Spalte Anzeigefehler: die Gruppenliste klebte immer min_stock_unit_name hinter den Bestand - auch wenn die Gruppe laengst im Gruppen-Gebinde zaehlt. Aus 790 g bei einem 190-g-Glas wurde so "4,16 Gramm" statt "4,16 Glaeser". Gerechnet war richtig, beschriftet falsch. Ursache war doppelte Logik: MinStock.jsx hatte grpPkgMode/grpFactor/grpUnit lokal, Groups.jsx gar nicht. Die drei stehen jetzt in units.js und werden von beiden benutzt. Die Einheiten-Spalte sagt ausserdem, wenn eine Gruppe im Gebinde zaehlt ("zaehlt in Glaeser a 190 g") und bietet den Weg zurueck an. Gruppenliste als Baum: eine Gruppe mit zwei Obergruppen erscheint unter beiden - das ist die ehrliche Darstellung eines Graphen. Jede Zeile traegt ihren PFAD als Schluessel, damit die vorhandene Baumlogik der DataTable unveraendert zurecht kommt; die kennt nur einen Elternteil je Zeile, und ein Pfad hat genau einen. Beim Sortieren/Filtern flacht sie wie gewohnt ab. "1 Untergruppen" heisst jetzt "1 Untergruppe" - an allen vier Stellen ueber einen gemeinsamen Helfer (anzahlWort), Web und App. Lebensmittelliste bekommt eine Spalte "Gruppe" mit Filter. Dafuer liefert ProductOut jetzt group_name, symmetrisch zu category_name, mit joinedload gegen N+1. Co-Authored-By: Claude Opus 5 --- backend/app/crud.py | 2 + backend/app/routers/products.py | 1 + backend/app/schemas.py | 3 + ios/Sources/BestBeforeText.swift | 6 ++ ios/Sources/GroupViews.swift | 4 +- ios/Sources/ListViews.swift | 3 +- web/src/pages/Groups.jsx | 98 ++++++++++++++++++++++++++------ web/src/pages/MinStock.jsx | 16 ++---- web/src/pages/Products.jsx | 10 ++++ web/src/pages/ShoppingList.jsx | 5 +- web/src/styles.css | 2 + web/src/units.js | 29 ++++++++++ 12 files changed, 148 insertions(+), 31 deletions(-) diff --git a/backend/app/crud.py b/backend/app/crud.py index 731d0a6..15abdfe 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -101,6 +101,7 @@ def product_to_out(db: Session, product: Product) -> ProductOut: for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all() ] out.category_name = product.category.name if product.category else None + out.group_name = product.group.name if product.group else None # Bild-Version = Epoch der letzten Bildänderung (identisch zum ETag der # Bild-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild. bild_ts = ( @@ -198,6 +199,7 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu o.expired_count = expired.get(product.id, 0) o.barcodes = [BarcodeOut.model_validate(b) for b in codes.get(product.id, [])] o.category_name = product.category.name if product.category else None + o.group_name = product.group.name if product.group else None ts = imgs.get(product.id) o.image_version = int(ts.timestamp()) if ts is not None else None o.tracking = CategoryTracking(product_tracking(db, product)) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index d16a03f..a0d01c5 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -141,6 +141,7 @@ def list_products( query = db.query(Product).options( # Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst. joinedload(Product.category), + joinedload(Product.group), joinedload(Product.shop), joinedload(Product.display_unit), joinedload(Product.min_stock_unit), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 29d9467..6a9f63f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -393,6 +393,9 @@ class ProductOut(BaseModel): secondary_factor: float | None = None date_precision: DatePrecision = DatePrecision.day group_id: int | None + # Name der Gruppe – wie category_name, damit die Listen danach filtern + # koennen, ohne die Gruppen zusaetzlich zu laden. + group_name: str | None = None category_id: int | None = None category_name: str | None = None min_stock: float | None diff --git a/ios/Sources/BestBeforeText.swift b/ios/Sources/BestBeforeText.swift index 4673136..627adcc 100644 --- a/ios/Sources/BestBeforeText.swift +++ b/ios/Sources/BestBeforeText.swift @@ -108,3 +108,9 @@ enum BestBeforeText { return Calendar.current.date(from: komponenten) } } + + +/// „1 Untergruppe" / „2 Untergruppen" – Ein-/Mehrzahl an einer Stelle. +func anzahlWort(_ n: Int, _ einzahl: String, _ mehrzahl: String) -> String { + "\(n) \(n == 1 ? einzahl : mehrzahl)" +} diff --git a/ios/Sources/GroupViews.swift b/ios/Sources/GroupViews.swift index 7ab874c..23e81cf 100644 --- a/ios/Sources/GroupViews.swift +++ b/ios/Sources/GroupViews.swift @@ -83,7 +83,9 @@ struct GroupsView: View { let eltern = (g.parentIds ?? []).compactMap { map[$0] } var teile = [eltern.isEmpty ? "oberste Ebene" : "unter: " + eltern.joined(separator: ", ")] teile.append("\(g.productCount ?? 0) Artikel") - if let n = g.childIds?.count, n > 0 { teile.append("\(n) Untergruppen") } + if let n = g.childIds?.count, n > 0 { + teile.append(anzahlWort(n, "Untergruppe", "Untergruppen")) + } return teile.joined(separator: " · ") } diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 7c5f756..7ac51e4 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -79,7 +79,8 @@ struct ShoppingListView: View { subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)" + " · \(group.productCount) Artikel" + ((group.subgroupCount ?? 0) > 0 - ? " · inkl. \(group.subgroupCount ?? 0) Untergruppen" : "") + ? " · inkl. " + anzahlWort(group.subgroupCount ?? 0, "Untergruppe", "Untergruppen") + : "") ) { GroupBadge() } } } diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx index e84625a..c76f922 100644 --- a/web/src/pages/Groups.jsx +++ b/web/src/pages/Groups.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { api } from "../api"; import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; @@ -7,7 +7,7 @@ import Icon from "../components/Icon"; import DataTable from "../components/DataTable"; import LocationMinStock from "../components/LocationMinStock"; import GroupParentSelect from "../components/GroupParentSelect"; -import { fmt, kindShort } from "../units"; +import { anzahlWort, fmt, grpPkgMode, grpUnit, kindShort } from "../units"; export default function Groups() { const confirm = useConfirm(); @@ -91,6 +91,40 @@ export default function Groups() { const selected = groups.find((g) => g.id === selectedId) || null; const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name])); + // Gruppen als Baum ausrollen. Eine Gruppe mit ZWEI Obergruppen erscheint + // unter beiden – das ist die ehrliche Darstellung eines Graphen; sie zu + // verstecken hiesse, den Sinn mehrerer Obergruppen zu verbergen. + // + // Jede Zeile traegt ihren PFAD als Schluessel („/3/7"). Damit kommt die + // vorhandene Baumlogik der DataTable unveraendert zurecht: sie kennt nur + // einen Elternteil je Zeile, und ein Pfad hat genau einen. + const baumZeilen = useMemo(() => { + const vorhanden = new Set(groups.map((g) => g.id)); + const nachName = (a, b) => a.name.localeCompare(b.name, "de"); + const kinderVon = new Map(); + const wurzeln = []; + for (const g of groups) { + const eltern = (g.parent_ids || []).filter((id) => vorhanden.has(id)); + if (eltern.length === 0) wurzeln.push(g); + for (const e of eltern) { + if (!kinderVon.has(e)) kinderVon.set(e, []); + kinderVon.get(e).push(g); + } + } + const out = []; + const walk = (g, elternPfad, gesehen) => { + const pfad = `${elternPfad ?? ""}/${g.id}`; + out.push({ ...g, _pfad: pfad, _elternPfad: elternPfad }); + if (gesehen.has(g.id)) return; // Ringschutz, falls doch einer da ist + const weiter = new Set(gesehen).add(g.id); + for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) { + walk(k, pfad, weiter); + } + }; + for (const w of wurzeln.slice().sort(nachName)) walk(w, null, new Set()); + return out; + }, [groups]); + const columns = [ { key: "name", header: "Gruppe", grow: true, min: 160, filterText: (g) => g.name, sortValue: (g) => g.name, @@ -105,7 +139,7 @@ export default function Groups() { {low && niedrig} {g.child_ids?.length > 0 && ( - {g.child_ids.length} Untergruppen + {anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")} )} {/* Ohne Einheit summiert eine Gruppe Gramm und Stück zu einer @@ -143,21 +177,49 @@ export default function Groups() { )} ) }, - { key: "stock", header: "Bestand", width: 130, align: "num", - sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` }, + // Bestand steht in der Einheit, in der die Gruppe ZÄHLT – bei aktivem + // Gruppen-Gebinde also in Gläsern, nicht in Gramm. Vorher klebte hier + // immer min_stock_unit_name dahinter, was aus 790 g „4,16 Gramm" machte. + { key: "stock", header: "Bestand", width: 140, align: "num", + sortValue: (g) => g.stock, + render: (g) => `${fmt(g.stock)} ${grpUnit(g, g.stock)}` }, { key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1, render: (g) => (isAdmin ? ( - { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} /> - ) : (g.min_stock != null ? fmt(g.min_stock) : "–")) }, + + { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} /> + {grpUnit(g, g.min_stock ?? 1)} + + ) : (g.min_stock != null ? `${fmt(g.min_stock)} ${grpUnit(g, g.min_stock)}` : "–")) }, { key: "unit", header: "Einheit", width: 150, filterText: (g) => g.min_stock_unit_name || "", - render: (g) => (isAdmin ? ( - - ) : (g.min_stock_unit_name || "–")) }, + render: (g) => ( + + {isAdmin ? ( + + ) : (g.min_stock_unit_name || "–")} + {/* Zählt die Gruppe im Gebinde, sagt die Einheit darüber nur die + halbe Wahrheit – der Zusatz nennt den Umrechnungswert. */} + {grpPkgMode(g) && ( + + zählt in {grpUnit(g, 2)} à {fmt(g.package_size)} {kindShort(g.kind)} + {isAdmin && ( + <> + {" · "} + + + )} + + )} + + ) }, { key: "codes", header: "EAN-Codes", width: 130, align: "num", render: (g) => (