diff --git a/web/src/pages/Charges.jsx b/web/src/pages/Charges.jsx
new file mode 100644
index 0000000..1f69ab6
--- /dev/null
+++ b/web/src/pages/Charges.jsx
@@ -0,0 +1,147 @@
+import { useEffect, useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import { api } from "../api";
+import { useAuth } from "../auth";
+import Icon from "../components/Icon";
+import DataTable from "../components/DataTable";
+import { ProduktThumb } from "../components/ProduktBild";
+import { useSettings } from "../settings";
+import { locationOptions, locationPathById } from "../locationPath";
+import { fmt, gebinde, unitShort } from "../units";
+
+// Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit).
+const artFactor = (l) => (l.package_size && l.package_size > 0 ? l.package_size : (l.unit_factor || 1));
+function mengeText(l) {
+ const menge = l.quantity / artFactor(l);
+ const label = l.package_size && l.package_size > 0
+ ? gebinde(menge, l.package_label)
+ : (l.unit_name || unitShort(l.base_unit));
+ return `${fmt(menge)} ${label}`;
+}
+
+/**
+ * Übergreifende Chargenliste (wie die Einzelstückliste, aber für Chargen). Zeigt
+ * je Charge Artikel, Menge, MHD und Lagerort; der Lagerort ist je Zeile direkt
+ * änderbar, und alle Chargen ohne Ort lassen sich in einem Klick zuordnen.
+ */
+export default function Charges() {
+ const { isAdmin } = useAuth();
+ const { formatBestBefore } = useSettings();
+ const [lots, setLots] = useState([]);
+ const [locations, setLocations] = useState([]);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [bulkLoc, setBulkLoc] = useState("");
+
+ async function load() {
+ setLoading(true);
+ try {
+ const [ls, locs] = await Promise.all([api.listAllLots(), api.listLocations()]);
+ setLots(ls);
+ setLocations(locs);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+ useEffect(() => { load(); }, []);
+
+ const ohneOrt = useMemo(() => lots.filter((l) => !l.location_id), [lots]);
+
+ async function setLocation(lotId, locId) {
+ setError(null);
+ try {
+ await api.updateLot(lotId, { location_id: locId || null });
+ setLots((ls) => ls.map((l) => (l.id === lotId ? { ...l, location_id: locId || null } : l)));
+ } catch (err) {
+ setError(err.message);
+ }
+ }
+
+ async function applyBulk() {
+ if (!bulkLoc || ohneOrt.length === 0) return;
+ setError(null);
+ try {
+ await api.bulkLotLocation(ohneOrt.map((l) => l.id), bulkLoc);
+ setBulkLoc("");
+ await load();
+ } catch (err) {
+ setError(err.message);
+ }
+ }
+
+ const columns = [
+ { key: "thumb", header: "", label: "Bild", fixed: true, width: 56,
+ render: (l) =>
},
+ { key: "product", header: "Artikel", grow: true, min: 180,
+ filterText: (l) => `${l.product_name} ${l.product_brand || ""}`, sortValue: (l) => l.product_name,
+ render: (l) => (
+
+ {l.product_name}
+ {l.product_brand && {l.product_brand}}
+
+ ) },
+ { key: "menge", header: "Menge", width: 150, align: "num",
+ sortValue: (l) => l.quantity / artFactor(l), render: mengeText },
+ { key: "mhd", header: "MHD", width: 150,
+ filterText: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : ""),
+ sortValue: (l) => l.best_before || "9999-99-99",
+ render: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) :
–) },
+ { key: "location", header: "Lagerort", width: 240,
+ filterText: (l) => (l.location_id ? (locationPathById(l.location_id, locations) || "") : "(ohne)"),
+ filterValues: (l) => [l.location_id ? (locationPathById(l.location_id, locations) || "?") : "(ohne)"],
+ render: (l) => (isAdmin ? (
+
+ ) : (
+ l.location_id ? (locationPathById(l.location_id, locations) || "?") :
– ohne –
+ )) },
+ { key: "created", header: "Eingelagert", width: 130,
+ sortValue: (l) => new Date(l.created_at).getTime(),
+ render: (l) =>
{new Date(l.created_at).toLocaleDateString("de-DE")} },
+ { key: "details", header: "", label: "Artikel", fixed: true, width: 84, align: "num",
+ render: (l) =>
Artikel },
+ ];
+
+ return (
+
+
+
+
Chargen
+
+ {loading ? "Wird geladen…" : `${lots.length} Chargen · ${ohneOrt.length} ohne Lagerort`}
+
+
+
+
+ {error &&
{error}
}
+
+ {isAdmin && ohneOrt.length > 0 && (
+
+
Lagerort für alle ohne Ort setzen
+
+ {ohneOrt.length} Charge(n) haben noch keinen Lagerort. Ort wählen und in einem Klick allen zuweisen.
+
+
+
+
+
+
+ )}
+
+
+ l.id} empty="Keine Chargen im Bestand." />
+
+
+ );
+}
diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx
index aeabc3e..355c10e 100644
--- a/web/src/pages/Groups.jsx
+++ b/web/src/pages/Groups.jsx
@@ -17,8 +17,10 @@ export default function Groups() {
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
async function load() {
+ setLoading(true);
try {
const [gs, us, ls] = await Promise.all([
api.listGroups(), api.listUnits(), api.listLocations(),
@@ -28,6 +30,8 @@ export default function Groups() {
setLocations(ls);
} catch (err) {
setError(err.message);
+ } finally {
+ setLoading(false);
}
}
@@ -138,7 +142,7 @@ export default function Groups() {
- g.id} empty="Noch keine Gruppen." />
diff --git a/web/src/pages/ItemList.jsx b/web/src/pages/ItemList.jsx
index 0751d6a..797caa2 100644
--- a/web/src/pages/ItemList.jsx
+++ b/web/src/pages/ItemList.jsx
@@ -21,14 +21,16 @@ export default function ItemList() {
const [shops, setShops] = useState([]);
const [categories, setCategories] = useState([]);
const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
async function load() {
+ setLoading(true);
try {
const [is, ls, ss, cs] = await Promise.all([
api.listAllItems(), api.listLocations(), api.listShops(), api.listCategories(),
]);
setItems(is); setLocations(ls); setShops(ss); setCategories(cs);
- } catch (err) { setError(err.message); }
+ } catch (err) { setError(err.message); } finally { setLoading(false); }
}
useEffect(() => { load(); }, []);
@@ -100,7 +102,7 @@ export default function ItemList() {
{error &&
{error}
}
- it.id} empty="Noch keine Einzelstücke." />
diff --git a/web/src/pages/MinStock.jsx b/web/src/pages/MinStock.jsx
index d51b732..12a0f9b 100644
--- a/web/src/pages/MinStock.jsx
+++ b/web/src/pages/MinStock.jsx
@@ -35,17 +35,19 @@ export default function MinStock() {
const [locations, setLocations] = useState([]);
const [categories, setCategories] = useState([]);
const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
// „+ hinzufügen"-Dialog: Ziel (Produkt/Gruppe) → Geltung (Gesamt/Lagerort) → Menge.
const [showAdd, setShowAdd] = useState(false);
const [draft, setDraft] = useState(EMPTY_DRAFT);
async function load() {
+ setLoading(true);
try {
const [ps, gs, ls, cs] = await Promise.all([
api.listProducts("", ""), api.listGroups(), api.listLocations(), api.listCategories(),
]);
setProducts(ps); setGroups(gs); setLocations(ls); setCategories(cs);
- } catch (err) { setError(err.message); }
+ } catch (err) { setError(err.message); } finally { setLoading(false); }
}
useEffect(() => { load(); }, []);
@@ -285,7 +287,7 @@ export default function MinStock() {
)}