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}

) : ( )} + + {inCart.length > 0 && ( +
+ + {inCart.length} {inCart.length === 1 ? 'Produkt' : 'Produkte'} im Wagen + + +
+ )} ); } @@ -354,16 +386,16 @@ function ItemRow({ item, list, other, actions, busy }) { const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`; return ( -
  • +
  • {isShopping && ( actions.move(item)} - aria-label={`${label} gekauft – in den Vorrat übernehmen`} - title="Gekauft: in den Vorrat übernehmen" + onChange={() => actions.check(item, !item.checked)} + aria-label={item.checked ? `${label} im Wagen – Haken entfernen` : `${label} abhaken (im Wagen)`} + title={item.checked ? 'Im Wagen – zum Zurücknehmen klicken' : 'Abhaken: kommt mit „Einkauf beenden“ in den Vorrat'} /> )} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 834944e..0af2eaf 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -885,6 +885,25 @@ h3 { font-size: var(--h3-size); line-height: 1.3; } } .household-check::after { content: '✓'; color: var(--accent); opacity: 0; font-weight: 700; } .household-check:hover::after { opacity: 0.6; } +.household-check:checked::after { opacity: 1; } + +/* Einkauf: abgehakt = im Wagen, unten in der Liste */ +.household-cart-head { + margin: 14px 0 4px; + font-size: var(--label-size); + letter-spacing: var(--label-tracking); + text-transform: uppercase; + color: var(--muted); +} +.household-item.checked .household-name strong { text-decoration: line-through; text-decoration-thickness: 2px; color: var(--muted); } +.household-item.checked .stepper, +.household-item.checked > .unit-select, +.household-item.checked .household-actions { opacity: 0.55; } +.household-finish { display: flex; align-items: center; justify-content: space-between; gap: 8px 12px; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--border); } +.household-finish .hint { margin: 0; } +/* Gleiten beim Abhaken/Einkauf beenden (nur während des Übergangs benannt, damit Designwechsel unberührt bleibt) */ +html.vt-items .household-item { view-transition-name: var(--vt); } +::view-transition-group(*) { animation-duration: 0.32s; animation-timing-function: var(--ease-out); } .stepper { display: flex; align-items: stretch; flex: none; border: 1px solid var(--border); } .stepper button { diff --git a/frontend/src/themes.css b/frontend/src/themes.css index b074818..6a3ce25 100644 --- a/frontend/src/themes.css +++ b/frontend/src/themes.css @@ -742,6 +742,8 @@ html[data-theme='fabrik'] .household-check { box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.4); } html[data-theme='fabrik'] .household-check::after { color: var(--fx-yellow); } +html[data-theme='fabrik'] .household-item.checked { background: linear-gradient(180deg, #2a2a2c, #252527); } +html[data-theme='fabrik'] .household-cart-head { font-family: var(--font-label); font-weight: 700; color: #cfcfd2; border-left: 3px solid var(--fx-orange); padding-left: 8px; } html[data-theme='fabrik'] .segmented { background: var(--fx-plate-dark); border: 1px solid var(--fx-edge); border-radius: var(--radius-m); } html[data-theme='fabrik'] .segmented button { font-family: var(--font-label); color: #d8d8da; } html[data-theme='fabrik'] .segmented button[aria-selected='true'] { background: var(--fx-orange-fill); color: #fff; box-shadow: var(--fx-bevel); }