Hausmasse und Oeffnungsfrist bekommen eine Oberflaeche

Die Endpunkte standen seit dem Backend-Commit, aber eintragen liess sich
nichts - die Mengen gingen nur per API rein. Damit war "2 EL Mehl" im
Verbrauchen-Dialog zwar vorgesehen, aber fuer niemanden erreichbar.

Zwei Stellen dafuer:

Stammdaten > Masse verwaltet nur die NAMEN ("Essloeffel", "Tasse"). Die
Seite ist absichtlich kurz, denn die Mengen gehoeren nicht an das Mass:
Ein Essloeffel Mehl sind etwa 10 g, einer Zucker etwa 12 g, und eine
Tasse Milch ist Milliliter. Ein globaler Wert waere bei jedem zweiten
Artikel falsch.

Kategorien > "Masse & Frist" ist deshalb der Ort, an dem die Arbeit
passiert. Beides steckt in einem Bedienfeld, weil es dieselbe Mechanik
ist: Der Wert gilt fuer die Kategorie und alles darunter, und der
naechste Vorfahr mit einem Eintrag gewinnt. Leere Felder erben - und
zeigen als Platzhalter, WAS sie erben ("erbt 5 Tage (Kategorie: Mehl &
Zucker)", "10 g" mit Abzeichen "geerbt"). Ohne das muesste man den
Kategorienbaum im Kopf verfolgen, um eine leere Eingabe zu verstehen.

Dafuer zwei kleine Ergaenzungen am Backend: CategoryOut nennt die
geerbte Frist samt Herkunft, und /categories/{id}/measures nimmt
?vererbt=true, um neben den eigenen auch die von oben geerbten Mengen zu
liefern. Die Bearbeitungsansicht ohne Flagge bleibt wie sie war.

Von Hand durchgeklickt - und dabei einen echten Fehler gefunden: Der
API-Aufruf im Browser hat das vererbt-Flag verschluckt, weil die
Funktion nur ein Argument annahm. Die Unterkategorie zeigte dadurch
leere Felder, obwohl der Server die geerbten Werte lieferte. Jetzt
stehen dort "10 g" und "3 g" mit der Quelle im Tooltip.

