Files
Vorrania/web/src/pages/ShoppingList.jsx
Scarriffle 3de168b598 Gruppen: Bestand in der richtigen Einheit, Baumansicht, Gruppen-Spalte
Anzeigefehler: die Gruppenliste klebte immer min_stock_unit_name hinter den
Bestand - auch wenn die Gruppe laengst im Gruppen-Gebinde zaehlt. Aus 790 g bei
einem 190-g-Glas wurde so "4,16 Gramm" statt "4,16 Glaeser". Gerechnet war
richtig, beschriftet falsch.

Ursache war doppelte Logik: MinStock.jsx hatte grpPkgMode/grpFactor/grpUnit
lokal, Groups.jsx gar nicht. Die drei stehen jetzt in units.js und werden von
beiden benutzt. Die Einheiten-Spalte sagt ausserdem, wenn eine Gruppe im
Gebinde zaehlt ("zaehlt in Glaeser a 190 g") und bietet den Weg zurueck an.

Gruppenliste als Baum: eine Gruppe mit zwei Obergruppen erscheint unter beiden -
das ist die ehrliche Darstellung eines Graphen. Jede Zeile traegt ihren PFAD als
Schluessel, damit die vorhandene Baumlogik der DataTable unveraendert zurecht
kommt; die kennt nur einen Elternteil je Zeile, und ein Pfad hat genau einen.
Beim Sortieren/Filtern flacht sie wie gewohnt ab.

"1 Untergruppen" heisst jetzt "1 Untergruppe" - an allen vier Stellen ueber
einen gemeinsamen Helfer (anzahlWort), Web und App.

Lebensmittelliste bekommt eine Spalte "Gruppe" mit Filter. Dafuer liefert
ProductOut jetzt group_name, symmetrisch zu category_name, mit joinedload gegen
N+1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:40:09 +02:00

143 lines
6.0 KiB
JavaScript

import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import ShoppingNeedText from "../components/ShoppingNeedText";
import { anzahlWort } from "../units";
export default function ShoppingList() {
const [items, setItems] = useState([]);
const [groups, setGroups] = useState([]);
const [byLocation, setByLocation] = useState([]);
const [checked, setChecked] = useState({});
const [error, setError] = useState(null);
useEffect(() => {
Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()])
.then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); })
.catch((e) => setError(e.message));
}, []);
const empty = items.length === 0 && groups.length === 0;
function toggle(key, val) {
setChecked((c) => ({ ...c, [key]: val }));
}
return (
<div>
<div className="page-head">
<div>
<h1>Einkaufsliste</h1>
<div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{byLocation.length > 0 && (
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Überall (egal wo)</div>
)}
<div className="card">
{empty ? (
<div className="empty">Alle Mindestbestände erreicht.</div>
) : (
<ul className="checklist">
{groups.map((it) => {
const key = `g${it.group_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="badge accent">Gruppe</span>
{it.subgroup_count > 0 && (
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
</span>
)}
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
</span>
</li>
);
})}
{items.map((it) => {
const key = `p${it.product_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.base_unit} />
</span>
</li>
);
})}
</ul>
)}
</div>
{byLocation.map((loc) => (
<div key={loc.location_id} style={{ marginTop: "var(--sp-4)" }}>
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>
<Icon name="location" size={14} /> Bedarf: {loc.location_name}
</div>
<div className="card">
<ul className="checklist">
{loc.groups.map((it) => {
const key = `l${loc.location_id}g${it.group_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="badge accent">Gruppe</span>
{it.subgroup_count > 0 && (
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
</span>
)}
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
</span>
</li>
);
})}
{loc.products.map((it) => {
const key = `l${loc.location_id}p${it.product_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_label} />
</span>
</li>
);
})}
</ul>
</div>
</div>
))}
<p className="muted small">
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
</p>
<p className="muted small">
Bedarfe sind gegeneinander verrechnet: Was du für einen Lagerort oder eine
Untergruppe kaufst, deckt Überall bzw. die Obergruppe mit ab. Hängt
dieselbe Untergruppe unter zwei Obergruppen, die einander <em>nicht</em>
enthalten (Grillwurst unter Wurst und unter Grillgut), stehen beide Bedarfe
getrennt da ein Kauf kann dann beide decken. Ein eigener Mindestbestand auf
der gemeinsamen Untergruppe verrechnet auch das.
</p>
</div>
);
}