diff --git a/README.md b/README.md index f94d48f..53c0612 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,10 @@ und einen eigenen Einkaufszettel. (für alle sichtbar). - Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der Rezeptabgleich wirken immer innerhalb der gewählten Liste. -- Haken bei „Gekauft“ übernimmt einen Eintrag in den Vorrat; −‑Klick bei der letzten Einheit - im Vorrat setzt ihn auf die Einkaufsliste. +- **Einkaufen:** Abhaken legt ein Produkt „in den Wagen“ – es rutscht nach unten, bleibt aber auf der + Einkaufsliste (für alle sichtbar, die die Liste sehen; erneut klicken nimmt den Haken zurück). + **„Einkauf beenden“** übernimmt alle abgehakten Produkte auf einmal in den Vorrat. + −‑Klick bei der letzten Einheit im Vorrat setzt ein Produkt auf die Einkaufsliste. - **Rezept abgleichen:** Link zu einer Rezeptseite (z. B. Chefkoch) oder eine Zutatenliste einfügen, Personenzahl angeben. Die Seite rechnet die Mengen um, vergleicht mit dem Vorrat und setzt Fehlendes auf die Einkaufsliste. Eiweiß/Eigelb werden zu Eiern. diff --git a/backend/app/db.py b/backend/app/db.py index 93afc37..09d7dbe 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -21,6 +21,7 @@ HOUSEHOLD_TABLE = """( list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, quantity REAL NOT NULL CHECK (quantity > 0), unit TEXT NOT NULL DEFAULT 'stk' CHECK (unit IN ('stk', 'kg', 'l')), + checked INTEGER NOT NULL DEFAULT 0, -- Einkauf: im Wagen, wandert erst mit "Einkauf beenden" in den Vorrat updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, UNIQUE (list_id, key, list, unit) @@ -294,6 +295,13 @@ def _migrate(conn) -> None: conn.execute("DROP TABLE household_items_alt") +def _migrate_checked(conn) -> None: + # Einkauf ohne Haken-Spalte: ergänzen, bestehende Einträge sind nicht abgehakt + columns = {r["name"] for r in conn.execute("PRAGMA table_info(household_items)")} + if "checked" not in columns: + conn.execute("ALTER TABLE household_items ADD COLUMN checked INTEGER NOT NULL DEFAULT 0") + + def _migrate_travel(conn) -> None: # Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")} @@ -387,6 +395,7 @@ def init() -> None: conn.execute("PRAGMA journal_mode = WAL") conn.executescript(SCHEMA) _migrate(conn) + _migrate_checked(conn) _migrate_travel(conn) _migrate_monitor_token(conn) _migrate_kanban_categories(conn) diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index fef5946..e5a7817 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -34,6 +34,7 @@ def _item(row) -> dict: "listId": row["list_id"], "quantity": _out_quantity(row["quantity"], row["unit"]), "unit": row["unit"], + "checked": bool(row["checked"]), "updatedAt": row["updated_at"], "updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None, } @@ -102,12 +103,16 @@ def _canonical_name(conn, name: str) -> str: def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None: - """Produkt in den Vorrat bzw. Einkauf einer Liste setzen; steht es dort schon in derselben Einheit, wird die Menge erhöht.""" + """Produkt in den Vorrat bzw. Einkauf einer Liste setzen; steht es dort schon in derselben Einheit, wird die Menge erhöht. + + Kommt ein schon abgehaktes Produkt erneut auf die Einkaufsliste, ist der Haken wieder weg (es fehlt ja noch etwas). + """ conn.execute( """INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (list_id, key, list, unit) DO UPDATE SET quantity = MIN(ROUND(quantity + excluded.quantity, 2), ?), + checked = 0, updated_at = excluded.updated_at, updated_by = excluded.updated_by""", (_key(name), name, list_, list_id, quantity, unit, now_iso(), user_id, MAX_QUANTITY), @@ -237,9 +242,20 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)): @router.patch("/api/haushalt/items/{item_id}") def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(current_user)): - """Menge und/oder Einheit ändern. Beim Einheitenwechsel wird mit einer bestehenden Zeile zusammengeführt.""" + """Menge und/oder Einheit ändern bzw. abhaken. Beim Einheitenwechsel wird mit einer bestehenden Zeile zusammengeführt.""" with transaction() as conn: row = _get_item(conn, item_id, user) + if "checked" in data: + # Abhaken: liegt im Wagen, bleibt aber bis "Einkauf beenden" auf der Einkaufsliste + if not isinstance(data["checked"], bool) or row["list"] != "einkauf": + raise ValidationError("Nur Einträge auf der Einkaufsliste lassen sich abhaken.") + conn.execute( + "UPDATE household_items SET checked = ?, updated_at = ?, updated_by = ? WHERE id = ?", + (int(data["checked"]), now_iso(), user["id"], item_id), + ) + if len(data) == 1: + return _state(conn, user) + row = _get_item(conn, item_id, user) unit = _unit(data.get("unit", row["unit"])) if "quantity" in data: quantity = _quantity(data["quantity"], unit) @@ -274,6 +290,22 @@ def move_item(item_id: int, user: dict = Depends(current_user)): return _state(conn, user) +@router.post("/api/haushalt/listen/{list_id}/einkauf-beenden") +def finish_shopping(list_id: int, user: dict = Depends(current_user)): + """Alle abgehakten Einträge der Einkaufsliste auf einmal in den Vorrat dieser Liste übernehmen.""" + with transaction() as conn: + _get_list(conn, list_id, user) + rows = conn.execute( + "SELECT * FROM household_items WHERE list_id = ? AND list = 'einkauf' AND checked = 1", (list_id,) + ).fetchall() + if not rows: + raise ValidationError("Nichts abgehakt – vielleicht hat gerade jemand anderes den Einkauf beendet.") + for row in rows: + conn.execute("DELETE FROM household_items WHERE id = ?", (row["id"],)) + _add(conn, row["name"], "vorrat", list_id, row["quantity"], row["unit"], user["id"]) + return {**_state(conn, user), "moved": len(rows)} + + @router.delete("/api/haushalt/items/{item_id}") def delete_item(item_id: int, user: dict = Depends(current_user)): with transaction() as conn: diff --git a/frontend/src/pages/Haushalt.jsx b/frontend/src/pages/Haushalt.jsx index fe2927f..0a876c6 100644 --- a/frontend/src/pages/Haushalt.jsx +++ b/frontend/src/pages/Haushalt.jsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; import { api, personName } from '../api.js'; +import { isCalm } from '../motion.js'; import { useAuth } from '../auth.jsx'; import ProductInput from '../components/ProductInput.jsx'; import RecipeCheck from '../components/RecipeCheck.jsx'; @@ -54,13 +56,21 @@ export default function Haushalt() { }; }, [load]); - // Jede Änderung liefert den aktuellen Stand aller Listen zurück - async function run(method, url, body) { + // Jede Änderung liefert den aktuellen Stand aller Listen zurück. + // animate: Einträge gleiten an ihren neuen Platz (z. B. abgehakt → nach unten), wo der Browser es kann + async function run(method, url, body, { animate = false } = {}) { setBusy(true); setError(null); try { const next = await api(method, url, body); - setData(next); + if (animate && document.startViewTransition && !isCalm()) { + const root = document.documentElement; + root.classList.add('vt-items'); + const vt = document.startViewTransition(() => flushSync(() => setData(next))); + vt.finished.finally(() => root.classList.remove('vt-items')); + } else { + setData(next); + } return next; } catch (e) { setError(e.message); @@ -123,6 +133,8 @@ export default function Haushalt() { add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit, listId: current?.id }), 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 }), + finish: () => run('POST', `/api/haushalt/listen/${current.id}/einkauf-beenden`, undefined, { animate: true }), remove: (item) => run('DELETE', `/api/haushalt/items/${item.id}`), }; @@ -220,6 +232,9 @@ export default function Haushalt() { function ListPanel({ list, items, products, find, actions, busy, hidden }) { const { title, lead, empty } = LISTS[list]; const other = list === 'vorrat' ? 'einkauf' : 'vorrat'; + // Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat + const open = items.filter((i) => !i.checked); + const inCart = items.filter((i) => i.checked); // Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo? function hint(name) { @@ -249,11 +264,28 @@ function ListPanel({ list, items, products, find, actions, busy, hidden }) {
{empty}
) : (