Charge aufteilen: Teilmenge mit gleichem MHD an anderen Lagerort umlagern

Bisher liess sich nur die ganze Charge umlagern. Neu: POST /lots/{id}/split zweigt
eine Teilmenge (Basiseinheiten) als neue Charge mit gleichem MHD an einen anderen
Lagerort ab; der Rest bleibt. Reine Umbuchung ohne Bewegungseintrag, Gesamtbestand
unveraendert. Schutz gegen zu grosse Menge und gleichen Zielort.

Web: gemeinsamer SplitLotDialog (Menge in Artikeleinheit + Zielort, zeigt Rest),
Aufteilen-Knopf auf der Chargen-Seite und in der Chargen-Tabelle der Artikelseite.
Neues Split-Icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-30 08:43:16 +02:00
parent 8cf7cef6e1
commit 231d86895f
7 changed files with 184 additions and 0 deletions

View File

@@ -196,6 +196,8 @@ export const api = {
bulkLotLocation: (lotIds, locationId) =>
request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }),
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 }),
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
setProductLocationMinStock: (id, list) =>

View File

@@ -38,6 +38,14 @@ const PATHS = {
<path d="M12 22V12" />
</>
),
split: (
<>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</>
),
tag: (
<>
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { fmt } from "../units";
import { locationOptions, locationPathById } from "../locationPath";
/**
* Charge aufteilen: einen Teil der Menge (in Artikeleinheiten Packungen/Stück/g)
* mit gleichem MHD an einen anderen Lagerort verschieben. Die Restmenge bleibt in
* der ursprünglichen Charge; der Gesamtbestand ändert sich nicht.
*
* `factor` Basiseinheiten je Artikeleinheit (z.B. 415 ml pro Packung).
* `labelFor` (menge) => Einheitentext, mengenabhängig ("1 Packung"/"3 Packungen").
*/
export default function SplitLotDialog({ lot, factor, labelFor, locations, onClose, onDone, onError }) {
const total = lot.quantity / factor; // Gesamtmenge in Artikeleinheiten
const [menge, setMenge] = useState("");
const [locId, setLocId] = useState("");
const [busy, setBusy] = useState(false);
const num = Number(String(menge).trim().replace(",", "."));
const gueltig = menge !== "" && !Number.isNaN(num) && num > 0 && num < total;
const rest = gueltig ? total - num : null;
const zielGleich = gueltig && (locId || null) === (lot.location_id || null);
async function submit() {
if (!gueltig || zielGleich || busy) return;
setBusy(true);
try {
await api.splitLot(lot.id, { quantity: num * factor, location_id: locId || null });
onClose();
await onDone();
} catch (err) {
onError?.(err.message);
} finally {
setBusy(false);
}
}
return (
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" role="dialog" aria-modal="true">
<div className="modal-head"><Icon name="split" size={18} /><h2>Charge aufteilen</h2></div>
<p className="modal-body mt-0">
Aktuell {fmt(total)} {labelFor(total)}
{lot.location_id ? ` in ${locationPathById(lot.location_id, locations) || "?"}` : " ohne Lagerort"}.
Ein Teil davon wird mit gleichem MHD an einen anderen Lagerort verschoben.
</p>
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
<label>
Menge abteilen (in {labelFor(2)})
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input type="number" step="any" min="0" max={total} value={menge} autoFocus
onChange={(e) => setMenge(e.target.value)} placeholder={`max. ${fmt(total)}`} />
</label>
<label>
nach Lagerort
<select value={locId} onChange={(e) => setLocId(e.target.value)}>
<option value=""> ohne Lagerort </option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</label>
{rest != null && !zielGleich && (
<p className="muted small mt-0">
Danach: {fmt(num)} {labelFor(num)} am neuen Ort, {fmt(rest)} {labelFor(rest)} bleiben.
</p>
)}
{zielGleich && (
<p className="small mt-0" style={{ color: "var(--danger)" }}>
Bitte einen anderen Lagerort als den aktuellen wählen.
</p>
)}
</div>
<div className="btn-pair">
<button type="button" className="btn" onClick={onClose}>Abbrechen</button>
<button type="button" className="btn primary" disabled={!gueltig || zielGleich || busy} onClick={submit}>
<Icon name="split" size={16} />Aufteilen
</button>
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ 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";
@@ -34,6 +35,7 @@ export default function Charges() {
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
async function load() {
setLoading(true);
@@ -130,6 +132,11 @@ export default function Charges() {
{ 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: "", label: "Aufteilen", fixed: true, 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: "", label: "Artikel", fixed: true, width: 84, align: "num",
render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> },
];
@@ -192,6 +199,20 @@ export default function Charges() {
}}
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>
);
}

View File

@@ -15,6 +15,7 @@ import LocationMinStock from "../components/LocationMinStock";
import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke";
import SplitLotDialog from "../components/SplitLotDialog";
import { locationOptions, locationPathById } from "../locationPath";
import {
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
@@ -1073,6 +1074,7 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin,
const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day", location_id: "" });
// In welcher Einheit die Menge bearbeitet wird: "unit" oder "package".
const [editUnit, setEditUnit] = useState("unit");
const [splitLotItem, setSplitLotItem] = useState(null); // Charge, die aufgeteilt wird
const unitFactor = product?.unit_factor || 1;
const unitName = product?.unit_name || baseShort;
@@ -1248,6 +1250,10 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin,
</div>
) : (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" onClick={() => setSplitLotItem(l)}
title="Charge aufteilen und Teil umlagern">
<Icon name="split" size={16} />
</button>
<button className="btn-icon" onClick={() => startEdit(l)} title="Charge bearbeiten">
<Icon name="edit" size={16} />
</button>
@@ -1267,6 +1273,18 @@ function LotsCard({ product, lots, locations = [], baseShort, warnDays, isAdmin,
{pkgSize > 0 && (
<p className="muted small">1 {pkgLabel} = {fmt(pkgSize)} {baseShort}</p>
)}
{splitLotItem && (
<SplitLotDialog
lot={splitLotItem}
factor={primaryFactor}
labelFor={primaryLabel}
locations={locations}
onClose={() => setSplitLotItem(null)}
onDone={onChanged}
onError={onError}
/>
)}
</section>
);
}