Barcodes:
- Neue Tabelle barcodes (code, note, product_id ODER group_id). Damit lassen sich
einem Produkt mehrere Codes geben und einer Gruppe beliebig viele Marken
zuordnen (z.B. alle Mehlmarken zu "Mehl"). Je Code eine Notiz wie
"Mehl bei Aldi".
- Lookup loest jetzt auch Alias-Codes auf; ist ein Code einer Gruppe zugeordnet,
liefert die API group_id/group_name zurueck. Beim Anlegen aus einem Scan wird
diese Gruppe automatisch gesetzt (Vorrang vor dem Kategorie-Rateversuch).
- Endpunkte: POST/DELETE /products/{id}/barcodes und /groups/{id}/barcodes.
- Web: wiederverwendbare BarcodeList-Komponente, eingebunden im Produktformular
und auf der Gruppen-Seite.
Gruppen:
- Namen lassen sich jetzt direkt in der Tabelle aendern (PATCH war vorhanden,
nur die Oberflaeche fehlte).
UI-Fix: Auswahlfelder in Tabellen richten sich nach ihrem Text statt nach der
Zellenbreite (Gruppen-/Benutzer-Dropdowns waren abgeschnitten).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
import { useEffect, useState } from "react";
|
||
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 { buildUnitOptions, fmt, isExpired } 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);
|
||
// Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl").
|
||
const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" });
|
||
const [unknownBarcode, setUnknownBarcode] = useState(null);
|
||
const [results, setResults] = useState([]);
|
||
const [search, setSearch] = useState("");
|
||
const [locations, setLocations] = useState([]);
|
||
const [groups, setGroups] = useState([]);
|
||
const [units, setUnits] = useState([]);
|
||
|
||
const [unit, setUnit] = useState("");
|
||
const [locationId, setLocationId] = useState("");
|
||
const [lines, setLines] = useState([emptyLine()]);
|
||
|
||
const [error, setError] = useState(null);
|
||
const [info, setInfo] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api.listLocations().then(setLocations).catch(() => {});
|
||
api.listGroups().then(setGroups).catch(() => {});
|
||
api.listUnits().then(setUnits).catch(() => {});
|
||
}, []);
|
||
|
||
function resetLookup() {
|
||
setSuggestion(null);
|
||
setUnknownBarcode(null);
|
||
}
|
||
|
||
function selectProduct(p) {
|
||
setProduct(p);
|
||
setResults([]);
|
||
resetLookup();
|
||
const opts = buildUnitOptions(p, units);
|
||
// Gibt es ein Gebinde (Glas, Packung, …), ist das die naheliegende Eingabe.
|
||
setUnit(p.package_size ? "package" : (p.unit_name || (opts[0] && opts[0].value) || ""));
|
||
setLines([emptyLine()]);
|
||
setLocationId("");
|
||
}
|
||
|
||
async function doLookup() {
|
||
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);
|
||
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
|
||
} else {
|
||
setUnknownBarcode(barcode.trim());
|
||
setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" });
|
||
}
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
async function createFromSuggestion() {
|
||
setError(null); setBusy(true);
|
||
try {
|
||
// Ist der Code einer Gruppe zugeordnet, hat das Vorrang vor dem Kategorie-Rateversuch.
|
||
const groupId = suggestionGroup.id || guessGroup(groups, suggestion);
|
||
const payload = suggestionToProduct(suggestion, groupId);
|
||
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 {
|
||
setResults(await api.listProducts(search));
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
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);
|
||
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),
|
||
}));
|
||
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 / (product.unit_factor || 1))} ${product.unit_name}`
|
||
);
|
||
setLines([emptyLine()]);
|
||
setProduct(await api.getProduct(product.id));
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head"><h1>Einlagern</h1></div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
|
||
|
||
{!product && (
|
||
<div className="grid-2">
|
||
<div className="card">
|
||
<div className="card-head"><Icon name="search" /><h2>Per Barcode</h2></div>
|
||
<div className="field-inline">
|
||
<label className="grow" style={{ margin: 0 }}>
|
||
<input placeholder="Barcode eingeben oder scannen" value={barcode}
|
||
onChange={(e) => setBarcode(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && doLookup()} autoFocus />
|
||
</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.
|
||
{suggestionGroup.name && ` Gruppe: ${suggestionGroup.name}`}
|
||
</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 weder im Katalog noch bei
|
||
Open Food Facts.{" "}
|
||
{isAdmin
|
||
? <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}>
|
||
<label className="grow" style={{ margin: 0 }}>
|
||
<input placeholder="Name suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||
</label>
|
||
<button className="btn">Suchen</button>
|
||
</form>
|
||
<ul className="picklist">
|
||
{results.map((p) => (
|
||
<li key={p.id}>
|
||
<button className="link-btn" onClick={() => selectProduct(p)}>
|
||
{p.name} <span className="muted">({fmt(p.stock / (p.unit_factor || 1))} {p.unit_name})</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{product && (
|
||
<form className="card form-narrow" onSubmit={submit}>
|
||
<div className="selected-product">
|
||
{product.image_url
|
||
? <img className="thumb" src={product.image_url} alt="" />
|
||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||
<div className="info">
|
||
<div className="title">{product.name}</div>
|
||
<div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
|
||
</div>
|
||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||
</div>
|
||
|
||
<div className="row">
|
||
<label className="grow">
|
||
Einheit
|
||
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||
{buildUnitOptions(product, units).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="grow">
|
||
Lagerort (optional)
|
||
<select value={locationId} onChange={(e) => setLocationId(e.target.value)}>
|
||
<option value="">– keiner –</option>
|
||
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<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)}
|
||
className={isExpired(line.best_before) ? "input-danger" : ""} />
|
||
{isExpired(line.best_before) && (
|
||
<span className="hint-danger"><Icon name="alert" size={12} />bereits abgelaufen</span>
|
||
)}
|
||
</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)} {
|
||
buildUnitOptions(product, units).find((o) => o.value === unit)?.label || ""
|
||
}</span>
|
||
)}
|
||
</div>
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|