Die Eingabefelder auf der Mindestbestaende-Seite standen je Zeile woanders. Die
Zeile war ein .field-inline, und dort waechst sowohl der Name als auch das
Eingabefeld (.field-inline > input { flex: 1 1 auto }) - beide teilten sich den
Restplatz, also verschob ein laengerer Name das Feld nach rechts und machte es
schmaler. Jetzt ist die Zeile ein Raster mit festen Spalten (Name, Menge,
Einheit, Bestand, Ort, Loeschen); ohne Adminrechte bleiben die letzten beiden
Zellen leer, damit die Zeilen trotzdem fluchten. Schmale Fenster stellen den
Namen ueber die volle Breite.
Die "Details"-Spalte liess sich nicht verschieben: sie war fixed und hatte
zusaetzlich einen leeren Kopf - es gab also nichts zu greifen. Sie ist jetzt
frei und traegt einen sichtbaren Titel. Dasselbe fuer die gleichartigen
Link-Spalten in der Einzelstueck- und der Chargenliste ("Artikel",
"Aufteilen"). Sortieren bleibt dort aus, weil toggleSort ohne sortValue
ohnehin aussteigt. Bild- und Auswahlspalten bleiben fest - die haben keinen
sinnvollen Titel und gehoeren an den Anfang.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
10 KiB
JavaScript
226 lines
10 KiB
JavaScript
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 SplitLotDialog from "../components/SplitLotDialog";
|
||
import { ProduktThumb } from "../components/ProduktBild";
|
||
import { useSettings } from "../settings";
|
||
import { locationOptions, locationPathById } from "../locationPath";
|
||
import { expiryRowClass, 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("");
|
||
const [selected, setSelected] = useState(() => new Set());
|
||
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
|
||
const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
|
||
const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend"
|
||
|
||
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(); }, []);
|
||
// Warnfrist (Tage) für die gelbe „bald ablaufend"-Markierung – wie auf der Artikelseite.
|
||
useEffect(() => {
|
||
api.listSettings()
|
||
.then((s) => { const r = s.find((x) => x.key === "expiry_warning_days"); if (r) setWarnDays(parseInt(r.value, 10) || 7); })
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const ohneOrt = useMemo(() => lots.filter((l) => !l.location_id), [lots]);
|
||
|
||
function toggle(id) {
|
||
setSelected((alt) => {
|
||
const neu = new Set(alt);
|
||
if (neu.has(id)) neu.delete(id); else neu.add(id);
|
||
return neu;
|
||
});
|
||
}
|
||
// Menge von IDs zur Auswahl hinzufügen (für „alle sichtbaren/ohne Ort").
|
||
function selectAll(ids) { setSelected(new Set(ids)); }
|
||
function clearSelection() { setSelected(new Set()); }
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Lagerort für die aktuell ausgewählten Chargen setzen (leer = Ort entfernen).
|
||
async function applyToSelection() {
|
||
if (selected.size === 0) return;
|
||
setError(null);
|
||
try {
|
||
await api.bulkLotLocation([...selected], bulkLoc);
|
||
setBulkLoc("");
|
||
clearSelection();
|
||
await load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
const columns = [
|
||
{ key: "select", header: "", label: "Auswahl", fixed: true, width: 44,
|
||
render: (l) => (
|
||
<input type="checkbox" style={{ width: "auto", margin: 0, cursor: "pointer" }}
|
||
checked={selected.has(l.id)} onChange={() => toggle(l.id)}
|
||
aria-label={`${l.product_name} auswählen`} />
|
||
) },
|
||
{ 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: "art", header: "Art", width: 130,
|
||
filterText: (l) => (l.tracking === "object" ? "Gegenstand" : "Lebensmittel"),
|
||
filterValues: (l) => [l.tracking === "object" ? "Gegenstand" : "Lebensmittel"],
|
||
render: (l) => (
|
||
<span className="badge nowrap">{l.tracking === "object" ? "Gegenstand" : "Lebensmittel"}</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: "split", header: "Aufteilen", label: "Aufteilen", width: 118, align: "num",
|
||
render: (l) => (isAdmin ? (
|
||
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
|
||
onClick={() => setSplitting(l)}><Icon name="split" size={15} />Aufteilen</button>
|
||
) : null) },
|
||
{ key: "details", header: "Artikel", label: "Artikel", width: 90, 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 && (
|
||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<div className="card-head"><Icon name="location" /><h2>Lagerort für ausgewählte Chargen setzen</h2></div>
|
||
<p className="muted small mt-0">
|
||
{selected.size > 0
|
||
? `${selected.size} Charge(n) ausgewählt – diese bekommen den gewählten Lagerort (leer = Ort entfernen).`
|
||
: "Chargen in der Tabelle anhaken, dann hier den Lagerort setzen. Oder unten schnell alle sichtbaren / alle ohne Ort auswählen."}
|
||
</p>
|
||
<div className="field-inline" style={{ flexWrap: "wrap" }}>
|
||
<select value={bulkLoc} onChange={(e) => setBulkLoc(e.target.value)} style={{ maxWidth: 360 }}
|
||
disabled={selected.size === 0}>
|
||
<option value="">– ohne Lagerort –</option>
|
||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||
</select>
|
||
<button className="btn primary" disabled={selected.size === 0} onClick={applyToSelection}>
|
||
<Icon name="check" size={16} />Auf {selected.size} Auswahl anwenden
|
||
</button>
|
||
</div>
|
||
<div className="field-inline" style={{ flexWrap: "wrap", marginTop: "var(--sp-2)", marginBottom: 0 }}>
|
||
<button type="button" className="btn sm ghost" onClick={() => selectAll(visible.map((l) => l.id))}
|
||
disabled={visible.length === 0}>
|
||
Alle {visible.length} sichtbaren auswählen
|
||
</button>
|
||
<button type="button" className="btn sm ghost" onClick={() => selectAll(ohneOrt.map((l) => l.id))}
|
||
disabled={ohneOrt.length === 0}>
|
||
Alle {ohneOrt.length} ohne Lagerort auswählen
|
||
</button>
|
||
{selected.size > 0 && (
|
||
<button type="button" className="btn sm ghost" onClick={clearSelection}>Auswahl aufheben</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="card">
|
||
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
|
||
onVisibleRows={setVisible}
|
||
rowClassName={(l) => [expiryRowClass(l.best_before, warnDays), selected.has(l.id) ? "row-active" : ""].filter(Boolean).join(" ")}
|
||
onRowClick={(l, e) => {
|
||
// Klicks auf Bedienelemente (Checkbox, Lagerort-Dropdown, Link) nicht
|
||
// als Zeilenauswahl werten – überall sonst die Zeile umschalten.
|
||
if (!e.target.closest("input, select, a, button, label")) toggle(l.id);
|
||
}}
|
||
getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." />
|
||
</div>
|
||
|
||
{splitting && (
|
||
<SplitLotDialog
|
||
lot={splitting}
|
||
factor={artFactor(splitting)}
|
||
labelFor={(q) => (splitting.package_size && splitting.package_size > 0
|
||
? gebinde(q, splitting.package_label)
|
||
: (splitting.unit_name || unitShort(splitting.base_unit)))}
|
||
locations={locations}
|
||
onClose={() => setSplitting(null)}
|
||
onDone={load}
|
||
onError={setError}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|