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,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -10,17 +11,35 @@ from .config import get_settings
|
|||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Faktoren, um eine Einheit in die Basiseinheit (Gramm bzw. Milliliter) umzurechnen.
|
||||||
|
_UNIT_TO_BASE: dict[str, tuple[str, float]] = {
|
||||||
|
"kg": ("gram", 1000.0),
|
||||||
|
"g": ("gram", 1.0),
|
||||||
|
"mg": ("gram", 0.001),
|
||||||
|
"l": ("milliliter", 1000.0),
|
||||||
|
"dl": ("milliliter", 100.0),
|
||||||
|
"cl": ("milliliter", 10.0),
|
||||||
|
"ml": ("milliliter", 1.0),
|
||||||
|
}
|
||||||
|
|
||||||
def _guess_base_unit(quantity: str | None) -> str:
|
_QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE)
|
||||||
"""Rät die Basiseinheit aus dem OFF-Feld 'quantity' (z.B. '500 g', '1 l')."""
|
|
||||||
|
|
||||||
|
def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
|
||||||
|
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
|
||||||
|
|
||||||
|
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
|
||||||
|
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None).
|
||||||
|
"""
|
||||||
if not quantity:
|
if not quantity:
|
||||||
return "piece"
|
return "piece", None
|
||||||
q = quantity.lower()
|
match = _QUANTITY_RE.search(quantity)
|
||||||
if "ml" in q or "cl" in q or "l" in q or "liter" in q:
|
if not match:
|
||||||
return "milliliter"
|
return "piece", None
|
||||||
if "kg" in q or " g" in q or "gramm" in q or q.strip().endswith("g"):
|
amount = float(match.group(1).replace(",", "."))
|
||||||
return "gram"
|
base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()]
|
||||||
return "piece"
|
package_size = round(amount * factor, 3)
|
||||||
|
return base_unit, (package_size if package_size > 0 else None)
|
||||||
|
|
||||||
|
|
||||||
def lookup_barcode(barcode: str) -> dict | None:
|
def lookup_barcode(barcode: str) -> dict | None:
|
||||||
@@ -65,12 +84,15 @@ def lookup_barcode(barcode: str) -> dict | None:
|
|||||||
categories = product.get("categories") or ""
|
categories = product.get("categories") or ""
|
||||||
category_tags = product.get("categories_tags") or []
|
category_tags = product.get("categories_tags") or []
|
||||||
|
|
||||||
|
base_unit, package_size = parse_quantity(product.get("quantity"))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"barcode": barcode,
|
"barcode": barcode,
|
||||||
"name": name,
|
"name": name,
|
||||||
"brand": (product.get("brands") or "").strip() or None,
|
"brand": (product.get("brands") or "").strip() or None,
|
||||||
"image_url": product.get("image_front_url") or product.get("image_url") or None,
|
"image_url": product.get("image_front_url") or product.get("image_url") or None,
|
||||||
"base_unit": _guess_base_unit(product.get("quantity")),
|
"base_unit": base_unit,
|
||||||
|
"package_size": package_size,
|
||||||
"quantity_text": product.get("quantity"),
|
"quantity_text": product.get("quantity"),
|
||||||
"category_suggestion": categories.split(",")[0].strip() if categories else None,
|
"category_suggestion": categories.split(",")[0].strip() if categories else None,
|
||||||
"category_tags": category_tags,
|
"category_tags": category_tags,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from ..database import get_db
|
|||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
from ..models import Lot, User
|
from ..models import Lot, User
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
BatchCheckInRequest,
|
||||||
|
BatchCheckInResponse,
|
||||||
CheckInRequest,
|
CheckInRequest,
|
||||||
CheckInResponse,
|
CheckInResponse,
|
||||||
CheckOutRequest,
|
CheckOutRequest,
|
||||||
@@ -45,6 +47,45 @@ def stock_checkin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stock/checkin/batch", response_model=BatchCheckInResponse)
|
||||||
|
def stock_checkin_batch(
|
||||||
|
payload: BatchCheckInRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> BatchCheckInResponse:
|
||||||
|
"""Mehrere Chargen desselben Produkts in einem Vorgang einlagern.
|
||||||
|
|
||||||
|
Jede Zeile erzeugt eine eigene Charge mit eigener Menge und eigenem MHD –
|
||||||
|
z.B. 5 Gläser mit unterschiedlichen Mindesthaltbarkeitsdaten.
|
||||||
|
"""
|
||||||
|
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||||
|
lots: list[Lot] = []
|
||||||
|
try:
|
||||||
|
for line in payload.lines:
|
||||||
|
lots.append(
|
||||||
|
check_in(
|
||||||
|
db,
|
||||||
|
product=product,
|
||||||
|
quantity=line.quantity,
|
||||||
|
unit=payload.unit,
|
||||||
|
best_before=line.best_before,
|
||||||
|
location_id=line.location_id,
|
||||||
|
user=user,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except UnitError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
|
db.commit()
|
||||||
|
for lot in lots:
|
||||||
|
db.refresh(lot)
|
||||||
|
return BatchCheckInResponse(
|
||||||
|
lots=[LotOut.model_validate(lot) for lot in lots],
|
||||||
|
product_stock=current_stock(db, product.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stock/checkout", response_model=CheckOutResponse)
|
@router.post("/stock/checkout", response_model=CheckOutResponse)
|
||||||
def stock_checkout(
|
def stock_checkout(
|
||||||
payload: CheckOutRequest,
|
payload: CheckOutRequest,
|
||||||
|
|||||||
@@ -152,6 +152,26 @@ class CheckInResponse(BaseModel):
|
|||||||
product_stock: float
|
product_stock: float
|
||||||
|
|
||||||
|
|
||||||
|
class CheckInLine(BaseModel):
|
||||||
|
"""Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD)."""
|
||||||
|
quantity: float = Field(gt=0)
|
||||||
|
best_before: date | None = None
|
||||||
|
location_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCheckInRequest(BaseModel):
|
||||||
|
product_id: int | None = None
|
||||||
|
barcode: str | None = None
|
||||||
|
unit: str
|
||||||
|
lines: list[CheckInLine] = Field(min_length=1)
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCheckInResponse(BaseModel):
|
||||||
|
lots: list[LotOut]
|
||||||
|
product_stock: float
|
||||||
|
|
||||||
|
|
||||||
class CheckOutResponse(BaseModel):
|
class CheckOutResponse(BaseModel):
|
||||||
affected_lots: list[dict]
|
affected_lots: list[dict]
|
||||||
product_stock: float
|
product_stock: float
|
||||||
|
|||||||
24
backend/tests/test_off.py
Normal file
24
backend/tests/test_off.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from app.off import parse_quantity
|
||||||
|
|
||||||
|
|
||||||
|
def test_grams():
|
||||||
|
assert parse_quantity("500 g") == ("gram", 500)
|
||||||
|
assert parse_quantity("500g") == ("gram", 500)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kilograms_to_grams():
|
||||||
|
assert parse_quantity("1 kg") == ("gram", 1000)
|
||||||
|
assert parse_quantity("1,5 kg") == ("gram", 1500)
|
||||||
|
|
||||||
|
|
||||||
|
def test_liters_to_milliliters():
|
||||||
|
assert parse_quantity("1 l") == ("milliliter", 1000)
|
||||||
|
assert parse_quantity("0,5 l") == ("milliliter", 500)
|
||||||
|
assert parse_quantity("33 cl") == ("milliliter", 330)
|
||||||
|
assert parse_quantity("330 ml") == ("milliliter", 330)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unparsable_is_piece():
|
||||||
|
assert parse_quantity(None) == ("piece", None)
|
||||||
|
assert parse_quantity("6 Stück") == ("piece", None)
|
||||||
|
assert parse_quantity("") == ("piece", None)
|
||||||
@@ -84,6 +84,7 @@ export const api = {
|
|||||||
|
|
||||||
// Bestand
|
// Bestand
|
||||||
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||||||
|
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),
|
||||||
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
|
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
|
||||||
listLots: (productId) =>
|
listLots: (productId) =>
|
||||||
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
|
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
|
||||||
|
|||||||
27
web/src/offUtils.js
Normal file
27
web/src/offUtils.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Gemeinsame Helfer rund um Open-Food-Facts-Vorschläge.
|
||||||
|
|
||||||
|
// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten.
|
||||||
|
export 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) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baut aus einem OFF-Vorschlag ein Produkt-Anlage-Payload.
|
||||||
|
export function suggestionToProduct(s, groupId = "") {
|
||||||
|
return {
|
||||||
|
barcode: s.barcode || null,
|
||||||
|
name: s.name,
|
||||||
|
brand: s.brand || null,
|
||||||
|
image_url: s.image_url || null,
|
||||||
|
base_unit: s.base_unit || "piece",
|
||||||
|
package_size: s.package_size ?? null,
|
||||||
|
min_stock: null,
|
||||||
|
group_id: groupId === "" || groupId == null ? null : Number(groupId),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,45 +3,59 @@ import { Link } from "react-router-dom";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { guessGroup, suggestionToProduct } from "../offUtils";
|
||||||
import { fmt, unitOptions, unitShort } from "../units";
|
import { fmt, unitOptions, unitShort } from "../units";
|
||||||
|
|
||||||
|
const emptyLine = () => ({ quantity: "", best_before: "" });
|
||||||
|
|
||||||
export default function CheckIn() {
|
export default function CheckIn() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [barcode, setBarcode] = useState("");
|
const [barcode, setBarcode] = useState("");
|
||||||
const [product, setProduct] = useState(null);
|
const [product, setProduct] = useState(null);
|
||||||
|
const [suggestion, setSuggestion] = useState(null);
|
||||||
|
const [unknownBarcode, setUnknownBarcode] = useState(null);
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [locations, setLocations] = useState([]);
|
const [locations, setLocations] = useState([]);
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
|
||||||
const [quantity, setQuantity] = useState("");
|
|
||||||
const [unit, setUnit] = useState("");
|
const [unit, setUnit] = useState("");
|
||||||
const [bestBefore, setBestBefore] = useState("");
|
|
||||||
const [locationId, setLocationId] = useState("");
|
const [locationId, setLocationId] = useState("");
|
||||||
|
const [lines, setLines] = useState([emptyLine()]);
|
||||||
|
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
const [unknownBarcode, setUnknownBarcode] = useState(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listLocations().then(setLocations).catch(() => {});
|
api.listLocations().then(setLocations).catch(() => {});
|
||||||
|
api.listGroups().then(setGroups).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function resetLookup() {
|
||||||
|
setSuggestion(null);
|
||||||
|
setUnknownBarcode(null);
|
||||||
|
}
|
||||||
|
|
||||||
function selectProduct(p) {
|
function selectProduct(p) {
|
||||||
setProduct(p);
|
setProduct(p);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setUnknownBarcode(null);
|
resetLookup();
|
||||||
setUnit(unitOptions(p)[0].value);
|
setUnit(unitOptions(p)[0].value);
|
||||||
|
setLines([emptyLine()]);
|
||||||
|
setLocationId("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doLookup() {
|
async function doLookup() {
|
||||||
setError(null); setInfo(null); setUnknownBarcode(null);
|
setError(null); setInfo(null); resetLookup();
|
||||||
if (!barcode) return;
|
if (!barcode) return;
|
||||||
try {
|
try {
|
||||||
const res = await api.lookup(barcode.trim());
|
const res = await api.lookup(barcode.trim());
|
||||||
if (res.found && res.existing_product) {
|
if (res.found && res.existing_product) {
|
||||||
selectProduct(res.existing_product);
|
selectProduct(res.existing_product);
|
||||||
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
|
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
|
||||||
|
} else if (res.found && res.suggestion) {
|
||||||
|
setSuggestion(res.suggestion);
|
||||||
} else {
|
} else {
|
||||||
setUnknownBarcode(barcode.trim());
|
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) {
|
async function doSearch(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
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) {
|
async function submit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null); setInfo(null); setBusy(true);
|
setError(null); setInfo(null);
|
||||||
try {
|
const payloadLines = lines
|
||||||
const res = await api.checkIn({
|
.filter((l) => Number(l.quantity) > 0)
|
||||||
product_id: product.id,
|
.map((l) => ({
|
||||||
quantity: Number(quantity),
|
quantity: Number(l.quantity),
|
||||||
unit,
|
best_before: l.best_before || null,
|
||||||
best_before: bestBefore || null,
|
|
||||||
location_id: locationId === "" ? null : Number(locationId),
|
location_id: locationId === "" ? null : Number(locationId),
|
||||||
});
|
}));
|
||||||
setInfo(`Eingelagert. Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
|
if (payloadLines.length === 0) {
|
||||||
setQuantity("");
|
setError("Bitte mindestens eine Menge angeben.");
|
||||||
setBestBefore("");
|
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));
|
setProduct(await api.getProduct(product.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -99,18 +147,44 @@ export default function CheckIn() {
|
|||||||
</label>
|
</label>
|
||||||
<button className="btn primary" onClick={doLookup}>Suchen</button>
|
<button className="btn primary" onClick={doLookup}>Suchen</button>
|
||||||
</div>
|
</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 && (
|
{unknownBarcode && (
|
||||||
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
|
||||||
<Icon name="alert" size={16} />
|
<Icon name="alert" size={16} />
|
||||||
<span>
|
<span>
|
||||||
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
|
Barcode <strong>{unknownBarcode}</strong> ist weder im Katalog noch bei
|
||||||
|
Open Food Facts.{" "}
|
||||||
{isAdmin
|
{isAdmin
|
||||||
? <Link to="/products/new">Produkt anlegen</Link>
|
? <Link to={`/products/new?barcode=${encodeURIComponent(unknownBarcode)}`}>Manuell anlegen</Link>
|
||||||
: "Bitte einen Administrator bitten, das Produkt anzulegen."}
|
: "Bitte einen Administrator bitten, es anzulegen."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
|
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
|
||||||
<form className="field-inline" onSubmit={doSearch}>
|
<form className="field-inline" onSubmit={doSearch}>
|
||||||
@@ -146,23 +220,12 @@ export default function CheckIn() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="row">
|
<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">
|
<label className="grow">
|
||||||
Einheit
|
Einheit
|
||||||
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||||||
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label className="grow">
|
||||||
Lagerort (optional)
|
Lagerort (optional)
|
||||||
<select value={locationId} onChange={(e) => setLocationId(e.target.value)}>
|
<select value={locationId} onChange={(e) => setLocationId(e.target.value)}>
|
||||||
@@ -171,7 +234,46 @@ export default function CheckIn() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,13 +42,19 @@ export default function Locations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name]));
|
const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name]));
|
||||||
const roots = locations.filter((l) => !l.parent_id || !nameById[l.parent_id]);
|
const ids = new Set(locations.map((l) => l.id));
|
||||||
const childrenOf = (pid) => locations.filter((l) => l.parent_id === pid);
|
const isRoot = (l) => !l.parent_id || !ids.has(l.parent_id);
|
||||||
|
|
||||||
|
// Rekursiv über beliebig viele Ebenen (Schrank -> Fach -> Kiste -> …).
|
||||||
const ordered = [];
|
const ordered = [];
|
||||||
for (const r of roots) {
|
const seen = new Set();
|
||||||
ordered.push({ ...r, depth: 0 });
|
function walk(node, depth) {
|
||||||
for (const c of childrenOf(r.id)) ordered.push({ ...c, depth: 1 });
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -86,7 +92,8 @@ export default function Locations() {
|
|||||||
<ul className="simple-list">
|
<ul className="simple-list">
|
||||||
{ordered.map((l) => (
|
{ordered.map((l) => (
|
||||||
<li key={l.id}>
|
<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" />
|
<Icon name="location" size={15} className="muted" />
|
||||||
{l.name}
|
{l.name}
|
||||||
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && (
|
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api } from "../api";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { guessGroup } from "../offUtils";
|
||||||
import { BASE_UNITS, fmt, unitShort } from "../units";
|
import { BASE_UNITS, fmt, unitShort } from "../units";
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
@@ -10,25 +11,12 @@ const EMPTY = {
|
|||||||
base_unit: "piece", package_size: "", min_stock: "", group_id: "",
|
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() {
|
export default function ProductForm() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const isNew = !id;
|
const isNew = !id;
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [form, setForm] = useState(EMPTY);
|
const [form, setForm] = useState(EMPTY);
|
||||||
const [product, setProduct] = useState(null);
|
const [product, setProduct] = useState(null);
|
||||||
@@ -38,10 +26,53 @@ export default function ProductForm() {
|
|||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
setGroups(await api.listGroups());
|
const gs = await api.listGroups();
|
||||||
|
setGroups(gs);
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
const p = await api.getProduct(id);
|
const p = await api.getProduct(id);
|
||||||
setProduct(p);
|
setProduct(p);
|
||||||
@@ -52,6 +83,12 @@ export default function ProductForm() {
|
|||||||
group_id: p.group_id ?? "",
|
group_id: p.group_id ?? "",
|
||||||
});
|
});
|
||||||
setLots(await api.listLots(id));
|
setLots(await api.listLots(id));
|
||||||
|
} else {
|
||||||
|
const bc = searchParams.get("barcode");
|
||||||
|
if (bc) {
|
||||||
|
set("barcode", bc);
|
||||||
|
await runLookup(bc, gs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -61,42 +98,6 @@ export default function ProductForm() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [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() {
|
function buildPayload() {
|
||||||
return {
|
return {
|
||||||
barcode: form.barcode || null,
|
barcode: form.barcode || null,
|
||||||
@@ -141,15 +142,16 @@ export default function ProductForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readOnly = !isAdmin;
|
const readOnly = !isAdmin;
|
||||||
|
const unitLabel = unitShort(form.base_unit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
|
<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>
|
</div>
|
||||||
<button className="btn ghost" onClick={() => navigate("/products")}>Zurück</button>
|
<button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</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} />
|
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
||||||
</label>
|
</label>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button type="button" className="btn" onClick={lookup}>
|
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups)}>
|
||||||
<Icon name="search" size={16} />Nachschlagen
|
<Icon name="search" size={16} />Nachschlagen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -184,9 +186,10 @@ export default function ProductForm() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
Packungsgröße
|
Packungsgröße{unitLabel !== "Stk" ? ` (in ${unitLabel})` : ""}
|
||||||
<input type="number" step="any" value={form.package_size}
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@@ -227,7 +230,7 @@ export default function ProductForm() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{lots.map((l) => (
|
{lots.map((l) => (
|
||||||
<tr key={l.id}>
|
<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>{l.best_before || "–"}</td>
|
||||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -245,6 +245,22 @@ input::placeholder { color: var(--muted); opacity: 0.7; }
|
|||||||
.selected-product > .info { flex: 1; min-width: 0; }
|
.selected-product > .info { flex: 1; min-width: 0; }
|
||||||
.selected-product .title { font-weight: 620; }
|
.selected-product .title { font-weight: 620; }
|
||||||
|
|
||||||
|
/* ---------- OFF suggestion ---------- */
|
||||||
|
.suggestion {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
margin-top: var(--sp-3); padding: var(--sp-3);
|
||||||
|
background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.suggestion > .info { flex: 1; min-width: 0; }
|
||||||
|
.suggestion .title { font-weight: 620; }
|
||||||
|
|
||||||
|
/* ---------- Check-in lines ---------- */
|
||||||
|
.lines { margin-top: var(--sp-4); border-top: 1px solid var(--border); padding-top: var(--sp-3); }
|
||||||
|
.lines-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: var(--sp-2); font-weight: 600; font-size: 0.85rem; }
|
||||||
|
.line { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-2); }
|
||||||
|
.line .btn-icon { margin-bottom: 1px; }
|
||||||
|
.line-label { display: block; font-size: 0.72rem; color: var(--muted); font-weight: 580; margin-bottom: 3px; }
|
||||||
|
|
||||||
/* ---------- Stat tiles ---------- */
|
/* ---------- Stat tiles ---------- */
|
||||||
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-5); }
|
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-5); }
|
||||||
.stat {
|
.stat {
|
||||||
|
|||||||
Reference in New Issue
Block a user