Dashboard kennt die Einkaufsliste jetzt ganz

Der Knopf im Rezept schrieb die fehlenden Zutaten weg, aber auf der
Uebersicht blieb davon nichts zu sehen: Die Karte "Einkaufsliste" holte
die drei GERECHNETEN Quellen einzeln und wusste von den gespeicherten
Eintraegen nichts. Wer die Uebersicht als Zettel benutzt, hat sie
schlicht nicht gesehen.

Die Karte holt jetzt alles mit einem Aufruf und zeigt oben den
Abschnitt "Selbst draufgesetzt" - eigene Eintraege und die aus einem
Rezept uebernommenen, mit Herkunft und Menge. Abgehakte verschwinden
dort, die Karte ist zum Einkaufen da und nicht zum Nachlesen.

Das Abhaken war in der Karte reiner Sitzungszustand und damit nach dem
Neuladen weg. Es benutzt jetzt dieselben Schluessel und dieselben Routen
wie die volle Seite: Der Haken wandert sofort, speichert im
Hintergrund, und springt zurueck, wenn das schiefgeht.

Dazu die Kennzahl "Nachzukaufen": Sie zaehlte nur die gerechneten
Zeilen und meldete "alles erreicht", waehrend auf der Liste fuenf Posten
standen. Offene eigene Eintraege zaehlen jetzt mit, abgehakte nicht -
die sind gekauft.

