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:
@@ -185,9 +185,16 @@ export const api = {
|
|||||||
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
|
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
|
||||||
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
|
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
|
// Views
|
||||||
shoppingList: () => request("/shopping-list"),
|
shoppingList: () => request("/shopping-list"),
|
||||||
groupShoppingList: () => request("/shopping-list/groups"),
|
groupShoppingList: () => request("/shopping-list/groups"),
|
||||||
|
shoppingByLocation: () => request("/shopping-list/by-location"),
|
||||||
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||||||
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
|
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
|
||||||
|
|
||||||
|
|||||||
85
web/src/components/LocationMinStock.jsx
Normal file
85
web/src/components/LocationMinStock.jsx
Normal file
@@ -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 <p className="muted small mt-0">Erst Lagerorte anlegen, dann Bedarfe je Ort möglich.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="muted small mt-0">Kein Bedarf je Lagerort festgelegt.</p>
|
||||||
|
)}
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
|
||||||
|
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 0 }}>
|
||||||
|
<select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}>
|
||||||
|
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ margin: 0, width: 130 }}>
|
||||||
|
<input type="number" min="0" step="0.01" placeholder="Menge" value={r.min_stock}
|
||||||
|
onChange={(e) => setRow(i, { min_stock: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
{unitLabel && <span className="muted small" style={{ alignSelf: "center" }}>{unitLabel}</span>}
|
||||||
|
<button type="button" className="btn-icon danger" onClick={() => removeRow(i)} title="Entfernen">
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||||||
|
<button type="button" className="btn" onClick={addRow} disabled={!frei.length}>
|
||||||
|
<Icon name="plus" size={16} />Lagerort
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={save} disabled={busy}>
|
||||||
|
<Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"}
|
||||||
|
</button>
|
||||||
|
{ok && <span className="muted small" style={{ alignSelf: "center" }}>Gespeichert.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useConfirm } from "../confirm";
|
|||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import BarcodeList from "../components/BarcodeList";
|
import BarcodeList from "../components/BarcodeList";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import LocationMinStock from "../components/LocationMinStock";
|
||||||
import { fmt } from "../units";
|
import { fmt } from "../units";
|
||||||
|
|
||||||
export default function Groups() {
|
export default function Groups() {
|
||||||
@@ -11,15 +12,19 @@ export default function Groups() {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
|
const [locations, setLocations] = useState([]);
|
||||||
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
|
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
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);
|
setGroups(gs);
|
||||||
setUnits(us);
|
setUnits(us);
|
||||||
|
setLocations(ls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
@@ -204,6 +209,30 @@ export default function Groups() {
|
|||||||
</section>
|
</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 && (
|
{isAdmin && (
|
||||||
<form className="card" onSubmit={add}>
|
<form className="card" onSubmit={add}>
|
||||||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useToast } from "../toast";
|
|||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
|
import LocationMinStock from "../components/LocationMinStock";
|
||||||
import { DynamicFields, FIELD_TYPES } from "../fields";
|
import { DynamicFields, FIELD_TYPES } from "../fields";
|
||||||
import ObjektBestand from "../components/ObjektBestand";
|
import ObjektBestand from "../components/ObjektBestand";
|
||||||
import Einzelstuecke from "../components/Einzelstuecke";
|
import Einzelstuecke from "../components/Einzelstuecke";
|
||||||
@@ -675,6 +676,31 @@ export default function ProductForm() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="row">
|
||||||
<label className="grow seg-label">
|
<label className="grow seg-label">
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { amountText, fmt, unitShort } from "../units";
|
|||||||
export default function ShoppingList() {
|
export default function ShoppingList() {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [byLocation, setByLocation] = useState([]);
|
||||||
const [checked, setChecked] = useState({});
|
const [checked, setChecked] = useState({});
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.shoppingList(), api.groupShoppingList()])
|
Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()])
|
||||||
.then(([p, g]) => { setItems(p); setGroups(g); })
|
.then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); })
|
||||||
.catch((e) => setError(e.message));
|
.catch((e) => setError(e.message));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -31,6 +32,10 @@ export default function ShoppingList() {
|
|||||||
</div>
|
</div>
|
||||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</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">
|
<div className="card">
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="empty">Alle Mindestbestände erreicht.</div>
|
<div className="empty">Alle Mindestbestände erreicht.</div>
|
||||||
@@ -70,6 +75,50 @@ export default function ShoppingList() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<p className="muted small">
|
||||||
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
|
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user