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

105
web/src/api.js Normal file
View 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" }),
};