diff --git a/web/src/App.jsx b/web/src/App.jsx index 9186b27..4e5fbac 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -8,6 +8,7 @@ import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Products from "./pages/Products"; import ItemList from "./pages/ItemList"; +import Charges from "./pages/Charges"; import MinStock from "./pages/MinStock"; import ProductForm from "./pages/ProductForm"; import CheckIn from "./pages/CheckIn"; @@ -79,6 +80,7 @@ function Sidebar() { + @@ -146,6 +148,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index d58446e..4b3970c 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -190,6 +190,11 @@ export const api = { listLots: (productId) => request(`/lots${productId ? `?product_id=${productId}` : ""}`), updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }), + // Alle Chargen (über alle Artikel) mit Artikel-Infos. + listAllLots: () => request("/lots/rows"), + // Lagerort mehrerer Chargen auf einmal setzen (null = leeren). + bulkLotLocation: (lotIds, locationId) => + request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }), deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }), // Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste). diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx index 475da13..3e9fdd6 100644 --- a/web/src/components/DataTable.jsx +++ b/web/src/components/DataTable.jsx @@ -28,7 +28,7 @@ function savePersisted(id, data) { export default function DataTable({ id, columns, rows, getRowKey, rowClassName, - empty = "Nichts gefunden.", toolbarExtra = null, tree = null, + empty = "Nichts gefunden.", loading = false, toolbarExtra = null, tree = null, }) { const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]); @@ -382,7 +382,11 @@ export default function DataTable({ ))} {displayRows.length === 0 && ( - {empty} + // Während des Ladens NICHT „nichts gefunden" behaupten – die Leer- + // Meldung kommt erst, wenn die Daten wirklich da (und leer) sind. + + {loading ? "Wird geladen…" : empty} + )} diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index 01d9177..bd6a3ba 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -26,12 +26,16 @@ export default function Categories() { const [feldKat, setFeldKat] = useState(null); // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. const [typFilter, setTypFilter] = useState(""); + const [loading, setLoading] = useState(true); async function load() { + setLoading(true); try { setCategories(await api.listCategories()); } catch (err) { setError(err.message); + } finally { + setLoading(false); } } @@ -170,7 +174,7 @@ export default function Categories() { getRowKey={(c) => c.id} rowClassName={(c) => (feldKat && feldKat.id === c.id ? "row-active" : "")} tree={{ column: "name", idOf: (c) => c.id, parentIdOf: (c) => c.parent_id }} - empty="Noch keine Kategorien." /> + loading={loading} empty="Noch keine Kategorien." />
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() { )}
- r.key} empty="Noch keine Mindestbestände." />
diff --git a/web/src/pages/PackageTypes.jsx b/web/src/pages/PackageTypes.jsx index 0077b97..4121493 100644 --- a/web/src/pages/PackageTypes.jsx +++ b/web/src/pages/PackageTypes.jsx @@ -19,8 +19,10 @@ export default function PackageTypes() { const [form, setForm] = useState({ singular: "", plural: "" }); const [bearbeitung, setBearbeitung] = useState(null); // { id, singular, plural } const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); async function load() { + setLoading(true); try { setArten(await api.listPackageTypes()); // Die Mehrzahlformen stecken in einer Modulvariablen, die beim Login @@ -29,6 +31,8 @@ export default function PackageTypes() { reloadSettings(); } catch (err) { setError(err.message); + } finally { + setLoading(false); } } @@ -136,7 +140,7 @@ export default function PackageTypes() {
- a.id} empty="Noch keine Gebinde." />
diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index d2dfbed..5e375a4 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -153,8 +153,7 @@ export default function Products({ fixedType = null }) {
p.id} - empty={loading ? "Produkte werden geladen…" : "Keine Produkte gefunden."} /> + getRowKey={(p) => p.id} loading={loading} empty="Keine Produkte gefunden." />
); diff --git a/web/src/pages/Units.jsx b/web/src/pages/Units.jsx index 806dd29..6df815e 100644 --- a/web/src/pages/Units.jsx +++ b/web/src/pages/Units.jsx @@ -12,12 +12,16 @@ export default function Units() { const [units, setUnits] = useState([]); const [form, setForm] = useState({ name: "", kind: "weight", factor: "" }); const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); async function load() { + setLoading(true); try { setUnits(await api.listUnits()); } catch (err) { setError(err.message); + } finally { + setLoading(false); } } @@ -87,7 +91,7 @@ export default function Units() {
- u.id} empty="Noch keine Einheiten." />