Vorrat: eigene Läden mit Produktzuordnung

- 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 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 20:11:39 +02:00
1 parent bbf740603e
commit fef9c8f16e
9 files changed
+382 -22

No files matched your search

+5
View File
@@ -163,6 +163,11 @@ und einen eigenen Einkaufszettel.
Kühlregal …). Neue Produkte bekommen ihre Kategorie automatisch (Stichwörter, `backend/app/kategorien.py`), 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 ä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. 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). - **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 „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. gewählten Liste sind, und was noch fehlt; ein Klick gleicht das Rezept vollständig ab.
+18
View File
@@ -148,6 +148,24 @@ CREATE TABLE IF NOT EXISTS products (
category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet 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 -- Mindestbestand: fällt der Vorrat einer Liste darunter, kommt das Produkt auf ihre Einkaufsliste
CREATE TABLE IF NOT EXISTS stock_minimums ( CREATE TABLE IF NOT EXISTS stock_minimums (
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
+112 -2
View File
@@ -25,6 +25,8 @@ MAX_QUANTITY = 999
MAX_PRODUCTS = 2000 MAX_PRODUCTS = 2000
MAX_LIST_NAME = 40 MAX_LIST_NAME = 40
MAX_LISTS = 50 MAX_LISTS = 50
MAX_STORES = 30
MAX_STORE_NAME = 40
def _item(row) -> dict: def _item(row) -> dict:
@@ -37,6 +39,7 @@ def _item(row) -> dict:
"unit": row["unit"], "unit": row["unit"],
"checked": bool(row["checked"]), "checked": bool(row["checked"]),
"category": row["category"] or guess(row["name"]), "category": row["category"] or guess(row["name"]),
"storeId": row["store_id"],
"updatedAt": row["updated_at"], "updatedAt": row["updated_at"],
"updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None, "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, "createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None,
"canManage": _can_manage(row, user), "canManage": _can_manage(row, user),
"categoryOrder": _category_order(row["category_order"]), "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] ids = [r["id"] for r in lists]
marks = ",".join("?" * len(ids)) marks = ",".join("?" * len(ids))
items = conn.execute( 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 users u ON u.id = i.updated_by
LEFT JOIN products p ON p.key = i.key 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})""", WHERE i.list_id IN ({marks})""",
ids, ids,
).fetchall() ).fetchall()
@@ -295,7 +303,11 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)):
quantity = _quantity(data.get("quantity", 1), unit) quantity = _quantity(data.get("quantity", 1), unit)
with transaction() as conn: with transaction() as conn:
list_id = _get_list(conn, data.get("listId"), user)["id"] 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) 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) _get_list(conn, list_id, user)
conn.execute("UPDATE shopping_lists SET category_order = ? WHERE id = ?", (json.dumps(order), list_id)) conn.execute("UPDATE shopping_lists SET category_order = ? WHERE id = ?", (json.dumps(order), list_id))
return _state(conn, user) 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)
+64
View File
@@ -131,3 +131,67 @@ def test_rezept_vorschlag_verraet_keine_privaten_produkte(login):
save(ben, "Test", ["2 Kondom"]) save(ben, "Test", ["2 Kondom"])
res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json() res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json()
assert res[0]["missing"] == ["Kondom"] # nicht Annas Schreibweise "Kondome" 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
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
// Reihenfolge der Abteilungen so, wie man durch den eigenen Laden läuft (gilt für die gewählte Liste) // 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 [current, setCurrent] = useState(order);
const title = Object.fromEntries(categories.map((c) => [c.key, c.title])); const title = Object.fromEntries(categories.map((c) => [c.key, c.title]));
const move = (index, delta) => setCurrent((list) => { const move = (index, delta) => setCurrent((list) => {
@@ -17,8 +17,8 @@ export default function CategoryOrderDialog({ ref, categories, order, onSave, bu
return ( return (
<dialog className="modal" ref={ref}> <dialog className="modal" ref={ref}>
<div className="form"> <div className="form">
<h2>Reihenfolge im Laden</h2> <h2>{heading || 'Reihenfolge der Abteilungen'}</h2>
<p className="hint">So sortiert „Nach Laden“ die Einkaufsliste – am besten in der Reihenfolge, in der du durch den Markt gehst.</p> <p className="hint">{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.</p>
<ol className="order-list"> <ol className="order-list">
{current.map((key, i) => ( {current.map((key, i) => (
<li key={key}> <li key={key}>
+61 -12
View File
@@ -13,8 +13,9 @@ export const LISTS = {
const same = (a, b) => a.toLocaleLowerCase('de-DE') === b.toLocaleLowerCase('de-DE'); const same = (a, b) => a.toLocaleLowerCase('de-DE') === b.toLocaleLowerCase('de-DE');
/** /**
* extras: { minimums, categories, categoryOrder, sortMode, onSortMode, onEditOrder, onEditMinimums, onCategory } * extras: { minimums, categories, categoryOrder, stores, storeFilter, sortMode, onSortMode, onStoreFilter, onEditOrder,
* – Mindestbestand (Vorrat) und Laden-Sortierung nach Kategorien (Einkauf). * 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 }) { export default function ListPanel({ list, items, products, find, actions, busy, hidden, extras }) {
const { title, lead, empty } = LISTS[list]; 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 byShop = list === 'einkauf' && extras.sortMode === 'laden';
const minimumOf = (item) => extras.minimums.find((m) => same(m.name, item.name) && m.unit === item.unit); 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])); const categoryTitle = Object.fromEntries(extras.categories.map((c) => [c.key, c.title]));
// Laden-Modus: nach Kategorien in der Reihenfolge dieser Liste gruppieren // Laden-Modus: erst nach Laden (falls welche angelegt sind), darin nach Abteilung in der Reihenfolge des Ladens
const groups = byShop const stores = extras.stores;
? extras.categoryOrder const byCategory = (list, order) => order
.map((key) => ({ key, title: categoryTitle[key], items: open.filter((i) => i.category === key) })) .map((key) => ({ key, title: categoryTitle[key], items: list.filter((i) => i.category === key) }))
.filter((g) => g.items.length) .filter((g) => g.items.length);
: [{ key: 'alle', items: open }]; 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) => ( const row = (item) => (
<ItemRow <ItemRow
key={item.id} key={item.id}
@@ -43,6 +55,8 @@ export default function ListPanel({ list, items, products, find, actions, busy,
onEditMinimum={() => extras.onEditMinimums(item)} onEditMinimum={() => extras.onEditMinimums(item)}
categories={byShop ? extras.categories : null} categories={byShop ? extras.categories : null}
onCategory={extras.onCategory} 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,
<button key={key} type="button" aria-pressed={extras.sortMode === key} onClick={() => extras.onSortMode(key)}>{label}</button> <button key={key} type="button" aria-pressed={extras.sortMode === key} onClick={() => extras.onSortMode(key)}>{label}</button>
))} ))}
</div> </div>
{byShop && <button className="link-btn" type="button" onClick={extras.onEditOrder}>⇅ Reihenfolge</button>} {byShop && (
<span className="household-tool-links">
<button className="link-btn" type="button" onClick={extras.onEditStores}>🏪 Läden{stores.length ? ` (${stores.length})` : ''}</button>
<button className="link-btn" type="button" onClick={() => extras.onEditOrder(null)}>⇅ Abteilungen</button>
</span>
)}
</div> </div>
)} )}
{byShop && stores.length > 0 && (
<ul className="chip-list store-filter" aria-label="Nur einen Laden zeigen">
{[{ id: 'alle', name: 'Alle' }, ...stores, { id: 'ohne', name: 'Ohne Laden' }].map((s) => (
<li key={s.id}>
<button type="button" className={`chip${extras.storeFilter === s.id ? ' active' : ''}`} aria-pressed={extras.storeFilter === s.id} onClick={() => extras.onStoreFilter(s.id)}>
{s.name}{s.id !== 'alle' && <small> {count(s.id)}</small>}
</button>
</li>
))}
</ul>
)}
<AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} /> <AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} />
@@ -92,9 +122,12 @@ export default function ListPanel({ list, items, products, find, actions, busy,
<p className="hint household-empty">{empty}</p> <p className="hint household-empty">{empty}</p>
) : ( ) : (
<ul className="household-list"> <ul className="household-list">
{groups.map((g) => [ {sections.map((sec) => [
byShop && <li key={`kat-${g.key}`} className="household-group-head">{g.title}</li>, sec.title && <li key={`laden-${sec.key}`} className="household-store-head">{sec.title}</li>,
...sec.groups.map((g) => [
byShop && <li key={`kat-${sec.key}-${g.key}`} className="household-group-head">{g.title}</li>,
...g.items.map(row), ...g.items.map(row),
]),
])} ])}
{inCart.length > 0 && ( {inCart.length > 0 && (
<li className="household-cart-head" aria-hidden="true">Im Wagen</li> <li className="household-cart-head" aria-hidden="true">Im Wagen</li>
@@ -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 isShopping = list === 'einkauf';
const step = STEP[item.unit]; const step = STEP[item.unit];
const last = item.quantity - step <= 0; const last = item.quantity - step <= 0;
@@ -245,6 +278,8 @@ function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, cat
)} )}
✎ {personName(item.updatedBy)} ✎ {personName(item.updatedBy)}
</small> </small>
{(categories || stores) && (
<span className="item-selects">
{categories && ( {categories && (
<select <select
className="unit-select category-select" className="unit-select category-select"
@@ -256,6 +291,20 @@ function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, cat
{categories.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)} {categories.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select> </select>
)} )}
{stores && (
<select
className="unit-select category-select"
value={stores.some((s) => s.id === item.storeId) ? item.storeId : ''}
onChange={(e) => onStore(item.name, e.target.value ? Number(e.target.value) : null)}
disabled={busy}
aria-label={`Laden für ${item.name}`}
>
<option value="">Kein Laden</option>
{stores.map((s) => <option key={s.id} value={s.id}>🏪 {s.name}</option>)}
</select>
)}
</span>
)}
</div> </div>
<div className="stepper" role="group" aria-label={`Menge ${label}`}> <div className="stepper" role="group" aria-label={`Menge ${label}`}>
@@ -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 (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Läden</h2>
<p className="hint">Produkte lassen sich einem Laden zuordnen – „Nach Laden“ zeigt dann, was du wo kaufst. Die Zuordnung gilt für diese Liste und bleibt gemerkt.</p>
{stores.length > 0 && (
<ol className="order-list stores-list">
{stores.map((s, i) => (
<li key={s.id}>
<input
value={names[s.id] ?? s.name}
onChange={(e) => 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}`}
/>
<button className="link-btn" type="button" onClick={() => onEditOrder(s)} title="Reihenfolge der Abteilungen in diesem Laden">⇅ Abteilungen</button>
<button className="link-btn" type="button" disabled={busy || i === 0} onClick={() => move(i, -1)} aria-label={`${s.name} nach oben`}>↑</button>
<button className="link-btn" type="button" disabled={busy || i === stores.length - 1} onClick={() => move(i, 1)} aria-label={`${s.name} nach unten`}>↓</button>
<button className="link-btn danger" type="button" disabled={busy} aria-label={`${s.name} löschen`}
onClick={() => window.confirm(`Laden „${s.name}“ löschen? Die Produkte bleiben auf der Liste, nur ohne Laden.`) && onDelete(s)}>✕</button>
</li>
))}
</ol>
)}
<form className="household-add" onSubmit={add}>
<label className="sr-only" htmlFor="store-name">Neuer Laden</label>
<input id="store-name" value={name} onChange={(e) => setName(e.target.value)} maxLength={40} placeholder="Neuer Laden, z. B. Aldi, dm, Baumarkt" />
<button className="btn" type="submit" disabled={busy || !name.trim()}>Anlegen</button>
</form>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
</div>
</div>
</dialog>
);
}
+44 -5
View File
@@ -8,6 +8,7 @@ import ListSwitcher from '../components/haushalt/ListSwitcher.jsx';
import MinimumDialog from '../components/haushalt/MinimumDialog.jsx'; import MinimumDialog from '../components/haushalt/MinimumDialog.jsx';
import ProductsDialog from '../components/haushalt/ProductsDialog.jsx'; import ProductsDialog from '../components/haushalt/ProductsDialog.jsx';
import RecipeCheck from '../components/haushalt/RecipeCheck.jsx'; import RecipeCheck from '../components/haushalt/RecipeCheck.jsx';
import StoresDialog from '../components/haushalt/StoresDialog.jsx';
import { isCalm } from '../motion.js'; import { isCalm } from '../motion.js';
import useLiveData from '../useLiveData.js'; import useLiveData from '../useLiveData.js';
@@ -16,6 +17,7 @@ const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab'; const TAB_KEY = 'p42.haushaltTab';
const LIST_KEY = 'p42.haushaltListe'; const LIST_KEY = 'p42.haushaltListe';
const SORT_KEY = 'p42.einkaufSortierung'; const SORT_KEY = 'p42.einkaufSortierung';
const STORE_KEY = 'p42.ladenFilter';
function loadTab() { function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; } 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 [orderDialog, setOrderDialog] = useState(null);
const minRef = useRef(null); const minRef = useRef(null);
const orderRef = 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); const listDialogRef = useRef(null);
// Jede Änderung liefert den aktuellen Stand aller Listen zurück. // Jede Änderung liefert den aktuellen Stand aller Listen zurück.
@@ -103,11 +109,17 @@ export default function Haushalt() {
requestAnimationFrame(() => minRef.current?.showModal()); requestAnimationFrame(() => minRef.current?.showModal());
} }
function openOrder() { // store: Abteilungs-Reihenfolge eines Ladens; null: Standard der Liste
setOrderDialog({ opened: Date.now() }); function openOrder(store) {
setOrderDialog({ store, opened: Date.now() });
requestAnimationFrame(() => orderRef.current?.showModal()); 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 setCategory = (name, category) => run('PUT', '/api/haushalt/produkte/kategorie', { name, category });
const items = data?.items || []; 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) // 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 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 = { 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), update: (item, patch) => run('PATCH', `/api/haushalt/items/${item.id}`, patch),
move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`), move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`),
check: (item, checked) => run('PATCH', `/api/haushalt/items/${item.id}`, { checked }, { animate: true }), 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), minimums: (data.minimums || []).filter((m) => m.listId === current.id),
categories: data.categories || [], categories: data.categories || [],
categoryOrder: current.categoryOrder || [], 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, sortMode,
onSortMode: chooseSort, onSortMode: chooseSort,
onEditOrder: openOrder, onEditOrder: openOrder,
@@ -253,9 +277,24 @@ export default function Haushalt() {
key={orderDialog.opened} key={orderDialog.opened}
ref={orderRef} ref={orderRef}
categories={data?.categories || []} 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} 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 && (
<StoresDialog
ref={storesRef}
stores={stores}
busy={busy}
onCreate={(name) => 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}
/> />
)} )}
</> </>
+10
View File
@@ -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-score { font-variant-numeric: tabular-nums; color: var(--warn); font-size: 0.82rem; }
.recipe-saved li.complete .recipe-score { color: var(--ok); } .recipe-saved li.complete .recipe-score { color: var(--ok); }
.recipe-missing { flex: 1 1 200px; color: var(--muted); } .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; }