Web: Mindestbestand je Lagerort + Einkaufsliste je Ort

- Neuer LocationMinStock-Editor (Zeilen: Lagerort + Menge, separat speichern).
- Produktformular: Abschnitt „Mindestbestand je Lagerort" (Menge in Artikel-
  einheit), zusaetzlich zum Gesamt-Mindestbestand.
- Gruppen: je Gruppe ein Editor „Mindestbestand je Lagerort".
- Einkaufsliste: Abschnitt „Bedarf: <Lagerort>" mit Produkten und Gruppen, die
  am Ort unter dem dort hinterlegten Mindestbestand liegen.
- API-Methoden set*LocationMinStock und shoppingByLocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 07:12:59 +02:00
parent 67bc2dd87d
commit dff7677e50
5 changed files with 199 additions and 3 deletions

View File

@@ -4,6 +4,7 @@ import { useConfirm } from "../confirm";
import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon";
import LocationMinStock from "../components/LocationMinStock";
import { fmt } from "../units";
export default function Groups() {
@@ -11,15 +12,19 @@ export default function Groups() {
const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [locations, setLocations] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null);
async function load() {
try {
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
const [gs, us, ls] = await Promise.all([
api.listGroups(), api.listUnits(), api.listLocations(),
]);
setGroups(gs);
setUnits(us);
setLocations(ls);
} catch (err) {
setError(err.message);
}
@@ -204,6 +209,30 @@ export default function Groups() {
</section>
)}
{selected && isAdmin && (
<section className="card">
<div className="card-head">
<Icon name="location" />
<h2>Mindestbestand je Lagerort</h2>
</div>
<p className="muted small mt-0">
Bedarf dieser Gruppe je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum
Gesamt-Mindestbestand{selected.min_stock_unit_name ? ` (in ${selected.min_stock_unit_name})` : ""}.
</p>
<LocationMinStock
key={selected.id}
locations={locations}
initial={selected.location_min_stocks || []}
unitLabel={selected.min_stock_unit_name || ""}
onError={setError}
onSave={async (list) => {
await api.setGroupLocationMinStock(selected.id, list);
await load();
}}
/>
</section>
)}
{isAdmin && (
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>

View File

@@ -11,6 +11,7 @@ import { useToast } from "../toast";
import { useSettings } from "../settings";
import { asTree } from "../categoryTree";
import CategorySelect from "../components/CategorySelect";
import LocationMinStock from "../components/LocationMinStock";
import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke";
@@ -675,6 +676,31 @@ export default function ProductForm() {
</select>
</label>
</div>
{!isNew && product && isAdmin && (
<div style={{ marginTop: "var(--sp-2)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
<Icon name="location" size={16} />
<h3 style={{ margin: 0 }}>Mindestbestand je Lagerort</h3>
</div>
<p className="muted small mt-0">
Zusätzlich zum Gesamt-Mindestbestand: eigener Bedarf je Lagerort (z.B.
Ferienhaus, Zuhause). Menge in {product.package_label || product.unit_name || "Stück"}.
Wird separat gespeichert.
</p>
<LocationMinStock
locations={locations}
initial={product.location_min_stocks || []}
unitLabel={product.package_label || product.unit_name || "Stück"}
onError={(m) => toast(m, "warn")}
onSave={async (list) => {
const updated = await api.setProductLocationMinStock(product.id, list);
setProduct(updated);
toast("Bedarfe je Lagerort gespeichert.");
}}
/>
</div>
)}
</>)}
<div className="row">
<label className="grow seg-label">

View File

@@ -6,12 +6,13 @@ import { amountText, fmt, unitShort } 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()])
.then(([p, g]) => { setItems(p); setGroups(g); })
Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()])
.then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); })
.catch((e) => setError(e.message));
}, []);
@@ -31,6 +32,10 @@ export default function ShoppingList() {
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{byLocation.length > 0 && (
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Gesamt</div>
)}
<div className="card">
{empty ? (
<div className="empty">Alle Mindestbestände erreicht.</div>
@@ -70,6 +75,50 @@ export default function ShoppingList() {
</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>
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
</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">
fehlt <strong>{fmt(it.deficit)} {it.unit_label}</strong>{" "}
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
</span>
</li>
);
})}
</ul>
</div>
</div>
))}
<p className="muted small">
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
</p>