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

View File

@@ -0,0 +1,228 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import { BASE_UNITS, fmt, unitShort } from "../units";
const EMPTY = {
barcode: "",
name: "",
brand: "",
image_url: "",
base_unit: "piece",
package_size: "",
min_stock: "",
group_id: "",
};
export default function ProductForm() {
const { id } = useParams();
const isNew = !id;
const { isAdmin } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState(EMPTY);
const [product, setProduct] = useState(null);
const [lots, setLots] = useState([]);
const [groups, setGroups] = useState([]);
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
async function load() {
try {
setGroups(await api.listGroups());
if (!isNew) {
const p = await api.getProduct(id);
setProduct(p);
setForm({
barcode: p.barcode || "",
name: p.name,
brand: p.brand || "",
image_url: p.image_url || "",
base_unit: p.base_unit,
package_size: p.package_size ?? "",
min_stock: p.min_stock ?? "",
group_id: p.group_id ?? "",
});
setLots(await api.listLots(id));
}
} catch (err) {
setError(err.message);
}
}
load();
// 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;
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,
}));
setInfo("Vorschlag von Open Food Facts übernommen.");
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
} catch (err) {
setError(err.message);
}
}
function buildPayload() {
return {
barcode: form.barcode || null,
name: form.name,
brand: form.brand || null,
image_url: form.image_url || null,
base_unit: form.base_unit,
package_size: form.package_size === "" ? null : Number(form.package_size),
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
group_id: form.group_id === "" ? null : Number(form.group_id),
};
}
async function save(e) {
e.preventDefault();
setError(null);
setBusy(true);
try {
if (isNew) {
const created = await api.createProduct(buildPayload());
navigate(`/products/${created.id}`);
} else {
await api.updateProduct(id, buildPayload());
setInfo("Gespeichert.");
setProduct(await api.getProduct(id));
}
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function remove() {
if (!confirm("Produkt wirklich löschen? Alle Chargen gehen verloren.")) return;
try {
await api.deleteProduct(id);
navigate("/products");
} catch (err) {
setError(err.message);
}
}
const readOnly = !isAdmin;
return (
<div>
<div className="page-head">
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
</div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
<div className="grid-2">
<form className="card" onSubmit={save}>
<div className="row">
<label className="grow">
Barcode
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={lookup} style={{ alignSelf: "flex-end" }}>
Nachschlagen
</button>
)}
</div>
<label>
Name
<input value={form.name} onChange={(e) => set("name", e.target.value)} required disabled={readOnly} />
</label>
<label>
Marke
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
</label>
<div className="row">
<label className="grow">
Basiseinheit
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
{BASE_UNITS.map((u) => (
<option key={u.value} value={u.value}>{u.label}</option>
))}
</select>
</label>
<label className="grow">
Packungsgröße (in Basiseinheit)
<input type="number" step="any" value={form.package_size}
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
placeholder="z.B. 500" />
</label>
</div>
<div className="row">
<label className="grow">
Mindestbestand (in Basiseinheit)
<input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} />
</label>
<label className="grow">
Gruppe
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
<option value=""> keine </option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
</div>
{isAdmin && (
<div className="row">
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
{!isNew && <button type="button" className="btn danger" onClick={remove}>Löschen</button>}
</div>
)}
{readOnly && <p className="muted">Nur Administratoren können Produkte bearbeiten.</p>}
</form>
{!isNew && (
<section className="card">
<h2>Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})</h2>
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
<table className="table">
<thead><tr><th>Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td>{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="muted">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>
</section>
)}
</div>
</div>
);
}