From 639126468f7724ba3d16892e3e79c8461c2cc054 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 09:14:14 +0200 Subject: [PATCH] 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 --- .gitattributes | 23 ++++ .gitignore | 26 ++++ README.md | 98 +++++++++++++ backend/.dockerignore | 8 ++ backend/Dockerfile | 21 +++ backend/app/__init__.py | 0 backend/app/config.py | 34 +++++ backend/app/crud.py | 34 +++++ backend/app/database.py | 34 +++++ backend/app/deps.py | 39 ++++++ backend/app/main.py | 61 +++++++++ backend/app/models.py | 147 ++++++++++++++++++++ backend/app/off.py | 72 ++++++++++ backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 30 ++++ backend/app/routers/groups.py | 44 ++++++ backend/app/routers/locations.py | 42 ++++++ backend/app/routers/products.py | 125 +++++++++++++++++ backend/app/routers/settings.py | 49 +++++++ backend/app/routers/stock.py | 83 +++++++++++ backend/app/routers/users.py | 68 +++++++++ backend/app/routers/views.py | 76 +++++++++++ backend/app/schemas.py | 174 +++++++++++++++++++++++ backend/app/security.py | 31 +++++ backend/app/seed.py | 21 +++ backend/app/services/__init__.py | 0 backend/app/services/stock.py | 125 +++++++++++++++++ backend/app/services/units.py | 64 +++++++++ backend/requirements.txt | 12 ++ backend/tests/conftest.py | 36 +++++ backend/tests/test_fefo.py | 81 +++++++++++ backend/tests/test_units.py | 37 +++++ deploy/.env.example | 20 +++ deploy/docker-compose.yml | 41 ++++++ deploy/install.sh | 135 ++++++++++++++++++ docs/ROADMAP.md | 32 +++++ web/.dockerignore | 4 + web/Dockerfile | 13 ++ web/index.html | 12 ++ web/nginx.conf | 20 +++ web/package.json | 20 +++ web/src/App.jsx | 78 +++++++++++ web/src/api.js | 105 ++++++++++++++ web/src/auth.jsx | 48 +++++++ web/src/main.jsx | 16 +++ web/src/pages/CheckIn.jsx | 178 ++++++++++++++++++++++++ web/src/pages/CheckOut.jsx | 140 +++++++++++++++++++ web/src/pages/Dashboard.jsx | 84 ++++++++++++ web/src/pages/Locations.jsx | 66 +++++++++ web/src/pages/Login.jsx | 47 +++++++ web/src/pages/ProductForm.jsx | 228 +++++++++++++++++++++++++++++++ web/src/pages/Products.jsx | 81 +++++++++++ web/src/pages/ShoppingList.jsx | 47 +++++++ web/src/pages/Users.jsx | 109 +++++++++++++++ web/src/styles.css | 185 +++++++++++++++++++++++++ web/src/units.js | 38 ++++++ web/vite.config.js | 18 +++ 57 files changed, 3460 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/crud.py create mode 100644 backend/app/database.py create mode 100644 backend/app/deps.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/off.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/groups.py create mode 100644 backend/app/routers/locations.py create mode 100644 backend/app/routers/products.py create mode 100644 backend/app/routers/settings.py create mode 100644 backend/app/routers/stock.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/routers/views.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/app/seed.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/stock.py create mode 100644 backend/app/services/units.py create mode 100644 backend/requirements.txt create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_fefo.py create mode 100644 backend/tests/test_units.py create mode 100644 deploy/.env.example create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/install.sh create mode 100644 docs/ROADMAP.md create mode 100644 web/.dockerignore create mode 100644 web/Dockerfile create mode 100644 web/index.html create mode 100644 web/nginx.conf create mode 100644 web/package.json create mode 100644 web/src/App.jsx create mode 100644 web/src/api.js create mode 100644 web/src/auth.jsx create mode 100644 web/src/main.jsx create mode 100644 web/src/pages/CheckIn.jsx create mode 100644 web/src/pages/CheckOut.jsx create mode 100644 web/src/pages/Dashboard.jsx create mode 100644 web/src/pages/Locations.jsx create mode 100644 web/src/pages/Login.jsx create mode 100644 web/src/pages/ProductForm.jsx create mode 100644 web/src/pages/Products.jsx create mode 100644 web/src/pages/ShoppingList.jsx create mode 100644 web/src/pages/Users.jsx create mode 100644 web/src/styles.css create mode 100644 web/src/units.js create mode 100644 web/vite.config.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fd7665a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +# Standardmäßig Zeilenenden von Git normalisieren lassen +* text=auto eol=lf + +# Shell-Skripte MÜSSEN LF haben (laufen sonst nicht unter Linux) +*.sh text eol=lf + +# Weitere Textdateien explizit auf LF +*.py text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.css text eol=lf +*.html text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.conf text eol=lf +*.md text eol=lf +Dockerfile text eol=lf + +# Binärdateien nicht anfassen +*.png binary +*.jpg binary +*.ico binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d80e905 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.pyc +.venv/ +venv/ +.pytest_cache/ +*.sqlite +*.db + +# Node +node_modules/ +dist/ +.vite/ +npm-debug.log* + +# Env / secrets +.env +deploy/.env + +# OS +.DS_Store +Thumbs.db + +# iOS (Schritt 2) +ios/**/xcuserdata/ +ios/**/DerivedData/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..71301de --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# 🥫 Pantry – Selfhostbare Lebensmittel-Lagerverwaltung + +Ein selbst gehosteter Dienst zur Verwaltung deines Lebensmittelvorrats: +Ein-/Auslagern per Barcode, MHD-/Ablaufverwaltung mit Chargen, Mindestbestände, +automatische Einkaufsliste – mit **Web-UI** und (in Vorbereitung) **nativer iOS-App**. + +> Dies ist **Schritt 1** (Fundament). Der komplette Fahrplan steht in +> [docs/ROADMAP.md](docs/ROADMAP.md). + +## Features (Schritt 1) +- **Barcode-Lookup** über [Open Food Facts](https://world.openfoodfacts.org) mit + lokalem Fallback (unbekannte Produkte selbst anlegen). +- **Chargen mit MHD:** Jedes Einlagern erzeugt eine Charge mit eigenem + Mindesthaltbarkeitsdatum. Beim Auslagern wird automatisch die zuerst ablaufende + Charge zuerst entnommen (**FEFO** – First Expired, First Out). +- **Einheiten:** Basiseinheit Stück / Gramm / Milliliter plus optionale + Packungsgröße → Ein-/Auslagern in Packungen **oder** Teilmengen (z. B. 200 g). +- **Mindestbestände** pro Produkt → automatische **Einkaufsliste**. +- **Ablaufwarnung** für bald ablaufende Chargen (Frist konfigurierbar). +- **Lagerorte** (flach; Unterlagerorte folgen in Schritt 3). +- **Mehrbenutzer mit Rollen:** + - **Admin** – verwaltet Produkte, Lagerorte, Benutzer, Mindestbestände. + - **Nutzer** – nur ein-/auslagern und ansehen. + - Bewegungen werden protokolliert (wer hat wann was ein-/ausgelagert). + +## Architektur +| Teil | Technik | +|---------|--------------------------------------| +| Backend | Python · FastAPI · SQLAlchemy | +| DB | PostgreSQL | +| Web-UI | React · Vite (via nginx ausgeliefert)| +| iOS | SwiftUI (Schritt 2) | +| Deploy | Docker Compose · `install.sh` | + +``` +backend/ FastAPI-App + Tests +web/ React/Vite SPA +deploy/ docker-compose.yml, install.sh, .env.example +docs/ Roadmap +``` + +## Installation (selfhosted, Linux) +Voraussetzung: eine Linux-Maschine (Server, NAS, Raspberry Pi …). Docker wird bei +Bedarf automatisch installiert. + +```bash +git clone pantry +cd pantry/deploy +chmod +x install.sh +./install.sh # interaktiv – fragt Port & Admin-Passwort +# oder vollautomatisch mit generiertem Admin-Passwort: +# ./install.sh --yes +``` + +Nach dem Start: +- Web-UI: `http://:8080` (Port konfigurierbar) +- Anmeldung mit dem im Installer angezeigten Admin-Benutzer/Passwort. + +Verwaltung: +```bash +cd deploy +docker compose logs -f # Logs ansehen +docker compose down # stoppen +docker compose up -d # starten +``` + +Die Konfiguration liegt in `deploy/.env` (Passwörter, Port, JWT-Secret). Für den +Produktivbetrieb bitte hinter einen HTTPS-Reverse-Proxy (z. B. Caddy/Traefik) stellen. + +## Entwicklung (ohne Docker) +**Backend:** +```bash +cd backend +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +# lokale SQLite-DB nutzen: +export DATABASE_URL="sqlite:///./pantry.db" +uvicorn app.main:app --reload +``` +API-Doku dann unter `http://localhost:8000/docs`. + +**Web:** +```bash +cd web +npm install +npm run dev # http://localhost:5173, proxyt /api → localhost:8000 +``` + +**Tests:** +```bash +cd backend +pip install -r requirements.txt +pytest # prüft FEFO-Abbuchung und Einheiten-Umrechnung +``` + +## Nächste Schritte +Native iOS-App (Schritt 2) und fortgeschrittene Features (Gruppen-Intelligenz, +Unterlagerorte, Push-Benachrichtigungen …) – siehe [docs/ROADMAP.md](docs/ROADMAP.md). diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..58a408a --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +venv/ +.env +*.sqlite +*.db diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..982835e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# System-Abhängigkeiten für psycopg2 & bcrypt +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY tests ./tests + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c88afac --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,34 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + # Database + database_url: str = "postgresql+psycopg2://pantry:pantry@localhost:5432/pantry" + + # Auth / JWT + jwt_secret: str = "change-me-in-production" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 60 * 24 * 7 # 7 days + + # First admin, created on startup if no users exist + admin_username: str = "admin" + admin_password: str = "changeme" + + # Open Food Facts + off_base_url: str = "https://world.openfoodfacts.org" + off_timeout_seconds: float = 8.0 + + # Behaviour + expiry_warning_days_default: int = 7 + + # CORS (comma separated); "*" allows all + cors_origins: str = "*" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..2197aec --- /dev/null +++ b/backend/app/crud.py @@ -0,0 +1,34 @@ +"""Kleine gemeinsame Helfer für Router.""" + +from __future__ import annotations + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from .models import Product +from .schemas import ProductOut +from .services.stock import current_stock + + +def product_to_out(db: Session, product: Product) -> ProductOut: + out = ProductOut.model_validate(product) + out.stock = current_stock(db, product.id) + return out + + +def resolve_product( + db: Session, product_id: int | None, barcode: str | None +) -> Product: + """Findet ein Produkt per ID oder Barcode; wirft 404, wenn keins passt.""" + product: Product | None = None + if product_id is not None: + product = db.get(Product, product_id) + elif barcode: + product = db.query(Product).filter(Product.barcode == barcode).first() + else: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "product_id oder barcode erforderlich" + ) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + return product diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..0d0438f --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,34 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import get_settings + +settings = get_settings() + +# SQLite (used in tests) needs a special connect arg; Postgres does not. +connect_args = {} +if settings.database_url.startswith("sqlite"): + connect_args = {"check_same_thread": False} + +engine = create_engine( + settings.database_url, + connect_args=connect_args, + pool_pre_ping=True, + future=True, +) + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..069aec6 --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,39 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session + +from .database import get_db +from .models import Role, User +from .security import decode_access_token + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") + +_credentials_exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Ungültige oder abgelaufene Anmeldung", + headers={"WWW-Authenticate": "Bearer"}, +) + + +def get_current_user( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> User: + payload = decode_access_token(token) + if payload is None: + raise _credentials_exc + username = payload.get("sub") + if not username: + raise _credentials_exc + user = db.query(User).filter(User.username == username).first() + if user is None: + raise _credentials_exc + return user + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != Role.admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Nur Administratoren dürfen diese Aktion ausführen", + ) + return current_user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c1cc88c --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,61 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import get_settings +from .database import Base, SessionLocal, engine +from .routers import ( + auth, + groups, + locations, + products, + settings as settings_router, + stock, + users, + views, +) +from .seed import ensure_first_admin + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Tabellen anlegen (MVP: create_all statt Alembic-Migrationen). + Base.metadata.create_all(bind=engine) + db = SessionLocal() + try: + ensure_first_admin(db) + finally: + db.close() + yield + + +app = FastAPI(title="Pantry – Lebensmittel-Lagerverwaltung", version="1.0.0", lifespan=lifespan) + +origins = ["*"] if settings.cors_origins.strip() == "*" else [ + o.strip() for o in settings.cors_origins.split(",") if o.strip() +] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health", tags=["meta"]) +def health() -> dict: + return {"status": "ok"} + + +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(products.router) +app.include_router(stock.router) +app.include_router(locations.router) +app.include_router(groups.router) +app.include_router(views.router) +app.include_router(settings_router.router) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..859dfdb --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import enum +from datetime import date, datetime, timezone + +from sqlalchemy import ( + Date, + DateTime, + Enum, + Float, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .database import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Role(str, enum.Enum): + admin = "admin" + user = "user" + + +class BaseUnit(str, enum.Enum): + piece = "piece" + gram = "gram" + milliliter = "milliliter" + + +class MovementType(str, enum.Enum): + in_ = "in" + out = "out" + adjust = "adjust" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + # Group-level minimum stock is a Schritt-3 feature; column kept for forward-compat. + min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) + + products: Mapped[list[Product]] = relationship(back_populates="group") + + +class Location(Base): + __tablename__ = "locations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + # Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat. + parent_id: Mapped[int | None] = mapped_column( + ForeignKey("locations.id", ondelete="SET NULL"), nullable=True + ) + + +class Product(Base): + __tablename__ = "products" + __table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + barcode: Mapped[str | None] = mapped_column(String(64), nullable=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + brand: Mapped[str | None] = mapped_column(String(255), nullable=True) + image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + + base_unit: Mapped[BaseUnit] = mapped_column( + Enum(BaseUnit), nullable=False, default=BaseUnit.piece + ) + # Number of base units contained in one package (e.g. 500 g per package). + package_size: Mapped[float | None] = mapped_column(Float, nullable=True) + + group_id: Mapped[int | None] = mapped_column( + ForeignKey("groups.id", ondelete="SET NULL"), nullable=True + ) + min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units + + source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") + off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + group: Mapped[Group | None] = relationship(back_populates="products") + lots: Mapped[list[Lot]] = relationship( + back_populates="product", cascade="all, delete-orphan" + ) + + +class Lot(Base): + __tablename__ = "lots" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), nullable=False + ) + quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units + best_before: Mapped[date | None] = mapped_column(Date, nullable=True) + location_id: Mapped[int | None] = mapped_column( + ForeignKey("locations.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + product: Mapped[Product] = relationship(back_populates="lots") + + +class Movement(Base): + __tablename__ = "movements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), nullable=False + ) + lot_id: Mapped[int | None] = mapped_column( + ForeignKey("lots.id", ondelete="SET NULL"), nullable=True + ) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + type: Mapped[MovementType] = mapped_column(Enum(MovementType), nullable=False) + quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units + unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered + note: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class Setting(Base): + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str] = mapped_column(String(255), nullable=False) diff --git a/backend/app/off.py b/backend/app/off.py new file mode 100644 index 0000000..11dc439 --- /dev/null +++ b/backend/app/off.py @@ -0,0 +1,72 @@ +"""Open Food Facts Lookup-Client.""" + +from __future__ import annotations + +import json + +import httpx + +from .config import get_settings + +settings = get_settings() + + +def _guess_base_unit(quantity: str | None) -> str: + """Rät die Basiseinheit aus dem OFF-Feld 'quantity' (z.B. '500 g', '1 l').""" + if not quantity: + return "piece" + q = quantity.lower() + if "ml" in q or "cl" in q or "l" in q or "liter" in q: + return "milliliter" + if "kg" in q or " g" in q or "gramm" in q or q.strip().endswith("g"): + return "gram" + return "piece" + + +def lookup_barcode(barcode: str) -> dict | None: + """Fragt Open Food Facts nach einem Barcode. + + Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden. + """ + url = f"{settings.off_base_url}/api/v2/product/{barcode}.json" + try: + resp = httpx.get( + url, + timeout=settings.off_timeout_seconds, + headers={"User-Agent": "Pantry-Selfhosted/1.0 (github: pantry)"}, + ) + except httpx.HTTPError: + return None + + if resp.status_code != 200: + return None + + data = resp.json() + if data.get("status") != 1: + return None + + product = data.get("product", {}) + name = ( + product.get("product_name_de") + or product.get("product_name") + or product.get("generic_name") + or "" + ).strip() + if not name: + return None + + categories = product.get("categories") or "" + category_tags = product.get("categories_tags") or [] + + return { + "barcode": barcode, + "name": name, + "brand": (product.get("brands") or "").strip() or None, + "image_url": product.get("image_front_url") or product.get("image_url") or None, + "base_unit": _guess_base_unit(product.get("quantity")), + "quantity_text": product.get("quantity"), + "category_suggestion": categories.split(",")[0].strip() if categories else None, + "category_tags": category_tags, + "source": "off", + "off_raw": json.dumps(product)[:20000], + } diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..5b27e4a --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user +from ..models import User +from ..schemas import Token, UserOut +from ..security import create_access_token, verify_password + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=Token) +def login( + form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) +) -> Token: + user = db.query(User).filter(User.username == form.username).first() + if user is None or not verify_password(form.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Benutzername oder Passwort falsch", + ) + token = create_access_token(subject=user.username, role=user.role.value) + return Token(access_token=token, role=user.role, username=user.username) + + +@router.get("/me", response_model=UserOut) +def me(current_user: User = Depends(get_current_user)) -> User: + return current_user diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py new file mode 100644 index 0000000..ea36f72 --- /dev/null +++ b/backend/app/routers/groups.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import Group, User +from ..schemas import GroupCreate, GroupOut + +router = APIRouter(prefix="/groups", tags=["groups"]) + + +@router.get("", response_model=list[GroupOut]) +def list_groups( + db: Session = Depends(get_db), _: User = Depends(get_current_user) +) -> list[Group]: + return db.query(Group).order_by(Group.name).all() + + +@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED) +def create_group( + payload: GroupCreate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> Group: + if db.query(Group).filter(Group.name == payload.name).first(): + raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits") + group = Group(name=payload.name, min_stock=payload.min_stock) + db.add(group) + db.commit() + db.refresh(group) + return group + + +@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_group( + group_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> None: + group = db.get(Group, group_id) + if group is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") + db.delete(group) + db.commit() diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py new file mode 100644 index 0000000..39d5ad3 --- /dev/null +++ b/backend/app/routers/locations.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import Location, User +from ..schemas import LocationCreate, LocationOut + +router = APIRouter(prefix="/locations", tags=["locations"]) + + +@router.get("", response_model=list[LocationOut]) +def list_locations( + db: Session = Depends(get_db), _: User = Depends(get_current_user) +) -> list[Location]: + return db.query(Location).order_by(Location.name).all() + + +@router.post("", response_model=LocationOut, status_code=status.HTTP_201_CREATED) +def create_location( + payload: LocationCreate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> Location: + loc = Location(name=payload.name, parent_id=payload.parent_id) + db.add(loc) + db.commit() + db.refresh(loc) + return loc + + +@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_location( + location_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> None: + loc = db.get(Location, location_id) + if loc is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") + db.delete(loc) + db.commit() diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py new file mode 100644 index 0000000..9591478 --- /dev/null +++ b/backend/app/routers/products.py @@ -0,0 +1,125 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..crud import product_to_out +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import BaseUnit, Product, User +from ..off import lookup_barcode +from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate + +router = APIRouter(prefix="/products", tags=["products"]) + + +@router.get("", response_model=list[ProductOut]) +def list_products( + q: str | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[ProductOut]: + query = db.query(Product) + if q: + like = f"%{q}%" + query = query.filter(Product.name.ilike(like)) + products = query.order_by(Product.name).all() + return [product_to_out(db, p) for p in products] + + +@router.get("/lookup", response_model=LookupResult) +def lookup( + barcode: str, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> LookupResult: + """Barcode: erst lokal, dann Open Food Facts. Nur Vorschlag, legt nichts an.""" + existing = db.query(Product).filter(Product.barcode == barcode).first() + if existing: + return LookupResult(found=True, existing_product=product_to_out(db, existing)) + + suggestion = lookup_barcode(barcode) + if suggestion: + return LookupResult(found=True, suggestion=suggestion) + return LookupResult(found=False) + + +@router.get("/{product_id}", response_model=ProductOut) +def get_product( + product_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> ProductOut: + product = db.get(Product, product_id) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + return product_to_out(db, product) + + +@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED) +def create_product( + payload: ProductCreate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> ProductOut: + if payload.barcode: + exists = db.query(Product).filter(Product.barcode == payload.barcode).first() + if exists: + raise HTTPException( + status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits" + ) + product = Product( + barcode=payload.barcode or None, + name=payload.name, + brand=payload.brand, + image_url=payload.image_url, + base_unit=payload.base_unit, + package_size=payload.package_size, + group_id=payload.group_id, + min_stock=payload.min_stock, + source="manual", + ) + db.add(product) + db.commit() + db.refresh(product) + return product_to_out(db, product) + + +@router.patch("/{product_id}", response_model=ProductOut) +def update_product( + product_id: int, + payload: ProductUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> ProductOut: + product = db.get(Product, product_id) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + + data = payload.model_dump(exclude_unset=True) + if "barcode" in data and data["barcode"]: + clash = ( + db.query(Product) + .filter(Product.barcode == data["barcode"], Product.id != product_id) + .first() + ) + if clash: + raise HTTPException( + status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits" + ) + for field, value in data.items(): + setattr(product, field, value) + db.commit() + db.refresh(product) + return product_to_out(db, product) + + +@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_product( + product_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> None: + product = db.get(Product, product_id) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + db.delete(product) + db.commit() diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..418df99 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from ..config import get_settings +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import Setting, User +from ..schemas import SettingOut + +router = APIRouter(prefix="/settings", tags=["settings"]) + +EXPIRY_WARNING_KEY = "expiry_warning_days" + + +def get_expiry_warning_days(db: Session) -> int: + row = db.get(Setting, EXPIRY_WARNING_KEY) + if row is None: + return get_settings().expiry_warning_days_default + try: + return int(row.value) + except ValueError: + return get_settings().expiry_warning_days_default + + +@router.get("", response_model=list[SettingOut]) +def list_settings( + db: Session = Depends(get_db), _: User = Depends(get_current_user) +) -> list[SettingOut]: + rows = db.query(Setting).all() + known = {r.key: r.value for r in rows} + known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db))) + return [SettingOut(key=k, value=v) for k, v in known.items()] + + +@router.put("/{key}", response_model=SettingOut) +def set_setting( + key: str, + value: str, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> SettingOut: + row = db.get(Setting, key) + if row is None: + row = Setting(key=key, value=value) + db.add(row) + else: + row.value = value + db.commit() + return SettingOut(key=key, value=value) diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py new file mode 100644 index 0000000..40a8f0a --- /dev/null +++ b/backend/app/routers/stock.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..crud import resolve_product +from ..database import get_db +from ..deps import get_current_user +from ..models import Lot, User +from ..schemas import ( + CheckInRequest, + CheckInResponse, + CheckOutRequest, + CheckOutResponse, + LotOut, +) +from ..services.stock import StockError, check_in, check_out, current_stock +from ..services.units import UnitError + +router = APIRouter(tags=["stock"]) + + +@router.post("/stock/checkin", response_model=CheckInResponse) +def stock_checkin( + payload: CheckInRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> CheckInResponse: + product = resolve_product(db, payload.product_id, payload.barcode) + try: + lot = check_in( + db, + product=product, + quantity=payload.quantity, + unit=payload.unit, + best_before=payload.best_before, + location_id=payload.location_id, + user=user, + note=payload.note, + ) + except UnitError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + db.commit() + db.refresh(lot) + return CheckInResponse( + lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id) + ) + + +@router.post("/stock/checkout", response_model=CheckOutResponse) +def stock_checkout( + payload: CheckOutRequest, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> CheckOutResponse: + product = resolve_product(db, payload.product_id, payload.barcode) + try: + affected = check_out( + db, + product=product, + quantity=payload.quantity, + unit=payload.unit, + user=user, + note=payload.note, + ) + except UnitError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + except StockError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc + db.commit() + return CheckOutResponse( + affected_lots=affected, product_stock=current_stock(db, product.id) + ) + + +@router.get("/lots", response_model=list[LotOut]) +def list_lots( + product_id: int | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[Lot]: + query = db.query(Lot) + if product_id is not None: + query = query.filter(Lot.product_id == product_id) + return query.order_by(Lot.best_before.is_(None), Lot.best_before).all() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..074de6f --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import require_admin +from ..models import Role, User +from ..schemas import UserCreate, UserOut, UserUpdate +from ..security import hash_password + +router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)]) + + +@router.get("", response_model=list[UserOut]) +def list_users(db: Session = Depends(get_db)) -> list[User]: + return db.query(User).order_by(User.username).all() + + +@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User: + if db.query(User).filter(User.username == payload.username).first(): + raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits") + user = User( + username=payload.username, + password_hash=hash_password(payload.password), + role=payload.role, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.patch("/{user_id}", response_model=UserOut) +def update_user( + user_id: int, payload: UserUpdate, db: Session = Depends(get_db) +) -> User: + user = db.get(User, user_id) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") + if payload.password is not None: + user.password_hash = hash_password(payload.password) + if payload.role is not None: + user.role = payload.role + db.commit() + db.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(require_admin), +) -> None: + user = db.get(User, user_id) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") + if user.id == current_admin.id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Du kannst dich nicht selbst löschen") + # Verhindere das Löschen des letzten Admins. + if user.role == Role.admin: + admin_count = db.query(User).filter(User.role == Role.admin).count() + if admin_count <= 1: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "Der letzte Administrator kann nicht gelöscht werden" + ) + db.delete(user) + db.commit() diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py new file mode 100644 index 0000000..716f2d8 --- /dev/null +++ b/backend/app/routers/views.py @@ -0,0 +1,76 @@ +from datetime import date, timedelta + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user +from ..models import Lot, Product, User +from ..schemas import ExpiringItem, ShoppingItem +from ..services.stock import current_stock +from .settings import get_expiry_warning_days + +router = APIRouter(tags=["views"]) + + +@router.get("/shopping-list", response_model=list[ShoppingItem]) +def shopping_list( + db: Session = Depends(get_db), _: User = Depends(get_current_user) +) -> list[ShoppingItem]: + """Produkte, deren Bestand unter dem Mindestbestand liegt.""" + items: list[ShoppingItem] = [] + products = ( + db.query(Product) + .filter(Product.min_stock.isnot(None), Product.min_stock > 0) + .all() + ) + for product in products: + stock = current_stock(db, product.id) + if stock < product.min_stock: + items.append( + ShoppingItem( + product_id=product.id, + name=product.name, + base_unit=product.base_unit, + stock=stock, + min_stock=product.min_stock, + deficit=product.min_stock - stock, + ) + ) + items.sort(key=lambda i: i.deficit, reverse=True) + return items + + +@router.get("/expiring", response_model=list[ExpiringItem]) +def expiring( + days: int | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[ExpiringItem]: + """Chargen, die innerhalb der Warnfrist ablaufen (oder schon abgelaufen sind).""" + if days is None: + days = get_expiry_warning_days(db) + today = date.today() + threshold = today + timedelta(days=days) + + lots = ( + db.query(Lot) + .filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0) + .order_by(Lot.best_before) + .all() + ) + result: list[ExpiringItem] = [] + for lot in lots: + product = lot.product + result.append( + ExpiringItem( + lot_id=lot.id, + product_id=product.id, + product_name=product.name, + quantity=lot.quantity, + base_unit=product.base_unit, + best_before=lot.best_before, + days_left=(lot.best_before - today).days, + ) + ) + return result diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..f8456c9 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict, Field + +from .models import BaseUnit, Role + + +# ---- Auth / Users ---- +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + role: Role + username: str + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + username: str + role: Role + created_at: datetime + + +class UserCreate(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=4, max_length=255) + role: Role = Role.user + + +class UserUpdate(BaseModel): + password: str | None = Field(default=None, min_length=4, max_length=255) + role: Role | None = None + + +# ---- Groups ---- +class GroupOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + min_stock: float | None = None + + +class GroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + min_stock: float | None = None + + +# ---- Locations ---- +class LocationOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + parent_id: int | None = None + + +class LocationCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + parent_id: int | None = None + + +# ---- Products ---- +class ProductBase(BaseModel): + barcode: str | None = None + name: str = Field(min_length=1, max_length=255) + brand: str | None = None + image_url: str | None = None + base_unit: BaseUnit = BaseUnit.piece + package_size: float | None = Field(default=None, gt=0) + group_id: int | None = None + min_stock: float | None = Field(default=None, ge=0) + + +class ProductCreate(ProductBase): + pass + + +class ProductUpdate(BaseModel): + barcode: str | None = None + name: str | None = Field(default=None, min_length=1, max_length=255) + brand: str | None = None + image_url: str | None = None + base_unit: BaseUnit | None = None + package_size: float | None = Field(default=None, gt=0) + group_id: int | None = None + min_stock: float | None = Field(default=None, ge=0) + + +class ProductOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + barcode: str | None + name: str + brand: str | None + image_url: str | None + base_unit: BaseUnit + package_size: float | None + group_id: int | None + min_stock: float | None + source: str + created_at: datetime + # angereichert: + stock: float = 0.0 + + +class LookupResult(BaseModel): + found: bool + existing_product: ProductOut | None = None + suggestion: dict | None = None + + +# ---- Stock movements ---- +class CheckInRequest(BaseModel): + product_id: int | None = None + barcode: str | None = None + quantity: float = Field(gt=0) + unit: str + best_before: date | None = None + location_id: int | None = None + note: str | None = None + + +class CheckOutRequest(BaseModel): + product_id: int | None = None + barcode: str | None = None + quantity: float = Field(gt=0) + unit: str + note: str | None = None + + +class LotOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + product_id: int + quantity: float + best_before: date | None + location_id: int | None + created_at: datetime + + +class CheckInResponse(BaseModel): + lot: LotOut + product_stock: float + + +class CheckOutResponse(BaseModel): + affected_lots: list[dict] + product_stock: float + + +# ---- Views ---- +class ShoppingItem(BaseModel): + product_id: int + name: str + base_unit: BaseUnit + stock: float + min_stock: float + deficit: float + + +class ExpiringItem(BaseModel): + lot_id: int + product_id: int + product_name: str + quantity: float + base_unit: BaseUnit + best_before: date + days_left: int + + +class SettingOut(BaseModel): + key: str + value: str diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..2d9c5fc --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from .config import get_settings + +settings = get_settings() + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + return pwd_context.verify(password, password_hash) + + +def create_access_token(subject: str, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": subject, "role": role, "exp": expire} + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict | None: + try: + return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + except JWTError: + return None diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..5b91de4 --- /dev/null +++ b/backend/app/seed.py @@ -0,0 +1,21 @@ +"""Erstellt beim ersten Start einen Admin-Benutzer, falls noch keiner existiert.""" + +from sqlalchemy.orm import Session + +from .config import get_settings +from .models import Role, User +from .security import hash_password + + +def ensure_first_admin(db: Session) -> None: + settings = get_settings() + has_users = db.query(User).first() is not None + if has_users: + return + admin = User( + username=settings.admin_username, + password_hash=hash_password(settings.admin_password), + role=Role.admin, + ) + db.add(admin) + db.commit() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py new file mode 100644 index 0000000..53111a3 --- /dev/null +++ b/backend/app/services/stock.py @@ -0,0 +1,125 @@ +"""Chargen-Logik: Einlagern erzeugt Lots, Auslagern bucht per FEFO ab.""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import asc +from sqlalchemy.orm import Session + +from ..models import Lot, Movement, MovementType, Product, User +from .units import to_base_quantity + + +class StockError(ValueError): + """Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand).""" + + +def current_stock(db: Session, product_id: int) -> float: + """Summe der Lot-Mengen eines Produkts (in Basiseinheiten).""" + total = ( + db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all() + ) + return float(sum(q for (q,) in total)) + + +def check_in( + db: Session, + product: Product, + quantity: float, + unit: str, + best_before: date | None, + location_id: int | None, + user: User | None, + note: str | None = None, +) -> Lot: + """Legt eine neue Charge an und protokolliert die Bewegung.""" + quantity_base = to_base_quantity(product, quantity, unit) + + lot = Lot( + product_id=product.id, + quantity=quantity_base, + best_before=best_before, + location_id=location_id, + ) + db.add(lot) + db.flush() # lot.id verfügbar machen + + db.add( + Movement( + product_id=product.id, + lot_id=lot.id, + user_id=user.id if user else None, + type=MovementType.in_, + quantity=quantity_base, + unit_used=unit, + note=note, + ) + ) + return lot + + +def _fefo_lots(db: Session, product_id: int) -> list[Lot]: + """Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter.""" + lots = ( + db.query(Lot) + .filter(Lot.product_id == product_id, Lot.quantity > 0) + .order_by(asc(Lot.created_at)) + .all() + ) + # NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit + # es über SQLite und Postgres identisch funktioniert. + return sorted( + lots, + key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id), + ) + + +def check_out( + db: Session, + product: Product, + quantity: float, + unit: str, + user: User | None, + note: str | None = None, +) -> list[dict]: + """Bucht ``quantity`` (in ``unit``) per FEFO von den Chargen ab. + + Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück. + Wirft StockError, wenn der Gesamtbestand nicht ausreicht. + """ + needed = to_base_quantity(product, quantity, unit) + available = current_stock(db, product.id) + if needed > available + 1e-9: + raise StockError( + f"Nicht genug Bestand: benötigt {needed:g}, verfügbar {available:g} " + f"{product.base_unit.value}" + ) + + affected: list[dict] = [] + remaining = needed + for lot in _fefo_lots(db, product.id): + if remaining <= 1e-9: + break + take = min(lot.quantity, remaining) + lot.quantity -= take + remaining -= take + + db.add( + Movement( + product_id=product.id, + lot_id=lot.id, + user_id=user.id if user else None, + type=MovementType.out, + quantity=take, + unit_used=unit, + note=note, + ) + ) + affected.append({"lot_id": lot.id, "quantity": take}) + + # Leere Charge entfernen, damit das "MHD-Array" sauber bleibt. + if lot.quantity <= 1e-9: + db.delete(lot) + + return affected diff --git a/backend/app/services/units.py b/backend/app/services/units.py new file mode 100644 index 0000000..fa18c00 --- /dev/null +++ b/backend/app/services/units.py @@ -0,0 +1,64 @@ +"""Einheiten-Umrechnung. + +Der Bestand einer Charge (Lot) wird immer in der Basiseinheit des Produkts +gespeichert (Stück, Gramm oder Milliliter). Nutzer geben Mengen entweder in der +Basiseinheit oder in "Packungen" (package) an. Eine Packung entspricht +``product.package_size`` Basiseinheiten. +""" + +from __future__ import annotations + +from ..models import BaseUnit, Product + +# Aliase, die als "Basiseinheit" akzeptiert werden. +_BASE_ALIASES: dict[str, BaseUnit] = { + "piece": BaseUnit.piece, + "pieces": BaseUnit.piece, + "stück": BaseUnit.piece, + "stueck": BaseUnit.piece, + "st": BaseUnit.piece, + "g": BaseUnit.gram, + "gram": BaseUnit.gram, + "gramm": BaseUnit.gram, + "ml": BaseUnit.milliliter, + "milliliter": BaseUnit.milliliter, +} + +PACKAGE_UNITS = {"package", "packung", "pkg", "pack"} + + +class UnitError(ValueError): + """Wird geworfen, wenn eine Einheit nicht zum Produkt passt.""" + + +def to_base_quantity(product: Product, quantity: float, unit: str) -> float: + """Rechnet eine vom Nutzer angegebene Menge in Basiseinheiten um.""" + if quantity <= 0: + raise UnitError("Menge muss größer als 0 sein") + + unit_norm = unit.strip().lower() + + if unit_norm in PACKAGE_UNITS: + if not product.package_size or product.package_size <= 0: + raise UnitError( + "Für dieses Produkt ist keine Packungsgröße hinterlegt, " + "Packungen können nicht umgerechnet werden" + ) + return quantity * product.package_size + + mapped = _BASE_ALIASES.get(unit_norm) + if mapped is None: + raise UnitError(f"Unbekannte Einheit: {unit}") + if mapped != product.base_unit: + raise UnitError( + f"Einheit '{unit}' passt nicht zur Basiseinheit " + f"'{product.base_unit.value}' des Produkts" + ) + return float(quantity) + + +def base_to_packages(product: Product, quantity_base: float) -> float | None: + """Rechnet Basiseinheiten in (ggf. gebrochene) Packungen um, falls möglich.""" + if not product.package_size or product.package_size <= 0: + return None + return quantity_base / product.package_size diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..bde3733 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +SQLAlchemy==2.0.36 +psycopg2-binary==2.9.10 +pydantic==2.10.4 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.2.1 +httpx==0.28.1 +python-dateutil==2.9.0.post0 +pytest==8.3.4 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f64a57d --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,36 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base +from app.models import BaseUnit, Product + + +@pytest.fixture() +def db(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False) + session = TestingSession() + try: + yield session + finally: + session.close() + engine.dispose() + + +@pytest.fixture() +def rice(db): + """Produkt mit Basiseinheit Gramm und 500 g pro Packung.""" + product = Product( + name="Basmati-Reis", base_unit=BaseUnit.gram, package_size=500, source="manual" + ) + db.add(product) + db.commit() + db.refresh(product) + return product diff --git a/backend/tests/test_fefo.py b/backend/tests/test_fefo.py new file mode 100644 index 0000000..24250a8 --- /dev/null +++ b/backend/tests/test_fefo.py @@ -0,0 +1,81 @@ +from datetime import date, timedelta + +import pytest + +from app.services.stock import StockError, check_in, check_out, current_stock + + +def _checkin(db, product, qty, unit, best_before=None): + lot = check_in(db, product, qty, unit, best_before, None, None) + db.commit() + return lot + + +def test_checkin_creates_lot_in_base_units(db, rice): + _checkin(db, rice, 3, "package") # 1500 g + assert current_stock(db, rice.id) == 1500 + + +def test_checkout_partial_amount(db, rice): + _checkin(db, rice, 3, "package") # 1500 g + check_out(db, rice, 200, "g", None) + db.commit() + assert current_stock(db, rice.id) == 1300 + + +def test_fefo_takes_earliest_expiry_first(db, rice): + soon = date.today() + timedelta(days=5) + later = date.today() + timedelta(days=60) + # Zuerst die später ablaufende Charge einlagern ... + _checkin(db, rice, 500, "g", best_before=later) + # ... dann die früher ablaufende. + _checkin(db, rice, 500, "g", best_before=soon) + + check_out(db, rice, 400, "g", None) + db.commit() + + # Die früh ablaufende Charge (soon) muss zuerst reduziert worden sein. + from app.models import Lot + + lots = {l.best_before: l.quantity for l in db.query(Lot).all()} + assert lots[soon] == 100 + assert lots[later] == 500 + + +def test_fefo_spans_multiple_lots_and_removes_empty(db, rice): + soon = date.today() + timedelta(days=5) + later = date.today() + timedelta(days=60) + _checkin(db, rice, 500, "g", best_before=soon) + _checkin(db, rice, 500, "g", best_before=later) + + check_out(db, rice, 700, "g", None) # 500 (soon) + 200 (later) + db.commit() + + from app.models import Lot + + lots = db.query(Lot).all() + assert len(lots) == 1 # leere soon-Charge entfernt + assert lots[0].best_before == later + assert lots[0].quantity == 300 + + +def test_null_best_before_is_last(db, rice): + dated = date.today() + timedelta(days=30) + _checkin(db, rice, 300, "g", best_before=None) + _checkin(db, rice, 300, "g", best_before=dated) + + check_out(db, rice, 300, "g", None) + db.commit() + + from app.models import Lot + + remaining = db.query(Lot).all() + # Die datierte Charge wird zuerst genommen, die NULL-Charge bleibt. + assert len(remaining) == 1 + assert remaining[0].best_before is None + + +def test_checkout_more_than_available_raises(db, rice): + _checkin(db, rice, 100, "g") + with pytest.raises(StockError): + check_out(db, rice, 200, "g", None) diff --git a/backend/tests/test_units.py b/backend/tests/test_units.py new file mode 100644 index 0000000..2a5cf68 --- /dev/null +++ b/backend/tests/test_units.py @@ -0,0 +1,37 @@ +import pytest + +from app.models import BaseUnit, Product +from app.services.units import UnitError, base_to_packages, to_base_quantity + + +def test_base_unit_passthrough(rice): + assert to_base_quantity(rice, 200, "g") == 200 + assert to_base_quantity(rice, 200, "gramm") == 200 + + +def test_package_conversion(rice): + # 3 Packungen * 500 g = 1500 g + assert to_base_quantity(rice, 3, "package") == 1500 + assert to_base_quantity(rice, 2, "packung") == 1000 + + +def test_wrong_unit_rejected(rice): + with pytest.raises(UnitError): + to_base_quantity(rice, 1, "ml") + + +def test_package_without_size_rejected(): + product = Product(name="Ei", base_unit=BaseUnit.piece, package_size=None) + with pytest.raises(UnitError): + to_base_quantity(product, 1, "package") + + +def test_non_positive_quantity_rejected(rice): + with pytest.raises(UnitError): + to_base_quantity(rice, 0, "g") + + +def test_base_to_packages(rice): + assert base_to_packages(rice, 1500) == 3 + product = Product(name="Ei", base_unit=BaseUnit.piece, package_size=None) + assert base_to_packages(product, 10) is None diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..7ea7124 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,20 @@ +# Kopiere diese Datei zu ".env" und passe die Werte an. +# install.sh erzeugt eine .env automatisch mit sicheren Zufallswerten. + +# Port, unter dem die Web-UI erreichbar ist +WEB_PORT=8080 + +# Datenbank +POSTGRES_USER=pantry +POSTGRES_PASSWORD=pantry +POSTGRES_DB=pantry + +# Sicherheit – im Betrieb unbedingt ändern! +JWT_SECRET=change-me-please + +# Erster Admin-Benutzer (wird nur beim allerersten Start angelegt) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=changeme + +# CORS (bei Betrieb hinter dem mitgelieferten nginx nicht nötig) +CORS_ORIGINS=* diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..b7009f3 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,41 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-pantry} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pantry} + POSTGRES_DB: ${POSTGRES_DB:-pantry} + volumes: + - pantry_db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pantry}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: ../backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-pantry}:${POSTGRES_PASSWORD:-pantry}@db:5432/${POSTGRES_DB:-pantry} + JWT_SECRET: ${JWT_SECRET:-change-me} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} + CORS_ORIGINS: ${CORS_ORIGINS:-*} + expose: + - "8000" + + web: + build: ../web + restart: unless-stopped + depends_on: + - backend + ports: + - "${WEB_PORT:-8080}:80" + +volumes: + pantry_db: diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..5f6bdc2 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# Pantry – Installer für selfhostbare Lebensmittel-Lagerverwaltung. +# Installiert bei Bedarf Docker und startet den kompletten Stack via Docker Compose. +# +# Nutzung: +# ./install.sh interaktiv (fragt Admin-Passwort etc.) +# ./install.sh --yes nicht-interaktiv (erzeugt Zufalls-Admin-Passwort) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" +ASSUME_YES=0 +[[ "${1:-}" == "--yes" || "${1:-}" == "-y" ]] && ASSUME_YES=1 + +info() { printf '\033[0;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[0;33m[!]\033[0m %s\n' "$*"; } +error() { printf '\033[0;31m[x]\033[0m %s\n' "$*" >&2; } + +rand() { LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null 2>&1; then + info "Docker gefunden: $(docker --version)" + else + warn "Docker ist nicht installiert." + if [[ "$ASSUME_YES" -eq 1 ]]; then + reply="j" + else + read -r -p "Soll Docker jetzt automatisch installiert werden? [J/n] " reply + reply="${reply:-j}" + fi + case "$reply" in + [JjYy]*) + info "Installiere Docker über get.docker.com …" + curl -fsSL https://get.docker.com | sh + ;; + *) + error "Docker wird benötigt. Bitte manuell installieren und erneut ausführen." + exit 1 + ;; + esac + fi + + # docker compose (v2) oder docker-compose (v1) ermitteln + if docker compose version >/dev/null 2>&1; then + COMPOSE="docker compose" + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE="docker-compose" + else + error "Docker Compose wurde nicht gefunden. Bitte Docker Compose v2 installieren." + exit 1 + fi + info "Verwende: $COMPOSE" + + if ! docker info >/dev/null 2>&1; then + error "Docker-Daemon nicht erreichbar. Läuft Docker? Ggf. 'sudo' nötig oder Nutzer zur 'docker'-Gruppe hinzufügen." + exit 1 + fi +} + +# --- .env erzeugen ------------------------------------------------------------ +create_env() { + if [[ -f "$ENV_FILE" ]]; then + info ".env existiert bereits – bestehende Konfiguration wird verwendet." + return + fi + + info "Erzeuge Konfiguration (.env) …" + + local web_port="8080" + local admin_user="admin" + local admin_pass + admin_pass="$(rand 16)" + + if [[ "$ASSUME_YES" -eq 0 ]]; then + read -r -p "Port für die Web-UI [8080]: " input; web_port="${input:-8080}" + read -r -p "Admin-Benutzername [admin]: " input; admin_user="${input:-admin}" + read -r -s -p "Admin-Passwort [leer = zufällig generieren]: " input; echo + [[ -n "$input" ]] && admin_pass="$input" + fi + + cat > "$ENV_FILE" <:${web_port})" + echo " Admin-Login: ${admin_user}" + if [[ -n "${GENERATED_ADMIN_PASS:-}" ]]; then + echo " Admin-Passwort: ${GENERATED_ADMIN_PASS}" + warn "Bitte dieses Passwort notieren – es wird nicht erneut angezeigt." + else + echo " Admin-Passwort: (wie in .env gesetzt)" + fi + echo + echo " Logs: ( cd $SCRIPT_DIR && $COMPOSE logs -f )" + echo " Stoppen: ( cd $SCRIPT_DIR && $COMPOSE down )" +} + +main "$@" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..2f59747 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,32 @@ +# Pantry – Roadmap + +Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. + +## Schritt 1 – Fundament ✅ (dieser Stand) +- Backend (FastAPI + PostgreSQL): Datenmodell, Auth mit Rollen, FEFO-Auslagern, + Einheiten-Umrechnung, Open-Food-Facts-Lookup mit lokalem Fallback. +- Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte, Lagerorte, + Benutzerverwaltung, Einkaufsliste – rollenabhängig. +- Deployment: Docker Compose + `install.sh`, erster Admin beim Setup. +- Tests: pytest für FEFO-Abbuchung und Einheiten-Umrechnung. + +## Schritt 2 – Native iOS-App (SwiftUI) +- Login (Server-URL + Passwort, Token im Keychain). +- Barcode-Scan (VisionKit / AVFoundation). +- Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD → speichern. +- Auslagern-Flow: scannen → Menge/Einheit (ganz oder Teilmenge g/l) → FEFO. +- Einkaufsliste- und „bald ablaufend“-Ansicht. +- Optional: unbekannte Produkte direkt in der App anlegen. + +## Schritt 3 – Fortgeschrittene Features +- **Gruppen-Intelligenz:** Produkt beim Einlagern automatisch einer Gruppe zuordnen + (Ableitung aus OFF-Kategorie), manuell korrigierbar. +- **Mindestbestände auf Gruppen** (z. B. „Nudeln“ gesamt), nicht nur einzelne Produkte. + (DB-Spalte `groups.min_stock` ist bereits vorbereitet.) +- **Unterlagerorte** (Regal xy / Fach xy). (DB-Spalte `locations.parent_id` vorbereitet.) +- **Einheiten-Umwandlung nachträglich** (Packung ↔ g/l) komfortabel in Web + App. +- **Ablauf-Push-Benachrichtigungen** via APNs. +- **Produktbilder** aus OFF anzeigen (Web + App) + lokaler Bild-Cache. +- **Einkaufsliste verfeinern:** Mengenvorschläge, manuelle Ergänzungen, Abhaken/Erledigen. +- **Bewegungsverlauf/Statistik** (wer/was wann ein-/ausgelagert) – Tabelle `movements` + wird bereits befüllt, es fehlt nur die Ansicht. diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..e3013b3 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +npm-debug.log* diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..cc0e24b --- /dev/null +++ b/web/Dockerfile @@ -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 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b39440c --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Pantry – Lagerverwaltung + + +
+ + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..03de95e --- /dev/null +++ b/web/nginx.conf @@ -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; + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..77324f3 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..1cf0586 --- /dev/null +++ b/web/src/App.jsx @@ -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 ( +
+
+
🥫 Pantry
+ +
+ + {user?.username} {isAdmin && Admin} + + +
+
+
{children}
+
+ ); +} + +function Protected({ children, adminOnly = false }) { + const { user, isAdmin, loading } = useAuth(); + if (loading) return
Lädt…
; + if (!user) return ; + if (adminOnly && !isAdmin) return ; + return {children}; +} + +export default function App() { + const { user, loading } = useAuth(); + + return ( + + : } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/web/src/api.js b/web/src/api.js new file mode 100644 index 0000000..a65c6af --- /dev/null +++ b/web/src/api.js @@ -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" }), +}; diff --git a/web/src/auth.jsx b/web/src/auth.jsx new file mode 100644 index 0000000..08db2df --- /dev/null +++ b/web/src/auth.jsx @@ -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 ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..0896a56 --- /dev/null +++ b/web/src/main.jsx @@ -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( + + + + + + + +); diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx new file mode 100644 index 0000000..da704e9 --- /dev/null +++ b/web/src/pages/CheckIn.jsx @@ -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 ( +
+

📥 Einlagern

+ {error &&
{error}
} + {info &&
{info}
} + + {!product && ( +
+
+

Per Barcode

+
+ setBarcode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && doLookup()} /> + +
+ {unknownBarcode && ( +
+ Barcode {unknownBarcode} ist unbekannt.{" "} + {isAdmin ? ( + Produkt anlegen + ) : ( + "Bitte einen Administrator bitten, das Produkt anzulegen." + )} +
+ )} +
+
+

Aus Produktliste

+
+ setSearch(e.target.value)} /> + +
+
    + {results.map((p) => ( +
  • + +
  • + ))} +
+
+
+ )} + + {product && ( +
+
+ {product.image_url && } +
+ {product.name} +
+ Aktueller Bestand: {fmt(product.stock)} {unitShort(product.base_unit)} +
+
+ +
+ +
+ + +
+
+ + +
+ +
+ )} +
+ ); +} diff --git a/web/src/pages/CheckOut.jsx b/web/src/pages/CheckOut.jsx new file mode 100644 index 0000000..e2fcda9 --- /dev/null +++ b/web/src/pages/CheckOut.jsx @@ -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 ( +
+

📤 Auslagern

+ {error &&
{error}
} + {info &&
{info}
} + + {!product && ( +
+
+

Per Barcode

+
+ setBarcode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && doLookup()} /> + +
+
+
+

Aus Produktliste

+
+ setSearch(e.target.value)} /> + +
+
    + {results.map((p) => ( +
  • + +
  • + ))} +
+
+
+ )} + + {product && ( +
+
+ {product.image_url && } +
+ {product.name} +
+ Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)} +
+
+ +
+ +
+ + +
+

+ Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO). +

+ +
+ )} +
+ ); +} diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx new file mode 100644 index 0000000..1570290 --- /dev/null +++ b/web/src/pages/Dashboard.jsx @@ -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 ( +
+
+

Übersicht

+
+ Einlagern + Auslagern +
+
+ {error &&
{error}
} + +
+
+

⏰ Bald ablaufend

+ {expiring.length === 0 ? ( +

Nichts läuft demnächst ab. 🎉

+ ) : ( + + + + + + {expiring.map((it) => ( + + + + + + + ))} + +
ProduktMengeMHDTage
{it.product_name}{fmt(it.quantity)} {unitShort(it.base_unit)}{it.best_before}{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}
+ )} +
+ +
+

🛒 Einkaufsliste

+ {shopping.length === 0 ? ( +

Alle Mindestbestände erreicht. 👍

+ ) : ( + + + + + + {shopping.map((it) => ( + + + + + + + ))} + +
ProduktBestandMindestFehlt
{it.name}{fmt(it.stock)} {unitShort(it.base_unit)}{fmt(it.min_stock)}{fmt(it.deficit)} {unitShort(it.base_unit)}
+ )} +
+
+
+ ); +} diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx new file mode 100644 index 0000000..03a93dd --- /dev/null +++ b/web/src/pages/Locations.jsx @@ -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 ( +
+

Lagerorte

+ {error &&
{error}
} +
+
+ setName(e.target.value)} required /> + +
+
    + {locations.map((l) => ( +
  • + {l.name} + +
  • + ))} + {locations.length === 0 &&
  • Noch keine Lagerorte.
  • } +
