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`),
ä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.
+18
View File
@@ -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,
+112 -2
View File
@@ -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)
+64
View File
@@ -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
@@ -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 (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Reihenfolge im Laden</h2>
<p className="hint">So sortiert „Nach Laden“ die Einkaufsliste – am besten in der Reihenfolge, in der du durch den Markt gehst.</p>
<h2>{heading || 'Reihenfolge der Abteilungen'}</h2>
<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">
{current.map((key, i) => (
<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');
/**
* 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) => (
<ItemRow
key={item.id}
@@ -43,6 +55,8 @@ export default function ListPanel({ list, items, products, find, actions, busy,
onEditMinimum={() => 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,
<button key={key} type="button" aria-pressed={extras.sortMode === key} onClick={() => extras.onSortMode(key)}>{label}</button>
))}
</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>
)}
{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} />
@@ -92,9 +122,12 @@ export default function ListPanel({ list, items, products, find, actions, busy,
<p className="hint household-empty">{empty}</p>
) : (
<ul className="household-list">
{groups.map((g) => [
byShop && <li key={`kat-${g.key}`} className="household-group-head">{g.title}</li>,
{sections.map((sec) => [
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),
]),
])}
{inCart.length > 0 && (
<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 step = STEP[item.unit];
const last = item.quantity - step <= 0;
@@ -245,6 +278,8 @@ function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, cat
)}
✎ {personName(item.updatedBy)}
</small>
{(categories || stores) && (
<span className="item-selects">
{categories && (
<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>)}
</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 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 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 && (
<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-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; }