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:
178
web/src/pages/CheckIn.jsx
Normal file
178
web/src/pages/CheckIn.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
web/src/pages/CheckOut.jsx
Normal file
140
web/src/pages/CheckOut.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { fmt, unitOptions, unitShort } from "../units";
|
||||
|
||||
export default function CheckOut() {
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [product, setProduct] = useState(null);
|
||||
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [unit, setUnit] = useState("");
|
||||
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function selectProduct(p) {
|
||||
setProduct(p);
|
||||
setResults([]);
|
||||
setUnit(unitOptions(p)[0].value);
|
||||
}
|
||||
|
||||
async function doLookup() {
|
||||
setError(null); setInfo(null);
|
||||
if (!barcode) return;
|
||||
try {
|
||||
const res = await api.lookup(barcode.trim());
|
||||
if (res.found && res.existing_product) {
|
||||
selectProduct(res.existing_product);
|
||||
} else {
|
||||
setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
|
||||
}
|
||||
} 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.checkOut({
|
||||
product_id: product.id,
|
||||
quantity: Number(quantity),
|
||||
unit,
|
||||
});
|
||||
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n) betroffen). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
|
||||
setQuantity("");
|
||||
setProduct(await api.getProduct(product.id));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>📤 Auslagern</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>
|
||||
</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">
|
||||
Verfügbar: {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>
|
||||
<p className="muted small">
|
||||
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||||
</p>
|
||||
<button className="btn primary" disabled={busy}>{busy ? "…" : "Auslagern"}</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
web/src/pages/Dashboard.jsx
Normal file
84
web/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { fmt, unitShort } from "../units";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [expiring, setExpiring] = useState([]);
|
||||
const [shopping, setShopping] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [e, s] = await Promise.all([api.expiring(), api.shoppingList()]);
|
||||
setExpiring(e);
|
||||
setShopping(s);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Übersicht</h1>
|
||||
<div className="actions">
|
||||
<Link className="btn primary" to="/checkin">Einlagern</Link>
|
||||
<Link className="btn" to="/checkout">Auslagern</Link>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<section className="card">
|
||||
<h2>⏰ Bald ablaufend</h2>
|
||||
{expiring.length === 0 ? (
|
||||
<p className="muted">Nichts läuft demnächst ab. 🎉</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Produkt</th><th>Menge</th><th>MHD</th><th>Tage</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expiring.map((it) => (
|
||||
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
|
||||
<td>{it.product_name}</td>
|
||||
<td>{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
|
||||
<td>{it.best_before}</td>
|
||||
<td>{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>🛒 Einkaufsliste</h2>
|
||||
{shopping.length === 0 ? (
|
||||
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Produkt</th><th>Bestand</th><th>Mindest</th><th>Fehlt</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shopping.map((it) => (
|
||||
<tr key={it.product_id}>
|
||||
<td>{it.name}</td>
|
||||
<td>{fmt(it.stock)} {unitShort(it.base_unit)}</td>
|
||||
<td>{fmt(it.min_stock)}</td>
|
||||
<td className="strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web/src/pages/Locations.jsx
Normal file
66
web/src/pages/Locations.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
export default function Locations() {
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLocations(await api.listLocations());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createLocation({ name });
|
||||
setName("");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm("Lagerort löschen?")) return;
|
||||
try {
|
||||
await api.deleteLocation(id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>Lagerorte</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
<div className="card form-narrow">
|
||||
<form className="row" onSubmit={add}>
|
||||
<input className="grow" placeholder="Neuer Lagerort (z.B. Speisekammer)" value={name}
|
||||
onChange={(e) => setName(e.target.value)} required />
|
||||
<button className="btn primary">Hinzufügen</button>
|
||||
</form>
|
||||
<ul className="simple-list">
|
||||
{locations.map((l) => (
|
||||
<li key={l.id}>
|
||||
<span>{l.name}</span>
|
||||
<button className="btn ghost danger" onClick={() => remove(l.id)}>Löschen</button>
|
||||
</li>
|
||||
))}
|
||||
{locations.length === 0 && <li className="muted">Noch keine Lagerorte.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
web/src/pages/Login.jsx
Normal file
47
web/src/pages/Login.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err.message || "Anmeldung fehlgeschlagen");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<form className="card login-card" onSubmit={onSubmit}>
|
||||
<div className="brand big">🥫 Pantry</div>
|
||||
<p className="muted">Lebensmittel-Lagerverwaltung</p>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
<label>
|
||||
Benutzername
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
{busy ? "Anmelden…" : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
81
web/src/pages/Products.jsx
Normal file
81
web/src/pages/Products.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { fmt, unitShort } from "../units";
|
||||
|
||||
export default function Products() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [products, setProducts] = useState([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setProducts(await api.listProducts(q));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function onSearch(e) {
|
||||
e.preventDefault();
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Produkte</h1>
|
||||
{isAdmin && <Link className="btn primary" to="/products/new">+ Neues Produkt</Link>}
|
||||
</div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
|
||||
<form className="search" onSubmit={onSearch}>
|
||||
<input placeholder="Suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn">Suchen</button>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Marke</th>
|
||||
<th>Basiseinheit</th>
|
||||
<th>Bestand</th>
|
||||
<th>Mindest</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="thumb-cell">
|
||||
{p.image_url ? <img className="thumb" src={p.image_url} alt="" /> : "📦"}
|
||||
</td>
|
||||
<td>{p.name}</td>
|
||||
<td className="muted">{p.brand || "–"}</td>
|
||||
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""}</td>
|
||||
<td className={p.min_stock != null && p.stock < p.min_stock ? "strong row-warn-text" : ""}>
|
||||
{fmt(p.stock)} {unitShort(p.base_unit)}
|
||||
</td>
|
||||
<td className="muted">{p.min_stock != null ? fmt(p.min_stock) : "–"}</td>
|
||||
<td><Link to={`/products/${p.id}`}>Details</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<tr><td colSpan={7} className="muted center">Keine Produkte.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
web/src/pages/ShoppingList.jsx
Normal file
47
web/src/pages/ShoppingList.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { fmt, unitShort } from "../units";
|
||||
|
||||
export default function ShoppingList() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [checked, setChecked] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.shoppingList().then(setItems).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>🛒 Einkaufsliste</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
<p className="muted">
|
||||
Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt.
|
||||
</p>
|
||||
<div className="card">
|
||||
{items.length === 0 ? (
|
||||
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
|
||||
) : (
|
||||
<ul className="checklist">
|
||||
{items.map((it) => (
|
||||
<li key={it.product_id} className={checked[it.product_id] ? "done" : ""}>
|
||||
<label>
|
||||
<input type="checkbox" checked={!!checked[it.product_id]}
|
||||
onChange={(e) => setChecked({ ...checked, [it.product_id]: e.target.checked })} />
|
||||
<span className="item-name">{it.name}</span>
|
||||
</label>
|
||||
<span className="item-qty">
|
||||
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>
|
||||
<span className="muted"> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Das Abhaken dient hier nur der Übersicht beim Einkaufen und wird (noch) nicht gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
web/src/pages/Users.jsx
Normal file
109
web/src/pages/Users.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
export default function Users() {
|
||||
const { user: me } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [form, setForm] = useState({ username: "", password: "", role: "user" });
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setUsers(await api.listUsers());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await api.createUser(form);
|
||||
setForm({ username: "", password: "", role: "user" });
|
||||
setInfo("Benutzer angelegt.");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(u, role) {
|
||||
try {
|
||||
await api.updateUser(u.id, { role });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u) {
|
||||
if (!confirm(`Benutzer "${u.username}" löschen?`)) return;
|
||||
try {
|
||||
await api.deleteUser(u.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h1>Benutzer</h1></div>
|
||||
{error && <div className="alert error">{error}</div>}
|
||||
{info && <div className="alert info">{info}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2>Benutzer</h2>
|
||||
<table className="table">
|
||||
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}{u.id === me.id && <span className="muted"> (du)</span>}</td>
|
||||
<td>
|
||||
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)} disabled={u.id === me.id}>
|
||||
<option value="user">Nutzer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.id !== me.id && (
|
||||
<button className="btn ghost danger" onClick={() => remove(u)}>Löschen</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form className="card form-narrow" onSubmit={add}>
|
||||
<h2>Neuer Benutzer</h2>
|
||||
<label>
|
||||
Benutzername
|
||||
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} />
|
||||
</label>
|
||||
<label>
|
||||
Rolle
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="user">Nutzer (nur ein-/auslagern)</option>
|
||||
<option value="admin">Admin (volle Verwaltung)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn primary">Anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user