From dff7677e504f0c2592887436130a4837d846526e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 07:12:59 +0200 Subject: [PATCH] Web: Mindestbestand je Lagerort + Einkaufsliste je Ort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: " 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 --- web/src/api.js | 7 ++ web/src/components/LocationMinStock.jsx | 85 +++++++++++++++++++++++++ web/src/pages/Groups.jsx | 31 ++++++++- web/src/pages/ProductForm.jsx | 26 ++++++++ web/src/pages/ShoppingList.jsx | 53 ++++++++++++++- 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 web/src/components/LocationMinStock.jsx diff --git a/web/src/api.js b/web/src/api.js index aa63329..c04243f 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -185,9 +185,16 @@ export const api = { updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }), deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }), + // Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste). + setProductLocationMinStock: (id, list) => + request(`/products/${id}/location-min-stock`, { method: "PUT", body: list }), + setGroupLocationMinStock: (id, list) => + request(`/groups/${id}/location-min-stock`, { method: "PUT", body: list }), + // Views shoppingList: () => request("/shopping-list"), groupShoppingList: () => request("/shopping-list/groups"), + shoppingByLocation: () => request("/shopping-list/by-location"), expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`), listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`), diff --git a/web/src/components/LocationMinStock.jsx b/web/src/components/LocationMinStock.jsx new file mode 100644 index 0000000..f3601ef --- /dev/null +++ b/web/src/components/LocationMinStock.jsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import Icon from "./Icon"; + +/** + * Editor für Mindestbestände je Lagerort (Bedarfe). Eine Zeile je Ort mit Menge; + * „Bedarfe speichern" ersetzt über onSave die komplette Liste. Menge 0/leer = + * Ort fällt weg. + */ +export default function LocationMinStock({ locations, initial = [], unitLabel = "", onSave, onError }) { + const [rows, setRows] = useState(() => + initial.map((e) => ({ location_id: String(e.location_id), min_stock: String(e.min_stock) })), + ); + const [busy, setBusy] = useState(false); + const [ok, setOk] = useState(false); + + const used = new Set(rows.map((r) => r.location_id)); + const frei = locations.filter((l) => !used.has(String(l.id))); + + function addRow() { + if (!frei.length) return; + setRows([...rows, { location_id: String(frei[0].id), min_stock: "" }]); + setOk(false); + } + function setRow(i, patch) { + setRows(rows.map((r, j) => (j === i ? { ...r, ...patch } : r))); + setOk(false); + } + function removeRow(i) { + setRows(rows.filter((_, j) => j !== i)); + setOk(false); + } + + async function save() { + setBusy(true); + try { + const list = rows + .filter((r) => r.location_id !== "" && Number(r.min_stock) > 0) + .map((r) => ({ location_id: Number(r.location_id), min_stock: Number(r.min_stock) })); + await onSave(list); + setOk(true); + } catch (err) { + onError?.(err.message); + } finally { + setBusy(false); + } + } + + if (!locations.length) { + return

Erst Lagerorte anlegen, dann Bedarfe je Ort möglich.

; + } + + return ( +
+ {rows.length === 0 && ( +

Kein Bedarf je Lagerort festgelegt.

+ )} + {rows.map((r, i) => ( +
+ + + {unitLabel && {unitLabel}} + +
+ ))} +
+ + + {ok && Gespeichert.} +
+
+ ); +} diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx index fed1ac5..3b7b8d8 100644 --- a/web/src/pages/Groups.jsx +++ b/web/src/pages/Groups.jsx @@ -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() { )} + {selected && isAdmin && ( +
+
+ +

Mindestbestand je Lagerort

+
+

+ 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})` : ""}. +

+ { + await api.setGroupLocationMinStock(selected.id, list); + await load(); + }} + /> +
+ )} + {isAdmin && (

Neue Gruppe

diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 5464c28..45460eb 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -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() { + + {!isNew && product && isAdmin && ( +
+
+ +

Mindestbestand je Lagerort

+
+

+ 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. +

+ toast(m, "warn")} + onSave={async (list) => { + const updated = await api.setProductLocationMinStock(product.id, list); + setProduct(updated); + toast("Bedarfe je Lagerort gespeichert."); + }} + /> +
+ )} )}
{error &&
{error}
} + {byLocation.length > 0 && ( +
Gesamt
+ )} +
{empty ? (
Alle Mindestbestände erreicht.
@@ -70,6 +75,50 @@ export default function ShoppingList() { )}
+ + {byLocation.map((loc) => ( +
+
+ Bedarf: {loc.location_name} +
+
+
    + {loc.groups.map((it) => { + const key = `l${loc.location_id}g${it.group_id}`; + return ( +
  • + + + fehlt {fmt(it.deficit)} {it.unit_name}{" "} + (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) + +
  • + ); + })} + {loc.products.map((it) => { + const key = `l${loc.location_id}p${it.product_id}`; + return ( +
  • + + + fehlt {fmt(it.deficit)} {it.unit_label}{" "} + (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) + +
  • + ); + })} +
+
+
+ ))} +

Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.