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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
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.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-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-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild.
|
||||||
bild_ts = (
|
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.expired_count = expired.get(product.id, 0)
|
||||||
o.barcodes = [BarcodeOut.model_validate(b) for b in codes.get(product.id, [])]
|
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.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)
|
ts = imgs.get(product.id)
|
||||||
o.image_version = int(ts.timestamp()) if ts is not None else None
|
o.image_version = int(ts.timestamp()) if ts is not None else None
|
||||||
o.tracking = CategoryTracking(product_tracking(db, product))
|
o.tracking = CategoryTracking(product_tracking(db, product))
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ def list_products(
|
|||||||
query = db.query(Product).options(
|
query = db.query(Product).options(
|
||||||
# Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst.
|
# Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst.
|
||||||
joinedload(Product.category),
|
joinedload(Product.category),
|
||||||
|
joinedload(Product.group),
|
||||||
joinedload(Product.shop),
|
joinedload(Product.shop),
|
||||||
joinedload(Product.display_unit),
|
joinedload(Product.display_unit),
|
||||||
joinedload(Product.min_stock_unit),
|
joinedload(Product.min_stock_unit),
|
||||||
|
|||||||
@@ -393,6 +393,9 @@ class ProductOut(BaseModel):
|
|||||||
secondary_factor: float | None = None
|
secondary_factor: float | None = None
|
||||||
date_precision: DatePrecision = DatePrecision.day
|
date_precision: DatePrecision = DatePrecision.day
|
||||||
group_id: int | None
|
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_id: int | None = None
|
||||||
category_name: str | None = None
|
category_name: str | None = None
|
||||||
min_stock: float | None
|
min_stock: float | None
|
||||||
|
|||||||
@@ -108,3 +108,9 @@ enum BestBeforeText {
|
|||||||
return Calendar.current.date(from: komponenten)
|
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)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ struct GroupsView: View {
|
|||||||
let eltern = (g.parentIds ?? []).compactMap { map[$0] }
|
let eltern = (g.parentIds ?? []).compactMap { map[$0] }
|
||||||
var teile = [eltern.isEmpty ? "oberste Ebene" : "unter: " + eltern.joined(separator: ", ")]
|
var teile = [eltern.isEmpty ? "oberste Ebene" : "unter: " + eltern.joined(separator: ", ")]
|
||||||
teile.append("\(g.productCount ?? 0) Artikel")
|
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: " · ")
|
return teile.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ struct ShoppingListView: View {
|
|||||||
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
|
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
|
||||||
+ " · \(group.productCount) Artikel"
|
+ " · \(group.productCount) Artikel"
|
||||||
+ ((group.subgroupCount ?? 0) > 0
|
+ ((group.subgroupCount ?? 0) > 0
|
||||||
? " · inkl. \(group.subgroupCount ?? 0) Untergruppen" : "")
|
? " · inkl. " + anzahlWort(group.subgroupCount ?? 0, "Untergruppe", "Untergruppen")
|
||||||
|
: "")
|
||||||
) { GroupBadge() }
|
) { GroupBadge() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
@@ -7,7 +7,7 @@ import Icon from "../components/Icon";
|
|||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
import LocationMinStock from "../components/LocationMinStock";
|
import LocationMinStock from "../components/LocationMinStock";
|
||||||
import GroupParentSelect from "../components/GroupParentSelect";
|
import GroupParentSelect from "../components/GroupParentSelect";
|
||||||
import { fmt, kindShort } from "../units";
|
import { anzahlWort, fmt, grpPkgMode, grpUnit, kindShort } from "../units";
|
||||||
|
|
||||||
export default function Groups() {
|
export default function Groups() {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -91,6 +91,40 @@ export default function Groups() {
|
|||||||
const selected = groups.find((g) => g.id === selectedId) || null;
|
const selected = groups.find((g) => g.id === selectedId) || null;
|
||||||
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
|
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 = [
|
const columns = [
|
||||||
{ key: "name", header: "Gruppe", grow: true, min: 160,
|
{ key: "name", header: "Gruppe", grow: true, min: 160,
|
||||||
filterText: (g) => g.name, sortValue: (g) => g.name,
|
filterText: (g) => g.name, sortValue: (g) => g.name,
|
||||||
@@ -105,7 +139,7 @@ export default function Groups() {
|
|||||||
{low && <span className="badge warn">niedrig</span>}
|
{low && <span className="badge warn">niedrig</span>}
|
||||||
{g.child_ids?.length > 0 && (
|
{g.child_ids?.length > 0 && (
|
||||||
<span className="badge" title="Bestand und Mindestbestand zählen diese Untergruppen mit.">
|
<span className="badge" title="Bestand und Mindestbestand zählen diese Untergruppen mit.">
|
||||||
{g.child_ids.length} Untergruppen
|
{anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Ohne Einheit summiert eine Gruppe Gramm und Stück zu einer
|
{/* Ohne Einheit summiert eine Gruppe Gramm und Stück zu einer
|
||||||
@@ -143,21 +177,49 @@ export default function Groups() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
) },
|
) },
|
||||||
{ key: "stock", header: "Bestand", width: 130, align: "num",
|
// Bestand steht in der Einheit, in der die Gruppe ZÄHLT – bei aktivem
|
||||||
sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` },
|
// 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,
|
{ key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1,
|
||||||
render: (g) => (isAdmin ? (
|
render: (g) => (isAdmin ? (
|
||||||
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 90 }}
|
<span className="field-inline" style={{ gap: "var(--sp-1)", flexWrap: "nowrap" }}>
|
||||||
|
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 70 }}
|
||||||
onBlur={(e) => { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} />
|
onBlur={(e) => { 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) : "–")) },
|
<span className="muted small" style={{ alignSelf: "center" }}>{grpUnit(g, g.min_stock ?? 1)}</span>
|
||||||
|
</span>
|
||||||
|
) : (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 || "",
|
{ key: "unit", header: "Einheit", width: 150, filterText: (g) => g.min_stock_unit_name || "",
|
||||||
render: (g) => (isAdmin ? (
|
render: (g) => (
|
||||||
|
<span className="cell-col">
|
||||||
|
{isAdmin ? (
|
||||||
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
|
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
|
||||||
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||||
<option value="">– Basiseinheit –</option>
|
<option value="">– Basiseinheit –</option>
|
||||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : (g.min_stock_unit_name || "–")) },
|
) : (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) && (
|
||||||
|
<span className="muted small">
|
||||||
|
zählt in {grpUnit(g, 2)} à {fmt(g.package_size)} {kindShort(g.kind)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<button type="button" className="link-btn"
|
||||||
|
title="Bestand und Mindestbestand wieder in der Einheit oben lesen"
|
||||||
|
onClick={() => patch(g, { min_stock_in_packages: false })}>
|
||||||
|
in {g.min_stock_unit_name || kindShort(g.kind)} rechnen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) },
|
||||||
{ key: "codes", header: "EAN-Codes", width: 130, align: "num",
|
{ key: "codes", header: "EAN-Codes", width: 130, align: "num",
|
||||||
render: (g) => (
|
render: (g) => (
|
||||||
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
||||||
@@ -186,8 +248,10 @@ export default function Groups() {
|
|||||||
|
|
||||||
<div className="grid-2 wide-aside">
|
<div className="grid-2 wide-aside">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<DataTable id="groups" columns={columns} rows={groups} loading={loading}
|
<DataTable id="groups" columns={columns} rows={baumZeilen} loading={loading}
|
||||||
getRowKey={(g) => g.id} empty="Noch keine Gruppen." />
|
getRowKey={(g) => g._pfad}
|
||||||
|
tree={{ column: "name", idOf: (g) => g._pfad, parentIdOf: (g) => g._elternPfad }}
|
||||||
|
empty="Noch keine Gruppen." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useAuth } from "../auth";
|
|||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
import { fmt, gebinde, kindShort } from "../units";
|
import { anzahlWort, fmt, gebinde, grpFactor, grpPkgMode, grpUnit, kindShort } from "../units";
|
||||||
|
|
||||||
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
|
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
|
||||||
// Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine
|
// Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine
|
||||||
@@ -24,13 +24,9 @@ const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p));
|
|||||||
// Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser).
|
// Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser).
|
||||||
const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p));
|
const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p));
|
||||||
|
|
||||||
// Gruppen: analog, mit dem GRUPPEN-Gebinde (Richtwert der Gruppe, nicht der
|
// Gruppen rechnen in ihrer verwalteten Einheit ODER im Gruppen-Gebinde –
|
||||||
// einzelnen Produkte).
|
// grpPkgMode/grpFactor/grpUnit stehen in units.js, weil Groups.jsx sie genauso
|
||||||
const grpPkgMode = (g) => !!g.min_stock_in_packages && !!(g.package_size && g.package_size > 0);
|
// braucht (und ohne sie den Bestand falsch beschriftet hat).
|
||||||
const grpFactor = (g) => (grpPkgMode(g) ? g.package_size : (g.min_stock_unit_factor || 1));
|
|
||||||
const grpUnit = (g, menge = 1) => (grpPkgMode(g)
|
|
||||||
? gebinde(menge, g.package_label || "Packung")
|
|
||||||
: (g.min_stock_unit_name || kindShort(g.kind) || ""));
|
|
||||||
|
|
||||||
// „Überall" ist der Ort NULL. Im <select> braucht es einen Wert, deshalb ein
|
// „Überall" ist der Ort NULL. Im <select> braucht es einen Wert, deshalb ein
|
||||||
// Platzhalter, der beim Speichern wieder zu null wird.
|
// Platzhalter, der beim Speichern wieder zu null wird.
|
||||||
@@ -269,7 +265,7 @@ export default function MinStock() {
|
|||||||
{g?.child_ids?.length > 0 && (
|
{g?.child_ids?.length > 0 && (
|
||||||
<span className="badge" style={{ marginLeft: 6 }}
|
<span className="badge" style={{ marginLeft: 6 }}
|
||||||
title="Bestand und Mindestbestand zählen die Untergruppen mit.">
|
title="Bestand und Mindestbestand zählen die Untergruppen mit.">
|
||||||
inkl. {g.child_ids.length} Untergruppen
|
inkl. {anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -375,7 +371,7 @@ export default function MinStock() {
|
|||||||
{t.name}
|
{t.name}
|
||||||
{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
|
{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
|
||||||
{draft.kind === "group" && t.child_ids?.length
|
{draft.kind === "group" && t.child_ids?.length
|
||||||
? ` (inkl. ${t.child_ids.length} Untergruppen)` : ""}
|
? ` (inkl. ${anzahlWort(t.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ export default function Products({ fixedType = null }) {
|
|||||||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
||||||
sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
|
sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
|
||||||
render: (p) => <CategoryPathLabel parts={catInfo.get(p.category_id)?.parts} fallback={p.category_name || "–"} /> },
|
render: (p) => <CategoryPathLabel parts={catInfo.get(p.category_id)?.parts} fallback={p.category_name || "–"} /> },
|
||||||
|
// Gruppe: zählt Bestände mehrerer Marken zusammen. Als eigene Spalte, damit
|
||||||
|
// sich die Liste danach filtern lässt („zeig mir alles aus Pesto Rosso").
|
||||||
|
{ key: "gruppe", header: "Gruppe", width: 170,
|
||||||
|
filterText: (p) => p.group_name || "",
|
||||||
|
filterValues: (p) => [p.group_name || ""],
|
||||||
|
filterOptionLabel: (v) => (v === "" ? "(Ohne Gruppe)" : v),
|
||||||
|
sortValue: (p) => p.group_name || "",
|
||||||
|
render: (p) => (p.group_name
|
||||||
|
? <span className="badge accent">{p.group_name}</span>
|
||||||
|
: <span className="muted">–</span>) },
|
||||||
// Nur in der Gegenstände-Liste: Menge je Lagerort / Einzelstücke / Verbrauchsgegenstand.
|
// Nur in der Gegenstände-Liste: Menge je Lagerort / Einzelstücke / Verbrauchsgegenstand.
|
||||||
activeTyp === "object" && { key: "verwaltung", header: "Verwaltung", label: "Verwaltung", width: 190,
|
activeTyp === "object" && { key: "verwaltung", header: "Verwaltung", label: "Verwaltung", width: 190,
|
||||||
filterText: (p) => verwaltungLabel(p),
|
filterText: (p) => verwaltungLabel(p),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import ShoppingNeedText from "../components/ShoppingNeedText";
|
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||||
|
import { anzahlWort } from "../units";
|
||||||
|
|
||||||
export default function ShoppingList() {
|
export default function ShoppingList() {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@@ -50,7 +51,7 @@ export default function ShoppingList() {
|
|||||||
<span className="badge accent">Gruppe</span>
|
<span className="badge accent">Gruppe</span>
|
||||||
{it.subgroup_count > 0 && (
|
{it.subgroup_count > 0 && (
|
||||||
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
|
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
|
||||||
inkl. {it.subgroup_count} Untergruppen
|
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
@@ -95,7 +96,7 @@ export default function ShoppingList() {
|
|||||||
<span className="badge accent">Gruppe</span>
|
<span className="badge accent">Gruppe</span>
|
||||||
{it.subgroup_count > 0 && (
|
{it.subgroup_count > 0 && (
|
||||||
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
|
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
|
||||||
inkl. {it.subgroup_count} Untergruppen
|
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
|
|||||||
@@ -387,6 +387,8 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
/* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das
|
/* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das
|
||||||
Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */
|
Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */
|
||||||
.cell-row { display: flex; align-items: center; gap: 6px; }
|
.cell-row { display: flex; align-items: center; gap: 6px; }
|
||||||
|
/* Zwei Zeilen in einer Zelle: Steuerelement oben, Erläuterung darunter. */
|
||||||
|
.cell-col { display: flex; flex-direction: column; gap: 2px; align-items: stretch; }
|
||||||
/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass
|
/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass
|
||||||
dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */
|
dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */
|
||||||
.tip { border-bottom: 1px dotted var(--border-strong); }
|
.tip { border-bottom: 1px dotted var(--border-strong); }
|
||||||
|
|||||||
@@ -54,6 +54,35 @@ export function kindShort(kind) {
|
|||||||
return KIND_SHORT[kind] || "";
|
return KIND_SHORT[kind] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** „1 Untergruppe" / „2 Untergruppen" – Ein-/Mehrzahl an einer Stelle. */
|
||||||
|
export function anzahlWort(n, einzahl, mehrzahl) {
|
||||||
|
return `${n} ${Number(n) === 1 ? einzahl : mehrzahl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gruppen: in welcher Einheit sie zählen ----
|
||||||
|
// Eine Gruppe rechnet entweder in ihrer verwalteten Einheit (Gramm, Kilogramm)
|
||||||
|
// ODER – wenn ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen.
|
||||||
|
// Beides zusammen an EINER Stelle, weil genau diese Unterscheidung vorher an
|
||||||
|
// zwei Orten stand und in Groups.jsx fehlte: dort klebte immer „Gramm" hinter
|
||||||
|
// einem Bestand, der längst in Gläsern gerechnet war.
|
||||||
|
|
||||||
|
/** Zählt die Gruppe in ihrem Gebinde (statt in der verwalteten Einheit)? */
|
||||||
|
export function grpPkgMode(g) {
|
||||||
|
return !!g?.min_stock_in_packages && !!(g.package_size && g.package_size > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basiseinheiten je Erfassungseinheit der Gruppe. */
|
||||||
|
export function grpFactor(g) {
|
||||||
|
return grpPkgMode(g) ? g.package_size : (g?.min_stock_unit_factor || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschriftung der Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser). */
|
||||||
|
export function grpUnit(g, menge = 1) {
|
||||||
|
return grpPkgMode(g)
|
||||||
|
? gebinde(menge, g.package_label || "Packung")
|
||||||
|
: (g?.min_stock_unit_name || kindShort(g?.kind) || "");
|
||||||
|
}
|
||||||
|
|
||||||
// Einheiten-ART je Basiseinheit – Spiegelbild von conversion.py::KIND_OF_BASE.
|
// Einheiten-ART je Basiseinheit – Spiegelbild von conversion.py::KIND_OF_BASE.
|
||||||
export const KIND_OF_BASE = { piece: "count", gram: "weight", milliliter: "volume" };
|
export const KIND_OF_BASE = { piece: "count", gram: "weight", milliliter: "volume" };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user