Files
Vorrania/web/src/pages/CheckIn.jsx
Scarriffle de6c8632a1 Mehrere EAN-Codes je Produkt und je Gruppe (mit Notiz) + Gruppen umbenennen
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>
2026-07-22 14:48:14 +02:00

299 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}