Schritt 1: Fundament der Lebensmittel-Lagerverwaltung (Pantry)

Backend (FastAPI + PostgreSQL): Chargen mit MHD, FEFO-Auslagern,
Einheiten-Umrechnung (Stueck/g/ml + Packungen), Open-Food-Facts-Lookup
mit lokalem Fallback, JWT-Auth mit Rollen (Admin/Nutzer), erster Admin
beim Setup, Einkaufsliste, Ablaufwarnung, Lagerorte, pytest fuer FEFO.

Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte,
Lagerorte, Benutzerverwaltung, Einkaufsliste - rollenabhaengig.

Deploy: docker-compose + install.sh (Docker-Autoinstall, Secrets),
README und Roadmap fuer Schritt 2 (iOS) und Schritt 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 09:14:14 +02:00
commit 639126468f
57 changed files with 3460 additions and 0 deletions

178
web/src/pages/CheckIn.jsx Normal file
View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import { fmt, unitOptions, unitShort } from "../units";
export default function CheckIn() {
const { isAdmin } = useAuth();
const [barcode, setBarcode] = useState("");
const [product, setProduct] = useState(null);
const [results, setResults] = useState([]);
const [search, setSearch] = useState("");
const [locations, setLocations] = useState([]);
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("");
const [bestBefore, setBestBefore] = useState("");
const [locationId, setLocationId] = useState("");
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(() => {});
}, []);
function selectProduct(p) {
setProduct(p);
setResults([]);
setUnknownBarcode(null);
const opts = unitOptions(p);
setUnit(opts[0].value);
}
async function doLookup() {
setError(null); setInfo(null); setUnknownBarcode(null);
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 {
setUnknownBarcode(barcode.trim());
setInfo(null);
}
} catch (err) {
setError(err.message);
}
}
async function doSearch(e) {
e.preventDefault();
try {
setResults(await api.listProducts(search));
} catch (err) {
setError(err.message);
}
}
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,
location_id: locationId === "" ? null : Number(locationId),
});
setInfo(`Eingelagert. Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
setQuantity("");
setBestBefore("");
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">{error}</div>}
{info && <div className="alert info">{info}</div>}
{!product && (
<div className="grid-2">
<div className="card">
<h2>Per Barcode</h2>
<div className="row">
<input className="grow" placeholder="Barcode" value={barcode}
onChange={(e) => setBarcode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
<button className="btn primary" onClick={doLookup}>Suchen</button>
</div>
{unknownBarcode && (
<div className="alert warn">
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
{isAdmin ? (
<Link to={`/products/new`}>Produkt anlegen</Link>
) : (
"Bitte einen Administrator bitten, das Produkt anzulegen."
)}
</div>
)}
</div>
<div className="card">
<h2>Aus Produktliste</h2>
<form className="row" onSubmit={doSearch}>
<input className="grow" placeholder="Name suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} />
<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)} {unitShort(p.base_unit)})</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="" />}
<div>
<strong>{product.name}</strong>
<div className="muted">
Aktueller Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}
</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</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">
MHD (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)}>
<option value=""> keiner </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
</div>
<button className="btn primary" disabled={busy}>{busy ? "…" : "Einlagern"}</button>
</form>
)}
</div>
);
}