diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 3aed98f..2b31690 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -48,6 +48,7 @@ from ..services.conversion import article_unit from ..services.loans import nicht_verliehen from ..services.stock import current_stock from .settings import get_expiry_warning_days +from ..services import einkaufsliste from .views import einkaufsliste_artikel, einkaufsliste_gruppen router = APIRouter(prefix="/dashboard", tags=["dashboard"]) @@ -373,7 +374,16 @@ def stats( # Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen – # inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter- # 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( products_in_stock=len(bestand_produkte), diff --git a/backend/tests/test_einkaufsliste_eintraege.py b/backend/tests/test_einkaufsliste_eintraege.py index a611f5a..f1d40f9 100644 --- a/backend/tests/test_einkaufsliste_eintraege.py +++ b/backend/tests/test_einkaufsliste_eintraege.py @@ -29,6 +29,7 @@ from app.routers.shopping import ( set_check, update_entry, ) +from app.routers.dashboard import stats as dashboard_stats from app.routers.views import shopping_list_all from app.schemas import ShoppingEntryIn, ShoppingEntryPatch 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) 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 diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 119a53c..7dff2fc 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -316,23 +316,59 @@ function KarteAblauf({ modus = "alle" }) { */ function KarteEinkaufsliste({ props: karteProps }) { 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 [produkte, gruppen, orte] = await Promise.all([ - api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation(), - ]); - return { produkte, gruppen, orte }; + const alles = await api.shoppingAll(); + return { + produkte: alles.products || [], gruppen: alles.groups || [], + 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
{fehler}
; - if (!daten) return ; + if (!daten || erledigt == null) return ; const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte; const gesamtLeer = !daten.produkte.length && !daten.gruppen.length; const orteLeer = orte.every((o) => !o.products.length && !o.groups.length); - if (gesamtLeer && orteLeer) return
Alle Mindestbestände erreicht.
; + const offeneEintraege = eintraege.filter((e) => !e.done); + if (gesamtLeer && orteLeer && eintraege.length === 0) { + return
Alle Mindestbestände erreicht.
; + } - 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. // `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die @@ -377,6 +413,28 @@ function KarteEinkaufsliste({ props: karteProps }) { return (
+ {offeneEintraege.length > 0 && ( +
+
Selbst draufgesetzt
+
    + {offeneEintraege.map((e) => ( +
  • + + + {[e.recipe_name ? `für ${e.recipe_name}` : "", e.menge_text] + .filter(Boolean).join(" · ")} + +
  • + ))} +
+
+ )} {!gesamtLeer && (
Überall