+
+

+ Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt. +

+
+ ); +} diff --git a/web/src/pages/Login.jsx b/web/src/pages/Login.jsx new file mode 100644 index 0000000..2a68e7b --- /dev/null +++ b/web/src/pages/Login.jsx @@ -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 ( +
+
+
🥫 Pantry
+

Lebensmittel-Lagerverwaltung

+ {error &&
{error}
} + + + +
+
+ ); +} diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx new file mode 100644 index 0000000..9c6f7ea --- /dev/null +++ b/web/src/pages/ProductForm.jsx @@ -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 ( +
+
+

{isNew ? "Neues Produkt" : form.name || "Produkt"}

+
+ {error &&
{error}
} + {info &&
{info}
} + +
+
+
+ + {isAdmin && ( + + )} +
+ + +
+ + +
+
+ + +
+ + {isAdmin && ( +
+ + {!isNew && } +
+ )} + {readOnly &&

Nur Administratoren können Produkte bearbeiten.

} +
+ + {!isNew && ( +
+

Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})

+ {form.image_url && } + + + + {lots.map((l) => ( + + + + + + ))} + {lots.length === 0 && } + +
MengeMHDEingelagert
{fmt(l.quantity)} {unitShort(form.base_unit)}{l.best_before || "–"}{new Date(l.created_at).toLocaleDateString("de-DE")}
Keine Chargen im Bestand.
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx new file mode 100644 index 0000000..c5848ed --- /dev/null +++ b/web/src/pages/Products.jsx @@ -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 ( +
+
+

Produkte

+ {isAdmin && + Neues Produkt} +
+ {error &&
{error}
} + +
+ setQ(e.target.value)} /> + +
+ +
+ + + + + + + + + + + + + + {products.map((p) => ( + + + + + + + + + + ))} + {products.length === 0 && ( + + )} + +
NameMarkeBasiseinheitBestandMindest
+ {p.image_url ? : "📦"} + {p.name}{p.brand || "–"}{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""} + {fmt(p.stock)} {unitShort(p.base_unit)} + {p.min_stock != null ? fmt(p.min_stock) : "–"}Details
Keine Produkte.
+
+
+ ); +} diff --git a/web/src/pages/ShoppingList.jsx b/web/src/pages/ShoppingList.jsx new file mode 100644 index 0000000..e6b0402 --- /dev/null +++ b/web/src/pages/ShoppingList.jsx @@ -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 ( +
+

🛒 Einkaufsliste

+ {error &&
{error}
} +

+ Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt. +

+
+ {items.length === 0 ? ( +

Alle Mindestbestände erreicht. 👍

+ ) : ( +
    + {items.map((it) => ( +
  • + + + fehlt {fmt(it.deficit)} {unitShort(it.base_unit)} + (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) + +
  • + ))} +
+ )} +
+

+ Das Abhaken dient hier nur der Übersicht beim Einkaufen und wird (noch) nicht gespeichert. +

+
+ ); +} diff --git a/web/src/pages/Users.jsx b/web/src/pages/Users.jsx new file mode 100644 index 0000000..046d414 --- /dev/null +++ b/web/src/pages/Users.jsx @@ -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 ( +
+

Benutzer

+ {error &&
{error}
} + {info &&
{info}
} + +
+
+

Benutzer

+ + + + {users.map((u) => ( + + + + + + ))} + +
NameRolle
{u.username}{u.id === me.id && (du)} + + + {u.id !== me.id && ( + + )} +
+
+ +
+

Neuer Benutzer

+ + + + +
+
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..f9f5f3f --- /dev/null +++ b/web/src/styles.css @@ -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; } +} diff --git a/web/src/units.js b/web/src/units.js new file mode 100644 index 0000000..a803826 --- /dev/null +++ b/web/src/units.js @@ -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 }); +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..b877a13 --- /dev/null +++ b/web/vite.config.js @@ -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/, ""), + }, + }, + }, +});