diff --git a/backend/app/main.py b/backend/app/main.py index 8fe75f0..a0a6581 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,7 @@ from .routers import ( products, recipes, settings as settings_router, + shopping, shops, stock, transfer, @@ -320,6 +321,9 @@ app.include_router(groups.router) app.include_router(units.router) app.include_router(package_types.router) app.include_router(views.router) +# Nach views.router: Die gerechnete Liste steht dort, die gespeicherten +# Eintraege und Haken hier - gleicher Pfadanfang /shopping-list. +app.include_router(shopping.router) app.include_router(transfer.router) app.include_router(api_tokens.router) app.include_router(settings_router.router) diff --git a/backend/app/models.py b/backend/app/models.py index 13dd75b..a013e2d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -815,6 +815,91 @@ class IntegrationSecret(Base): ) +class ShoppingEntry(Base): + """Ein Eintrag, den jemand bewusst auf die Einkaufsliste gesetzt hat. + + Die Einkaufsliste war bis hierher vollstaendig **gerechnet**: Artikel unter + ihrem Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt + die Staerke der Liste und wird nicht ersetzt. Was fehlte, war ein Ort fuer + "Milch gehoert drauf, weil ich das sage" - und damit auch fuer "diese + Zutaten fehlen mir fuer das Rezept". + + Genau EINES von ``product_id``, ``group_id`` und ``free_text`` ist gesetzt. + Geprueft wird das beim Anlegen (services/einkaufsliste.py), nicht in der + Datenbank: Als CHECK-Bedingung waere es ueber drei nullbare Spalten + unleserlich, und die Fehlermeldung waere keine, die man jemandem zeigen + kann. Der Freitext ist unverzichtbar - fuer eine Rezeptzutat, die hier + keinem Artikel entspricht ("Ras el Hanout"), ist er die einzige Form, in + der sie auf den Zettel kommt. + + ``quantity_base`` steht in Basiseinheiten, wie alle Mengen im Projekt. Bei + einem Freitext bleibt sie leer: "etwas Hefe" hat keine Zahl. + + ``recipe_slug``/``recipe_name`` halten die Herkunft fest. Sie sind mehr als + Zierde: Derselbe Artikel kann fuer zwei Rezepte fehlen, und dann braucht man + ihn zweimal. Je Rezept und Ziel gibt es deshalb hoechstens einen offenen + Eintrag - ein zweiter Druck auf "fehlende Zutaten hinzufuegen" rechnet die + Menge neu, statt die Liste zu verdoppeln. + """ + + __tablename__ = "shopping_entries" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + product_id: Mapped[int | None] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True + ) + group_id: Mapped[int | None] = mapped_column( + ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True + ) + free_text: Mapped[str | None] = mapped_column(String(120), nullable=True) + quantity_base: Mapped[float | None] = mapped_column(Float, nullable=True) + # BaseUnit-Wert (piece/gram/milliliter) als String - wie ``Product + # .secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist. + base: Mapped[str | None] = mapped_column(String(16), nullable=True) + # "manual" | "recipe" + source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") + recipe_slug: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + recipe_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + done_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, nullable=False + ) + + product: Mapped[Product | None] = relationship() + group: Mapped[Group | None] = relationship() + + +class ShoppingCheck(Base): + """Ein abgehakter Posten der GERECHNETEN Einkaufsliste. + + Die gerechneten Vorschlaege haben keine Zeile in der Datenbank - sie + entstehen bei jedem Aufruf neu aus den Mindestbestaenden. Abhaken liess sich + deshalb nur im Browser merken und war nach dem Neuladen weg, mitten im + Supermarkt. + + Gemerkt wird hier nur der Schluessel des Postens, nicht der Posten selbst: + ``p12`` (Artikel), ``g3`` (Gruppe), ``l KUECHE p12`` (Artikel an einem + Lagerort) - dieselben Schluessel, die die Oberflaeche ohnehin bildet. Damit + bleibt die Liste gerechnet, und das Abhaken haengt sich nur daran. Sinkt der + Bestand spaeter wieder, taucht der Posten mit seinem alten Haken auf; das + Aufraeumen erledigt "Erledigte entfernen". + """ + + __tablename__ = "shopping_checks" + + key: Mapped[str] = mapped_column(String(80), primary_key=True) + checked_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, nullable=False + ) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + class MealieFoodMap(Base): """Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y. diff --git a/backend/app/routers/recipes.py b/backend/app/routers/recipes.py index 3932518..c05096b 100644 --- a/backend/app/routers/recipes.py +++ b/backend/app/routers/recipes.py @@ -17,15 +17,17 @@ from ..schemas import ( RecipeDetail, RecipePage, RecipeSummary, + ShoppingFromRecipe, ZielVorschlag, ZuordnungIn, ZuordnungOut, ZutatKandidat, ZutatOut, ) -from ..services import mealie, rezepte, zuordnung +from ..services import einkaufsliste, mealie, rezepte, zuordnung from ..services.conversion import group_min_context from ..services.einkaufsliste_text import fehlmengen_text +from .shopping import eintrag_out from ..services.matching import match_score, normalisieren router = APIRouter(prefix="/recipes", tags=["recipes"]) @@ -229,6 +231,43 @@ def get_recipe( ) +@router.post("/{slug}/to-shopping-list", response_model=ShoppingFromRecipe) +def recipe_to_shopping_list( + slug: str, + servings: float | None = None, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> ShoppingFromRecipe: + """Was für dieses Rezept fehlt, auf die Einkaufsliste setzen. + + Gerechnet wird dabei frisch — mit derselben Portionszahl, die auf dem + Bildschirm steht. Sonst landete die Menge für vier Portionen auf der Liste, + während die Seite zwölf anzeigt. + + Übernommen wird die **Fehlmenge**, nicht die Rezeptmenge: Wer 250 g braucht + und 100 g hat, kauft 150 g. Zutaten ohne Zuordnung kommen als Freitext drauf + — gerade die unbekannte Zutat ist die, die man vergisst. + """ + try: + daten = mealie.rezept(db, slug) + except mealie.MealieFehler as fehler: + raise _fehler(fehler) from fehler + + faktor, _ = rezepte.faktor_fuer_portionen(daten, servings) + zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or [] + pruefungen = rezepte.pruefe_zutaten( + db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db), + ) + eintraege, aktualisiert = einkaufsliste.aus_rezept( + db, pruefungen, slug=slug, name=str(daten.get("name") or slug), user=user, + ) + return ShoppingFromRecipe( + entries=[eintrag_out(db, e) for e in eintraege], + aktualisiert=aktualisiert, + freitext=sum(1 for e in eintraege if e.free_text), + ) + + def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut: return ZutatOut( text=p.text, menge=p.menge, einheit=p.einheit, diff --git a/backend/app/routers/shopping.py b/backend/app/routers/shopping.py new file mode 100644 index 0000000..b86787d --- /dev/null +++ b/backend/app/routers/shopping.py @@ -0,0 +1,150 @@ +"""Die gespeicherte Seite der Einkaufsliste: eigene Einträge und Haken. + +Die gerechnete Liste steht in ``routers/views.py`` und bleibt dort. Dieser +Router verwaltet nur, was jemand selbst draufgesetzt hat — und das Abhaken, das +bisher im Browser verfiel und nach dem Neuladen mitten im Supermarkt weg war. +""" + +# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py. + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user +from ..models import ShoppingEntry, User +from ..schemas import ShoppingEntryIn, ShoppingEntryOut, ShoppingEntryPatch +from ..services import einkaufsliste + +router = APIRouter(prefix="/shopping-list", tags=["shopping"]) + + +def eintrag_out(db: Session, eintrag: ShoppingEntry) -> ShoppingEntryOut: + art = ( + "product" if eintrag.product_id is not None + else "group" if eintrag.group_id is not None + else "text" + ) + return ShoppingEntryOut( + id=eintrag.id, art=art, + product_id=eintrag.product_id, group_id=eintrag.group_id, + free_text=eintrag.free_text, + name=einkaufsliste.bezeichnung(db, eintrag), + quantity_base=eintrag.quantity_base, base=eintrag.base, + menge_text=einkaufsliste.mengen_text(db, eintrag), + source=eintrag.source, recipe_slug=eintrag.recipe_slug, + recipe_name=eintrag.recipe_name, done=eintrag.done, + created_at=eintrag.created_at, + ) + + +@router.get("/entries", response_model=list[ShoppingEntryOut]) +def list_entries( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[ShoppingEntryOut]: + """Die eigenen Einträge, offene zuerst.""" + return [eintrag_out(db, e) for e in einkaufsliste.eintraege(db)] + + +@router.post("/entries", response_model=ShoppingEntryOut, + status_code=status.HTTP_201_CREATED) +def create_entry( + payload: ShoppingEntryIn, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> ShoppingEntryOut: + """Etwas auf die Liste setzen – Artikel, Gruppe oder einfach Text.""" + try: + eintrag = einkaufsliste.anlegen( + db, product_id=payload.product_id, group_id=payload.group_id, + free_text=payload.free_text, quantity_base=payload.quantity_base, + base=(payload.base.value if payload.base is not None else None), + user=user, + ) + except einkaufsliste.EintragFehler as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler + return eintrag_out(db, eintrag) + + +@router.patch("/entries/{entry_id}", response_model=ShoppingEntryOut) +def update_entry( + entry_id: int, + payload: ShoppingEntryPatch, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> ShoppingEntryOut: + """Abhaken oder die Menge korrigieren.""" + eintrag = db.get(ShoppingEntry, entry_id) + if eintrag is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden") + if payload.quantity_base is not None: + eintrag.quantity_base = payload.quantity_base + if payload.free_text is not None: + if eintrag.free_text is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Dieser Eintrag zeigt auf einen Artikel – sein Text lässt sich " + "nicht ändern.", + ) + eintrag.free_text = payload.free_text.strip()[:120] + if payload.done is not None: + einkaufsliste.abhaken(db, eintrag, payload.done) + else: + db.commit() + db.refresh(eintrag) + return eintrag_out(db, eintrag) + + +@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_entry( + entry_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> None: + eintrag = db.get(ShoppingEntry, entry_id) + if eintrag is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden") + einkaufsliste.loeschen(db, eintrag) + + +@router.post("/clear-done", response_model=int) +def clear_done( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> int: + """Nach dem Einkauf aufräumen: alles Abgehakte weg. + + Bewusst ein POST und kein DELETE auf einer Sammlung: Es löscht zwei + verschiedene Dinge (eigene Einträge und Haken an gerechneten Posten), und + das ist eine Handlung, keine Ressource. + """ + return einkaufsliste.erledigte_entfernen(db) + + +@router.put("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT) +def set_check( + key: str, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> None: + """Einen gerechneten Posten abhaken – dauerhaft. + + Der Schlüssel kommt aus der Oberfläche („p12", „g3", „lKUECHEp12"). Der + Server prüft ihn nicht gegen die gerechnete Liste: Sie ändert sich mit jedem + Einlagern, und ein Haken auf einen Posten, der gerade nicht mehr fehlt, + schadet nichts — er verschwindet mit „Erledigte entfernen". + """ + try: + einkaufsliste.haken_setzen(db, key, user) + except einkaufsliste.EintragFehler as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler + + +@router.delete("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT) +def clear_check( + key: str, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> None: + einkaufsliste.haken_loesen(db, key) diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index c30ea4a..5b73b4f 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -42,7 +42,9 @@ from ..services.conversion import ( display_unit_info, group_min_context, ) +from ..services import einkaufsliste from ..services import gruppen as gruppen_graph +from .shopping import eintrag_out from ..services.stock import ( current_stock, descendant_location_ids, @@ -469,13 +471,20 @@ def shopping_list_by_location( def shopping_list_all( db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> ShoppingListAll: - """Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je - Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt. - Erspart drei getrennte Abfragen.""" + """Komplette Einkaufsliste in einem Aufruf: die gerechneten Bedarfe + (Produkte, Gruppen, je Lagerort), die gespeicherten eigenen Einträge und die + dauerhaft gesetzten Haken. Erspart der Oberfläche fünf Abfragen. + + Gerechnet und gespeichert stehen bewusst nebeneinander: Die Bedarfe aus den + Mindestbeständen entstehen bei jedem Aufruf neu, die eigenen Einträge + bleiben liegen, bis sie abgehakt sind. + """ return ShoppingListAll( products=shopping_list(db, user), groups=group_shopping_list(db, user), by_location=shopping_list_by_location(db, user), + entries=[eintrag_out(db, e) for e in einkaufsliste.eintraege(db)], + checked=einkaufsliste.abgehakte(db), ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 16d0e02..417f4de 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1254,12 +1254,61 @@ class LocationNeeds(BaseModel): groups: list[LocationNeedGroup] = [] +# ---- Gespeicherte Einkaufsliste ---- +class ShoppingEntryIn(BaseModel): + """Ein eigener Eintrag. Genau eines von Artikel, Gruppe, Freitext.""" + product_id: int | None = None + group_id: int | None = None + free_text: str | None = Field(default=None, max_length=120) + quantity_base: float | None = Field(default=None, gt=0) + base: BaseUnit | None = None + + +class ShoppingEntryPatch(BaseModel): + """Abhaken oder die Menge korrigieren.""" + done: bool | None = None + quantity_base: float | None = Field(default=None, gt=0) + free_text: str | None = Field(default=None, max_length=120) + + +class ShoppingEntryOut(BaseModel): + """Ein gespeicherter Eintrag, fertig für die Liste.""" + id: int + art: str # product | group | text + product_id: int | None = None + group_id: int | None = None + free_text: str | None = None + name: str = "" + quantity_base: float | None = None + base: BaseUnit | None = None + # „2 Packungen" bzw. „150 g" – dieselbe Sprache wie die gerechneten Posten. + menge_text: str = "" + source: str = "manual" # manual | recipe + recipe_slug: str | None = None + recipe_name: str | None = None + done: bool = False + created_at: datetime + + +class ShoppingFromRecipe(BaseModel): + """Was die Übernahme eines Rezepts bewirkt hat.""" + entries: list[ShoppingEntryOut] = [] + # Wie viele davon schon dastanden und nur neu gerechnet wurden. + aktualisiert: int = 0 + # Zutaten ohne Zuordnung, die als Freitext draufkamen. + freitext: int = 0 + + class ShoppingListAll(BaseModel): - """Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch - die Oberfläche zusammenführt.""" + """Komplette Einkaufsliste in einem Aufruf – die gerechneten Quellen, die + eigenen Einträge und die dauerhaft gesetzten Haken.""" products: list[ShoppingItem] = [] groups: list[GroupShoppingItem] = [] by_location: list[LocationNeeds] = [] + # Eigene und aus Rezepten übernommene Einträge. + entries: list[ShoppingEntryOut] = [] + # Schlüssel der abgehakten GERECHNETEN Posten („p12", „g3", „lKUECHEp12"). + checked: list[str] = [] class LocationContentEntry(BaseModel): diff --git a/backend/app/services/einkaufsliste.py b/backend/app/services/einkaufsliste.py new file mode 100644 index 0000000..2951cf5 --- /dev/null +++ b/backend/app/services/einkaufsliste.py @@ -0,0 +1,287 @@ +"""Die gespeicherte Seite der Einkaufsliste. + +Gerechnet wird die Liste in ``routers/views.py`` – Artikel unter ihrem +Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt so. Hier +liegt daneben das, was jemand **bewusst** draufgesetzt hat: eigene Einträge, +übernommene Rezeptzutaten, und das dauerhafte Abhaken der gerechneten +Vorschläge. + +Beides getrennt zu halten ist Absicht. Die gerechneten Posten dürfen keine +Zeilen in der Datenbank bekommen: Sie ändern sich mit jedem Einlagern, und eine +materialisierte Kopie würde sofort von der Wirklichkeit abweichen. Was gemerkt +wird, ist nur der Haken daran. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from ..models import ( + Group, + Product, + ShoppingCheck, + ShoppingEntry, + User, +) +from .einkaufsliste_text import fehlmengen_text + + +class EintragFehler(ValueError): + """Unbrauchbarer Eintrag – der Router macht daraus eine 400.""" + + +def _jetzt() -> datetime: + return datetime.now(timezone.utc) + + +# -------------------------------------------------------------------------- +# Eigene Einträge +# -------------------------------------------------------------------------- +def eintraege(db: Session, *, mit_erledigten: bool = True) -> list[ShoppingEntry]: + """Alle gespeicherten Einträge, offene zuerst. + + Zeigt ein Eintrag auf einen Artikel oder eine Gruppe, die es nicht mehr + gibt, fällt er hier still heraus: Auf SQLite greift kein Fremdschlüssel, und + einen gelöschten Artikel kann man nicht einkaufen. + """ + reihen = db.query(ShoppingEntry).order_by( + ShoppingEntry.done, ShoppingEntry.created_at + ).all() + gueltig = [e for e in reihen if _ziel_existiert(db, e)] + if mit_erledigten: + return gueltig + return [e for e in gueltig if not e.done] + + +def _ziel_existiert(db: Session, eintrag: ShoppingEntry) -> bool: + if eintrag.product_id is not None: + return db.get(Product, eintrag.product_id) is not None + if eintrag.group_id is not None: + return db.get(Group, eintrag.group_id) is not None + return bool(eintrag.free_text) + + +def bezeichnung(db: Session, eintrag: ShoppingEntry) -> str: + """Was auf dem Zettel steht.""" + if eintrag.product_id is not None: + produkt = db.get(Product, eintrag.product_id) + return produkt.name if produkt is not None else "" + if eintrag.group_id is not None: + gruppe = db.get(Group, eintrag.group_id) + return gruppe.name if gruppe is not None else "" + return eintrag.free_text or "" + + +@dataclass +class _Menge: + """Die Form, die ``fehlmengen_text`` erwartet. + + Umweg mit Absicht: So formatiert die Einkaufsliste ihre Mengen mit genau + derselben Funktion wie der Rezeptabgleich („2 Packungen" statt „1000 ml", + aufgerundet auf ganze Gebinde). Zwei Formatierungen wuerden auseinander + laufen. + """ + + art: str + ziel_id: int | None + fehlt_base: float | None + basis_einheit: str + + +def mengen_text(db: Session, eintrag: ShoppingEntry) -> str: + """„2 Packungen" bzw. „150 g" – leer, wenn keine Menge dransteht.""" + if not eintrag.quantity_base: + return "" + art = "product" if eintrag.product_id is not None else "group" + return fehlmengen_text(db, _Menge( + art=art, ziel_id=eintrag.product_id or eintrag.group_id, + fehlt_base=eintrag.quantity_base, basis_einheit=eintrag.base or "", + )) + + +def anlegen( + db: Session, + *, + product_id: int | None = None, + group_id: int | None = None, + free_text: str | None = None, + quantity_base: float | None = None, + base: str | None = None, + source: str = "manual", + recipe_slug: str | None = None, + recipe_name: str | None = None, + user: User | None = None, +) -> ShoppingEntry: + """Einen Eintrag anlegen. Genau ein Ziel, und es muss existieren.""" + text = (free_text or "").strip() or None + ziele = [z for z in (product_id, group_id, text) if z is not None] + if len(ziele) != 1: + raise EintragFehler( + "Ein Eintrag zeigt auf genau eines: einen Artikel, eine Gruppe oder " + "einen freien Text." + ) + if product_id is not None and db.get(Product, product_id) is None: + raise EintragFehler("Diesen Artikel gibt es nicht.") + if group_id is not None and db.get(Group, group_id) is None: + raise EintragFehler("Diese Gruppe gibt es nicht.") + if quantity_base is not None and quantity_base <= 0: + raise EintragFehler("Eine Menge muss größer als 0 sein.") + + eintrag = ShoppingEntry( + product_id=product_id, group_id=group_id, free_text=text, + quantity_base=quantity_base, base=base, source=source, + recipe_slug=recipe_slug, recipe_name=recipe_name, + created_by=(user.id if user is not None else None), + ) + db.add(eintrag) + db.commit() + db.refresh(eintrag) + return eintrag + + +def abhaken(db: Session, eintrag: ShoppingEntry, done: bool) -> ShoppingEntry: + eintrag.done = done + eintrag.done_at = _jetzt() if done else None + db.commit() + db.refresh(eintrag) + return eintrag + + +def loeschen(db: Session, eintrag: ShoppingEntry) -> None: + db.delete(eintrag) + db.commit() + + +def erledigte_entfernen(db: Session) -> int: + """Alles Abgehakte weg – eigene Einträge und die Haken an den gerechneten. + + Ein Durchgang nach dem Einkauf. Bewusst beides zusammen: Für den Nutzer ist + es eine Liste, auch wenn sie aus zwei Quellen kommt. + """ + anzahl = 0 + for eintrag in db.query(ShoppingEntry).filter(ShoppingEntry.done.is_(True)).all(): + db.delete(eintrag) + anzahl += 1 + for haken in db.query(ShoppingCheck).all(): + db.delete(haken) + anzahl += 1 + db.commit() + return anzahl + + +# -------------------------------------------------------------------------- +# Haken an den gerechneten Posten +# -------------------------------------------------------------------------- +def abgehakte(db: Session) -> list[str]: + return [h.key for h in db.query(ShoppingCheck).all()] + + +def haken_setzen(db: Session, key: str, user: User | None = None) -> None: + sauber = (key or "").strip()[:80] + if not sauber: + raise EintragFehler("Ohne Kennung lässt sich kein Haken merken.") + if db.get(ShoppingCheck, sauber) is None: + db.add(ShoppingCheck( + key=sauber, user_id=(user.id if user is not None else None), + )) + db.commit() + + +def haken_loesen(db: Session, key: str) -> None: + haken = db.get(ShoppingCheck, (key or "").strip()[:80]) + if haken is not None: + db.delete(haken) + db.commit() + + +# -------------------------------------------------------------------------- +# Aus einem Rezept übernehmen +# -------------------------------------------------------------------------- +def aus_rezept( + db: Session, + pruefungen: list, + *, + slug: str, + name: str, + user: User | None = None, +) -> tuple[list[ShoppingEntry], int]: + """Was für dieses Rezept fehlt, auf die Liste setzen. + + Übernommen wird, was fehlt oder zu wenig ist — mit der **Fehlmenge**, nicht + der Rezeptmenge: Wer 250 g braucht und 100 g hat, kauft 150 g. + + Zutaten ohne Zuordnung kommen als Freitext drauf. Das ist der wichtigere + Teil, als es klingt: Gerade die unbekannte Zutat ist die, die man vergisst. + + Je Rezept und Ziel gibt es höchstens einen offenen Eintrag. Ein zweiter + Druck auf den Knopf — etwa nach dem Hochdrehen der Portionen — rechnet die + Menge neu, statt die Liste zu verdoppeln. Über verschiedene Rezepte hinweg + bleiben es getrennte Zeilen, denn derselbe Artikel kann zweimal fehlen. + + Rückgabe: die betroffenen Einträge und wie viele davon aktualisiert (statt + neu angelegt) wurden. + """ + betroffen: list[ShoppingEntry] = [] + aktualisiert = 0 + + for p in pruefungen: + if p.status in ("fehlt", "zu_wenig"): + menge = p.fehlt_base if p.fehlt_base else None + vorhanden = _offener_eintrag( + db, slug, product_id=(p.ziel_id if p.art == "product" else None), + group_id=(p.ziel_id if p.art == "group" else None), + ) + if vorhanden is not None: + vorhanden.quantity_base = menge + vorhanden.base = p.basis_einheit or None + aktualisiert += 1 + betroffen.append(vorhanden) + continue + betroffen.append(anlegen( + db, + product_id=(p.ziel_id if p.art == "product" else None), + group_id=(p.ziel_id if p.art == "group" else None), + quantity_base=menge, base=p.basis_einheit or None, + source="recipe", recipe_slug=slug, recipe_name=name, user=user, + )) + elif p.status == "kein_treffer": + text = (p.food or p.text or "").strip()[:120] + if not text: + continue + vorhanden = _offener_eintrag(db, slug, free_text=text) + if vorhanden is not None: + aktualisiert += 1 + betroffen.append(vorhanden) + continue + betroffen.append(anlegen( + db, free_text=text, source="recipe", + recipe_slug=slug, recipe_name=name, user=user, + )) + + db.commit() + return betroffen, aktualisiert + + +def _offener_eintrag( + db: Session, + slug: str, + *, + product_id: int | None = None, + group_id: int | None = None, + free_text: str | None = None, +) -> ShoppingEntry | None: + """Ein noch nicht abgehakter Eintrag dieses Rezepts für dasselbe Ziel.""" + frage = db.query(ShoppingEntry).filter( + ShoppingEntry.recipe_slug == slug, + ShoppingEntry.done.is_(False), + ) + if product_id is not None: + frage = frage.filter(ShoppingEntry.product_id == product_id) + elif group_id is not None: + frage = frage.filter(ShoppingEntry.group_id == group_id) + else: + frage = frage.filter(ShoppingEntry.free_text == free_text) + return frage.first() diff --git a/backend/tests/test_einkaufsliste_eintraege.py b/backend/tests/test_einkaufsliste_eintraege.py new file mode 100644 index 0000000..a611f5a --- /dev/null +++ b/backend/tests/test_einkaufsliste_eintraege.py @@ -0,0 +1,290 @@ +"""Die Einkaufsliste bekommt ein Gedächtnis: eigene Einträge, Haken, Rezepte. + +Bisher war die Liste vollständig gerechnet. Was hier geprüft wird, ist das +Daneben: Dinge, die jemand selbst draufgesetzt hat, das dauerhafte Abhaken, und +die Übernahme der fehlenden Zutaten eines Rezepts — ohne Mealie, die Zutaten +stehen direkt im Test. +""" + +import pytest +from fastapi import HTTPException + +from app.models import ( + BaseUnit, + Category, + CategoryTracking, + Product, + ProductLocationMinStock, + Role, + ShoppingCheck, + ShoppingEntry, + User, +) +from app.routers.shopping import ( + clear_check, + clear_done, + create_entry, + delete_entry, + list_entries, + set_check, + update_entry, +) +from app.routers.views import shopping_list_all +from app.schemas import ShoppingEntryIn, ShoppingEntryPatch +from app.services import einkaufsliste +from app.services.rezepte import pruefe_zutaten +from app.services.stock import check_in + + +@pytest.fixture() +def nutzer(db): + person = User(username="einkaeufer", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +def _zutat(name, menge=None, einheit=None): + return { + "quantity": menge, + "unit": {"name": einheit, "abbreviation": einheit} if einheit else None, + "food": {"name": name}, + "display": f"{menge or ''} {einheit or ''} {name}".strip(), + } + + +def _kategorie(db, name="Vorrat"): + cat = Category(name=name, tracking=CategoryTracking.food.value) + db.add(cat) + db.commit() + db.refresh(cat) + return cat + + +def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0): + produkt = Product(name=name, base_unit=base, package_size=package_size, + category_id=cat.id, source="manual") + db.add(produkt) + db.commit() + db.refresh(produkt) + if bestand: + check_in(db, produkt, bestand, base.value, None, None, None) + db.commit() + return produkt + + +def _uebernehmen(db, zutaten, nutzer, *, slug="pfannkuchen", name="Pfannkuchen"): + """Wie der Endpunkt, nur ohne Mealie davor.""" + pruefungen = pruefe_zutaten(db, zutaten) + return einkaufsliste.aus_rezept(db, pruefungen, slug=slug, name=name, user=nutzer) + + +# -------------------------------------------------------------------------- +# Eigene Einträge +# -------------------------------------------------------------------------- +def test_freitext_landet_auf_der_liste(db, nutzer): + """Der Fall, für den es bisher keinen Ort gab: 'Milch, weil ich das sage'.""" + create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer) + + [eintrag] = list_entries(db=db, _=nutzer) + + assert eintrag.art == "text" + assert eintrag.name == "Backpapier" + assert eintrag.done is False + + +def test_artikel_eintrag_nennt_die_menge_lesbar(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + + eintrag = create_entry( + ShoppingEntryIn(product_id=mehl.id, quantity_base=1500, base=BaseUnit.gram), + db=db, user=nutzer, + ) + + assert eintrag.name == "Weissmehl" + # Man kauft keine halbe Packung - dieselbe Sprache wie die gerechnete Liste. + assert eintrag.menge_text == "2 Packungen" + + +def test_genau_ein_ziel_ist_pflicht(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + + with pytest.raises(HTTPException) as leer: + create_entry(ShoppingEntryIn(), db=db, user=nutzer) + assert leer.value.status_code == 400 + + with pytest.raises(HTTPException) as doppelt: + create_entry( + ShoppingEntryIn(product_id=mehl.id, free_text="Mehl"), db=db, user=nutzer, + ) + assert doppelt.value.status_code == 400 + assert db.query(ShoppingEntry).count() == 0 + + +def test_abhaken_haelt_das_neuladen_aus(db, nutzer): + """Der eigentliche Punkt: Im Supermarkt wird die Seite neu geladen.""" + eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer) + + update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer) + + [frisch] = list_entries(db=db, _=nutzer) + assert frisch.done is True + assert db.get(ShoppingEntry, eintrag.id).done_at is not None + + +def test_text_eines_artikeleintrags_laesst_sich_nicht_umschreiben(db, nutzer): + """Sonst stünde im Eintrag ein Name, der nichts mit dem Artikel zu tun hat.""" + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + eintrag = create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer) + + with pytest.raises(HTTPException) as fehler: + update_entry(eintrag.id, ShoppingEntryPatch(free_text="Zucker"), db=db, _=nutzer) + + assert fehler.value.status_code == 400 + + +def test_eintrag_auf_einen_geloeschten_artikel_faellt_heraus(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer) + + db.delete(mehl) + db.commit() + + assert list_entries(db=db, _=nutzer) == [] + + +def test_loeschen_entfernt_den_eintrag(db, nutzer): + eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer) + + delete_entry(eintrag.id, db=db, _=nutzer) + + assert db.query(ShoppingEntry).count() == 0 + + +# -------------------------------------------------------------------------- +# Haken an den gerechneten Posten +# -------------------------------------------------------------------------- +def test_haken_am_gerechneten_posten_bleibt(db, nutzer): + """Gerechnete Posten haben keine Zeile - gemerkt wird nur der Haken.""" + set_check("p12", db=db, user=nutzer) + + assert einkaufsliste.abgehakte(db) == ["p12"] + + clear_check("p12", db=db, _=nutzer) + assert einkaufsliste.abgehakte(db) == [] + + +def test_derselbe_haken_zweimal_ist_kein_fehler(db, nutzer): + set_check("g3", db=db, user=nutzer) + set_check("g3", db=db, user=nutzer) + + assert db.query(ShoppingCheck).count() == 1 + + +def test_erledigte_entfernen_raeumt_beide_quellen(db, nutzer): + eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer) + update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer) + offen = create_entry(ShoppingEntryIn(free_text="Salz"), db=db, user=nutzer) + set_check("p12", db=db, user=nutzer) + + anzahl = clear_done(db=db, _=nutzer) + + assert anzahl == 2 + assert [e.id for e in list_entries(db=db, _=nutzer)] == [offen.id] + assert einkaufsliste.abgehakte(db) == [] + + +def test_die_gebuendelte_liste_fuehrt_beides(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat, bestand=200) + db.add(ProductLocationMinStock(product_id=mehl.id, location_id=None, min_stock=1000)) + db.commit() + create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer) + set_check(f"p{mehl.id}", db=db, user=nutzer) + + alles = shopping_list_all(db=db, user=nutzer) + + assert [i.name for i in alles.products] == ["Weissmehl"] + assert [e.name for e in alles.entries] == ["Backpapier"] + assert alles.checked == [f"p{mehl.id}"] + + +# -------------------------------------------------------------------------- +# Aus einem Rezept übernehmen +# -------------------------------------------------------------------------- +def test_uebernommen_wird_die_fehlmenge_nicht_die_rezeptmenge(db, nutzer): + """Wer 250 g braucht und 100 g hat, kauft 150 g.""" + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=100) + + eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + + assert aktualisiert == 0 + [eintrag] = eintraege + assert eintrag.quantity_base == 150 + assert eintrag.source == "recipe" + assert eintrag.recipe_name == "Pfannkuchen" + + +def test_vorhandene_zutaten_kommen_nicht_drauf(db, nutzer): + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=5000) + + eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + + assert eintraege == [] + assert db.query(ShoppingEntry).count() == 0 + + +def test_zutat_ohne_zuordnung_kommt_als_freitext_drauf(db, nutzer): + """Gerade die unbekannte Zutat ist die, die man vergisst.""" + _kategorie(db) + + eintraege, _ = _uebernehmen(db, [_zutat("Ras el Hanout", 1, "Prise")], nutzer) + + [eintrag] = eintraege + assert eintrag.free_text == "Ras el Hanout" + assert eintrag.product_id is None + + +def test_zweimal_uebernehmen_verdoppelt_die_liste_nicht(db, nutzer): + """Nach dem Hochdrehen der Portionen drückt man den Knopf noch einmal.""" + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=100) + + _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 500, "g")], nutzer) + + assert aktualisiert == 1 + assert db.query(ShoppingEntry).count() == 1 + assert eintraege[0].quantity_base == 400 # 500 gebraucht, 100 da + + +def test_zwei_rezepte_brauchen_denselben_artikel_zweimal(db, nutzer): + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=0) + + _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + _uebernehmen(db, [_zutat("Weissmehl", 300, "g")], nutzer, + slug="brot", name="Brot") + + zeilen = db.query(ShoppingEntry).all() + assert len(zeilen) == 2 + assert {z.recipe_name for z in zeilen} == {"Pfannkuchen", "Brot"} + + +def test_ein_abgehakter_eintrag_blockiert_die_neue_uebernahme_nicht(db, nutzer): + """Abgehakt heisst gekauft – braucht man es wieder, ist es eine neue Zeile.""" + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=0) + eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + einkaufsliste.abhaken(db, db.get(ShoppingEntry, eintraege[0].id), True) + + _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + + assert db.query(ShoppingEntry).count() == 2 diff --git a/web/src/api.js b/web/src/api.js index e72fe91..cd878bb 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -307,6 +307,21 @@ export const api = { // Views shoppingList: () => request("/shopping-list"), + // Alles in einem Aufruf: die gerechneten Bedarfe, die gespeicherten Eintraege + // und die dauerhaft gesetzten Haken. + shoppingAll: () => request("/shopping-list/all"), + addShoppingEntry: (body) => request("/shopping-list/entries", { method: "POST", body }), + updateShoppingEntry: (id, body) => + request(`/shopping-list/entries/${id}`, { method: "PATCH", body }), + deleteShoppingEntry: (id) => + request(`/shopping-list/entries/${id}`, { method: "DELETE" }), + clearDoneShopping: () => request("/shopping-list/clear-done", { method: "POST" }), + // Haken an einem GERECHNETEN Posten. Der Schluessel kommt von hier ("p12", + // "g3", "lKUECHEp12") - der Server haengt ihn nur daran. + setShoppingCheck: (key) => + request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "PUT" }), + clearShoppingCheck: (key) => + request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "DELETE" }), groupShoppingList: () => request("/shopping-list/groups"), shoppingByLocation: () => request("/shopping-list/by-location"), expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`), @@ -475,6 +490,12 @@ export const api = { zuordnungsZiele: (q) => request(`/recipes/zuordnungen/ziele${q ? `?q=${encodeURIComponent(q)}` : ""}`), setzeZuordnung: (body) => request("/recipes/zuordnungen", { method: "PUT", body }), + // Fehlende Zutaten uebernehmen - mit der Portionszahl, die auf dem Schirm steht. + rezeptAufEinkaufsliste: (slug, servings) => + request( + `/recipes/${encodeURIComponent(slug)}/to-shopping-list${servings ? `?servings=${servings}` : ""}`, + { method: "POST" }, + ), loescheZuordnung: (food) => request(`/recipes/zuordnungen?food=${encodeURIComponent(food)}`, { method: "DELETE" }), listSettings: () => request("/settings"), diff --git a/web/src/pages/Rezepte.jsx b/web/src/pages/Rezepte.jsx index a6e5429..0e0367e 100644 --- a/web/src/pages/Rezepte.jsx +++ b/web/src/pages/Rezepte.jsx @@ -3,6 +3,7 @@ import { api } from "../api"; import Icon from "../components/Icon"; import { Skeleton } from "../components/Skeleton"; import ZuordnungDialog from "../components/ZuordnungDialog"; +import { useToast } from "../toast"; import { fmt } from "../units"; /** @@ -53,6 +54,8 @@ export default function Rezepte() { // Antworten in falscher Reihenfolge eintreffen - nur die neueste zaehlt. const anfrage = useRef(0); const tippTimer = useRef(null); + const [uebernimmt, setUebernimmt] = useState(false); + const toast = useToast(); async function ladeListe(q = "") { setLaedt(true); @@ -114,6 +117,32 @@ export default function Rezepte() { const fehlende = (rezept?.ingredients || []).filter( (z) => z.status === "fehlt" || z.status === "zu_wenig", ); + // Auch die nicht zugeordneten Zutaten gehoeren auf den Zettel - gerade die + // unbekannte Zutat ist die, die man vergisst. + const offene = (rezept?.ingredients || []).filter( + (z) => z.status === "fehlt" || z.status === "zu_wenig" || z.status === "kein_treffer", + ); + + async function aufEinkaufsliste() { + if (!slug || uebernimmt) return; + setUebernimmt(true); + try { + const zahl = Number(String(portionen).replace(",", ".")); + const antwort = await api.rezeptAufEinkaufsliste( + slug, Number.isFinite(zahl) && zahl > 0 ? zahl : undefined, + ); + const n = antwort.entries.length; + const teile = [`${n} Posten auf der Einkaufsliste`]; + if (antwort.aktualisiert) teile.push(`${antwort.aktualisiert} davon neu gerechnet`); + if (antwort.freitext) teile.push(`${antwort.freitext} als Notiz ohne Artikel`); + toast(n === 0 ? "Es fehlt nichts – nichts hinzugefügt." : `${teile.join(", ")}.`); + setFehler(null); + } catch (err) { + setFehler(err.message); + } finally { + setUebernimmt(false); + } + } return (
@@ -260,11 +289,19 @@ export default function Rezepte() {
- {!rezept.kochbar && ( -

- Was fehlt, steht oben rot. Der Knopf „Fehlende zur Einkaufsliste" - kommt, sobald die Einkaufsliste eigene Einträge speichern kann. -

+ {offene.length > 0 && ( +
+ + + {offene.length === 1 ? "1 Posten" : `${offene.length} Posten`} – mit der + Fehlmenge für {fmt(Number(String(portionen).replace(",", ".")) || rezept.base_servings || 1)} Portionen. + Nicht zugeordnete Zutaten kommen als Notiz drauf. + +
)} )} diff --git a/web/src/pages/ShoppingList.jsx b/web/src/pages/ShoppingList.jsx index 0680ed8..c6253b6 100644 --- a/web/src/pages/ShoppingList.jsx +++ b/web/src/pages/ShoppingList.jsx @@ -2,25 +2,97 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; import ShoppingNeedText from "../components/ShoppingNeedText"; +import { useToast } from "../toast"; import { anzahlWort } from "../units"; +/** + * Einkaufsliste aus zwei Quellen. + * + * **Gerechnet**: Artikel und Gruppen unter ihrem Mindestbestand, Bedarfe je + * Lagerort. Diese Posten haben keine Zeile in der Datenbank — sie entstehen bei + * jedem Aufruf neu. Gemerkt wird nur der Haken daran, sonst wäre er nach dem + * Neuladen weg, mitten im Supermarkt. + * + * **Gespeichert**: was jemand selbst draufgesetzt hat, und die fehlenden + * Zutaten eines Rezepts. Die bleiben liegen, bis sie abgehakt sind. + */ export default function ShoppingList() { const [items, setItems] = useState([]); const [groups, setGroups] = useState([]); const [byLocation, setByLocation] = useState([]); + const [entries, setEntries] = useState([]); const [checked, setChecked] = useState({}); + const [neu, setNeu] = useState(""); const [error, setError] = useState(null); + const toast = useToast(); - useEffect(() => { - Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()]) - .then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); }) - .catch((e) => setError(e.message)); - }, []); + async function laden() { + try { + const alles = await api.shoppingAll(); + setItems(alles.products || []); + setGroups(alles.groups || []); + setByLocation(alles.by_location || []); + setEntries(alles.entries || []); + setChecked(Object.fromEntries((alles.checked || []).map((k) => [k, true]))); + setError(null); + } catch (e) { setError(e.message); } + } + useEffect(() => { laden(); }, []); + // Bezieht sich nur auf die GERECHNETEN Posten: Die eigenen Eintraege stehen in + // ihrer eigenen Karte. Zaehlte man sie hier mit, zeigte die untere Karte eine + // leere Liste statt "alles erreicht". const empty = items.length === 0 && groups.length === 0; + const erledigt = entries.filter((e) => e.done).length + + Object.values(checked).filter(Boolean).length; - function toggle(key, val) { + // Der Haken wandert sofort und wird im Hintergrund gespeichert: Im Laden soll + // niemand auf eine Antwort warten. Schlaegt es fehl, springt er zurueck. + async function toggle(key, val) { setChecked((c) => ({ ...c, [key]: val })); + try { + if (val) await api.setShoppingCheck(key); + else await api.clearShoppingCheck(key); + } catch (e) { + setChecked((c) => ({ ...c, [key]: !val })); + setError(e.message); + } + } + + async function eintragAbhaken(eintrag, val) { + setEntries((es) => es.map((e) => (e.id === eintrag.id ? { ...e, done: val } : e))); + try { + await api.updateShoppingEntry(eintrag.id, { done: val }); + } catch (e) { + setEntries((es) => es.map((x) => (x.id === eintrag.id ? { ...x, done: !val } : x))); + setError(e.message); + } + } + + async function eintragLoeschen(eintrag) { + try { + await api.deleteShoppingEntry(eintrag.id); + setEntries((es) => es.filter((e) => e.id !== eintrag.id)); + } catch (e) { setError(e.message); } + } + + async function hinzufuegen(ev) { + ev.preventDefault(); + const text = neu.trim(); + if (!text) return; + try { + const eintrag = await api.addShoppingEntry({ free_text: text }); + setEntries((es) => [...es, eintrag]); + setNeu(""); + } catch (e) { setError(e.message); } + } + + async function erledigteEntfernen() { + try { + const n = await api.clearDoneShopping(); + await laden(); + toast(n > 0 ? `${n} erledigte Posten entfernt.` : "Nichts abgehakt."); + } catch (e) { setError(e.message); } } return ( @@ -28,7 +100,9 @@ export default function ShoppingList() {

Einkaufsliste

-
Produkte und Gruppen unter ihrem Mindestbestand
+
+ Eigene Einträge und was unter dem Mindestbestand liegt +
{error &&
{error}
} @@ -37,6 +111,54 @@ export default function ShoppingList() {
Überall (egal wo)
)} + {/* Dieser Abschnitt steht auch leer da - er ist das Eingabefeld fuer + eigene Eintraege und muss deshalb auffindbar sein. */} +
+

Selbst draufgesetzt

+
+ setNeu(e.target.value)} /> + +
+ {entries.length === 0 ? ( +

+ Hier stehen eigene Einträge und die fehlenden Zutaten, die du aus + einem Rezept übernimmst. Anders als die gerechneten Posten unten + bleiben sie liegen, bis du sie abhakst. +

+ ) : ( + + )} +
+
{empty ? (
Alle Mindestbestände erreicht.
@@ -126,9 +248,16 @@ export default function ShoppingList() {
))} -

- Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert. -

+
+ + + Das Abhaken wird gespeichert – ein Neuladen im Laden verliert nichts. + +

Bedarfe sind gegeneinander verrechnet: Was du für einen Lagerort oder eine Untergruppe kaufst, deckt „Überall“ bzw. die Obergruppe mit ab. Hängt