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>
);
}

140
web/src/pages/CheckOut.jsx Normal file
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

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>
);
}

View 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>
);
}

View 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
View 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>
);
}