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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user