Von Hand geprueft: Zwei Eintraege angelegt, Kennzahl springt auf 2, die
Karte zeigt beide samt "2 Packungen", und ein Haken aus der Karte steht
anschliessend in der Datenbank. 1 neuer Test, Suite bei 517.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 15:20:05 +02:00
co-authored by Claude Opus 5
parent b903de3841
commit 2e610677aa
3 changed files with 90 additions and 9 deletions
+11 -1
View File
@@ -48,6 +48,7 @@ from ..services.conversion import article_unit
from ..services.loans import nicht_verliehen from ..services.loans import nicht_verliehen
from ..services.stock import current_stock from ..services.stock import current_stock
from .settings import get_expiry_warning_days from .settings import get_expiry_warning_days
from ..services import einkaufsliste
from .views import einkaufsliste_artikel, einkaufsliste_gruppen from .views import einkaufsliste_artikel, einkaufsliste_gruppen
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -373,7 +374,16 @@ def stats(
# Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen – # Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen –
# inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter- # inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
# gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab. # gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db)) #
# Dazu die selbst draufgesetzten Eintraege, offene wie die aus einem Rezept
# uebernommenen: Sonst meldet die Kennzahl „alles erreicht", waehrend auf der
# Liste fuenf Posten stehen. Abgehakte zaehlen nicht mehr mit - sie sind
# gekauft.
bedarf = (
len(einkaufsliste_artikel(db))
+ len(einkaufsliste_gruppen(db))
+ len(einkaufsliste.eintraege(db, mit_erledigten=False))
)
return DashboardStats( return DashboardStats(
products_in_stock=len(bestand_produkte), products_in_stock=len(bestand_produkte),
@@ -29,6 +29,7 @@ from app.routers.shopping import (
set_check, set_check,
update_entry, update_entry,
) )
from app.routers.dashboard import stats as dashboard_stats
from app.routers.views import shopping_list_all from app.routers.views import shopping_list_all
from app.schemas import ShoppingEntryIn, ShoppingEntryPatch from app.schemas import ShoppingEntryIn, ShoppingEntryPatch
from app.services import einkaufsliste from app.services import einkaufsliste
@@ -288,3 +289,15 @@ def test_ein_abgehakter_eintrag_blockiert_die_neue_uebernahme_nicht(db, nutzer):
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
assert db.query(ShoppingEntry).count() == 2 assert db.query(ShoppingEntry).count() == 2
def test_die_kennzahl_zaehlt_die_eigenen_eintraege_mit(db, nutzer):
"""Sonst meldet das Dashboard „alles erreicht", waehrend die Liste voll ist."""
vorher = dashboard_stats(db=db, _=nutzer).shopping_items
eintrag = create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
assert dashboard_stats(db=db, _=nutzer).shopping_items == vorher + 1
# Abgehakt heisst gekauft - und zaehlt nicht mehr als Bedarf.
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
assert dashboard_stats(db=db, _=nutzer).shopping_items == vorher
+66 -8
View File
@@ -316,23 +316,59 @@ function KarteAblauf({ modus = "alle" }) {
*/ */
function KarteEinkaufsliste({ props: karteProps }) { function KarteEinkaufsliste({ props: karteProps }) {
const ortId = karteProps?.location_id || ""; const ortId = karteProps?.location_id || "";
const [erledigt, setErledigt] = useState({}); // abgehakte Zeilen (nur in dieser Sitzung) const [erledigt, setErledigt] = useState(null); // null = noch nicht geladen
const [eintraege, setEintraege] = useState([]);
// Ein Aufruf statt drei, und er bringt beides mit: die gerechneten Bedarfe
// UND die selbst draufgesetzten Eintraege samt dauerhaft gesetzter Haken.
// Vorher kannte die Karte nur die gerechnete Seite - was aus einem Rezept
// uebernommen wurde, stand nur im Tab und hier nicht.
const { daten, fehler } = useDaten(async () => { const { daten, fehler } = useDaten(async () => {
const [produkte, gruppen, orte] = await Promise.all([ const alles = await api.shoppingAll();
api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation(), return {
]); produkte: alles.products || [], gruppen: alles.groups || [],
return { produkte, gruppen, orte }; orte: alles.by_location || [], eintraege: alles.entries || [],
checked: alles.checked || [],
};
}); });
useEffect(() => {
if (!daten) return;
setErledigt(Object.fromEntries((daten.checked || []).map((k) => [k, true])));
setEintraege(daten.eintraege || []);
}, [daten]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>; if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <CardSkeleton variant="liste" />; if (!daten || erledigt == null) return <CardSkeleton variant="liste" />;
const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte; const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte;
const gesamtLeer = !daten.produkte.length && !daten.gruppen.length; const gesamtLeer = !daten.produkte.length && !daten.gruppen.length;
const orteLeer = orte.every((o) => !o.products.length && !o.groups.length); const orteLeer = orte.every((o) => !o.products.length && !o.groups.length);
if (gesamtLeer && orteLeer) return <div className="empty">Alle Mindestbestände erreicht.</div>; const offeneEintraege = eintraege.filter((e) => !e.done);
if (gesamtLeer && orteLeer && eintraege.length === 0) {
return <div className="empty">Alle Mindestbestände erreicht.</div>;
}
const abhaken = (key, val) => setErledigt((e) => ({ ...e, [key]: val })); // Der Haken wandert sofort und wird im Hintergrund gespeichert - genau wie auf
// der vollen Seite, mit denselben Schluesseln. Schlaegt es fehl, springt er
// zurueck, statt eine Erledigung vorzutaeuschen.
const abhaken = async (key, val) => {
setErledigt((e) => ({ ...e, [key]: val }));
try {
if (val) await api.setShoppingCheck(key);
else await api.clearShoppingCheck(key);
} catch {
setErledigt((e) => ({ ...e, [key]: !val }));
}
};
const eintragAbhaken = async (eintrag, val) => {
setEintraege((es) => es.map((e) => (e.id === eintrag.id ? { ...e, done: val } : e)));
try {
await api.updateShoppingEntry(eintrag.id, { done: val });
} catch {
setEintraege((es) => es.map((e) => (e.id === eintrag.id ? { ...e, done: !val } : e)));
}
};
// Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste – wie die volle Seite. // Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste – wie die volle Seite.
// `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die // `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die
@@ -377,6 +413,28 @@ function KarteEinkaufsliste({ props: karteProps }) {
return ( return (
<div className="card-stack"> <div className="card-stack">
{offeneEintraege.length > 0 && (
<div>
<div className="muted small" style={{ fontWeight: 600 }}>Selbst draufgesetzt</div>
<ul className="checklist">
{offeneEintraege.map((e) => (
<li key={`e${e.id}`}>
<label>
<input type="checkbox" checked={e.done}
onChange={(ev) => eintragAbhaken(e, ev.target.checked)} />
{e.art === "group" && <span className="badge accent">Gruppe</span>}
{e.art === "text" && <span className="badge">Notiz</span>}
<span className="item-name">{e.name}</span>
</label>
<span className="muted small nowrap">
{[e.recipe_name ? `für ${e.recipe_name}` : "", e.menge_text]
.filter(Boolean).join(" · ")}
</span>
</li>
))}
</ul>
</div>
)}
{!gesamtLeer && ( {!gesamtLeer && (
<div> <div>
<div className="muted small" style={{ fontWeight: 600 }}>Überall</div> <div className="muted small" style={{ fontWeight: 600 }}>Überall</div>