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:
4
web/.dockerignore
Normal file
4
web/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
npm-debug.log*
|
||||
13
web/Dockerfile
Normal file
13
web/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# --- Build-Stage: React/Vite bauen ---
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Runtime-Stage: statische Dateien via nginx, /api → Backend proxen ---
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pantry – Lagerverwaltung</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
web/nginx.conf
Normal file
20
web/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA-Routing: unbekannte Pfade auf index.html mappen
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API an das Backend weiterleiten (Service-Name "backend" aus docker-compose)
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
20
web/package.json
Normal file
20
web/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pantry-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
78
web/src/App.jsx
Normal file
78
web/src/App.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "./auth";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Products from "./pages/Products";
|
||||
import ProductForm from "./pages/ProductForm";
|
||||
import CheckIn from "./pages/CheckIn";
|
||||
import CheckOut from "./pages/CheckOut";
|
||||
import Locations from "./pages/Locations";
|
||||
import Users from "./pages/Users";
|
||||
import ShoppingList from "./pages/ShoppingList";
|
||||
|
||||
function Layout({ children }) {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
const link = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand">🥫 Pantry</div>
|
||||
<nav className="nav">
|
||||
<NavLink to="/" end className={link}>Übersicht</NavLink>
|
||||
<NavLink to="/checkin" className={link}>Einlagern</NavLink>
|
||||
<NavLink to="/checkout" className={link}>Auslagern</NavLink>
|
||||
<NavLink to="/products" className={link}>Produkte</NavLink>
|
||||
<NavLink to="/shopping" className={link}>Einkaufsliste</NavLink>
|
||||
{isAdmin && <NavLink to="/locations" className={link}>Lagerorte</NavLink>}
|
||||
{isAdmin && <NavLink to="/users" className={link}>Benutzer</NavLink>}
|
||||
</nav>
|
||||
<div className="userbox">
|
||||
<span className="username">
|
||||
{user?.username} {isAdmin && <span className="badge">Admin</span>}
|
||||
</span>
|
||||
<button className="btn ghost" onClick={handleLogout}>Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Protected({ children, adminOnly = false }) {
|
||||
const { user, isAdmin, loading } = useAuth();
|
||||
if (loading) return <div className="center muted">Lädt…</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
||||
return <Layout>{children}</Layout>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
<Route path="/" element={<Protected><Dashboard /></Protected>} />
|
||||
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
|
||||
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
||||
<Route path="/products" element={<Protected><Products /></Protected>} />
|
||||
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||||
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
105
web/src/api.js
Normal file
105
web/src/api.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// Zentraler API-Client. In Produktion und Dev läuft alles über /api
|
||||
// (nginx bzw. Vite-Proxy leiten an das FastAPI-Backend weiter).
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
const TOKEN_KEY = "pantry_token";
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
export function setToken(token) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, { method = "GET", body, form } = {}) {
|
||||
const headers = {};
|
||||
const token = getToken();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
let payload;
|
||||
if (form) {
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
payload = new URLSearchParams(form).toString();
|
||||
} else if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
|
||||
|
||||
if (resp.status === 204) return null;
|
||||
|
||||
let data = null;
|
||||
const text = await resp.text();
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const detail =
|
||||
(data && data.detail) ||
|
||||
(typeof data === "string" ? data : null) ||
|
||||
`Fehler ${resp.status}`;
|
||||
throw new ApiError(
|
||||
Array.isArray(detail) ? detail.map((d) => d.msg).join(", ") : detail,
|
||||
resp.status
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username, password) =>
|
||||
request("/auth/login", { method: "POST", form: { username, password } }),
|
||||
me: () => request("/auth/me"),
|
||||
|
||||
// Produkte
|
||||
listProducts: (q) => request(`/products${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||
getProduct: (id) => request(`/products/${id}`),
|
||||
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
|
||||
createProduct: (body) => request("/products", { method: "POST", body }),
|
||||
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
|
||||
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Bestand
|
||||
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||||
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
|
||||
listLots: (productId) =>
|
||||
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
|
||||
|
||||
// Views
|
||||
shoppingList: () => request("/shopping-list"),
|
||||
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||||
|
||||
// Stammdaten
|
||||
listLocations: () => request("/locations"),
|
||||
createLocation: (body) => request("/locations", { method: "POST", body }),
|
||||
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
|
||||
|
||||
listGroups: () => request("/groups"),
|
||||
createGroup: (body) => request("/groups", { method: "POST", body }),
|
||||
|
||||
// Benutzer
|
||||
listUsers: () => request("/users"),
|
||||
createUser: (body) => request("/users", { method: "POST", body }),
|
||||
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Einstellungen
|
||||
listSettings: () => request("/settings"),
|
||||
setSetting: (key, value) =>
|
||||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||||
};
|
||||
48
web/src/auth.jsx
Normal file
48
web/src/auth.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { api, getToken, setToken } from "./api";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function boot() {
|
||||
if (getToken()) {
|
||||
try {
|
||||
setUser(await api.me());
|
||||
} catch {
|
||||
setToken(null);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
boot();
|
||||
}, []);
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await api.login(username, password);
|
||||
setToken(res.access_token);
|
||||
const me = await api.me();
|
||||
setUser(me);
|
||||
return me;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isAdmin, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
16
web/src/main.jsx
Normal file
16
web/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./auth";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
185
web/src/styles.css
Normal file
185
web/src/styles.css
Normal file
@@ -0,0 +1,185 @@
|
||||
:root {
|
||||
--bg: #f4f6f8;
|
||||
--card: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--primary: #2f855a;
|
||||
--primary-dark: #276749;
|
||||
--danger: #c53030;
|
||||
--warn: #b7791f;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
a { color: var(--primary); }
|
||||
|
||||
.app { min-height: 100vh; }
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 20px; }
|
||||
.brand.big { font-size: 30px; }
|
||||
.nav { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
|
||||
.nav-link {
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-link:hover { background: var(--bg); }
|
||||
.nav-link.active { background: var(--primary); color: #fff; }
|
||||
.userbox { display: flex; align-items: center; gap: 10px; }
|
||||
.username { font-size: 14px; color: var(--muted); }
|
||||
.badge {
|
||||
background: var(--primary); color: #fff; font-size: 11px;
|
||||
padding: 1px 6px; border-radius: 6px; font-weight: 700;
|
||||
}
|
||||
|
||||
.content { max-width: 1000px; margin: 0 auto; padding: 24px 20px 60px; }
|
||||
|
||||
.page-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 18px; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.page-head h1 { margin: 0; font-size: 24px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
|
||||
/* Cards & layout */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 { margin-top: 0; font-size: 17px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-narrow { max-width: 560px; }
|
||||
@media (max-width: 760px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Forms */
|
||||
label { display: block; margin-bottom: 12px; font-size: 13px; color: var(--muted); font-weight: 600; }
|
||||
input, select {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
input:focus, select:focus { outline: 2px solid var(--primary); border-color: var(--primary); }
|
||||
.row { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.row .grow { flex: 1; }
|
||||
.search { display: flex; gap: 8px; margin-bottom: 16px; max-width: 480px; }
|
||||
.search input { margin-top: 0; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.btn:hover { background: var(--bg); }
|
||||
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.btn.primary:hover { background: var(--primary-dark); }
|
||||
.btn.danger { color: var(--danger); border-color: var(--danger); }
|
||||
.btn.ghost { border-color: transparent; background: transparent; }
|
||||
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.link-btn { background: none; border: none; color: var(--primary); cursor: pointer; font-size: 15px; padding: 4px 0; text-align: left; }
|
||||
|
||||
/* Tables */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.row-danger { background: #fff5f5; }
|
||||
.row-warn { background: #fffaf0; }
|
||||
.row-warn-text { color: var(--warn); }
|
||||
.strong { font-weight: 700; }
|
||||
.thumb-cell { width: 40px; }
|
||||
.thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; }
|
||||
.product-img { max-width: 160px; border-radius: 8px; margin-bottom: 10px; }
|
||||
|
||||
/* Lists */
|
||||
.picklist, .simple-list, .checklist { list-style: none; padding: 0; margin: 10px 0 0; }
|
||||
.simple-list li, .checklist li {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 9px 4px; border-bottom: 1px solid var(--border); gap: 10px;
|
||||
}
|
||||
.picklist li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.checklist li.done .item-name { text-decoration: line-through; color: var(--muted); }
|
||||
.checklist label { display: flex; align-items: center; gap: 8px; margin: 0; }
|
||||
.checklist input[type="checkbox"] { width: auto; margin: 0; }
|
||||
.item-name { font-weight: 600; color: var(--text); }
|
||||
|
||||
/* Alerts */
|
||||
.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||
.alert.error { background: #fff5f5; color: var(--danger); border: 1px solid #feb2b2; }
|
||||
.alert.info { background: #ebf8ff; color: #2b6cb0; border: 1px solid #bee3f8; }
|
||||
.alert.warn { background: #fffaf0; color: var(--warn); border: 1px solid #fbd38d; }
|
||||
|
||||
/* Selected product banner */
|
||||
.selected-product {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px; background: var(--bg); border-radius: 8px; margin-bottom: 16px;
|
||||
}
|
||||
.selected-product > div { flex: 1; }
|
||||
|
||||
/* Login */
|
||||
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.login-card { width: 100%; max-width: 360px; text-align: center; }
|
||||
.login-card label { text-align: left; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 13px; }
|
||||
.center { text-align: center; padding: 40px; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--border: #334155;
|
||||
--text: #e2e8f0;
|
||||
--muted: #94a3b8;
|
||||
}
|
||||
input, select { background: #0f172a; }
|
||||
.btn { background: #0f172a; }
|
||||
.btn:hover { background: #172033; }
|
||||
.row-danger { background: #3b1a1a; }
|
||||
.row-warn { background: #3a2f14; }
|
||||
.alert.error { background: #3b1a1a; border-color: #7f1d1d; }
|
||||
.alert.info { background: #16324d; border-color: #1e4e79; }
|
||||
.alert.warn { background: #3a2f14; border-color: #7c5c14; }
|
||||
}
|
||||
38
web/src/units.js
Normal file
38
web/src/units.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Anzeige-Helfer für Einheiten (müssen zu backend/app/models.py::BaseUnit passen).
|
||||
|
||||
export const BASE_UNITS = [
|
||||
{ value: "piece", label: "Stück" },
|
||||
{ value: "gram", label: "Gramm (g)" },
|
||||
{ value: "milliliter", label: "Milliliter (ml)" },
|
||||
];
|
||||
|
||||
export const BASE_UNIT_SHORT = {
|
||||
piece: "Stk",
|
||||
gram: "g",
|
||||
milliliter: "ml",
|
||||
};
|
||||
|
||||
export function unitShort(baseUnit) {
|
||||
return BASE_UNIT_SHORT[baseUnit] || baseUnit;
|
||||
}
|
||||
|
||||
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
|
||||
export function unitOptions(product) {
|
||||
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
|
||||
if (product.package_size) {
|
||||
opts.push({ value: "package", label: `Packung (${product.package_size} ${unitShort(product.base_unit)})` });
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// Das Backend akzeptiert 'g', 'ml', 'piece'/'st' etc. als Basiseinheit-Eingabe.
|
||||
export function baseUnitToInput(baseUnit) {
|
||||
if (baseUnit === "gram") return "g";
|
||||
if (baseUnit === "milliliter") return "ml";
|
||||
return "piece";
|
||||
}
|
||||
|
||||
export function fmt(n) {
|
||||
if (n == null) return "–";
|
||||
return Number(n).toLocaleString("de-DE", { maximumFractionDigits: 2 });
|
||||
}
|
||||
18
web/vite.config.js
Normal file
18
web/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Im Dev-Modus wird /api an das lokale Backend weitergeleitet.
|
||||
// In Produktion übernimmt nginx das Proxying (siehe nginx.conf).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user