diff --git a/README.md b/README.md index b6987be..d43dd73 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,16 @@ und einen eigenen Einkaufszettel. Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste. - Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“ (für alle sichtbar). +- **Mindestbestand** („⚑ Mindestbestand“ im Vorrat): pro Produkt eine Untergrenze, z. B. „Milch: immer 2“. + Fällt der Vorrat darunter (weniger, aufgebraucht, gelöscht), kommt genau die fehlende Menge auf die + Einkaufsliste. Die Zeile im Vorrat zeigt „min 2“, darunter hervorgehoben. +- **Nach Laden sortieren:** Die Einkaufsliste lässt sich nach Abteilungen gruppieren (Obst & Gemüse, + Kühlregal …). Neue Produkte bekommen ihre Kategorie automatisch (Stichwörter, `backend/app/kategorien.py`), + ändern lässt sie sich direkt in der Zeile oder unter „Vorschläge verwalten“. „⇅ Reihenfolge“ legt pro + Liste fest, in welcher Reihenfolge man durch seinen Laden geht. +- **Rezeptsammlung:** Ein abgeglichenes Rezept lässt sich mit „☆ Merken“ speichern (für alle Konten). + „Was kann ich kochen?“ zeigt die gemerkten Rezepte, sortiert danach, wie viele Zutaten im Vorrat der + gewählten Liste sind, und was noch fehlt; ein Klick gleicht das Rezept vollständig ab. - **Vorschläge** beim Eintippen und die Zuordnung beim Rezeptabgleich kommen nur aus Listen, die man sehen darf – Einträge privater Listen tauchen bei anderen nicht auf. „Vorschläge verwalten“ löscht nur aus den eigenen sichtbaren Listen. @@ -255,7 +265,8 @@ backend/ app/links.py Schnellzugriff-Links pro Konto app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf) app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten - app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen) + app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen), Rezeptsammlung + app/kategorien.py Laden-Kategorien und automatische Zuordnung neuer Produkte app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen, Archiv + automatisches Löschen app/tracking/ Paketdienst-Erkennung, 17TRACK, DHL diff --git a/backend/app/db.py b/backend/app/db.py index 57ed180..c89baf7 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -127,7 +127,8 @@ CREATE TABLE IF NOT EXISTS shopping_lists ( everyone INTEGER NOT NULL DEFAULT 1, position REAL NOT NULL, created_at TEXT NOT NULL, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + category_order TEXT -- JSON-Liste der Kategorien in Laden-Reihenfolge; NULL = Standard ); CREATE TABLE IF NOT EXISTS shopping_list_members ( @@ -143,7 +144,29 @@ CREATE TABLE IF NOT EXISTS products ( key TEXT PRIMARY KEY, name TEXT NOT NULL, uses INTEGER NOT NULL DEFAULT 0, - last_used TEXT NOT NULL + last_used TEXT NOT NULL, + category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet +); + +-- Mindestbestand: fällt der Vorrat einer Liste darunter, kommt das Produkt auf ihre Einkaufsliste +CREATE TABLE IF NOT EXISTS stock_minimums ( + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + key TEXT NOT NULL, + name TEXT NOT NULL, + unit TEXT NOT NULL CHECK (unit IN ('stk', 'kg', 'l')), + minimum REAL NOT NULL CHECK (minimum > 0), + PRIMARY KEY (list_id, key, unit) +); + +-- Gemerkte Rezepte (gemeinsam für alle Konten): Zutatenzeilen wie im Rezept, Abgleich passiert beim Abruf +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + source TEXT, + servings INTEGER, + lines TEXT NOT NULL, -- JSON-Liste der Zutatenzeilen + created_at TEXT NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL ); -- Welche Produkte in welcher Liste vorkamen: Vorschläge sieht man nur aus Listen, die man sehen darf diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index 0d8f756..f281595 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -6,6 +6,7 @@ Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte (Anzahl, kg, l) nur einmal; erneutes Hinzufügen in derselben Einheit erhöht die Menge. Neue Produktnamen landen im Katalog und werden als Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch". """ +import json import math from fastapi import APIRouter, Body, Depends, HTTPException @@ -13,6 +14,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user from .common import ValidationError, clean_text, now_iso, user_list from .db import transaction +from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess router = APIRouter() @@ -34,6 +36,7 @@ def _item(row) -> dict: "quantity": _out_quantity(row["quantity"], row["unit"]), "unit": row["unit"], "checked": bool(row["checked"]), + "category": row["category"] or guess(row["name"]), "updatedAt": row["updated_at"], "updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None, } @@ -93,7 +96,10 @@ def _canonical_name(conn, name: str, list_id=None) -> str: raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.") # Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen name = name[0].upper() + name[1:] - conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso())) + conn.execute( + "INSERT INTO products (key, name, uses, last_used, category) VALUES (?, ?, 1, ?, ?)", + (_key(name), name, now_iso(), guess(name)), + ) if list_id is not None: conn.execute( """INSERT INTO list_products (list_id, key, uses, last_used) VALUES (?, ?, 1, ?) @@ -103,16 +109,20 @@ def _canonical_name(conn, name: str, list_id=None) -> str: return name -def visible_products(conn, user: dict) -> list: +def _visible_product_rows(conn, user: dict) -> list: """Produkte aus den Listen, die das Konto sehen darf – häufig genutzte zuerst.""" rows = conn.execute( - f"""SELECT p.name, SUM(lp.uses) AS uses FROM list_products lp + f"""SELECT p.name, p.category, SUM(lp.uses) AS uses FROM list_products lp JOIN products p ON p.key = lp.key JOIN shopping_lists l ON l.id = lp.list_id WHERE {_VISIBLE} GROUP BY p.key""", _who(user), ).fetchall() - return [r["name"] for r in sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))] + return sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"]))) + + +def visible_products(conn, user: dict) -> list: + return [r["name"] for r in _visible_product_rows(conn, user)] def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None: @@ -176,9 +186,19 @@ def _shopping_list(conn, row, user: dict) -> dict: "members": members, "createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None, "canManage": _can_manage(row, user), + "categoryOrder": _category_order(row["category_order"]), } +def _category_order(value) -> list: + """Gespeicherte Reihenfolge, ergänzt um neu hinzugekommene Kategorien (am Ende).""" + try: + order = [k for k in json.loads(value) if k in CATEGORY_KEYS] if value else [] + except ValueError: + order = [] + return order + [k for k in CATEGORY_KEYS if k not in order] + + def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple: """Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern.""" name = clean_text(data.get("name"), "Name der Liste", MAX_LIST_NAME) @@ -212,20 +232,53 @@ def _get_item(conn, item_id: int, user: dict): def _state(conn, user: dict) -> dict: lists = _visible_lists(conn, user) ids = [r["id"] for r in lists] + marks = ",".join("?" * len(ids)) items = conn.execute( - f"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i + f"""SELECT i.*, u.display_name AS updated_by_name, p.category FROM household_items i LEFT JOIN users u ON u.id = i.updated_by - WHERE i.list_id IN ({','.join('?' * len(ids))})""", + LEFT JOIN products p ON p.key = i.key + WHERE i.list_id IN ({marks})""", ids, ).fetchall() + minimums = conn.execute(f"SELECT * FROM stock_minimums WHERE list_id IN ({marks}) ORDER BY name", ids).fetchall() + products = _visible_product_rows(conn, user) return { "items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))], "lists": [_shopping_list(conn, r, user) for r in lists], "users": user_list(conn), - "products": visible_products(conn, user), + "products": [r["name"] for r in products], + "productCategories": {r["name"]: r["category"] or guess(r["name"]) for r in products}, + "categories": [{"key": k, "title": t} for k, t in CATEGORIES], + "minimums": [ + {"listId": r["list_id"], "key": r["key"], "name": r["name"], "unit": r["unit"], "minimum": _out_quantity(r["minimum"], r["unit"])} + for r in minimums + ], } +# ---------- Mindestbestand ---------- + + +def _refill(conn, list_id: int, key: str, unit: str, user_id: int) -> None: + """Liegt der Vorrat unter dem Mindestbestand, die Einkaufsliste bis zur fehlenden Menge auffüllen.""" + minimum = conn.execute( + "SELECT name, minimum FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, key, unit) + ).fetchone() + if not minimum: + return + def amount(list_): + row = conn.execute( + "SELECT quantity FROM household_items WHERE list_id = ? AND key = ? AND list = ? AND unit = ?", (list_id, key, list_, unit) + ).fetchone() + return row["quantity"] if row else 0 + needed = minimum["minimum"] - amount("vorrat") - amount("einkauf") + if unit == "stk": + needed = math.ceil(needed) + needed = round(needed, 2) + if needed > 0: + _add(conn, minimum["name"], "einkauf", list_id, min(needed, MAX_QUANTITY), unit, user_id) + + @router.get("/api/haushalt") def get_household(user: dict = Depends(current_user)): with transaction() as conn: @@ -280,6 +333,9 @@ def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(curre "UPDATE household_items SET quantity = ?, unit = ?, updated_at = ?, updated_by = ? WHERE id = ?", (quantity, unit, now_iso(), user["id"], item_id), ) + if row["list"] == "vorrat": + for u in {row["unit"], unit}: # bei Einheitenwechsel beide Einheiten prüfen + _refill(conn, row["list_id"], row["key"], u, user["id"]) return _state(conn, user) @@ -293,6 +349,7 @@ def move_item(item_id: int, user: dict = Depends(current_user)): quantity = row["quantity"] if target == "vorrat" else 1 conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,)) _add(conn, row["name"], target, row["list_id"], quantity, row["unit"], user["id"]) + _refill(conn, row["list_id"], row["key"], row["unit"], user["id"]) return _state(conn, user) @@ -315,8 +372,10 @@ def finish_shopping(list_id: int, user: dict = Depends(current_user)): @router.delete("/api/haushalt/items/{item_id}") def delete_item(item_id: int, user: dict = Depends(current_user)): with transaction() as conn: - _get_item(conn, item_id, user) + row = _get_item(conn, item_id, user) conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,)) + if row["list"] == "vorrat": + _refill(conn, row["list_id"], row["key"], row["unit"], user["id"]) return _state(conn, user) @@ -373,3 +432,59 @@ def delete_product(name: str, user: dict = Depends(current_user)): "DELETE FROM products WHERE key = ? AND NOT EXISTS (SELECT 1 FROM list_products WHERE key = ?)", (_key(name), _key(name)) ) return _state(conn, user) + + + +@router.put("/api/haushalt/listen/{list_id}/mindestbestand") +def set_minimum(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Mindestbestand setzen ({name, unit, minimum}); minimum 0 oder null entfernt ihn.""" + name = _clean_name(data.get("name")) + unit = _unit(data.get("unit")) + with transaction() as conn: + _get_list(conn, list_id, user) + if data.get("minimum") in (None, 0): + conn.execute("DELETE FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, _key(name), unit)) + return _state(conn, user) + minimum = _quantity(data.get("minimum"), unit) + name = _canonical_name(conn, name, list_id) + conn.execute( + """INSERT INTO stock_minimums (list_id, key, name, unit, minimum) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (list_id, key, unit) DO UPDATE SET minimum = excluded.minimum, name = excluded.name""", + (list_id, _key(name), name, unit, minimum), + ) + _refill(conn, list_id, _key(name), unit, user["id"]) + return _state(conn, user) + + +# ---------- Laden-Kategorien ---------- + + +@router.put("/api/haushalt/produkte/kategorie") +def set_category(data: dict = Body(...), user: dict = Depends(current_user)): + """Kategorie eines Produkts ändern – nur für Produkte aus Listen, die das Konto sieht.""" + if data.get("category") not in CATEGORY_KEYS: + raise ValidationError("Unbekannte Kategorie.") + with transaction() as conn: + ids = [r["id"] for r in _visible_lists(conn, user)] + key = _key(_clean_name(data.get("name"))) + visible = conn.execute( + f"SELECT 1 FROM household_items WHERE key = ? AND list_id IN ({','.join('?' * len(ids))}) " + f"UNION SELECT 1 FROM list_products WHERE key = ? AND list_id IN ({','.join('?' * len(ids))})", + (key, *ids, key, *ids), + ).fetchone() + if not visible: + raise HTTPException(404, "Produkt nicht gefunden.") + conn.execute("UPDATE products SET category = ? WHERE key = ?", (data["category"], key)) + return _state(conn, user) + + +@router.put("/api/haushalt/listen/{list_id}/reihenfolge") +def set_category_order(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Reihenfolge der Kategorien für den Laden dieser Liste (alle, die die Liste sehen, dürfen sie anpassen).""" + order = data.get("order") + if not isinstance(order, list) or sorted(order) != sorted(CATEGORY_KEYS): + raise ValidationError("Die Reihenfolge muss jede Kategorie genau einmal enthalten.") + with transaction() as conn: + _get_list(conn, list_id, user) + conn.execute("UPDATE shopping_lists SET category_order = ? WHERE id = ?", (json.dumps(order), list_id)) + return _state(conn, user) diff --git a/backend/app/kategorien.py b/backend/app/kategorien.py new file mode 100644 index 0000000..80411c0 --- /dev/null +++ b/backend/app/kategorien.py @@ -0,0 +1,88 @@ +"""Laden-Kategorien für die Einkaufsliste und eine Zuordnung neuer Produkte über Stichwörter. + +Die Zuordnung ist nur ein Vorschlag: Jedes Produkt lässt sich in der Oberfläche umsortieren, +die Reihenfolge der Kategorien kann jede Liste an ihren Laden anpassen. +""" +import re + +# Standard-Reihenfolge = typischer Weg durch einen Supermarkt +CATEGORIES = [ + ("obst", "Obst & Gemüse"), + ("brot", "Brot & Backwaren"), + ("kuehl", "Kühlregal"), + ("fleisch", "Fleisch & Fisch"), + ("tiefkuehl", "Tiefkühl"), + ("vorrat", "Nudeln, Reis & Konserven"), + ("backen", "Backen & Gewürze"), + ("suess", "Süßes & Snacks"), + ("getraenke", "Getränke"), + ("drogerie", "Drogerie & Haushalt"), + ("sonstiges", "Sonstiges"), +] +KEYS = [k for k, _ in CATEGORIES] + +# Stichwörter (Wortanfang oder ganzes Wort, klein geschrieben); das erste passende gewinnt +_WORDS = { + "tiefkuehl": ["tiefkühl", "tk-", "tk ", "pizza", "eis", "pommes", "fischstäbchen", "rahmspinat"], + "obst": [ + "apfel", "äpfel", "banane", "birne", "orange", "zitrone", "limette", "mandarine", "traube", "beere", "erdbeer", + "kiwi", "mango", "ananas", "melone", "pfirsich", "nektarine", "kirsche", "pflaume", "avocado", "tomate", + "gurke", "salat", "paprika", "zwiebel", "knoblauch", "karotte", "möhre", "kartoffel", "zucchini", "aubergine", + "brokkoli", "blumenkohl", "kohl", "spinat", "lauch", "porree", "sellerie", "pilz", "champignon", "ingwer", + "petersilie", "schnittlauch", "basilikum", "dill", "kräuter", "radieschen", "rucola", "mais", "kürbis", + "rote bete", "spargel", "fenchel", "frühlingszwiebel", "obst", "gemüse", "lorbeer", + ], + "brot": ["brot", "brötchen", "toast", "baguette", "croissant", "brezel", "laugen", "knäcke", "kuchen", "semmel"], + "kuehl": [ + "milch", "joghurt", "quark", "käse", "butter", "sahne", "schmand", "crème", "creme fraiche", "frischkäse", + "mozzarella", "feta", "parmesan", "gouda", "skyr", "kefir", "ei", "eier", "eigelb", "eiweiß", "margarine", "pudding", "tofu", + "hefe", "aufschnitt", "wurst", "schinken", "salami", + ], + "fleisch": [ + "fleisch", "hack", "hähnchen", "huhn", "pute", "rind", "schwein", "steak", "schnitzel", "filet", "lachs", + "fisch", "thunfisch", "garnele", "speck", "bratwurst", "würstchen", "kotelett", "gulasch", + ], + "vorrat": [ + "nudel", "spaghetti", "pasta", "penne", "reis", "linsen", "bohnen", "kichererbsen", "konserve", "dose", + "tomatenmark", "passierte", "brühe", "fond", "couscous", "bulgur", "haferflocken", "müsli", "cornflakes", + "ketchup", "senf", "mayonnaise", "essig", "öl", "olivenöl", "soße", "sauce", "pesto", "honig", "marmelade", + "nutella", "erdnussbutter", "suppe", + ], + "backen": [ + "mehl", "zucker", "backpulver", "natron", "vanille", "puderzucker", "speisestärke", "kakao", "salz", "pfeffer", + "zimt", "paprikapulver", "gewürz", "curry", "oregano", "muskat", "kümmel", "chili", "gelatine", "rosinen", + "mandeln", "nüsse", "walnüsse", "haselnüsse", "kokos", "streusel", "schokostreusel", "kuvertüre", "butterschmalz", + ], + "suess": ["schokolade", "chips", "keks", "gummibärchen", "bonbon", "riegel", "popcorn", "nüsschen", "süßigkeit", "salzstangen"], + "getraenke": [ + "wasser", "saft", "cola", "limo", "bier", "wein", "sekt", "kaffee", "tee", "sprudel", "schorle", "energy", + "rotwein", "weißwein", "smoothie", + ], + "drogerie": [ + "toilettenpapier", "klopapier", "küchenrolle", "taschentuch", "spülmittel", "waschmittel", "weichspüler", + "seife", "shampoo", "duschgel", "zahnpasta", "zahnbürste", "deo", "müllbeutel", "schwamm", "reiniger", + "creme", "pflaster", "windel", "batterie", "alufolie", "frischhaltefolie", "backpapier", "spülmaschine", + "tabs", "kondom", "test", "tampon", "binde", "rasier", + ], +} + + +def guess(name: str) -> str: + """Kategorie für einen Produktnamen raten. Das längste passende Stichwort gewinnt + ("Tomatenmark" → Konserven statt "Tomate" → Gemüse). Stichwörter zählen am Wortanfang und + – ab 4 Buchstaben – auch am Wortende ("Vollmilch"); kurze (bis 3 Buchstaben) nur als ganzes Wort ("Ei", "Öl").""" + text = name.casefold() + words = re.findall(r"[\wäöüß]+", text) + best, best_len = "sonstiges", 0 + for key, stems in _WORDS.items(): + for stem in stems: + s = stem.strip() + if " " in s or s.endswith("-"): + hit = s in text + elif len(s) <= 3: + hit = s in words + else: + hit = any(w.startswith(s) or w.endswith(s) for w in words) + if hit and len(s) > best_len: + best, best_len = key, len(s) + return best diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 9617cd5..57eabcc 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -4,6 +4,8 @@ SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Sche 1. SCHEMA in db.py anpassen – neue Datenbanken entstehen direkt so und bekommen die höchste Nummer. 2. Unten an STEPS eine Funktion anhängen, die eine bestehende Datenbank vom vorigen Stand dorthin bringt. Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer. +Achtung: Eine Tabelle, die es in einer alten Datenbank noch gar nicht gab, legt SCHEMA schon in der +neuesten Form an. Spalten deshalb mit _add_column() ergänzen (fügt nur hinzu, was fehlt). Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen). """ import logging @@ -14,6 +16,7 @@ from .common import ValidationError, now_iso from .config import DATA_DIR from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE from .htmlclean import sanitize +from .kategorien import guess log = logging.getLogger(__name__) @@ -32,6 +35,12 @@ def upgrade(conn) -> None: conn.execute(f"PRAGMA user_version = {number}") +def _add_column(conn, table: str, column: str, definition: str) -> None: + """Spalte ergänzen, falls sie fehlt (table/column/definition stammen nur aus diesem Modul).""" + if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") + + def _rebuild(conn, table: str, definition: str, columns: str, select: str, params=()) -> None: """Tabelle neu aufbauen: neue Tabelle unter Zwischennamen anlegen, Daten kopieren, alte löschen, umbenennen. @@ -229,8 +238,20 @@ def _v2_datenschutz(conn) -> None: log.warning("Bild %s konnte nicht bereinigt werden: %s", f.name, e) +# ---------- Schritt 3: Laden-Kategorien ---------- + + +def _v3_kategorien(conn) -> None: + """Produkte bekommen eine Laden-Kategorie (geraten), Listen eine eigene Kategorie-Reihenfolge.""" + _add_column(conn, "products", "category", "TEXT") + _add_column(conn, "shopping_lists", "category_order", "TEXT") + for row in conn.execute("SELECT key, name FROM products").fetchall(): + conn.execute("UPDATE products SET category = ? WHERE key = ?", (guess(row["name"]), row["key"])) + + # Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen. STEPS = [ _v1_altbestand, _v2_datenschutz, + _v3_kategorien, ] diff --git a/backend/app/rezept.py b/backend/app/rezept.py index e336271..ba30348 100644 --- a/backend/app/rezept.py +++ b/backend/app/rezept.py @@ -12,12 +12,12 @@ import socket from urllib.parse import urljoin, urlsplit import httpx -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user from .db import transaction from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit, visible_products -from .common import ValidationError +from .common import ValidationError, clean_text, now_iso router = APIRouter() @@ -26,6 +26,8 @@ MAX_REDIRECTS = 3 TIMEOUT = 10 MAX_INGREDIENTS = 80 MAX_TEXT = 10_000 +MAX_LINE = 300 +MAX_RECIPES = 500 # ---------- Seite abrufen ---------- @@ -291,6 +293,8 @@ def _result(conn, user, title, source, servings, raw_ingredients) -> dict: "source": source, "servings": servings, "ingredients": [match(i) for i in merge(parsed)], + # Zeilen wie im Rezept – damit lässt es sich merken und später neu abgleichen + "lines": [str(r)[:MAX_LINE] for r in raw_ingredients[:MAX_INGREDIENTS]], } @@ -360,3 +364,96 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)): for name, quantity, unit in checked: _add(conn, _canonical_name(conn, name, list_id), "einkauf", list_id, quantity, unit, user["id"]) return _state(conn, user) + + +# ---------- Rezeptsammlung: merken und nach Vorrat vorschlagen ---------- + + +def _recipe_fields(data: dict) -> tuple: + title = clean_text(data.get("title"), "Titel", 200) + source = data.get("source") or None + if source is not None and (not isinstance(source, str) or not re.match(r"^https?://\S+$", source) or len(source) > 500): + raise ValidationError("Ungültiger Rezept-Link.") + servings = data.get("servings") + if servings is not None and (isinstance(servings, bool) or not isinstance(servings, int) or not 0 < servings <= 100): + servings = None + lines = data.get("lines") + if not isinstance(lines, list) or not lines or len(lines) > MAX_INGREDIENTS or not all(isinstance(x, str) for x in lines): + raise ValidationError("Das Rezept hat keine gültigen Zutaten.") + return title, source, servings, [x.strip()[:MAX_LINE] for x in lines if x.strip()] + + +def _suggestion(row, match, stock: set, user: dict) -> dict: + """Wie viele Zutaten sind im Vorrat? Salz, Wasser & Co. und optionale Zutaten zählen nicht.""" + parsed = merge([p for p in (parse_ingredient(x) for x in json.loads(row["lines"])) if p]) + have, missing = 0, [] + counted = [i for i in parsed if not i["staple"] and not i["optional"]] + for ing in counted: + names = [ing["name"], *ing["alternatives"]] + found = [m["product"] for m in (match({**ing, "name": n, "alternatives": []}) for n in names) if m["known"]] + if any(_key(f) in stock for f in found): + have += 1 + else: + missing.append(found[0] if found else ing["name"]) + return { + "id": row["id"], "title": row["title"], "source": row["source"], "servings": row["servings"], + "total": len(counted), "have": have, "missing": missing, + "canDelete": user["isAdmin"] or row["created_by"] == user["id"], + } + + +@router.get("/api/haushalt/rezepte") +def list_recipes(listId: int, user: dict = Depends(current_user)): + """Gemerkte Rezepte, sortiert danach, wie viel davon im Vorrat der Liste ist.""" + with transaction() as conn: + _get_list(conn, listId, user) + stock = {r["key"] for r in conn.execute( + "SELECT key FROM household_items WHERE list_id = ? AND list = 'vorrat'", (listId,) + )} + match = _matcher(conn, user) + rows = conn.execute("SELECT * FROM recipes ORDER BY title COLLATE NOCASE").fetchall() + result = [_suggestion(r, match, stock, user) for r in rows] + return sorted(result, key=lambda r: (-(r["have"] / r["total"] if r["total"] else 1), len(r["missing"]), r["title"].casefold())) + + +@router.post("/api/haushalt/rezepte", status_code=201) +def save_recipe(data: dict = Body(...), user: dict = Depends(current_user)): + """Rezept merken; gleicher Link = dasselbe Rezept (wird aktualisiert).""" + title, source, servings, lines = _recipe_fields(data) + with transaction() as conn: + existing = conn.execute("SELECT id FROM recipes WHERE source = ?", (source,)).fetchone() if source else None + if existing: + conn.execute( + "UPDATE recipes SET title = ?, servings = ?, lines = ? WHERE id = ?", + (title, servings, json.dumps(lines, ensure_ascii=False), existing["id"]), + ) + return {"id": existing["id"]} + if conn.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] >= MAX_RECIPES: + raise ValidationError(f"Es sind schon {MAX_RECIPES} Rezepte gemerkt. Bitte alte löschen.") + recipe_id = conn.execute( + "INSERT INTO recipes (title, source, servings, lines, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)", + (title, source, servings, json.dumps(lines, ensure_ascii=False), now_iso(), user["id"]), + ).lastrowid + return {"id": recipe_id} + + +@router.get("/api/haushalt/rezepte/{recipe_id}") +def get_recipe(recipe_id: int, user: dict = Depends(current_user)): + """Gemerktes Rezept vollständig abgleichen (ohne die Seite erneut abzurufen).""" + with transaction() as conn: + row = conn.execute("SELECT * FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not row: + raise HTTPException(404, "Rezept nicht gefunden.") + return {**_result(conn, user, row["title"], row["source"], row["servings"], json.loads(row["lines"])), "id": row["id"]} + + +@router.delete("/api/haushalt/rezepte/{recipe_id}") +def delete_recipe(recipe_id: int, user: dict = Depends(current_user)): + with transaction() as conn: + row = conn.execute("SELECT created_by FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not row: + raise HTTPException(404, "Rezept nicht gefunden.") + if not user["isAdmin"] and row["created_by"] != user["id"]: + raise HTTPException(403, "Nur wer das Rezept gemerkt hat (oder ein Administrator) kann es löschen.") + conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,)) + return {"ok": True} diff --git a/backend/tests/test_datenschutz.py b/backend/tests/test_datenschutz.py index 1391a45..fbec053 100644 --- a/backend/tests/test_datenschutz.py +++ b/backend/tests/test_datenschutz.py @@ -156,6 +156,7 @@ def test_problembilder_verhindern_start_nicht(conn): with conn: conn.execute("PRAGMA user_version = 1") db.init() - assert conn.execute("PRAGMA user_version").fetchone()[0] == 2 + from app import migrations + assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS) finally: gesperrt.chmod(0o644) diff --git a/backend/tests/test_vorrat_extras.py b/backend/tests/test_vorrat_extras.py new file mode 100644 index 0000000..c157de8 --- /dev/null +++ b/backend/tests/test_vorrat_extras.py @@ -0,0 +1,133 @@ +"""Mindestbestand, Laden-Kategorien und Rezeptsammlung.""" +from app.kategorien import KEYS + +from test_haushalt import add, items, new_list, state + + +def home(client): + return state(client)["lists"][0]["id"] + + +def amounts(data, list_id, name): + return {(i["list"], i["unit"]): i["quantity"] for i in items(data, list_id) if i["name"] == name} + + +def set_min(client, list_id, name, minimum, unit="stk"): + res = client.put(f"/api/haushalt/listen/{list_id}/mindestbestand", json={"name": name, "unit": unit, "minimum": minimum}) + assert res.status_code == 200, res.text + return res.json() + + +# ---------- Mindestbestand ---------- + + +def test_mindestbestand_fuellt_einkauf_auf(login): + anna = login("anna") + lid = home(anna) + milch = items(add(anna, lid, "Milch", area="vorrat", quantity=3).json(), lid)[0] + data = set_min(anna, lid, "Milch", 2) + assert amounts(data, lid, "Milch") == {("vorrat", "stk"): 3} # genug da + data = anna.patch(f"/api/haushalt/items/{milch['id']}", json={"quantity": 1}).json() + assert amounts(data, lid, "Milch") == {("vorrat", "stk"): 1, ("einkauf", "stk"): 1} + data = anna.delete(f"/api/haushalt/items/{milch['id']}").json() # alles weg → auf 2 auffüllen + assert amounts(data, lid, "Milch") == {("einkauf", "stk"): 2} + assert data["minimums"] == [{"listId": lid, "key": "milch", "name": "Milch", "unit": "stk", "minimum": 2}] + + +def test_mindestbestand_aufgebraucht_und_sofort(login): + anna = login("anna") + lid = home(anna) + mehl = items(add(anna, lid, "Mehl", area="vorrat", quantity=1, unit="kg").json(), lid)[0] + set_min(anna, lid, "Mehl", 2.5, unit="kg") # schon darunter → sofort + data = state(anna) + assert amounts(data, lid, "Mehl") == {("vorrat", "kg"): 1, ("einkauf", "kg"): 1.5} + data = anna.post(f"/api/haushalt/items/{mehl['id']}/move").json() # aufgebraucht + assert amounts(data, lid, "Mehl") == {("einkauf", "kg"): 2.5} + data = set_min(anna, lid, "Mehl", 0, unit="kg") + assert data["minimums"] == [] + + +def test_mindestbestand_nur_mit_listenzugriff(login): + anna, ben = login("anna"), login("ben") + privat = new_list(anna, "Privat", everyone=False) + res = ben.put(f"/api/haushalt/listen/{privat}/mindestbestand", json={"name": "x", "unit": "stk", "minimum": 1}) + assert res.status_code == 404 + + +# ---------- Kategorien ---------- + + +def test_kategorie_geraten_und_aenderbar(login): + anna = login("anna") + lid = home(anna) + data = add(anna, lid, "Vollmilch").json() + assert items(data, lid)[0]["category"] == "kuehl" + assert data["productCategories"]["Vollmilch"] == "kuehl" + data = anna.put("/api/haushalt/produkte/kategorie", json={"name": "Vollmilch", "category": "getraenke"}).json() + assert items(data, lid)[0]["category"] == "getraenke" + assert anna.put("/api/haushalt/produkte/kategorie", json={"name": "Vollmilch", "category": "quatsch"}).status_code == 400 + + +def test_kategorie_privater_produkte_nicht_aenderbar(login): + anna, ben = login("anna"), login("ben") + privat = new_list(anna, "Privat", everyone=False) + add(anna, privat, "Geheimzutat") + assert ben.put("/api/haushalt/produkte/kategorie", json={"name": "Geheimzutat", "category": "obst"}).status_code == 404 + + +def test_laden_reihenfolge(login): + anna = login("anna") + lid = home(anna) + assert state(anna)["lists"][0]["categoryOrder"] == KEYS + order = list(reversed(KEYS)) + data = anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": order}).json() + assert data["lists"][0]["categoryOrder"] == order + assert anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": KEYS[:3]}).status_code == 400 + + +# ---------- Rezeptsammlung ---------- + + +def save(client, title, lines, source=None): + res = client.post("/api/haushalt/rezepte", json={"title": title, "lines": lines, "source": source, "servings": 2}) + assert res.status_code == 201, res.text + return res.json()["id"] + + +def test_rezepte_nach_vorrat_sortiert(login): + anna = login("anna") + lid = home(anna) + for name in ("Nudeln", "Tomaten"): + add(anna, lid, name, area="vorrat") + save(anna, "Pasta", ["250 g Nudeln", "3 Tomaten", "1 Prise Salz"]) # Salz zählt nicht + save(anna, "Kuchen", ["200 g Mehl", "3 Eier", "100 g Zucker"]) + res = anna.get("/api/haushalt/rezepte", params={"listId": lid}).json() + assert [(r["title"], r["have"], r["total"]) for r in res] == [("Pasta", 2, 2), ("Kuchen", 0, 3)] + assert res[1]["missing"] == ["Mehl", "Eier", "Zucker"] + + +def test_rezept_gleicher_link_wird_aktualisiert(login): + anna = login("anna") + a = save(anna, "Alt", ["1 Ei"], source="https://example.org/r") + b = save(anna, "Neu", ["2 Eier"], source="https://example.org/r") + assert a == b + detail = anna.get(f"/api/haushalt/rezepte/{a}").json() + assert detail["title"] == "Neu" and detail["lines"] == ["2 Eier"] + + +def test_rezept_loeschen_nur_ersteller_oder_admin(login): + anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True) + a = save(anna, "A", ["1 Ei"]) + b = save(anna, "B", ["1 Ei"]) + assert ben.delete(f"/api/haushalt/rezepte/{a}").status_code == 403 + assert anna.delete(f"/api/haushalt/rezepte/{a}").status_code == 200 + assert chef.delete(f"/api/haushalt/rezepte/{b}").status_code == 200 + + +def test_rezept_vorschlag_verraet_keine_privaten_produkte(login): + anna, ben = login("anna"), login("ben") + privat = new_list(anna, "Privat", everyone=False) + add(anna, privat, "Kondome", area="vorrat") + save(ben, "Test", ["2 Kondom"]) + res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json() + assert res[0]["missing"] == ["Kondom"] # nicht Annas Schreibweise "Kondome" diff --git a/frontend/src/components/haushalt/CategoryOrderDialog.jsx b/frontend/src/components/haushalt/CategoryOrderDialog.jsx new file mode 100644 index 0000000..510194c --- /dev/null +++ b/frontend/src/components/haushalt/CategoryOrderDialog.jsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +// Reihenfolge der Abteilungen so, wie man durch den eigenen Laden läuft (gilt für die gewählte Liste) +export default function CategoryOrderDialog({ ref, categories, order, onSave, busy }) { + const [current, setCurrent] = useState(order); + const title = Object.fromEntries(categories.map((c) => [c.key, c.title])); + const move = (index, delta) => setCurrent((list) => { + const next = [...list]; + [next[index], next[index + delta]] = [next[index + delta], next[index]]; + return next; + }); + + async function save() { + if (await onSave(current)) ref.current.close(); + } + + return ( + +
+

