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:
1 parent
bbf740603e
commit
fef9c8f16e
9 files changed
+382
-22
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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; }
|
||||||
Reference in new issue
Block a user