Verwaltbare Einheiten mit Umrechnung + Chargen bearbeiten + gezieltes Auslagern
Einheiten (neu):
- Tabelle units (name, kind=count|weight|volume, factor, is_builtin); eingebaut
Stueck/Gramm/Kilogramm/Milliliter/Liter, Admin kann eigene anlegen (z.B. Pfund=500g).
- Bestaende bleiben intern in kanonischer Basis (Stueck/Gramm/Milliliter);
Product.display_unit_id und Group.min_stock_unit_id als nullable FKs.
- Neuer Umrechnungs-Service (services/conversion.py) ersetzt die feste Einheitenlogik;
Ein-/Auslagern und Gruppen-Mindestbestand rechnen ueber den Faktor.
- Gruppen-Mindestbestand mit Einheit; Gruppenbestand summiert nur Produkte
passender Art. Neue Verwaltungsseite "Einheiten" (Admin).
- Schonende Migration beim Start: ADD COLUMN IF NOT EXISTS (Postgres), damit
bestehende Installationen ihre Daten behalten.
Chargen:
- PATCH /lots/{id} und DELETE /lots/{id}: Menge/MHD korrigieren, Charge loeschen
(wird als Korrektur-Bewegung protokolliert). Bearbeitung im Produktdetail.
Auslagern:
- Optionales lot_id: gezielt aus einer bestimmten Charge/MHD abbuchen statt FEFO;
Auswahl-Dropdown in der Auslagern-Seite.
Sonstiges: Roadmap aktualisiert, Tests fuer die Umrechnung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,16 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { guessGroup } from "../offUtils";
|
||||
import { BASE_UNITS, daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
|
||||
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
|
||||
|
||||
const EMPTY = {
|
||||
barcode: "", name: "", brand: "", image_url: "",
|
||||
base_unit: "piece", package_size: "", min_stock: "", group_id: "",
|
||||
unit_id: "", package_size: "", min_stock: "", group_id: "",
|
||||
};
|
||||
|
||||
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
|
||||
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
|
||||
|
||||
export default function ProductForm() {
|
||||
const { id } = useParams();
|
||||
const isNew = !id;
|
||||
@@ -22,28 +25,45 @@ export default function ProductForm() {
|
||||
const [product, setProduct] = useState(null);
|
||||
const [lots, setLots] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Einheit, in der der Mindestbestand eingegeben wird: "base" oder "package".
|
||||
const [minUnit, setMinUnit] = useState("base");
|
||||
// Einheit für die Mindestbestand-Eingabe: "unit" (gewählte Einheit) oder "package".
|
||||
const [minUnit, setMinUnit] = useState("unit");
|
||||
const [warnDays, setWarnDays] = useState(7);
|
||||
|
||||
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
|
||||
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
|
||||
const unitName = selectedUnit ? selectedUnit.name : "";
|
||||
|
||||
function set(k, v) {
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
}
|
||||
|
||||
// Wechselt die Mindestbestand-Einheit und rechnet den angezeigten Wert um.
|
||||
function canonicalUnitId(unitList, baseUnit) {
|
||||
const name = CANONICAL_NAME[baseUnit];
|
||||
const hit = unitList.find((u) => u.name === name);
|
||||
return hit ? String(hit.id) : "";
|
||||
}
|
||||
|
||||
// Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird.
|
||||
function minFactor(mode, pkgSize, uFactor) {
|
||||
if (mode === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1;
|
||||
return uFactor || 1;
|
||||
}
|
||||
|
||||
function changeMinUnit(newUnit) {
|
||||
const ps = Number(form.package_size);
|
||||
if (form.min_stock !== "" && ps > 0 && newUnit !== minUnit) {
|
||||
const v = Number(form.min_stock);
|
||||
set("min_stock", String(newUnit === "package" ? v / ps : v * ps));
|
||||
if (form.min_stock !== "" && newUnit !== minUnit) {
|
||||
const oldF = minFactor(minUnit, form.package_size, unitFactor);
|
||||
const newF = minFactor(newUnit, form.package_size, unitFactor);
|
||||
const base = Number(form.min_stock) * oldF;
|
||||
set("min_stock", String(base / newF));
|
||||
}
|
||||
setMinUnit(newUnit);
|
||||
}
|
||||
|
||||
function applySuggestion(s, groupsList) {
|
||||
function applySuggestion(s, groupsList, unitList) {
|
||||
const groupGuess = guessGroup(groupsList, s);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
@@ -51,7 +71,7 @@ export default function ProductForm() {
|
||||
name: s.name || f.name,
|
||||
brand: s.brand || f.brand,
|
||||
image_url: s.image_url || f.image_url,
|
||||
base_unit: s.base_unit || f.base_unit,
|
||||
unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"),
|
||||
package_size: s.package_size != null ? String(s.package_size) : f.package_size,
|
||||
group_id: f.group_id || groupGuess,
|
||||
}));
|
||||
@@ -63,7 +83,7 @@ export default function ProductForm() {
|
||||
);
|
||||
}
|
||||
|
||||
async function runLookup(code, groupsList) {
|
||||
async function runLookup(code, groupsList, unitList) {
|
||||
if (!code) return;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
@@ -73,7 +93,7 @@ export default function ProductForm() {
|
||||
setInfo("Dieses Produkt existiert bereits.");
|
||||
navigate(`/products/${res.existing_product.id}`);
|
||||
} else if (res.found && res.suggestion) {
|
||||
applySuggestion(res.suggestion, groupsList);
|
||||
applySuggestion(res.suggestion, groupsList, unitList);
|
||||
} else {
|
||||
setInfo("Barcode unbekannt – bitte Daten selbst eingeben.");
|
||||
}
|
||||
@@ -85,29 +105,30 @@ export default function ProductForm() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const gs = await api.listGroups();
|
||||
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
|
||||
setGroups(gs);
|
||||
setUnits(us);
|
||||
try {
|
||||
const s = await api.listSettings();
|
||||
const row = s.find((x) => x.key === "expiry_warning_days");
|
||||
if (row) setWarnDays(parseInt(row.value, 10) || 7);
|
||||
} catch { /* Einstellungen optional */ }
|
||||
} catch { /* optional */ }
|
||||
|
||||
if (!isNew) {
|
||||
const p = await api.getProduct(id);
|
||||
setProduct(p);
|
||||
// Mindestbestand ist in Basiseinheiten gespeichert; bei Packungsprodukten
|
||||
// zeigen wir ihn zur besseren Verständlichkeit in Packungen an.
|
||||
let minDisplay = p.min_stock ?? "";
|
||||
if (p.min_stock != null && p.package_size && p.package_size > 0) {
|
||||
minDisplay = p.min_stock / p.package_size;
|
||||
setMinUnit("package");
|
||||
} else {
|
||||
setMinUnit("base");
|
||||
}
|
||||
const uid = p.display_unit_id
|
||||
? String(p.display_unit_id)
|
||||
: canonicalUnitId(us, p.base_unit);
|
||||
const uf = us.find((u) => String(u.id) === uid)?.factor || 1;
|
||||
const mode = p.package_size && p.package_size > 0 ? "package" : "unit";
|
||||
const f = mode === "package" ? p.package_size : uf;
|
||||
setMinUnit(mode);
|
||||
setForm({
|
||||
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
||||
image_url: p.image_url || "", base_unit: p.base_unit,
|
||||
package_size: p.package_size ?? "", min_stock: minDisplay,
|
||||
image_url: p.image_url || "", unit_id: uid,
|
||||
package_size: p.package_size ?? "",
|
||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||
group_id: p.group_id ?? "",
|
||||
});
|
||||
setLots(await api.listLots(id));
|
||||
@@ -115,7 +136,7 @@ export default function ProductForm() {
|
||||
const bc = searchParams.get("barcode");
|
||||
if (bc) {
|
||||
set("barcode", bc);
|
||||
await runLookup(bc, gs);
|
||||
await runLookup(bc, gs, us);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -127,18 +148,16 @@ export default function ProductForm() {
|
||||
}, [id]);
|
||||
|
||||
function buildPayload() {
|
||||
const ps = Number(form.package_size);
|
||||
let minBase = null;
|
||||
if (form.min_stock !== "") {
|
||||
const v = Number(form.min_stock);
|
||||
minBase = minUnit === "package" && ps > 0 ? v * ps : v;
|
||||
minBase = Number(form.min_stock) * minFactor(minUnit, form.package_size, unitFactor);
|
||||
}
|
||||
return {
|
||||
barcode: form.barcode || null,
|
||||
name: form.name,
|
||||
brand: form.brand || null,
|
||||
image_url: form.image_url || null,
|
||||
base_unit: form.base_unit,
|
||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||
min_stock: minBase,
|
||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||
@@ -157,6 +176,7 @@ export default function ProductForm() {
|
||||
await api.updateProduct(id, buildPayload());
|
||||
setInfo("Gespeichert.");
|
||||
setProduct(await api.getProduct(id));
|
||||
setLots(await api.listLots(id));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -176,14 +196,18 @@ export default function ProductForm() {
|
||||
}
|
||||
|
||||
const readOnly = !isAdmin;
|
||||
const unitLabel = unitShort(form.base_unit);
|
||||
const baseShort = product ? unitShort(product.base_unit) : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
|
||||
{!isNew && <div className="sub">Bestand: {fmt(product?.stock)} {unitLabel}</div>}
|
||||
{!isNew && product && (
|
||||
<div className="sub">
|
||||
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button>
|
||||
</div>
|
||||
@@ -199,7 +223,7 @@ export default function ProductForm() {
|
||||
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups)}>
|
||||
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups, units)}>
|
||||
<Icon name="search" size={16} />Nachschlagen
|
||||
</button>
|
||||
)}
|
||||
@@ -214,16 +238,16 @@ export default function ProductForm() {
|
||||
</label>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Basiseinheit
|
||||
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
|
||||
{BASE_UNITS.map((u) => <option key={u.value} value={u.value}>{u.label}</option>)}
|
||||
Einheit
|
||||
<select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
|
||||
<option value="">– wählen –</option>
|
||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">
|
||||
Packungsgröße{unitLabel !== "Stk" ? ` (in ${unitLabel})` : ""}
|
||||
Packungsgröße{baseShort ? ` (in ${baseShort})` : ""}
|
||||
<input type="number" step="any" value={form.package_size}
|
||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
|
||||
placeholder="z.B. 500" />
|
||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
@@ -231,11 +255,10 @@ export default function ProductForm() {
|
||||
Mindestbestand
|
||||
<div className="field-inline">
|
||||
<input type="number" step="any" value={form.min_stock}
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly}
|
||||
placeholder="z.B. 2" />
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
||||
<select value={minUnit} onChange={(e) => changeMinUnit(e.target.value)}
|
||||
disabled={readOnly} style={{ maxWidth: 140, marginTop: 0 }}>
|
||||
<option value="base">{unitShort(form.base_unit)}</option>
|
||||
disabled={readOnly} style={{ maxWidth: 150, marginTop: 0 }}>
|
||||
<option value="unit">{unitName || "Einheit"}</option>
|
||||
{form.package_size && <option value="package">Packung(en)</option>}
|
||||
</select>
|
||||
</div>
|
||||
@@ -263,40 +286,127 @@ export default function ProductForm() {
|
||||
</form>
|
||||
|
||||
{!isNew && (
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
|
||||
{lots.some((l) => isExpired(l.best_before)) && (
|
||||
<div className="alert error"><Icon name="alert" size={16} />
|
||||
Dieses Produkt hat abgelaufene Chargen im Bestand.
|
||||
</div>
|
||||
)}
|
||||
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
|
||||
<tbody>
|
||||
{lots.map((l) => {
|
||||
const cls = expiryRowClass(l.best_before, warnDays);
|
||||
const dLeft = daysUntil(l.best_before);
|
||||
return (
|
||||
<tr key={l.id} className={cls}>
|
||||
<td className="num">{fmt(l.quantity)} {unitLabel}</td>
|
||||
<td>
|
||||
{l.best_before || "–"}
|
||||
{cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>}
|
||||
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>}
|
||||
</td>
|
||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<LotsCard
|
||||
productId={id}
|
||||
lots={lots}
|
||||
baseShort={baseShort}
|
||||
warnDays={warnDays}
|
||||
isAdmin={isAdmin}
|
||||
imageUrl={form.image_url}
|
||||
onChanged={async () => {
|
||||
setLots(await api.listLots(id));
|
||||
setProduct(await api.getProduct(id));
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chargen-Karte mit Bearbeiten/Löschen. */
|
||||
function LotsCard({ productId, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [draft, setDraft] = useState({ quantity: "", best_before: "" });
|
||||
|
||||
function startEdit(l) {
|
||||
setEditId(l.id);
|
||||
setDraft({ quantity: l.quantity, best_before: l.best_before || "" });
|
||||
}
|
||||
|
||||
async function saveEdit(l) {
|
||||
try {
|
||||
await api.updateLot(l.id, {
|
||||
quantity: Number(draft.quantity),
|
||||
best_before: draft.best_before || null,
|
||||
});
|
||||
setEditId(null);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLot(l) {
|
||||
if (!confirm("Charge wirklich löschen?")) return;
|
||||
try {
|
||||
await api.deleteLot(l.id);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
|
||||
{lots.some((l) => isExpired(l.best_before)) && (
|
||||
<div className="alert error"><Icon name="alert" size={16} />
|
||||
Dieses Produkt hat abgelaufene Chargen im Bestand.
|
||||
</div>
|
||||
)}
|
||||
{imageUrl && <img className="product-img" src={imageUrl} alt="" />}
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lots.map((l) => {
|
||||
const cls = expiryRowClass(l.best_before, warnDays);
|
||||
const dLeft = daysUntil(l.best_before);
|
||||
const editing = editId === l.id;
|
||||
return (
|
||||
<tr key={l.id} className={cls}>
|
||||
<td className="num">
|
||||
{editing ? (
|
||||
<input type="number" step="any" min="0" value={draft.quantity}
|
||||
onChange={(e) => setDraft({ ...draft, quantity: e.target.value })}
|
||||
style={{ maxWidth: 100, marginTop: 0 }} />
|
||||
) : (
|
||||
<>{fmt(l.quantity)} {baseShort}</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<input type="date" value={draft.best_before}
|
||||
onChange={(e) => setDraft({ ...draft, best_before: e.target.value })}
|
||||
style={{ maxWidth: 150, marginTop: 0 }} />
|
||||
) : (
|
||||
<>
|
||||
{l.best_before || "–"}
|
||||
{cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>}
|
||||
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||
<td className="num">
|
||||
{isAdmin && (editing ? (
|
||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="btn sm primary" onClick={() => saveEdit(l)}>Speichern</button>
|
||||
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="btn-icon" onClick={() => startEdit(l)} title="Charge bearbeiten">
|
||||
<Icon name="edit" size={16} />
|
||||
</button>
|
||||
<button className="btn-icon danger" onClick={() => removeLot(l)} title="Charge löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{lots.length === 0 && <tr><td colSpan={4} className="empty">Keine Chargen im Bestand.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="muted small">Mengen sind in der Basiseinheit ({baseShort}) angegeben.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user