Web: Chargenliste (mit Sammel-Lagerort) + Ladezustand auf allen Tabellen
Neue Seite "Chargen": alle Chargen ueber alle Artikel (Artikel, Menge, MHD, Lagerort, eingelagert). Lagerort je Zeile direkt aenderbar; alle Chargen ohne Ort per Klick zuweisbar (bulk-location) - gut, wenn der Lagerort erst spaeter angelegt wurde. DataTable bekommt einen loading-Prop: waehrend des Ladens steht "Wird geladen..." statt der Leer-Meldung. Auf ALLEN Tabellen angewendet (Produkte, Mindestbestaende, Kategorien, Gruppen, Einheiten, Gebinde, Einzelstuecke, Chargen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
147
web/src/pages/Charges.jsx
Normal file
147
web/src/pages/Charges.jsx
Normal file
@@ -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) => <ProduktThumb productId={l.product_id} alt={l.product_name} /> },
|
||||
{ key: "product", header: "Artikel", grow: true, min: 180,
|
||||
filterText: (l) => `${l.product_name} ${l.product_brand || ""}`, sortValue: (l) => l.product_name,
|
||||
render: (l) => (
|
||||
<span className="cell-row">
|
||||
<span className="strong">{l.product_name}</span>
|
||||
{l.product_brand && <span className="muted small">{l.product_brand}</span>}
|
||||
</span>
|
||||
) },
|
||||
{ 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) : <span className="muted">–</span>) },
|
||||
{ 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 ? (
|
||||
<select value={l.location_id || ""} style={{ marginTop: 0, minWidth: 150 }}
|
||||
onChange={(e) => setLocation(l.id, e.target.value)}>
|
||||
<option value="">– ohne –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
l.location_id ? (locationPathById(l.location_id, locations) || "?") : <span className="muted">– ohne –</span>
|
||||
)) },
|
||||
{ key: "created", header: "Eingelagert", width: 130,
|
||||
sortValue: (l) => new Date(l.created_at).getTime(),
|
||||
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
|
||||
{ key: "details", header: "", label: "Artikel", fixed: true, width: 84, align: "num",
|
||||
render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Chargen</h1>
|
||||
<div className="sub">
|
||||
{loading ? "Wird geladen…" : `${lots.length} Chargen · ${ohneOrt.length} ohne Lagerort`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
{isAdmin && ohneOrt.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<div className="card-head"><Icon name="location" /><h2>Lagerort für alle ohne Ort setzen</h2></div>
|
||||
<p className="muted small mt-0">
|
||||
{ohneOrt.length} Charge(n) haben noch keinen Lagerort. Ort wählen und in einem Klick allen zuweisen.
|
||||
</p>
|
||||
<div className="field-inline">
|
||||
<select value={bulkLoc} onChange={(e) => setBulkLoc(e.target.value)} style={{ maxWidth: 360 }}>
|
||||
<option value="">– Lagerort wählen –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<button className="btn primary" disabled={!bulkLoc} onClick={applyBulk}>
|
||||
<Icon name="check" size={16} />Auf alle {ohneOrt.length} anwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
|
||||
getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user