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:
228
web/src/pages/ProductForm.jsx
Normal file
228
web/src/pages/ProductForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user