Einlagern-UX: 3-Wege-Barcode, Inline-Anlage aus OFF, Mehr-Chargen mit MHD

Einlagern:
- Barcode-Erkennung dreiteilig: bekannt -> direkt Menge; unbekannt aber in OFF
  -> "Anlegen & einlagern" inline; gar nicht gefunden -> manuell anlegen.
- Mehrere Chargen pro Vorgang: je Zeile eigene Menge + eigenes MHD
  (z.B. 5 Glaeser mit unterschiedlichen Daten). Neuer Endpoint /stock/checkin/batch.

Produkte/OFF:
- OFF-Fuellmenge (quantity) wird in Basiseinheit + Packungsgroesse geparst
  (kg->g, l->ml, cl/dl); parse_quantity + Tests.
- Produktformular uebernimmt Barcode aus ?barcode= und schlaegt automatisch nach,
  fuellt Packungsgroesse; gemeinsame offUtils (guessGroup/suggestionToProduct).

Lagerorte:
- Baum jetzt rekursiv ueber beliebig viele Ebenen (Schrank->Fach->Kiste->...).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 10:40:54 +02:00
parent b683f2d93b
commit 4c17705290
10 changed files with 368 additions and 105 deletions

View File

@@ -3,45 +3,59 @@ import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { guessGroup, suggestionToProduct } from "../offUtils";
import { fmt, unitOptions, unitShort } from "../units";
const emptyLine = () => ({ quantity: "", best_before: "" });
export default function CheckIn() {
const { isAdmin } = useAuth();
const [barcode, setBarcode] = useState("");
const [product, setProduct] = useState(null);
const [suggestion, setSuggestion] = useState(null);
const [unknownBarcode, setUnknownBarcode] = useState(null);
const [results, setResults] = useState([]);
const [search, setSearch] = useState("");
const [locations, setLocations] = useState([]);
const [groups, setGroups] = useState([]);
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("");
const [bestBefore, setBestBefore] = useState("");
const [locationId, setLocationId] = useState("");
const [lines, setLines] = useState([emptyLine()]);
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [unknownBarcode, setUnknownBarcode] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.listLocations().then(setLocations).catch(() => {});
api.listGroups().then(setGroups).catch(() => {});
}, []);
function resetLookup() {
setSuggestion(null);
setUnknownBarcode(null);
}
function selectProduct(p) {
setProduct(p);
setResults([]);
setUnknownBarcode(null);
resetLookup();
setUnit(unitOptions(p)[0].value);
setLines([emptyLine()]);
setLocationId("");
}
async function doLookup() {
setError(null); setInfo(null); setUnknownBarcode(null);
setError(null); setInfo(null); resetLookup();
if (!barcode) return;
try {
const res = await api.lookup(barcode.trim());
if (res.found && res.existing_product) {
selectProduct(res.existing_product);
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
} else if (res.found && res.suggestion) {
setSuggestion(res.suggestion);
} else {
setUnknownBarcode(barcode.trim());
}
@@ -50,6 +64,20 @@ export default function CheckIn() {
}
}
async function createFromSuggestion() {
setError(null); setBusy(true);
try {
const payload = suggestionToProduct(suggestion, guessGroup(groups, suggestion));
const created = await api.createProduct(payload);
selectProduct(created);
setInfo(`Produkt "${created.name}" angelegt.`);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function doSearch(e) {
e.preventDefault();
try {
@@ -59,20 +87,40 @@ export default function CheckIn() {
}
}
function setLine(i, key, value) {
setLines((ls) => ls.map((l, idx) => (idx === i ? { ...l, [key]: value } : l)));
}
function addLine() {
setLines((ls) => [...ls, emptyLine()]);
}
function removeLine(i) {
setLines((ls) => (ls.length > 1 ? ls.filter((_, idx) => idx !== i) : ls));
}
const totalQty = lines.reduce((s, l) => s + (Number(l.quantity) || 0), 0);
async function submit(e) {
e.preventDefault();
setError(null); setInfo(null); setBusy(true);
try {
const res = await api.checkIn({
product_id: product.id,
quantity: Number(quantity),
unit,
best_before: bestBefore || null,
setError(null); setInfo(null);
const payloadLines = lines
.filter((l) => Number(l.quantity) > 0)
.map((l) => ({
quantity: Number(l.quantity),
best_before: l.best_before || null,
location_id: locationId === "" ? null : Number(locationId),
});
setInfo(`Eingelagert. Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
setQuantity("");
setBestBefore("");
}));
if (payloadLines.length === 0) {
setError("Bitte mindestens eine Menge angeben.");
return;
}
setBusy(true);
try {
const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines });
setInfo(
`${payloadLines.length} Charge(n) eingelagert. Neuer Bestand: ` +
`${fmt(res.product_stock)} ${unitShort(product.base_unit)}`
);
setLines([emptyLine()]);
setProduct(await api.getProduct(product.id));
} catch (err) {
setError(err.message);
@@ -99,18 +147,44 @@ export default function CheckIn() {
</label>
<button className="btn primary" onClick={doLookup}>Suchen</button>
</div>
{suggestion && (
<div className="suggestion">
{suggestion.image_url
? <img className="thumb" src={suggestion.image_url} alt="" />
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info">
<div className="title">{suggestion.name}</div>
<div className="muted small">
{suggestion.brand ? `${suggestion.brand} · ` : ""}auf Open Food Facts gefunden
{suggestion.quantity_text ? ` · ${suggestion.quantity_text}` : ""}
</div>
<div className="muted small">Noch nicht im Katalog.</div>
</div>
{isAdmin ? (
<button className="btn primary" onClick={createFromSuggestion} disabled={busy}>
<Icon name="plus" size={16} />{busy ? "…" : "Anlegen & einlagern"}
</button>
) : (
<span className="muted small">Ein Administrator muss es anlegen.</span>
)}
</div>
)}
{unknownBarcode && (
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
Barcode <strong>{unknownBarcode}</strong> ist weder im Katalog noch bei
Open Food Facts.{" "}
{isAdmin
? <Link to="/products/new">Produkt anlegen</Link>
: "Bitte einen Administrator bitten, das Produkt anzulegen."}
? <Link to={`/products/new?barcode=${encodeURIComponent(unknownBarcode)}`}>Manuell anlegen</Link>
: "Bitte einen Administrator bitten, es anzulegen."}
</span>
</div>
)}
</div>
<div className="card">
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
<form className="field-inline" onSubmit={doSearch}>
@@ -146,23 +220,12 @@ export default function CheckIn() {
</div>
<div className="row">
<label className="grow">
Menge
<input type="number" step="any" min="0" value={quantity} required
onChange={(e) => setQuantity(e.target.value)} autoFocus />
</label>
<label className="grow">
Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
<div className="row">
<label className="grow">
Mindesthaltbarkeit (optional)
<input type="date" value={bestBefore} onChange={(e) => setBestBefore(e.target.value)} />
</label>
<label className="grow">
Lagerort (optional)
<select value={locationId} onChange={(e) => setLocationId(e.target.value)}>
@@ -171,7 +234,46 @@ export default function CheckIn() {
</select>
</label>
</div>
<button className="btn primary" disabled={busy}><Icon name="checkin" size={16} />{busy ? "…" : "Einlagern"}</button>
<div className="lines">
<div className="lines-head">
<span>Chargen</span>
<span className="muted small">je Charge ein eigenes MHD</span>
</div>
{lines.map((line, i) => (
<div className="line" key={i}>
<label className="grow" style={{ margin: 0 }}>
{i === 0 && <span className="line-label">Menge</span>}
<input type="number" step="any" min="0" placeholder="Menge"
value={line.quantity} onChange={(e) => setLine(i, "quantity", e.target.value)}
autoFocus={i === 0} />
</label>
<label className="grow" style={{ margin: 0 }}>
{i === 0 && <span className="line-label">MHD (optional)</span>}
<input type="date" value={line.best_before}
onChange={(e) => setLine(i, "best_before", e.target.value)} />
</label>
<button type="button" className="btn-icon danger" title="Charge entfernen"
onClick={() => removeLine(i)} disabled={lines.length === 1}>
<Icon name="trash" size={16} />
</button>
</div>
))}
<button type="button" className="btn sm" onClick={addLine}>
<Icon name="plus" size={14} />Weitere Charge (anderes MHD)
</button>
</div>
<div className="field-inline" style={{ marginTop: "var(--sp-4)" }}>
<button className="btn primary" disabled={busy}>
<Icon name="checkin" size={16} />{busy ? "…" : "Einlagern"}
</button>
{totalQty > 0 && (
<span className="muted small">Summe: {fmt(totalQty)} {
unitOptions(product).find((o) => o.value === unit)?.label || ""
}</span>
)}
</div>
</form>
)}
</div>

View File

@@ -42,13 +42,19 @@ export default function Locations() {
}
const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name]));
const roots = locations.filter((l) => !l.parent_id || !nameById[l.parent_id]);
const childrenOf = (pid) => locations.filter((l) => l.parent_id === pid);
const ids = new Set(locations.map((l) => l.id));
const isRoot = (l) => !l.parent_id || !ids.has(l.parent_id);
// Rekursiv über beliebig viele Ebenen (Schrank -> Fach -> Kiste -> …).
const ordered = [];
for (const r of roots) {
ordered.push({ ...r, depth: 0 });
for (const c of childrenOf(r.id)) ordered.push({ ...c, depth: 1 });
const seen = new Set();
function walk(node, depth) {
if (seen.has(node.id)) return; // Schutz vor Zyklen
seen.add(node.id);
ordered.push({ ...node, depth });
for (const c of locations.filter((l) => l.parent_id === node.id)) walk(c, depth + 1);
}
for (const r of locations.filter(isRoot)) walk(r, 0);
return (
<div>
@@ -86,7 +92,8 @@ export default function Locations() {
<ul className="simple-list">
{ordered.map((l) => (
<li key={l.id}>
<span className={l.depth ? "tree-child" : ""} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: l.depth * 22 }}>
{l.depth > 0 && <span className="muted"></span>}
<Icon name="location" size={15} className="muted" />
{l.name}
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && (

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { guessGroup } from "../offUtils";
import { BASE_UNITS, fmt, unitShort } from "../units";
const EMPTY = {
@@ -10,25 +11,12 @@ const EMPTY = {
base_unit: "piece", package_size: "", min_stock: "", group_id: "",
};
// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten.
function guessGroup(groups, suggestion) {
if (!suggestion) return "";
const haystack = [
suggestion.category_suggestion || "",
...(suggestion.category_tags || []),
suggestion.name || "",
]
.join(" ")
.toLowerCase();
const hit = groups.find((g) => haystack.includes(g.name.toLowerCase()));
return hit ? String(hit.id) : "";
}
export default function ProductForm() {
const { id } = useParams();
const isNew = !id;
const { isAdmin } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [form, setForm] = useState(EMPTY);
const [product, setProduct] = useState(null);
@@ -38,10 +26,53 @@ export default function ProductForm() {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
function set(k, v) {
setForm((f) => ({ ...f, [k]: v }));
}
function applySuggestion(s, groupsList) {
const groupGuess = guessGroup(groupsList, s);
setForm((f) => ({
...f,
barcode: s.barcode || f.barcode,
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,
package_size: s.package_size != null ? String(s.package_size) : f.package_size,
group_id: f.group_id || groupGuess,
}));
setInfo(
"Daten von Open Food Facts übernommen." +
(s.package_size != null ? "" : " (Füllmenge nicht hinterlegt bitte Packungsgröße prüfen.)") +
(groupGuess ? " Passende Gruppe vorgeschlagen." : "")
);
}
async function runLookup(code, groupsList) {
if (!code) return;
setError(null);
setInfo(null);
try {
const res = await api.lookup(code.trim());
if (res.found && res.existing_product) {
setInfo("Dieses Produkt existiert bereits.");
navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) {
applySuggestion(res.suggestion, groupsList);
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
} catch (err) {
setError(err.message);
}
}
useEffect(() => {
async function load() {
try {
setGroups(await api.listGroups());
const gs = await api.listGroups();
setGroups(gs);
if (!isNew) {
const p = await api.getProduct(id);
setProduct(p);
@@ -52,6 +83,12 @@ export default function ProductForm() {
group_id: p.group_id ?? "",
});
setLots(await api.listLots(id));
} else {
const bc = searchParams.get("barcode");
if (bc) {
set("barcode", bc);
await runLookup(bc, gs);
}
}
} catch (err) {
setError(err.message);
@@ -61,42 +98,6 @@ export default function ProductForm() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
function set(k, v) {
setForm((f) => ({ ...f, [k]: v }));
}
async function lookup() {
if (!form.barcode) return;
setError(null);
setInfo(null);
try {
const res = await api.lookup(form.barcode);
if (res.found && res.existing_product) {
setInfo("Dieses Produkt existiert bereits.");
navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) {
const s = res.suggestion;
const groupGuess = guessGroup(groups, s);
setForm((f) => ({
...f,
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,
group_id: f.group_id || groupGuess,
}));
setInfo(
"Daten von Open Food Facts übernommen." +
(groupGuess ? " Passende Gruppe vorgeschlagen." : "")
);
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
} catch (err) {
setError(err.message);
}
}
function buildPayload() {
return {
barcode: form.barcode || null,
@@ -141,15 +142,16 @@ export default function ProductForm() {
}
const readOnly = !isAdmin;
const unitLabel = unitShort(form.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)} {unitShort(form.base_unit)}</div>}
{!isNew && <div className="sub">Bestand: {fmt(product?.stock)} {unitLabel}</div>}
</div>
<button className="btn ghost" onClick={() => navigate("/products")}>Zurück</button>
<button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
@@ -163,7 +165,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={lookup}>
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups)}>
<Icon name="search" size={16} />Nachschlagen
</button>
)}
@@ -184,9 +186,10 @@ export default function ProductForm() {
</select>
</label>
<label className="grow">
Packungsgröße
Packungsgröße{unitLabel !== "Stk" ? ` (in ${unitLabel})` : ""}
<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">
@@ -227,7 +230,7 @@ export default function ProductForm() {
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td className="num">{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
<td className="num">{fmt(l.quantity)} {unitLabel}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>