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