From fef9c8f16ecd84dee954aac517d0c6f14a675e3d Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:11:39 +0200 Subject: [PATCH] =?UTF-8?q?Vorrat:=20eigene=20L=C3=A4den=20mit=20Produktzu?= =?UTF-8?q?ordnung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Läden pro Liste (Tabelle stores): anlegen, umbenennen, sortieren, löschen; je Laden eigene Reihenfolge der Abteilungen - Produkte einem Laden zuordnen (Tabelle store_products, pro Liste, bleibt nach dem Einkauf gemerkt); gelöschter Laden → Produkte ohne Laden - Einkaufsliste "Nach Laden": nach Laden gruppiert, darin nach Abteilung; Filter "Alle | Laden | Ohne Laden", im Filter Hinzugefügtes wird diesem Laden zugeordnet - Läden fremder privater Listen sind nicht nutzbar (Tests) Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + backend/app/db.py | 18 +++ backend/app/haushalt.py | 114 +++++++++++++++++- backend/tests/test_vorrat_extras.py | 64 ++++++++++ .../haushalt/CategoryOrderDialog.jsx | 6 +- .../src/components/haushalt/ListPanel.jsx | 95 +++++++++++---- .../src/components/haushalt/StoresDialog.jsx | 65 ++++++++++ frontend/src/pages/Haushalt.jsx | 49 +++++++- frontend/src/styles/haushalt.css | 10 ++ 9 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/haushalt/StoresDialog.jsx diff --git a/README.md b/README.md index 5bc3726..e9d50b9 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,11 @@ und einen eigenen Einkaufszettel. 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. +- **Läden:** In der Ansicht „Nach Laden“ unter „🏪 Läden“ eigene Läden anlegen (z. B. Aldi, dm), umbenennen, + sortieren, löschen. Jede Zeile hat eine Laden-Auswahl; die Zuordnung gilt pro Liste und bleibt gemerkt. + Die Einkaufsliste ist dann nach Laden gruppiert (darin nach Abteilung, je Laden eigene Reihenfolge + über „⇅ Abteilungen“). Der Filter „Alle | Aldi | dm | Ohne Laden“ zeigt nur einen Laden – was man dort + hinzufügt, wird gleich diesem Laden zugeordnet. - **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. diff --git a/backend/app/db.py b/backend/app/db.py index 0bb5011..fb828f5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -148,6 +148,24 @@ CREATE TABLE IF NOT EXISTS products ( category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet ); +-- Läden einer Liste (z. B. Aldi, dm): Produkte lassen sich einem Laden zuordnen, die Einkaufsliste +-- gruppiert danach; jeder Laden kann eine eigene Reihenfolge der Abteilungen haben +CREATE TABLE IF NOT EXISTS stores ( + id INTEGER PRIMARY KEY, + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position REAL NOT NULL, + category_order TEXT -- JSON wie bei shopping_lists; NULL = Reihenfolge der Liste +); + +-- In welchem Laden ein Produkt (pro Liste) gekauft wird +CREATE TABLE IF NOT EXISTS store_products ( + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + key TEXT NOT NULL, + store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + PRIMARY KEY (list_id, key) +); + -- 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, diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index f281595..535e9af 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -25,6 +25,8 @@ MAX_QUANTITY = 999 MAX_PRODUCTS = 2000 MAX_LIST_NAME = 40 MAX_LISTS = 50 +MAX_STORES = 30 +MAX_STORE_NAME = 40 def _item(row) -> dict: @@ -37,6 +39,7 @@ def _item(row) -> dict: "unit": row["unit"], "checked": bool(row["checked"]), "category": row["category"] or guess(row["name"]), + "storeId": row["store_id"], "updatedAt": row["updated_at"], "updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None, } @@ -187,6 +190,10 @@ def _shopping_list(conn, row, user: dict) -> dict: "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"]), + "stores": [ + {"id": s["id"], "name": s["name"], "categoryOrder": _category_order(s["category_order"] or row["category_order"])} + for s in conn.execute("SELECT * FROM stores WHERE list_id = ? ORDER BY position, id", (row["id"],)) + ], } @@ -234,9 +241,10 @@ def _state(conn, user: dict) -> dict: ids = [r["id"] for r in lists] marks = ",".join("?" * len(ids)) items = conn.execute( - f"""SELECT i.*, u.display_name AS updated_by_name, p.category FROM household_items i + f"""SELECT i.*, u.display_name AS updated_by_name, p.category, sp.store_id FROM household_items i LEFT JOIN users u ON u.id = i.updated_by LEFT JOIN products p ON p.key = i.key + LEFT JOIN store_products sp ON sp.list_id = i.list_id AND sp.key = i.key WHERE i.list_id IN ({marks})""", ids, ).fetchall() @@ -295,7 +303,11 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)): quantity = _quantity(data.get("quantity", 1), unit) with transaction() as conn: list_id = _get_list(conn, data.get("listId"), user)["id"] - _add(conn, _canonical_name(conn, name, list_id), list_, list_id, quantity, unit, user["id"]) + name = _canonical_name(conn, name, list_id) + _add(conn, name, list_, list_id, quantity, unit, user["id"]) + # Im Laden-Filter hinzugefügt: Produkt gleich diesem Laden zuordnen + if data.get("storeId") is not None: + _assign_store(conn, list_id, _key(name), _get_store(conn, data["storeId"], user, list_id)["id"]) return _state(conn, user) @@ -488,3 +500,101 @@ def set_category_order(list_id: int, data: dict = Body(...), user: dict = Depend _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) + + +# ---------- Läden ---------- + + +def _get_store(conn, store_id, user: dict, list_id=None): + """Laden einer Liste, die das Konto sieht (optional: muss zu list_id gehören).""" + if isinstance(store_id, bool) or not isinstance(store_id, int): + raise ValidationError("Ungültiger Laden.") + row = conn.execute("SELECT * FROM stores WHERE id = ?", (store_id,)).fetchone() + if not row or (list_id is not None and row["list_id"] != list_id): + raise HTTPException(404, "Laden nicht gefunden.") + _get_list(conn, row["list_id"], user) + return row + + +def _assign_store(conn, list_id: int, key: str, store_id) -> None: + if store_id is None: + conn.execute("DELETE FROM store_products WHERE list_id = ? AND key = ?", (list_id, key)) + else: + conn.execute( + """INSERT INTO store_products (list_id, key, store_id) VALUES (?, ?, ?) + ON CONFLICT (list_id, key) DO UPDATE SET store_id = excluded.store_id""", + (list_id, key, store_id), + ) + + +@router.post("/api/haushalt/listen/{list_id}/laeden", status_code=201) +def create_store(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Laden anlegen (alle, die die Liste sehen, dürfen Läden verwalten).""" + name = clean_text(data.get("name"), "Name des Ladens", MAX_STORE_NAME) + with transaction() as conn: + _get_list(conn, list_id, user) + if conn.execute("SELECT COUNT(*) FROM stores WHERE list_id = ?", (list_id,)).fetchone()[0] >= MAX_STORES: + raise ValidationError(f"Maximal {MAX_STORES} Läden pro Liste.") + if conn.execute("SELECT 1 FROM stores WHERE list_id = ? AND name = ? COLLATE NOCASE", (list_id, name)).fetchone(): + raise ValidationError("Diesen Laden gibt es in der Liste schon.") + position = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM stores WHERE list_id = ?", (list_id,)).fetchone()[0] + store_id = conn.execute( + "INSERT INTO stores (list_id, name, position) VALUES (?, ?, ?)", (list_id, name, position) + ).lastrowid + return {**_state(conn, user), "created": store_id} + + +@router.patch("/api/haushalt/laeden/{store_id}") +def update_store(store_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Laden umbenennen ({name}) oder seine Abteilungs-Reihenfolge setzen ({categoryOrder}, null = wie die Liste).""" + with transaction() as conn: + row = _get_store(conn, store_id, user) + if "name" in data: + name = clean_text(data.get("name"), "Name des Ladens", MAX_STORE_NAME) + if conn.execute( + "SELECT 1 FROM stores WHERE list_id = ? AND name = ? COLLATE NOCASE AND id != ?", (row["list_id"], name, store_id) + ).fetchone(): + raise ValidationError("Diesen Laden gibt es in der Liste schon.") + conn.execute("UPDATE stores SET name = ? WHERE id = ?", (name, store_id)) + if "categoryOrder" in data: + order = data["categoryOrder"] + if order is not None and (not isinstance(order, list) or sorted(order) != sorted(CATEGORY_KEYS)): + raise ValidationError("Die Reihenfolge muss jede Kategorie genau einmal enthalten.") + conn.execute("UPDATE stores SET category_order = ? WHERE id = ?", (json.dumps(order) if order else None, store_id)) + return _state(conn, user) + + +@router.put("/api/haushalt/listen/{list_id}/laeden-reihenfolge") +def order_stores(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Reihenfolge der Läden ({order: [ids]}) – so erscheinen sie in der Einkaufsliste.""" + with transaction() as conn: + _get_list(conn, list_id, user) + ids = [r["id"] for r in conn.execute("SELECT id FROM stores WHERE list_id = ?", (list_id,))] + order = data.get("order") + if not isinstance(order, list) or sorted(order) != sorted(ids): + raise ValidationError("Die Reihenfolge muss jeden Laden genau einmal enthalten.") + conn.executemany("UPDATE stores SET position = ? WHERE id = ?", [(i, s) for i, s in enumerate(order)]) + return _state(conn, user) + + +@router.delete("/api/haushalt/laeden/{store_id}") +def delete_store(store_id: int, user: dict = Depends(current_user)): + """Laden löschen; seine Produkte bleiben auf der Liste, nur ohne Laden.""" + with transaction() as conn: + _get_store(conn, store_id, user) + conn.execute("DELETE FROM stores WHERE id = ?", (store_id,)) + return _state(conn, user) + + +@router.put("/api/haushalt/listen/{list_id}/produkt-laden") +def set_product_store(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Produkt einem Laden zuordnen ({name, storeId}); storeId null = kein Laden. Gilt für diese Liste.""" + name = _clean_name(data.get("name")) + with transaction() as conn: + _get_list(conn, list_id, user) + store_id = data.get("storeId") + if store_id is not None: + store_id = _get_store(conn, store_id, user, list_id)["id"] + _assign_store(conn, list_id, _key(name), store_id) + return _state(conn, user) + diff --git a/backend/tests/test_vorrat_extras.py b/backend/tests/test_vorrat_extras.py index c157de8..d2cbdb4 100644 --- a/backend/tests/test_vorrat_extras.py +++ b/backend/tests/test_vorrat_extras.py @@ -131,3 +131,67 @@ def test_rezept_vorschlag_verraet_keine_privaten_produkte(login): 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" + + +# ---------- Läden ---------- + + +def new_store(client, list_id, name): + res = client.post(f"/api/haushalt/listen/{list_id}/laeden", json={"name": name}) + assert res.status_code == 201, res.text + return res.json()["created"] + + +def stores(data, list_id): + return next(l for l in data["lists"] if l["id"] == list_id)["stores"] + + +def test_laeden_anlegen_sortieren_umbenennen_loeschen(login): + anna = login("anna") + lid = home(anna) + aldi, dm = new_store(anna, lid, "Aldi"), new_store(anna, lid, "dm") + assert anna.post(f"/api/haushalt/listen/{lid}/laeden", json={"name": "aldi"}).status_code == 400 # doppelt + data = anna.put(f"/api/haushalt/listen/{lid}/laeden-reihenfolge", json={"order": [dm, aldi]}).json() + assert [s["name"] for s in stores(data, lid)] == ["dm", "Aldi"] + data = anna.patch(f"/api/haushalt/laeden/{aldi}", json={"name": "Aldi Süd", "categoryOrder": list(reversed(KEYS))}).json() + s = next(x for x in stores(data, lid) if x["id"] == aldi) + assert s["name"] == "Aldi Süd" and s["categoryOrder"] == list(reversed(KEYS)) + assert next(x for x in stores(data, lid) if x["id"] == dm)["categoryOrder"] == KEYS # Standard der Liste + data = anna.delete(f"/api/haushalt/laeden/{dm}").json() + assert [s["name"] for s in stores(data, lid)] == ["Aldi Süd"] + + +def test_produkt_laden_zuordnen_und_behalten(login): + anna = login("anna") + lid = home(anna) + dm = new_store(anna, lid, "dm") + milch = items(add(anna, lid, "Zahnpasta").json(), lid)[0] + assert milch["storeId"] is None + data = anna.put(f"/api/haushalt/listen/{lid}/produkt-laden", json={"name": "Zahnpasta", "storeId": dm}).json() + assert items(data, lid)[0]["storeId"] == dm + # Gekauft und später wieder auf die Liste: Laden bleibt gemerkt + anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True}) + anna.post(f"/api/haushalt/listen/{lid}/einkauf-beenden") + data = add(anna, lid, "Zahnpasta").json() + assert [i["storeId"] for i in items(data, lid, "einkauf")] == [dm] + # Laden gelöscht → Produkt ohne Laden, bleibt aber auf der Liste + data = anna.delete(f"/api/haushalt/laeden/{dm}").json() + assert [i["storeId"] for i in items(data, lid, "einkauf")] == [None] + + +def test_im_laden_hinzufuegen(login): + anna = login("anna") + lid = home(anna) + aldi = new_store(anna, lid, "Aldi") + data = anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Brot", "listId": lid, "storeId": aldi}).json() + assert items(data, lid)[0]["storeId"] == aldi + + +def test_laden_fremder_liste_nicht_nutzbar(login): + anna, ben = login("anna"), login("ben") + privat = new_list(anna, "Privat", everyone=False) + fremd = new_store(anna, privat, "Geheimladen") + lid = home(ben) + assert ben.patch(f"/api/haushalt/laeden/{fremd}", json={"name": "x"}).status_code == 404 + assert ben.put(f"/api/haushalt/listen/{lid}/produkt-laden", json={"name": "Brot", "storeId": fremd}).status_code == 404 + assert ben.post("/api/haushalt/items", json={"list": "einkauf", "name": "Brot", "listId": lid, "storeId": fremd}).status_code == 404 diff --git a/frontend/src/components/haushalt/CategoryOrderDialog.jsx b/frontend/src/components/haushalt/CategoryOrderDialog.jsx index 510194c..c5319aa 100644 --- a/frontend/src/components/haushalt/CategoryOrderDialog.jsx +++ b/frontend/src/components/haushalt/CategoryOrderDialog.jsx @@ -1,7 +1,7 @@ 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 }) { +export default function CategoryOrderDialog({ ref, categories, order, title: heading, onSave, busy }) { const [current, setCurrent] = useState(order); const title = Object.fromEntries(categories.map((c) => [c.key, c.title])); const move = (index, delta) => setCurrent((list) => { @@ -17,8 +17,8 @@ export default function CategoryOrderDialog({ ref, categories, order, onSave, bu return (
-

