Chargen: mehrere auf einmal ausbuchen, teilweise abbuchen oder loeschen

Die Mehrfachauswahl gab es schon, sie konnte aber nur den Lagerort setzen.
Jetzt kommen drei Aktionen dazu: komplett ausbuchen, von jeder Charge
dieselbe Teilmenge abbuchen, und loeschen als Korrektur.

Anders als bulk-location kein Sammel-UPDATE: Je Charge muss eine Bewegung
entstehen, sonst fehlte der Vorgang im Verlauf und in der
Verbrauchsstatistik. Schlaegt eine Charge fehl, weil sie zu wenig enthaelt,
wird alles zurueckgerollt - ein halb gebuchter Stand waere schlimmer als
gar keiner, dieselbe Haltung wie beim Stapel-Einlagern.

Die gemeinsame Teilmenge gibt es nur, wenn alle ausgewaehlten Chargen zum
selben Artikel gehoeren. "1 Packung" bedeutet bei Milch und Broetchen
Verschiedenes; der Server lehnt es ab, und die Oberflaeche sperrt das Feld
gleich mit einem Hinweis, statt den Fehler erst nach dem Klick zu zeigen.

Loeschen schreibt wie das einzelne Loeschen eine Korrektur-Bewegung
(adjust, negative Menge) statt einer Entnahme - es ist eine Fehleingabe,
kein Verbrauch, und darf die Verbrauchsstatistik nicht verfaelschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 12:41:23 +02:00
co-authored by Claude Opus 5
parent a182037153
commit c66bd0b09e
5 changed files with 408 additions and 4 deletions
+10
View File
@@ -270,6 +270,16 @@ export const api = {
// 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 } }),
// Mehrere Chargen ausbuchen: ohne Menge komplett, mit Menge je Charge
// dieselbe (nur bei einheitlichem Artikel erlaubt).
bulkLotCheckout: (lotIds, { quantity = null, unit = null, note = null } = {}) =>
request("/lots/bulk-checkout", {
method: "POST",
body: { lot_ids: lotIds, quantity, unit, note },
}),
// Mehrere Chargen als Korrektur entfernen (nicht als Verbrauch buchen).
bulkLotDelete: (lotIds) =>
request("/lots/bulk-delete", { method: "POST", body: { lot_ids: lotIds } }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
+93 -4
View File
@@ -7,6 +7,8 @@ import DataTable from "../components/DataTable";
import SplitLotDialog from "../components/SplitLotDialog";
import { ProduktThumb } from "../components/ProduktBild";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import { locationOptions, locationPathById } from "../locationPath";
import { expiryRowClass, fmt, gebinde, unitShort } from "../units";
@@ -28,6 +30,8 @@ function mengeText(l) {
export default function Charges() {
const { isAdmin } = useAuth();
const { formatBestBefore } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [lots, setLots] = useState([]);
const [locations, setLocations] = useState([]);
const [error, setError] = useState(null);
@@ -36,6 +40,7 @@ export default function Charges() {
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 [teilmenge, setTeilmenge] = useState(""); // Menge für „von jeder abbuchen"
const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend"
async function load() {
@@ -81,6 +86,64 @@ export default function Charges() {
}
}
// Gehören alle ausgewählten Chargen zum selben Artikel? Nur dann ist eine
// gemeinsame Menge eindeutig – „1 Packung" bei Milch und Brötchen sind
// verschiedene Dinge.
const ausgewaehlt = useMemo(
() => lots.filter((l) => selected.has(l.id)), [lots, selected],
);
const einArtikel = useMemo(() => {
const ids = new Set(ausgewaehlt.map((l) => l.product_id));
return ids.size === 1 ? ausgewaehlt[0] : null;
}, [ausgewaehlt]);
async function ausbuchen() {
if (selected.size === 0) return;
const ok = await confirm({
title: `${selected.size} Charge(n) komplett ausbuchen?`,
message: "Der Bestand wird abgebucht und im Verlauf als Entnahme festgehalten.",
confirmLabel: "Ausbuchen",
});
if (!ok) return;
try {
const n = await api.bulkLotCheckout([...selected]);
clearSelection();
await load();
toast(`${n} Charge(n) ausgebucht.`);
} catch (err) { setError(err.message); }
}
async function teilmengeAusbuchen() {
const menge = Number(String(teilmenge).replace(",", "."));
if (!einArtikel || !Number.isFinite(menge) || menge <= 0) return;
try {
const n = await api.bulkLotCheckout([...selected], {
quantity: menge * (einArtikel.package_size || 1),
unit: einArtikel.base_unit,
});
setTeilmenge("");
clearSelection();
await load();
toast(`Von ${n} Charge(n) abgebucht.`);
} catch (err) { setError(err.message); }
}
async function loeschen() {
if (selected.size === 0) return;
const ok = await confirm({
title: `${selected.size} Charge(n) löschen?`,
message: "Als Korrektur – nicht als Verbrauch. Für „aufgebraucht\" bitte ausbuchen.",
confirmLabel: "Löschen", danger: true,
});
if (!ok) return;
try {
const n = await api.bulkLotDelete([...selected]);
clearSelection();
await load();
toast(`${n} Charge(n) gelöscht.`);
} 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;
@@ -163,11 +226,11 @@ export default function Charges() {
{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>
<div className="card-head"><Icon name="check" /><h2>Ausgewählte Chargen</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."}
? `${selected.size} Charge(n) ausgewählt.`
: "Chargen in der Tabelle anhaken, dann hier handeln. 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 }}
@@ -176,9 +239,35 @@ export default function Charges() {
{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
<Icon name="location" size={16} />Lagerort setzen
</button>
</div>
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: "var(--sp-2)" }}>
<button className="btn" disabled={selected.size === 0} onClick={ausbuchen}>
<Icon name="checkout" size={16} />Komplett ausbuchen
</button>
<button className="btn danger" disabled={selected.size === 0} onClick={loeschen}>
<Icon name="trash" size={16} />Löschen (Korrektur)
</button>
</div>
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
<label style={{ margin: 0, maxWidth: 220 }}>
Von jeder Charge abbuchen
<input type="number" step="any" min="0" value={teilmenge} disabled={!einArtikel}
placeholder={einArtikel ? `in ${einArtikel.package_label || einArtikel.unit_name || "Einheiten"}` : "–"}
onChange={(e) => setTeilmenge(e.target.value)} />
</label>
<button className="btn" disabled={!einArtikel || !teilmenge} onClick={teilmengeAusbuchen}>
<Icon name="checkout" size={16} />Teilmenge ausbuchen
</button>
</div>
{selected.size > 0 && !einArtikel && (
<p className="muted small mt-0">
Eine gemeinsame Menge geht nur bei einem einzigen Artikel – „1 Packung"
bedeutet bei Milch und Brötchen Verschiedenes. Komplett ausbuchen geht
immer.
</p>
)}
<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}>