4 neue Tests, Suite bei 516.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 14:37:53 +02:00
co-authored by Claude Opus 5
parent 795e233a1b
commit f61c76034c
9 changed files with 508 additions and 10 deletions
+2
View File
@@ -21,6 +21,7 @@ from ..models import (
) )
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
from ..services.fields import effective_field_definitions from ..services.fields import effective_field_definitions
from ..services.vererbung import geerbte_kategorie_frist
from .field_definitions import to_out as field_to_out from .field_definitions import to_out as field_to_out
router = APIRouter(prefix="/categories", tags=["categories"]) router = APIRouter(prefix="/categories", tags=["categories"])
@@ -54,6 +55,7 @@ def _to_out(db: Session, category: Category) -> CategoryOut:
out.product_count = ( out.product_count = (
db.query(Product).filter(Product.category_id == category.id).count() db.query(Product).filter(Product.category_id == category.id).count()
) )
out.inherited_open_days, out.open_days_source = geerbte_kategorie_frist(db, category)
return out return out
+27 -5
View File
@@ -30,7 +30,7 @@ from ..schemas import (
MeasureOut, MeasureOut,
) )
from ..services.conversion import PACKAGE_TOKENS, find_unit from ..services.conversion import PACKAGE_TOKENS, find_unit
from ..services.vererbung import verfuegbare_masse from ..services.vererbung import geerbtes_mass_kategorie, verfuegbare_masse
router = APIRouter(tags=["measures"]) router = APIRouter(tags=["measures"])
@@ -224,13 +224,17 @@ def delete_product_measure(
@router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut]) @router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut])
def category_measures( def category_measures(
category_id: int, category_id: int,
vererbt: bool = False,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
) -> list[MeasureAmountOut]: ) -> list[MeasureAmountOut]:
"""Die an DIESER Kategorie hinterlegten Mengen (ohne die geerbten). """Die an DIESER Kategorie hinterlegten Mengen.
Bewusst ohne Vererbung: Diese Liste ist die Bearbeitungsansicht. Was ein Ohne ``vererbt`` nur die eigenen – das ist die Bearbeitungsansicht. Mit
Artikel am Ende wirklich bekommt, zeigt ``/products/{id}/measures``. ``vererbt=true`` kommen die von oben geerbten dazu, erkennbar an ihrer
Herkunft („Kategorie: Mehl & Zucker"); die Maske zeigt damit auch, was
gelten wuerde, wenn man den eigenen Eintrag loescht. Was ein ARTIKEL am Ende
bekommt, zeigt ``/products/{id}/measures``.
""" """
if db.get(Category, category_id) is None: if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
@@ -241,13 +245,31 @@ def category_measures(
.order_by(Measure.position, Measure.id) .order_by(Measure.position, Measure.id)
.all() .all()
) )
return [ eigene = [
MeasureAmountOut( MeasureAmountOut(
measure_id=m.id, name=m.name, short=m.short, measure_id=m.id, name=m.name, short=m.short,
amount=cm.amount, base=cm.base, herkunft="Kategorie", amount=cm.amount, base=cm.base, herkunft="Kategorie",
) )
for cm, m in rows for cm, m in rows
] ]
if not vererbt:
return eigene
# Zusaetzlich, was diese Kategorie von oben ERBT. Die Maske zeigt damit
# beides: was hier steht und was gelten wuerde, wenn man es loescht.
category = db.get(Category, category_id)
gesetzt = {e.measure_id for e in eigene}
for measure in db.query(Measure).order_by(Measure.position, Measure.id).all():
if measure.id in gesetzt:
continue
angabe = geerbtes_mass_kategorie(db, category, measure.id)
if angabe is None:
continue
eigene.append(MeasureAmountOut(
measure_id=measure.id, name=measure.name, short=measure.short,
amount=angabe.amount, base=angabe.base, herkunft=angabe.herkunft,
))
return eigene
@router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut) @router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut)
+5
View File
@@ -278,6 +278,11 @@ class CategoryOut(BaseModel):
# Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der # Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der
# Oberkategorie, zuletzt vom globalen Wert. # Oberkategorie, zuletzt vom globalen Wert.
open_days: int | None = None open_days: int | None = None
# Was gelten wuerde, wenn ``open_days`` leer bleibt – samt Herkunft
# („Kategorie: Molkerei" / „Einstellungen"). Fuer den Platzhalter in der
# Maske: Ein leeres Feld soll zeigen, was es erbt, statt nur leer zu sein.
inherited_open_days: int = 0
open_days_source: str = ""
class CategoryCreate(BaseModel): class CategoryCreate(BaseModel):
+38
View File
@@ -104,6 +104,19 @@ def frist_herkunft(db: Session, product: Product) -> tuple[int, str]:
return globale_oeffnungsfrist(db), "Einstellungen" return globale_oeffnungsfrist(db), "Einstellungen"
def geerbte_kategorie_frist(db: Session, category) -> tuple[int, str]:
"""Die Frist, die fuer diese Kategorie gelten WUERDE, haette sie keine eigene.
Gefragt wird also nach dem Platzhalter in der Maske: Was passiert, wenn ich
das Feld leer lasse? Deshalb beginnt die Suche beim Elternteil, nicht bei
der Kategorie selbst.
"""
for cat in _vorfahren_abwaerts(db, category.parent_id):
if cat.open_days is not None and cat.open_days > 0:
return cat.open_days, f"Kategorie: {cat.name}"
return globale_oeffnungsfrist(db), "Einstellungen"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Hausmaße # Hausmaße
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -147,6 +160,31 @@ def _mass_fuer_artikel(db: Session, product: Product, measure_id: int) -> MassAn
return None return None
def geerbtes_mass_kategorie(
db: Session, category, measure_id: int
) -> MassAngabe | None:
"""Was eine Kategorie fuer dieses Mass ERBT, ohne ihren eigenen Eintrag.
Dasselbe wie bei der Frist: Die Maske soll zeigen, was gilt, wenn das Feld
leer bleibt. Deshalb beginnt die Suche beim Elternteil.
"""
measure = db.get(Measure, measure_id)
if measure is None:
return None
for cat in _vorfahren_abwaerts(db, category.parent_id):
row = (
db.query(CategoryMeasure)
.filter(
CategoryMeasure.category_id == cat.id,
CategoryMeasure.measure_id == measure_id,
)
.first()
)
if row is not None:
return MassAngabe(measure, row.amount, row.base, f"Kategorie: {cat.name}")
return None
def verfuegbare_masse(db: Session, product: Product) -> list[MassAngabe]: def verfuegbare_masse(db: Session, product: Product) -> list[MassAngabe]:
"""Alle Hausmaße, die für diesen Artikel eine Menge haben. """Alle Hausmaße, die für diesen Artikel eine Menge haben.
+57 -1
View File
@@ -15,10 +15,15 @@ from app.models import (
Product, Product,
ProductMeasure, ProductMeasure,
) )
from app.routers.measures import category_measures
from app.seed import ensure_builtin_measures from app.seed import ensure_builtin_measures
from app.services.conversion import ConversionError, to_base from app.services.conversion import ConversionError, to_base
from app.services.stock import check_in, check_out, current_stock from app.services.stock import check_in, check_out, current_stock
from app.services.vererbung import mass_aufloesen, verfuegbare_masse from app.services.vererbung import (
geerbte_kategorie_frist,
mass_aufloesen,
verfuegbare_masse,
)
@pytest.fixture() @pytest.fixture()
@@ -253,3 +258,54 @@ def test_artikel_ohne_kategorie_hat_keine_masse(db, masse):
assert verfuegbare_masse(db, product) == [] assert verfuegbare_masse(db, product) == []
assert mass_aufloesen(db, product, "EL") is None assert mass_aufloesen(db, product, "EL") is None
# --------------------------------------------------------------------------
# Bearbeitungsansicht: eigene und geerbte Mengen
# --------------------------------------------------------------------------
def test_kategorie_sieht_ihre_eigenen_mengen(db, masse):
"""Ohne Vererbung ist die Liste die reine Bearbeitungsansicht."""
ober = _kategorie(db, "Mehl & Zucker")
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
liste = category_measures(ober.id, db=db, _=None)
assert [(e.name, e.amount, e.herkunft) for e in liste] == [("Esslöffel", 10, "Kategorie")]
def test_unterkategorie_zeigt_das_geerbte_als_solches(db, masse):
"""Der Platzhalter in der Maske: Was gilt, wenn ich das Feld leer lasse?"""
ober = _kategorie(db, "Mehl & Zucker")
unter = _kategorie(db, "Backzutaten", parent=ober)
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
ohne = category_measures(unter.id, db=db, _=None)
mit = category_measures(unter.id, vererbt=True, db=db, _=None)
assert ohne == []
assert [(e.name, e.amount, e.herkunft) for e in mit] == [
("Esslöffel", 10, "Kategorie: Mehl & Zucker"),
]
def test_eigener_wert_verdeckt_den_geerbten_in_der_liste(db, masse):
"""Sonst stünde dieselbe Zeile zweimal da, mit verschiedenen Zahlen."""
ober = _kategorie(db, "Mehl & Zucker")
unter = _kategorie(db, "Backzutaten", parent=ober)
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
_mass_an_kategorie(db, masse["Esslöffel"], unter, 12)
liste = category_measures(unter.id, vererbt=True, db=db, _=None)
assert [(e.amount, e.herkunft) for e in liste] == [(12, "Kategorie")]
def test_geerbte_frist_nennt_die_kategorie_die_sie_setzt(db):
ober = _kategorie(db, "Molkerei")
unter = _kategorie(db, "Frischmilch", parent=ober)
ober.open_days = 5
db.commit()
assert geerbte_kategorie_frist(db, unter) == (5, "Kategorie: Molkerei")
# Die Kategorie, die den Wert selbst setzt, erbt ihn NICHT von sich.
assert geerbte_kategorie_frist(db, ober)[1] == "Einstellungen"
+3
View File
@@ -23,6 +23,7 @@ import ItemResolve from "./pages/ItemResolve";
import LocationResolve from "./pages/LocationResolve"; import LocationResolve from "./pages/LocationResolve";
import Locations from "./pages/Locations"; import Locations from "./pages/Locations";
import PackageTypes from "./pages/PackageTypes"; import PackageTypes from "./pages/PackageTypes";
import Masse from "./pages/Masse";
import Units from "./pages/Units"; import Units from "./pages/Units";
import Users from "./pages/Users"; import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList"; import ShoppingList from "./pages/ShoppingList";
@@ -156,6 +157,7 @@ function Sidebar() {
<NavItem to="/shops" icon="cart" label="Shops" /> <NavItem to="/shops" icon="cart" label="Shops" />
<NavItem to="/units" icon="box" label="Einheiten" /> <NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/package-types" icon="package" label="Gebinde" /> <NavItem to="/package-types" icon="package" label="Gebinde" />
<NavItem to="/masse" icon="split" label="Maße" />
<div className="nav-section">System</div> <div className="nav-section">System</div>
<NavItem to="/users" icon="users" label="Benutzer" /> <NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/transfer" icon="history" label="Import / Export" /> <NavItem to="/transfer" icon="history" label="Import / Export" />
@@ -258,6 +260,7 @@ export default function App() {
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} /> <Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} /> <Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
<Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} /> <Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} />
<Route path="/masse" element={<Protected adminOnly wide><Masse /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} /> <Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} /> <Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} /> <Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
+18
View File
@@ -293,6 +293,24 @@ export const api = {
// Einheitenauswahl beim Verbrauchen ("2 EL" sind bei Mehl und Zucker // Einheitenauswahl beim Verbrauchen ("2 EL" sind bei Mehl und Zucker
// verschieden viel Gramm). // verschieden viel Gramm).
productMeasures: (id) => request(`/products/${id}/measures`), productMeasures: (id) => request(`/products/${id}/measures`),
// Hausmasse: die NAMEN sind global, die MENGEN haengen an Kategorie bzw.
// Artikel - "1 EL" ist bei Mehl und Zucker verschieden schwer.
listMeasures: () => request("/measures"),
createMeasure: (body) => request("/measures", { method: "POST", body }),
updateMeasure: (id, body) => request(`/measures/${id}`, { method: "PATCH", body }),
deleteMeasure: (id) => request(`/measures/${id}`, { method: "DELETE" }),
// `vererbt` holt zusaetzlich, was die Kategorie von oben erbt - die Maske
// zeigt damit auch, was gelten wuerde, wenn man den eigenen Eintrag loescht.
categoryMeasures: (id, vererbt = false) =>
request(`/categories/${id}/measures${vererbt ? "?vererbt=true" : ""}`),
setCategoryMeasure: (catId, measureId, body) =>
request(`/categories/${catId}/measures/${measureId}`, { method: "PUT", body }),
deleteCategoryMeasure: (catId, measureId) =>
request(`/categories/${catId}/measures/${measureId}`, { method: "DELETE" }),
setProductMeasure: (productId, measureId, body) =>
request(`/products/${productId}/measures/${measureId}`, { method: "PUT", body }),
deleteProductMeasure: (productId, measureId) =>
request(`/products/${productId}/measures/${measureId}`, { method: "DELETE" }),
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste). // Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
setProductLocationMinStock: (id, list) => setProductLocationMinStock: (id, list) =>
+195 -4
View File
@@ -24,6 +24,8 @@ export default function Categories() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Kategorie, deren Felder gerade verwaltet werden. // Kategorie, deren Felder gerade verwaltet werden.
const [feldKat, setFeldKat] = useState(null); const [feldKat, setFeldKat] = useState(null);
// Welche Kategorie gerade ihre Masse und ihre Oeffnungsfrist zeigt.
const [massKat, setMassKat] = useState(null);
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
const [typFilter, setTypFilter] = useState(""); const [typFilter, setTypFilter] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -131,13 +133,17 @@ export default function Categories() {
} }, } },
{ key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count, { key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count,
render: (c) => <span className="muted">{c.product_count}</span> }, render: (c) => <span className="muted">{c.product_count}</span> },
{ key: "actions", header: "", label: "Aktionen", fixed: true, width: 96, align: "num", { key: "actions", header: "", label: "Aktionen", fixed: true, width: 132, align: "num",
render: (c) => ( render: (c) => (
<span className="cell-row" style={{ justifyContent: "flex-end" }}> <span className="cell-row" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Felder verwalten" <button className="btn-icon" title="Felder verwalten"
onClick={() => setFeldKat(feldKat && feldKat.id === c.id ? null : c)}> onClick={() => { setMassKat(null); setFeldKat(feldKat && feldKat.id === c.id ? null : c); }}>
<Icon name="tag" size={16} /> <Icon name="tag" size={16} />
</button> </button>
<button className="btn-icon" title="Maße & Frist"
onClick={() => { setFeldKat(null); setMassKat(massKat && massKat.id === c.id ? null : c); }}>
<Icon name="split" size={16} />
</button>
{isAdmin && ( {isAdmin && (
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen"> <button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />
@@ -155,7 +161,8 @@ export default function Categories() {
<div className="sub"> <div className="sub">
Ordnen die Artikelliste und legen die Verwaltungsart fest: Ordnen die Artikelliste und legen die Verwaltungsart fest:
„Lebensmittel“ (Chargen + MHD) oder „Gegenstand“ (Menge je Lagerort). „Lebensmittel“ (Chargen + MHD) oder „Gegenstand“ (Menge je Lagerort).
Je Kategorie lassen sich eigene Felder festlegen, die Unterkategorien erben. Je Kategorie lassen sich eigene Felder festlegen, die Unterkategorien erben –
ebenso Küchenmaße („1 EL Mehl = 10 g") und die Haltbarkeit nach dem Anbrechen.
</div> </div>
</div> </div>
</div> </div>
@@ -172,7 +179,9 @@ export default function Categories() {
<DataTable id="categories" columns={columns} <DataTable id="categories" columns={columns}
rows={categories.filter((c) => !typFilter || c.tracking === typFilter)} rows={categories.filter((c) => !typFilter || c.tracking === typFilter)}
getRowKey={(c) => c.id} getRowKey={(c) => c.id}
rowClassName={(c) => (feldKat && feldKat.id === c.id ? "row-active" : "")} rowClassName={(c) => (
(feldKat && feldKat.id === c.id) || (massKat && massKat.id === c.id)
? "row-active" : "")}
tree={{ column: "name", idOf: (c) => c.id, parentIdOf: (c) => c.parent_id }} tree={{ column: "name", idOf: (c) => c.id, parentIdOf: (c) => c.parent_id }}
loading={loading} empty="Noch keine Kategorien." /> loading={loading} empty="Noch keine Kategorien." />
</div> </div>
@@ -219,6 +228,16 @@ export default function Categories() {
onClose={() => setFeldKat(null)} onClose={() => setFeldKat(null)}
/> />
)} )}
{massKat && (
<CategoryMasse
key={massKat.id}
category={massKat}
isAdmin={isAdmin}
onClose={() => setMassKat(null)}
onFrist={() => load()}
/>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -401,3 +420,175 @@ function CategoryFields({ category, isAdmin, onClose }) {
</div> </div>
); );
} }
const BASIS_LABEL = { gram: "Gramm", milliliter: "Milliliter", piece: "Stück" };
const BASIS_KURZ = { gram: "g", milliliter: "ml", piece: "Stk" };
/**
* Küchenmaße und Öffnungsfrist einer Kategorie.
*
* Beides steckt hier zusammen, weil es dieselbe Mechanik ist: Der Wert gilt für
* die Kategorie und alle darunter, und der nächste Vorfahr mit einem Eintrag
* gewinnt. Leere Felder erben – was sie erben, steht als Platzhalter dabei,
* sonst müsste man den Baum im Kopf verfolgen.
*
* Die Mengen gehören an die Kategorie und nicht an das Maß selbst: Ein
* Esslöffel Mehl sind etwa 10 g, einer Zucker etwa 12 g. Ein globaler Wert wäre
* bei jedem zweiten Artikel falsch.
*/
function CategoryMasse({ category, isAdmin, onClose, onFrist }) {
const confirm = useConfirm();
const [masse, setMasse] = useState([]); // alle Maß-Namen (global)
const [werte, setWerte] = useState([]); // eigene + geerbte Mengen
const [frist, setFrist] = useState(category.open_days ?? "");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const [alle, eigene] = await Promise.all([
api.listMeasures(),
api.categoryMeasures(category.id, true),
]);
setMasse(alle);
setWerte(eigene);
setError(null);
} catch (err) { setError(err.message); } finally { setLoading(false); }
}
useEffect(() => { load(); }, [category.id]);
const wertFuer = (id) => werte.find((w) => w.measure_id === id) || null;
const istEigen = (w) => w && w.herkunft === "Kategorie";
async function speichern(mass, menge, basis) {
const zahl = Number(String(menge).replace(",", "."));
if (!Number.isFinite(zahl) || zahl <= 0) return;
setError(null);
try {
await api.setCategoryMeasure(category.id, mass.id, { amount: zahl, base: basis });
load();
} catch (err) { setError(err.message); }
}
async function entfernen(mass) {
setError(null);
try {
await api.deleteCategoryMeasure(category.id, mass.id);
load();
} catch (err) { setError(err.message); }
}
async function fristSpeichern() {
const roh = String(frist).trim();
const zahl = roh === "" ? null : Number(roh);
if (roh !== "" && (!Number.isFinite(zahl) || zahl <= 0)) {
setError("Die Frist ist eine Anzahl Tage größer als 0 – oder leer, dann wird geerbt.");
return;
}
setError(null);
try {
await api.updateCategory(category.id, { open_days: zahl });
onFrist?.();
} catch (err) { setError(err.message); }
}
return (
<div className="card">
<div className="card-head">
<Icon name="split" />
<h2>Maße &amp; Frist: {category.name}</h2>
<button className="btn-icon" onClick={onClose} title="Schließen"
style={{ marginLeft: "auto" }}>
<Icon name="close" size={16} />
</button>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<label>
Nach Öffnung haltbar (Tage)
<input type="number" min="1" step="1" value={frist} disabled={!isAdmin}
placeholder={`erbt ${category.inherited_open_days} Tage (${category.open_days_source})`}
onChange={(e) => setFrist(e.target.value)} onBlur={fristSpeichern} />
</label>
<p className="muted small mt-0">
Gilt für angebrochene Packungen dieser Kategorie und aller darunter.
Leer lassen heißt erben.
</p>
<div className="card-head" style={{ marginTop: "var(--sp-4)" }}>
<Icon name="box" /><h2>Küchenmaße</h2>
</div>
{loading && <p className="muted small">Wird geladen…</p>}
{!loading && masse.length === 0 && (
<p className="muted small mt-0">
Es gibt noch keine Maße. Angelegt werden sie unter Stammdaten ▸ Maße.
</p>
)}
{!loading && masse.map((m) => {
const w = wertFuer(m.id);
return (
<MassZeile key={m.id} mass={m} wert={w} eigen={istEigen(w)} isAdmin={isAdmin}
onSpeichern={(menge, basis) => speichern(m, menge, basis)}
onEntfernen={async () => {
const ok = await confirm({
title: `„${m.name}" hier entfernen?`,
message: "Danach erbt die Kategorie den Wert von oben – oder das Maß "
+ "gilt hier gar nicht mehr.",
confirmLabel: "Entfernen",
});
if (ok) entfernen(m);
}} />
);
})}
<p className="muted small">
„1 EL Mehl ≈ 10 g", „1 Tasse Milch ≈ 200 ml". Damit lässt sich beim
Verbrauchen „2 EL" buchen, statt Gramm zu schätzen. Die Art (Gramm,
Milliliter, Stück) gehört zur Menge, nicht zum Namen: Ein Esslöffel Öl
ist Milliliter, einer Mehl ist Gramm.
</p>
</div>
);
}
/** Eine Maß-Zeile: eigene Menge eintragen, geerbte als Platzhalter zeigen. */
function MassZeile({ mass, wert, eigen, isAdmin, onSpeichern, onEntfernen }) {
const [menge, setMenge] = useState(eigen && wert ? String(wert.amount) : "");
const [basis, setBasis] = useState(wert ? wert.base : "gram");
// Nach dem Neuladen (Speichern, Entfernen) die Felder nachziehen.
useEffect(() => {
setMenge(eigen && wert ? String(wert.amount) : "");
setBasis(wert ? wert.base : "gram");
}, [wert?.amount, wert?.base, eigen]);
const geerbt = wert && !eigen;
return (
<div className="field-inline" style={{ flexWrap: "wrap", alignItems: "flex-end" }}>
<label style={{ margin: 0, minWidth: 110, flex: "0 0 auto" }}>
<span className="muted small">{mass.short ? `${mass.name} (${mass.short})` : mass.name}</span>
<input type="number" step="any" min="0" value={menge} disabled={!isAdmin}
placeholder={geerbt ? `${wert.amount} ${BASIS_KURZ[wert.base] || wert.base}` : "–"}
onChange={(e) => setMenge(e.target.value)}
onBlur={() => { if (menge !== "") onSpeichern(menge, basis); }} />
</label>
<select value={basis} disabled={!isAdmin} style={{ maxWidth: 130, marginTop: 0 }}
onChange={(e) => {
setBasis(e.target.value);
if (menge !== "") onSpeichern(menge, e.target.value);
}}>
{Object.entries(BASIS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
{geerbt && (
<span className="badge nowrap" title={`Geerbt von ${wert.herkunft}`}>geerbt</span>
)}
{eigen && isAdmin && (
<button type="button" className="btn-icon danger" title="Hier entfernen"
onClick={onEntfernen}>
<Icon name="trash" size={16} />
</button>
)}
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
/**
* Hausmaße – Stammdaten.
*
* Hier stehen nur die **Namen** („Esslöffel", „Tasse"). Die Mengen gehören
* nicht hierher, sondern an die Kategorie oder den Artikel: Ein Esslöffel Mehl
* sind etwa 10 g, einer Zucker etwa 12 g, und eine Tasse Milch ist Milliliter.
* Ein globaler Wert wäre bei jedem zweiten Artikel falsch.
*
* Deshalb ist diese Seite absichtlich kurz. Die Arbeit passiert unter
* Kategorien ▸ Maße & Frist.
*/
export default function Masse() {
const confirm = useConfirm();
const [masse, setMasse] = useState([]);
const [form, setForm] = useState({ name: "", short: "" });
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
setMasse(await api.listMeasures());
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createMeasure({
name: form.name.trim(),
short: form.short.trim() || null,
position: masse.length,
});
setForm({ name: "", short: "" });
load();
} catch (err) { setError(err.message); }
}
async function patch(mass, body) {
setError(null);
try {
await api.updateMeasure(mass.id, {
name: body.name ?? mass.name,
short: body.short !== undefined ? body.short : mass.short,
position: mass.position,
});
load();
} catch (err) { setError(err.message); load(); }
}
async function remove(mass) {
const ok = await confirm({
title: `Maß „${mass.name}" löschen?`,
message: "Die bei Kategorien und Artikeln hinterlegten Mengen dieses Maßes "
+ "verschwinden mit. Buchungen, die damit gemacht wurden, bleiben.",
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.deleteMeasure(mass.id);
load();
} catch (err) { setError(err.message); }
}
const columns = [
{ key: "name", header: "Name", grow: true, min: 160,
filterText: (m) => m.name, sortValue: (m) => m.name,
render: (m) => (
<span className="cell-row">
<input defaultValue={m.name} style={{ marginTop: 0, minWidth: 140 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== m.name) patch(m, { name: v });
}} />
{m.is_builtin && <span className="badge nowrap">eingebaut</span>}
</span>
) },
{ key: "short", header: "Kurzform", width: 160,
filterText: (m) => m.short || "",
render: (m) => (
<input defaultValue={m.short || ""} placeholder="z.B. EL"
style={{ marginTop: 0, minWidth: 90 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v !== (m.short || "")) patch(m, { short: v || null });
}} />
) },
{ key: "actions", header: "", label: "Aktionen", fixed: true, width: 56, align: "num",
render: (m) => (
<button className="btn-icon danger" onClick={() => remove(m)} title="Löschen">
<Icon name="trash" size={16} />
</button>
) },
];
return (
<div>
<div className="page-head">
<div>
<h1>Maße</h1>
<div className="sub">
Küchenmaße zum Verbrauchen: „2 EL Mehl", „1 Tasse Milch".
Wie viel das ist, steht je Kategorie bzw. Artikel.
</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2 wide-aside">
<div className="card">
<DataTable id="masse" columns={columns} rows={masse} loading={loading}
getRowKey={(m) => m.id} empty="Noch keine Maße." />
</div>
<div className="stack">
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="box" /><h2>Neues Maß</h2></div>
<label>
Name
<input placeholder="z.B. Schöpfkelle" value={form.name} required
onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<label>
Kurzform (freiwillig)
<input placeholder="z.B. Kelle" value={form.short} maxLength={10}
onChange={(e) => setForm({ ...form, short: e.target.value })} />
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Ein Name darf nicht mit einer echten Einheit zusammenfallen: Ein
Hausmaß namens „Liter" würde die Einheit verdecken, und ein
geschätzter Liter wäre etwas anderes als ein gemessener.
</p>
</form>
<div className="card">
<div className="card-head"><Icon name="alert" /><h2>Wo die Mengen stehen</h2></div>
<p className="muted small mt-0">
Unter <strong>Kategorien</strong> öffnet der Knopf „Maße &amp; Frist"
je Kategorie die Mengen – dort trägst du ein, dass ein Esslöffel bei
Mehl 10 g sind. Unterkategorien erben das; der nächste Vorfahr mit
einem Eintrag gewinnt. Einzelne Artikel können davon abweichen.
</p>
</div>
</div>
</div>
</div>
);
}