Reihenfolge im Laden

+

So sortiert „Nach Laden“ die Einkaufsliste – am besten in der Reihenfolge, in der du durch den Markt gehst.

+
    + {current.map((key, i) => ( +
  1. + {title[key]} + + +
  2. + ))} +
+
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/haushalt/ListPanel.jsx b/frontend/src/components/haushalt/ListPanel.jsx index 6125924..e91be71 100644 --- a/frontend/src/components/haushalt/ListPanel.jsx +++ b/frontend/src/components/haushalt/ListPanel.jsx @@ -10,12 +10,41 @@ export const LISTS = { einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' }, }; -export default function ListPanel({ list, items, products, find, actions, busy, hidden }) { +const same = (a, b) => a.toLocaleLowerCase('de-DE') === b.toLocaleLowerCase('de-DE'); + +/** + * extras: { minimums, categories, categoryOrder, sortMode, onSortMode, onEditOrder, onEditMinimums, onCategory } + * – Mindestbestand (Vorrat) und Laden-Sortierung nach Kategorien (Einkauf). + */ +export default function ListPanel({ list, items, products, find, actions, busy, hidden, extras }) { const { title, lead, empty } = LISTS[list]; const other = list === 'vorrat' ? 'einkauf' : 'vorrat'; // Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat const open = items.filter((i) => !i.checked); const inCart = items.filter((i) => i.checked); + const byShop = list === 'einkauf' && extras.sortMode === 'laden'; + const minimumOf = (item) => extras.minimums.find((m) => same(m.name, item.name) && m.unit === item.unit); + const categoryTitle = Object.fromEntries(extras.categories.map((c) => [c.key, c.title])); + // Laden-Modus: nach Kategorien in der Reihenfolge dieser Liste gruppieren + const groups = byShop + ? extras.categoryOrder + .map((key) => ({ key, title: categoryTitle[key], items: open.filter((i) => i.category === key) })) + .filter((g) => g.items.length) + : [{ key: 'alle', items: open }]; + const row = (item) => ( + extras.onEditMinimums(item)} + categories={byShop ? extras.categories : null} + onCategory={extras.onCategory} + /> + ); // Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo? function hint(name) { @@ -39,21 +68,38 @@ export default function ListPanel({ list, items, products, find, actions, busy, {lead} + {list === 'vorrat' && ( +
+ +
+ )} + {list === 'einkauf' && ( +
+
+ {[['az', 'A–Z'], ['laden', 'Nach Laden']].map(([key, label]) => ( + + ))} +
+ {byShop && } +
+ )} + {items.length === 0 ? (

{empty}

) : ( )} @@ -159,7 +205,7 @@ function QuantityInput({ item, label, onCommit, disabled }) { ); } -function ItemRow({ item, list, other, actions, busy }) { +function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, categories, onCategory }) { const isShopping = list === 'einkauf'; const step = STEP[item.unit]; const last = item.quantity - step <= 0; @@ -184,8 +230,32 @@ function ItemRow({ item, list, other, actions, busy }) { {item.name} {other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `} + {minimum && ( + <> + + {' · '} + + )} ✎ {personName(item.updatedBy)} + {categories && ( + + )}
diff --git a/frontend/src/components/haushalt/MinimumDialog.jsx b/frontend/src/components/haushalt/MinimumDialog.jsx new file mode 100644 index 0000000..976151d --- /dev/null +++ b/frontend/src/components/haushalt/MinimumDialog.jsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { parseQty } from '../../units.js'; +import ProductInput from './ProductInput.jsx'; +import UnitSelect from './UnitSelect.jsx'; + +/** + * Mindestbestände der gewählten Liste: fällt der Vorrat darunter, kommt das Produkt automatisch auf die + * Einkaufsliste (bis zur fehlenden Menge). start: Produkt zum Vorbelegen ({ name, unit, quantity }) oder null. + */ +export default function MinimumDialog({ ref, minimums, products, start, onSave, busy }) { + const current = start && minimums.find((m) => m.name.toLocaleLowerCase('de-DE') === start.name.toLocaleLowerCase('de-DE') && m.unit === start.unit); + const [name, setName] = useState(start?.name || ''); + const [unit, setUnit] = useState(start?.unit || 'stk'); + const [qty, setQty] = useState(current ? String(current.minimum).replace('.', ',') : ''); + const [invalid, setInvalid] = useState(false); + + async function submit(e) { + e.preventDefault(); + const q = parseQty(qty, unit); + setInvalid(q == null || !name.trim()); + if (q == null || !name.trim()) return; + if (await onSave(name.trim(), unit, q)) { setName(''); setQty(''); } + } + + return ( + +
+

Mindestbestand

+

Fällt der Vorrat unter diese Menge, kommt das Produkt automatisch auf die Einkaufsliste – genau so viel, wie fehlt.

+ {minimums.length > 0 && ( +
    + {minimums.map((m) => ( +
  • + {m.name} immer {String(m.minimum).replace('.', ',')} {m.unit === 'stk' ? 'Stück' : m.unit} + +
  • + ))} +
+ )} +
+ + +
+ + { setQty(e.target.value); setInvalid(false); }} aria-invalid={invalid || undefined} /> + +
+ + {invalid &&

Produkt und Menge angeben (Anzahl: ganze Zahl, kg/l: z. B. 0,5).

} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/haushalt/ProductsDialog.jsx b/frontend/src/components/haushalt/ProductsDialog.jsx index 1b47086..089572b 100644 --- a/frontend/src/components/haushalt/ProductsDialog.jsx +++ b/frontend/src/components/haushalt/ProductsDialog.jsx @@ -1,18 +1,28 @@ // Vorschläge (Produktkatalog) aufräumen, z. B. Tippfehler; Einträge auf den Listen bleiben -export default function ProductsDialog({ ref, products, onDelete }) { +export default function ProductsDialog({ ref, products, categories = [], productCategories = {}, onCategory, onDelete }) { const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE')); return (

Vorschläge verwalten

- Tippfehler oder doppelte Schreibweisen hier löschen. Einträge auf den Listen bleiben erhalten. + Kategorie für „Nach Laden“ festlegen, Tippfehler oder doppelte Schreibweisen löschen. Einträge auf den Listen bleiben erhalten.

    {sorted.map((name) => (
  • {name} + {categories.length > 0 && ( + + )} diff --git a/frontend/src/components/haushalt/RecipeCheck.jsx b/frontend/src/components/haushalt/RecipeCheck.jsx index 360c9e8..da2634a 100644 --- a/frontend/src/components/haushalt/RecipeCheck.jsx +++ b/frontend/src/components/haushalt/RecipeCheck.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { api } from '../../api.js'; import { ceil2, fmtItems, fmtQty, parseQty } from '../../units.js'; import ProductInput from './ProductInput.jsx'; @@ -23,36 +23,83 @@ function loadPeople() { * Rezept abgleichen: Zutaten laden, auf Personenzahl umrechnen, mit dem Vorrat vergleichen * und Fehlendes in den Einkauf der gewählten Liste setzen. find(list, name) sucht in Vorrat bzw. Einkauf dieser Liste. */ -export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy }) { +/** + * stockKey: ändert sich, wenn sich der Vorrat ändert – dann werden die Rezeptvorschläge neu bewertet. + */ +export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy, stockKey }) { const [mode, setMode] = useState('url'); // url | text const [url, setUrl] = useState(''); const [text, setText] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [recipe, setRecipe] = useState(null); + const [saved, setSaved] = useState(null); // gemerkte Rezepte mit Bewertung nach Vorrat - async function load(e) { - e.preventDefault(); + const listId = shoppingList?.id; + const loadSaved = useCallback(async () => { + if (!listId) return; + try { + setSaved(await api('GET', `/api/haushalt/rezepte?listId=${listId}`)); + } catch { + setSaved([]); + } + }, [listId]); + useEffect(() => { loadSaved(); }, [loadSaved, stockKey]); + + function show(data) { + // Ohne Portionsangabe gilt die Liste für die gewählte Personenzahl, Mengen bleiben wie eingegeben + const people = loadPeople() || data.servings || 4; + setRecipe({ + ...data, + base: String(data.servings || people), + people: String(people), + rows: data.ingredients.map((i) => ({ ...i, matched: i.product, take: null, qty: null })), + done: null, + }); + } + + async function request(method, path, body) { setLoading(true); setError(null); try { - const data = await api('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text }); - // Ohne Portionsangabe gilt die Liste für die gewählte Personenzahl, Mengen bleiben wie eingegeben - const people = loadPeople() || data.servings || 4; - setRecipe({ - ...data, - base: String(data.servings || people), - people: String(people), - rows: data.ingredients.map((i) => ({ ...i, matched: i.product, take: null, qty: null })), - done: null, - }); + return await api(method, path, body); } catch (err) { setError(err.message); + return null; } finally { setLoading(false); } } + async function load(e) { + e.preventDefault(); + const data = await request('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text }); + if (data) show(data); + } + + async function openSaved(id) { + const data = await request('GET', `/api/haushalt/rezepte/${id}`); + if (data) show(data); + } + + async function remember() { + // Eingefügte Zutatenlisten haben keinen eigenen Titel – beim Merken nach einem Namen fragen + let title = recipe.title; + if (!recipe.source) { + title = window.prompt('Name für das Rezept:', title === 'Eigene Zutatenliste' ? '' : title)?.trim(); + if (!title) return; + } + const res = await request('POST', '/api/haushalt/rezepte', { + title, source: recipe.source, servings: recipe.servings, lines: recipe.lines, + }); + if (res) { setRecipe((r) => ({ ...r, id: res.id, title })); loadSaved(); } + } + + async function forget(r) { + if (!window.confirm(`Rezept „${r.title}“ aus der Sammlung löschen?`)) return; + if (await request('DELETE', `/api/haushalt/rezepte/${r.id}`)) loadSaved(); + } + if (!recipe) { return (
    @@ -86,6 +133,25 @@ export default function RecipeCheck({ products, find, shoppingList, onTakeOver, {error &&

    {error}

    } + {saved?.length > 0 && ( +
    +

    Was kann ich kochen? – gemerkte Rezepte, nach Vorrat sortiert

    +
      + {saved.map((r) => ( +
    • + + {r.total ? `${r.have}/${r.total} da` : 'nur Grundzutaten'} + + {r.missing.length ? `fehlt: ${r.missing.slice(0, 4).join(', ')}${r.missing.length > 4 ? ` +${r.missing.length - 4}` : ''}` : 'alles da'} + + {r.canDelete && ( + + )} +
    • + ))} +
    +
    + )}
    ); } @@ -181,7 +247,12 @@ export default function RecipeCheck({ products, find, shoppingList, onTakeOver, ? {recipe.title} ↗ : recipe.title} - + + {recipe.id + ? ★ Gemerkt + : } + +
    diff --git a/frontend/src/pages/Haushalt.jsx b/frontend/src/pages/Haushalt.jsx index da33b57..298e580 100644 --- a/frontend/src/pages/Haushalt.jsx +++ b/frontend/src/pages/Haushalt.jsx @@ -1,9 +1,11 @@ import { useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { api } from '../api.js'; +import CategoryOrderDialog from '../components/haushalt/CategoryOrderDialog.jsx'; import ListDialog from '../components/haushalt/ListDialog.jsx'; import ListPanel, { LISTS } from '../components/haushalt/ListPanel.jsx'; import ListSwitcher from '../components/haushalt/ListSwitcher.jsx'; +import MinimumDialog from '../components/haushalt/MinimumDialog.jsx'; import ProductsDialog from '../components/haushalt/ProductsDialog.jsx'; import RecipeCheck from '../components/haushalt/RecipeCheck.jsx'; import { isCalm } from '../motion.js'; @@ -13,11 +15,16 @@ import useLiveData from '../useLiveData.js'; const REFRESH_SECONDS = 15; const TAB_KEY = 'p42.haushaltTab'; const LIST_KEY = 'p42.haushaltListe'; +const SORT_KEY = 'p42.einkaufSortierung'; function loadTab() { try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; } } +function loadSort() { + try { return localStorage.getItem(SORT_KEY) === 'laden' ? 'laden' : 'az'; } catch { return 'az'; } +} + function loadListId() { try { return Number(localStorage.getItem(LIST_KEY)) || null; } catch { return null; } } @@ -29,6 +36,12 @@ export default function Haushalt() { // Dialog für Listen: null = zu, { list: null } = neue Liste, { list } = bearbeiten const [listDialog, setListDialog] = useState(null); const dialog = useRef(null); + const [sortMode, setSortMode] = useState(loadSort); + // Dialoge: Mindestbestand (mit vorbelegtem Produkt) und Laden-Reihenfolge; opened erzwingt frischen Inhalt + const [minDialog, setMinDialog] = useState(null); + const [orderDialog, setOrderDialog] = useState(null); + const minRef = useRef(null); + const orderRef = useRef(null); const listDialogRef = useRef(null); // Jede Änderung liefert den aktuellen Stand aller Listen zurück. @@ -80,6 +93,23 @@ export default function Haushalt() { return Boolean(await run('DELETE', `/api/haushalt/listen/${list.id}`)); } + function chooseSort(mode) { + setSortMode(mode); + try { localStorage.setItem(SORT_KEY, mode); } catch { /* ignorieren */ } + } + + function openMinimums(item) { + setMinDialog({ start: item ? { name: item.name, unit: item.unit } : null, opened: Date.now() }); + requestAnimationFrame(() => minRef.current?.showModal()); + } + + function openOrder() { + setOrderDialog({ opened: Date.now() }); + requestAnimationFrame(() => orderRef.current?.showModal()); + } + + const setCategory = (name, category) => run('PUT', '/api/haushalt/produkte/kategorie', { name, category }); + const items = data?.items || []; const lists = data?.lists || []; // Gewählte Liste (z. B. Zuhause, Urlaub); gibt es sie nicht (mehr), die erste sichtbare @@ -139,6 +169,7 @@ export default function Haushalt() { find={find} busy={busy} shoppingList={current} + stockKey={byList.vorrat.map((i) => `${i.id}:${i.quantity}`).join(',')} onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', { ...body, listId: current?.id })} /> @@ -169,6 +200,16 @@ export default function Haushalt() { actions={actions} busy={busy} hidden={tab !== list} + extras={{ + minimums: (data.minimums || []).filter((m) => m.listId === current.id), + categories: data.categories || [], + categoryOrder: current.categoryOrder || [], + sortMode, + onSortMode: chooseSort, + onEditOrder: openOrder, + onEditMinimums: openMinimums, + onCategory: setCategory, + }} /> ))}
    @@ -187,7 +228,36 @@ export default function Haushalt() { /> )} - run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} /> + run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} + /> + + {minDialog && current && ( + m.listId === current.id)} + products={data?.products || []} + start={minDialog.start} + busy={busy} + onSave={(name, unit, minimum) => run('PUT', `/api/haushalt/listen/${current.id}/mindestbestand`, { name, unit, minimum })} + /> + )} + {orderDialog && current && ( + run('PUT', `/api/haushalt/listen/${current.id}/reihenfolge`, { order })} + /> + )} ); } diff --git a/frontend/src/styles/haushalt.css b/frontend/src/styles/haushalt.css index 379c554..4b4bbba 100644 --- a/frontend/src/styles/haushalt.css +++ b/frontend/src/styles/haushalt.css @@ -280,3 +280,33 @@ html.vt-items .household-item { view-transition-name: var(--vt); } .household-item, .recipe-row, .product-list li { border-top-style: solid; border-top-color: var(--border); } .household-item:hover { background: var(--accent-soft); } .segmented .count::before, .segmented .count::after { content: none; } + +/* Mindestbestand, Laden-Sortierung */ +.household-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; margin-top: -6px; } +.sort-toggle { display: inline-flex; border: 1px solid var(--line-strong); border-radius: var(--radius-s); overflow: hidden; } +.sort-toggle button { font: inherit; font-size: 0.78rem; padding: 4px 10px; border: 0; background: transparent; color: var(--text); cursor: pointer; } +.sort-toggle button[aria-pressed='true'] { background: var(--accent); color: var(--accent-ink); } +.household-group-head { margin: 12px 0 2px; font-size: var(--label-size); letter-spacing: var(--label-tracking); text-transform: uppercase; color: var(--accent); } +.household-group-head:first-child { margin-top: 0; } +.min-badge { font: inherit; font-size: inherit; padding: 0; border: 0; background: none; color: var(--muted); text-decoration: underline dotted; cursor: pointer; } +.min-badge.below { color: var(--warn); font-weight: 600; } +.category-select { margin-top: 2px; max-width: 14em; font-size: 0.72rem; min-height: 22px; padding: 0 2px; color: var(--muted); background: transparent; border-color: transparent; } +.category-select:hover, .category-select:focus { border-color: var(--border); color: var(--text); } +.order-list { margin: 0; padding-left: 1.6em; display: grid; gap: 4px; } +.order-list li { display: flex; align-items: center; gap: 8px; } +.order-list li span { flex: 1; } +.product-list .category-select { margin: 0 8px 0 auto; } + +/* Rezeptsammlung */ +.recipe-head-actions { display: inline-flex; gap: 14px; align-items: baseline; } +.recipe-saved { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); } +.recipe-saved h3 { margin: 0 0 8px; font-size: 0.95rem; } +.recipe-saved h3 small { font-weight: 400; color: var(--muted); text-transform: none; letter-spacing: 0; } +.recipe-saved ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; } +.recipe-saved li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 6px 0; border-top: 1px dashed var(--border); } +.recipe-saved li:first-child { border-top: 0; } +.recipe-saved-title { font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; } +.recipe-saved-title:hover { color: var(--accent); } +.recipe-score { font-variant-numeric: tabular-nums; color: var(--warn); font-size: 0.82rem; } +.recipe-saved li.complete .recipe-score { color: var(--ok); } +.recipe-missing { flex: 1 1 200px; color: var(--muted); }