Einkauf: Abhaken legt Produkte in den Wagen, "Einkauf beenden" übernimmt sie

- Abgehakte Produkte bleiben auf der Einkaufsliste, rutschen durchgestrichen
  nach unten ("Im Wagen") und lassen sich zurücknehmen
- Button "Einkauf beenden (n)" verschiebt alle abgehakten Produkte auf einmal
  in den Vorrat derselben Liste (Mengen werden zusammengeführt)
- Haken wird gespeichert (Spalte checked, Migration ergänzt sie) und ist für
  alle sichtbar, die die Liste sehen; erneut hinzugefügte Produkte verlieren
  den Haken
- Einträge gleiten per View Transition an ihren neuen Platz (aus bei
  "Animation: aus")

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 12:11:13 +02:00
1 parent b30acb652a
commit f0e17b06b5
6 files changed
+108 -12

No files matched your search

+4 -2
View File
@@ -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.
+9
View File
@@ -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)
+34 -2
View File
@@ -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:
+40 -8
View File
@@ -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);
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 }) {
<p className="hint household-empty">{empty}</p>
) : (
<ul className="household-list">
{items.map((item) => (
{open.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
{inCart.length > 0 && (
<li className="household-cart-head" aria-hidden="true">Im Wagen</li>
)}
{inCart.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
</ul>
)}
{inCart.length > 0 && (
<div className="household-finish">
<span className="hint">
{inCart.length} {inCart.length === 1 ? 'Produkt' : 'Produkte'} im Wagen
</span>
<button className="btn" type="button" disabled={busy} onClick={actions.finish}>
Einkauf beenden ({inCart.length})
</button>
</div>
)}
</section>
);
}
@@ -354,16 +386,16 @@ function ItemRow({ item, list, other, actions, busy }) {
const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`;
return (
<li className="household-item">
<li className={`household-item${item.checked ? ' checked' : ''}`} style={{ '--vt': `item-${item.id}` }}>
{isShopping && (
<input
type="checkbox"
className="household-check"
checked={false}
checked={item.checked}
disabled={busy}
onChange={() => 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'}
/>
)}
+19
View File
@@ -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 {
+2
View File
@@ -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); }