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:
1 parent
b30acb652a
commit
f0e17b06b5
6 files changed
+108
-12
No files matched your search
@@ -142,8 +142,10 @@ und einen eigenen Einkaufszettel.
|
|||||||
(für alle sichtbar).
|
(für alle sichtbar).
|
||||||
- Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der
|
- Welche Liste gewählt ist, merkt sich der Browser. Gekauft, aufgebraucht, Nachkaufen und der
|
||||||
Rezeptabgleich wirken immer innerhalb der gewählten Liste.
|
Rezeptabgleich wirken immer innerhalb der gewählten Liste.
|
||||||
- Haken bei „Gekauft“ übernimmt einen Eintrag in den Vorrat; −‑Klick bei der letzten Einheit
|
- **Einkaufen:** Abhaken legt ein Produkt „in den Wagen“ – es rutscht nach unten, bleibt aber auf der
|
||||||
im Vorrat setzt ihn auf die Einkaufsliste.
|
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
|
- **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
|
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.
|
und setzt Fehlendes auf die Einkaufsliste. Eiweiß/Eigelb werden zu Eiern.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ HOUSEHOLD_TABLE = """(
|
|||||||
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
||||||
quantity REAL NOT NULL CHECK (quantity > 0),
|
quantity REAL NOT NULL CHECK (quantity > 0),
|
||||||
unit TEXT NOT NULL DEFAULT 'stk' CHECK (unit IN ('stk', 'kg', 'l')),
|
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_at TEXT NOT NULL,
|
||||||
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
UNIQUE (list_id, key, list, unit)
|
UNIQUE (list_id, key, list, unit)
|
||||||
@@ -294,6 +295,13 @@ def _migrate(conn) -> None:
|
|||||||
conn.execute("DROP TABLE household_items_alt")
|
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:
|
def _migrate_travel(conn) -> None:
|
||||||
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen
|
# 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)")}
|
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.execute("PRAGMA journal_mode = WAL")
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
_migrate(conn)
|
_migrate(conn)
|
||||||
|
_migrate_checked(conn)
|
||||||
_migrate_travel(conn)
|
_migrate_travel(conn)
|
||||||
_migrate_monitor_token(conn)
|
_migrate_monitor_token(conn)
|
||||||
_migrate_kanban_categories(conn)
|
_migrate_kanban_categories(conn)
|
||||||
|
|||||||
+34
-2
@@ -34,6 +34,7 @@ def _item(row) -> dict:
|
|||||||
"listId": row["list_id"],
|
"listId": row["list_id"],
|
||||||
"quantity": _out_quantity(row["quantity"], row["unit"]),
|
"quantity": _out_quantity(row["quantity"], row["unit"]),
|
||||||
"unit": row["unit"],
|
"unit": row["unit"],
|
||||||
|
"checked": bool(row["checked"]),
|
||||||
"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,
|
||||||
}
|
}
|
||||||
@@ -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:
|
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(
|
conn.execute(
|
||||||
"""INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at, updated_by)
|
"""INSERT INTO household_items (key, name, list, list_id, quantity, unit, updated_at, updated_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (list_id, key, list, unit) DO UPDATE SET
|
ON CONFLICT (list_id, key, list, unit) DO UPDATE SET
|
||||||
quantity = MIN(ROUND(quantity + excluded.quantity, 2), ?),
|
quantity = MIN(ROUND(quantity + excluded.quantity, 2), ?),
|
||||||
|
checked = 0,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
updated_by = excluded.updated_by""",
|
updated_by = excluded.updated_by""",
|
||||||
(_key(name), name, list_, list_id, quantity, unit, now_iso(), user_id, MAX_QUANTITY),
|
(_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}")
|
@router.patch("/api/haushalt/items/{item_id}")
|
||||||
def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
|
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:
|
with transaction() as conn:
|
||||||
row = _get_item(conn, item_id, user)
|
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"]))
|
unit = _unit(data.get("unit", row["unit"]))
|
||||||
if "quantity" in data:
|
if "quantity" in data:
|
||||||
quantity = _quantity(data["quantity"], unit)
|
quantity = _quantity(data["quantity"], unit)
|
||||||
@@ -274,6 +290,22 @@ def move_item(item_id: int, user: dict = Depends(current_user)):
|
|||||||
return _state(conn, 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}")
|
@router.delete("/api/haushalt/items/{item_id}")
|
||||||
def delete_item(item_id: int, user: dict = Depends(current_user)):
|
def delete_item(item_id: int, user: dict = Depends(current_user)):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { api, personName } from '../api.js';
|
import { api, personName } from '../api.js';
|
||||||
|
import { isCalm } from '../motion.js';
|
||||||
import { useAuth } from '../auth.jsx';
|
import { useAuth } from '../auth.jsx';
|
||||||
import ProductInput from '../components/ProductInput.jsx';
|
import ProductInput from '../components/ProductInput.jsx';
|
||||||
import RecipeCheck from '../components/RecipeCheck.jsx';
|
import RecipeCheck from '../components/RecipeCheck.jsx';
|
||||||
@@ -54,13 +56,21 @@ export default function Haushalt() {
|
|||||||
};
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Jede Änderung liefert den aktuellen Stand aller Listen zurück
|
// Jede Änderung liefert den aktuellen Stand aller Listen zurück.
|
||||||
async function run(method, url, body) {
|
// 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);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const next = await api(method, url, body);
|
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);
|
setData(next);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
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 }),
|
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),
|
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 }),
|
||||||
|
finish: () => run('POST', `/api/haushalt/listen/${current.id}/einkauf-beenden`, undefined, { animate: true }),
|
||||||
remove: (item) => run('DELETE', `/api/haushalt/items/${item.id}`),
|
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 }) {
|
function ListPanel({ list, items, products, find, actions, busy, hidden }) {
|
||||||
const { title, lead, empty } = LISTS[list];
|
const { title, lead, empty } = LISTS[list];
|
||||||
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
|
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?
|
// Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo?
|
||||||
function hint(name) {
|
function hint(name) {
|
||||||
@@ -249,11 +264,28 @@ function ListPanel({ list, items, products, find, actions, busy, hidden }) {
|
|||||||
<p className="hint household-empty">{empty}</p>
|
<p className="hint household-empty">{empty}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="household-list">
|
<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} />
|
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -354,16 +386,16 @@ function ItemRow({ item, list, other, actions, busy }) {
|
|||||||
const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`;
|
const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="household-item">
|
<li className={`household-item${item.checked ? ' checked' : ''}`} style={{ '--vt': `item-${item.id}` }}>
|
||||||
{isShopping && (
|
{isShopping && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="household-check"
|
className="household-check"
|
||||||
checked={false}
|
checked={item.checked}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={() => actions.move(item)}
|
onChange={() => actions.check(item, !item.checked)}
|
||||||
aria-label={`${label} gekauft – in den Vorrat übernehmen`}
|
aria-label={item.checked ? `${label} im Wagen – Haken entfernen` : `${label} abhaken (im Wagen)`}
|
||||||
title="Gekauft: in den Vorrat übernehmen"
|
title={item.checked ? 'Im Wagen – zum Zurücknehmen klicken' : 'Abhaken: kommt mit „Einkauf beenden“ in den Vorrat'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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::after { content: '✓'; color: var(--accent); opacity: 0; font-weight: 700; }
|
||||||
.household-check:hover::after { opacity: 0.6; }
|
.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 { display: flex; align-items: stretch; flex: none; border: 1px solid var(--border); }
|
||||||
.stepper button {
|
.stepper button {
|
||||||
|
|||||||
@@ -742,6 +742,8 @@ html[data-theme='fabrik'] .household-check {
|
|||||||
box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.4);
|
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-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 { 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 { 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); }
|
html[data-theme='fabrik'] .segmented button[aria-selected='true'] { background: var(--fx-orange-fill); color: #fff; box-shadow: var(--fx-bevel); }
|
||||||
|
|||||||
Reference in new issue
Block a user