Reihenfolge im Laden

-

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

+

{heading || 'Reihenfolge der Abteilungen'}

+

{heading ? 'Gilt für diesen Laden.' : 'Gilt für Produkte ohne Laden und für Läden ohne eigene Reihenfolge.'} Am besten so, wie du durch den Markt gehst.

    {current.map((key, i) => (
  1. diff --git a/frontend/src/components/haushalt/ListPanel.jsx b/frontend/src/components/haushalt/ListPanel.jsx index e91be71..f983126 100644 --- a/frontend/src/components/haushalt/ListPanel.jsx +++ b/frontend/src/components/haushalt/ListPanel.jsx @@ -13,8 +13,9 @@ export const LISTS = { 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). + * extras: { minimums, categories, categoryOrder, stores, storeFilter, sortMode, onSortMode, onStoreFilter, onEditOrder, + * onEditStores, onEditMinimums, onCategory, onStore } + * – Mindestbestand (Vorrat); Einkauf "Nach Laden": nach Laden gruppiert (falls angelegt), darin nach Abteilung. */ export default function ListPanel({ list, items, products, find, actions, busy, hidden, extras }) { const { title, lead, empty } = LISTS[list]; @@ -25,12 +26,23 @@ export default function ListPanel({ list, items, products, find, actions, busy, 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 }]; + // Laden-Modus: erst nach Laden (falls welche angelegt sind), darin nach Abteilung in der Reihenfolge des Ladens + const stores = extras.stores; + const byCategory = (list, order) => order + .map((key) => ({ key, title: categoryTitle[key], items: list.filter((i) => i.category === key) })) + .filter((g) => g.items.length); + const known = new Set(stores.map((s) => s.id)); + const sections = !byShop + ? [{ key: 'alle', groups: [{ key: 'alle', items: open }] }] + : !stores.length + ? [{ key: 'alle', groups: byCategory(open, extras.categoryOrder) }] + : [ + ...stores.map((s) => ({ key: `s${s.id}`, id: s.id, title: s.name, groups: byCategory(open.filter((i) => i.storeId === s.id), s.categoryOrder) })), + { key: 'ohne', id: 'ohne', title: 'Ohne Laden', groups: byCategory(open.filter((i) => !known.has(i.storeId)), extras.categoryOrder) }, + ] + .filter((sec) => extras.storeFilter === 'alle' || extras.storeFilter === sec.id) + .filter((sec) => sec.groups.length); + const count = (id) => open.filter((i) => (id === 'ohne' ? !known.has(i.storeId) : i.storeId === id)).length; const row = (item) => ( extras.onEditMinimums(item)} categories={byShop ? extras.categories : null} onCategory={extras.onCategory} + stores={byShop && stores.length ? stores : null} + onStore={extras.onStore} /> ); @@ -82,9 +96,25 @@ export default function ListPanel({ list, items, products, find, actions, busy, ))}
- {byShop && } + {byShop && ( + + + + + )} )} + {byShop && stores.length > 0 && ( + + )} @@ -92,9 +122,12 @@ export default function ListPanel({ list, items, products, find, actions, busy,

{empty}

) : (
    - {groups.map((g) => [ - byShop &&
  • {g.title}
  • , - ...g.items.map(row), + {sections.map((sec) => [ + sec.title &&
  • {sec.title}
  • , + ...sec.groups.map((g) => [ + byShop &&
  • {g.title}
  • , + ...g.items.map(row), + ]), ])} {inCart.length > 0 && ( @@ -205,7 +238,7 @@ function QuantityInput({ item, label, onCommit, disabled }) { ); } -function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, categories, onCategory }) { +function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, categories, onCategory, stores, onStore }) { const isShopping = list === 'einkauf'; const step = STEP[item.unit]; const last = item.quantity - step <= 0; @@ -245,16 +278,32 @@ function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, cat )} ✎ {personName(item.updatedBy)} - {categories && ( - + {(categories || stores) && ( + + {categories && ( + + )} + {stores && ( + + )} + )} diff --git a/frontend/src/components/haushalt/StoresDialog.jsx b/frontend/src/components/haushalt/StoresDialog.jsx new file mode 100644 index 0000000..605dafc --- /dev/null +++ b/frontend/src/components/haushalt/StoresDialog.jsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; + +/** + * Läden der gewählten Liste verwalten: anlegen, umbenennen, sortieren, löschen und je Laden die + * Reihenfolge der Abteilungen (jeder Markt ist anders aufgebaut). Alle Aktionen liefern den neuen Stand. + */ +export default function StoresDialog({ ref, stores, onCreate, onRename, onOrder, onDelete, onEditOrder, busy }) { + const [name, setName] = useState(''); + const [names, setNames] = useState({}); // Entwürfe beim Umbenennen + + async function add(e) { + e.preventDefault(); + if (name.trim() && await onCreate(name.trim())) setName(''); + } + + function rename(store) { + const draft = names[store.id]; + if (draft != null && draft.trim() && draft.trim() !== store.name) onRename(store, draft.trim()); + setNames((n) => ({ ...n, [store.id]: undefined })); + } + + function move(index, delta) { + const ids = stores.map((s) => s.id); + [ids[index], ids[index + delta]] = [ids[index + delta], ids[index]]; + onOrder(ids); + } + + return ( + +
    +

    Läden

    +

    Produkte lassen sich einem Laden zuordnen – „Nach Laden“ zeigt dann, was du wo kaufst. Die Zuordnung gilt für diese Liste und bleibt gemerkt.

    + {stores.length > 0 && ( +
      + {stores.map((s, i) => ( +
    1. + setNames((n) => ({ ...n, [s.id]: e.target.value }))} + onBlur={() => rename(s)} + onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }} + maxLength={40} + aria-label={`Name von ${s.name}`} + /> + + + + +
    2. + ))} +
    + )} +
    + + setName(e.target.value)} maxLength={40} placeholder="Neuer Laden, z. B. Aldi, dm, Baumarkt" /> + +
    +
    + +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Haushalt.jsx b/frontend/src/pages/Haushalt.jsx index 298e580..f80c668 100644 --- a/frontend/src/pages/Haushalt.jsx +++ b/frontend/src/pages/Haushalt.jsx @@ -8,6 +8,7 @@ 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 StoresDialog from '../components/haushalt/StoresDialog.jsx'; import { isCalm } from '../motion.js'; import useLiveData from '../useLiveData.js'; @@ -16,6 +17,7 @@ const REFRESH_SECONDS = 15; const TAB_KEY = 'p42.haushaltTab'; const LIST_KEY = 'p42.haushaltListe'; const SORT_KEY = 'p42.einkaufSortierung'; +const STORE_KEY = 'p42.ladenFilter'; function loadTab() { try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; } @@ -42,6 +44,10 @@ export default function Haushalt() { const [orderDialog, setOrderDialog] = useState(null); const minRef = useRef(null); const orderRef = useRef(null); + const storesRef = useRef(null); + const [storeFilter, setStoreFilter] = useState(() => { + try { const v = localStorage.getItem(STORE_KEY); return v && v !== 'alle' && v !== 'ohne' ? Number(v) : v || 'alle'; } catch { return 'alle'; } + }); const listDialogRef = useRef(null); // Jede Änderung liefert den aktuellen Stand aller Listen zurück. @@ -103,11 +109,17 @@ export default function Haushalt() { requestAnimationFrame(() => minRef.current?.showModal()); } - function openOrder() { - setOrderDialog({ opened: Date.now() }); + // store: Abteilungs-Reihenfolge eines Ladens; null: Standard der Liste + function openOrder(store) { + setOrderDialog({ store, opened: Date.now() }); requestAnimationFrame(() => orderRef.current?.showModal()); } + function chooseStoreFilter(value) { + setStoreFilter(value); + try { localStorage.setItem(STORE_KEY, String(value)); } catch { /* ignorieren */ } + } + const setCategory = (name, category) => run('PUT', '/api/haushalt/produkte/kategorie', { name, category }); const items = data?.items || []; @@ -122,8 +134,15 @@ export default function Haushalt() { // Alle Zeilen eines Produkts (ein Produkt kann in mehreren Einheiten auf einer Liste stehen) const find = (list, name) => byList[list].filter((i) => i.name.toLocaleLowerCase('de-DE') === name.toLocaleLowerCase('de-DE')); + const stores = current?.stores || []; + // Gewählter Laden-Filter; gibt es den Laden nicht (mehr), alle zeigen + const activeStore = storeFilter === 'alle' || storeFilter === 'ohne' || stores.some((s) => s.id === storeFilter) ? storeFilter : 'alle'; const actions = { - add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit, listId: current?.id }), + add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { + list, name, quantity, unit, listId: current?.id, + // Im Laden-Filter hinzugefügt: gleich diesem Laden zuordnen + storeId: list === 'einkauf' && sortMode === 'laden' && typeof activeStore === 'number' ? activeStore : undefined, + }), update: (item, patch) => run('PATCH', `/api/haushalt/items/${item.id}`, patch), move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`), check: (item, checked) => run('PATCH', `/api/haushalt/items/${item.id}`, { checked }, { animate: true }), @@ -204,6 +223,11 @@ export default function Haushalt() { minimums: (data.minimums || []).filter((m) => m.listId === current.id), categories: data.categories || [], categoryOrder: current.categoryOrder || [], + stores, + storeFilter: activeStore, + onStoreFilter: chooseStoreFilter, + onEditStores: () => storesRef.current?.showModal(), + onStore: (name, storeId) => run('PUT', `/api/haushalt/listen/${current.id}/produkt-laden`, { name, storeId }), sortMode, onSortMode: chooseSort, onEditOrder: openOrder, @@ -253,9 +277,24 @@ export default function Haushalt() { key={orderDialog.opened} ref={orderRef} categories={data?.categories || []} - order={current.categoryOrder || []} + title={orderDialog.store ? `Abteilungen bei ${orderDialog.store.name}` : null} + order={(orderDialog.store ? stores.find((s) => s.id === orderDialog.store.id)?.categoryOrder : current.categoryOrder) || []} busy={busy} - onSave={(order) => run('PUT', `/api/haushalt/listen/${current.id}/reihenfolge`, { order })} + onSave={(order) => (orderDialog.store + ? run('PATCH', `/api/haushalt/laeden/${orderDialog.store.id}`, { categoryOrder: order }) + : run('PUT', `/api/haushalt/listen/${current.id}/reihenfolge`, { order }))} + /> + )} + {current && ( + run('POST', `/api/haushalt/listen/${current.id}/laeden`, { name })} + onRename={(store, name) => run('PATCH', `/api/haushalt/laeden/${store.id}`, { name })} + onOrder={(order) => run('PUT', `/api/haushalt/listen/${current.id}/laeden-reihenfolge`, { order })} + onDelete={(store) => run('DELETE', `/api/haushalt/laeden/${store.id}`)} + onEditOrder={openOrder} /> )} diff --git a/frontend/src/styles/haushalt.css b/frontend/src/styles/haushalt.css index 4b4bbba..45bfbb8 100644 --- a/frontend/src/styles/haushalt.css +++ b/frontend/src/styles/haushalt.css @@ -310,3 +310,13 @@ html.vt-items .household-item { view-transition-name: var(--vt); } .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); } + +/* Läden */ +.household-tool-links { display: inline-flex; gap: 14px; } +.store-filter { margin-top: -4px; } +.store-filter small { opacity: 0.7; } +.household-store-head { margin: 16px 0 4px; padding: 6px 10px; font-weight: 700; color: var(--bright); background: var(--accent-soft); border-left: 3px solid var(--accent); border-radius: var(--radius-s); } +.household-store-head:first-child { margin-top: 0; } +.stores-list input { flex: 1; min-width: 0; } +.item-selects { display: flex; flex-wrap: wrap; gap: 4px 8px; } +.item-selects .category-select { margin-top: 2px; }