Schritt 1: Fundament der Lebensmittel-Lagerverwaltung (Pantry)

Backend (FastAPI + PostgreSQL): Chargen mit MHD, FEFO-Auslagern,
Einheiten-Umrechnung (Stueck/g/ml + Packungen), Open-Food-Facts-Lookup
mit lokalem Fallback, JWT-Auth mit Rollen (Admin/Nutzer), erster Admin
beim Setup, Einkaufsliste, Ablaufwarnung, Lagerorte, pytest fuer FEFO.

Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte,
Lagerorte, Benutzerverwaltung, Einkaufsliste - rollenabhaengig.

Deploy: docker-compose + install.sh (Docker-Autoinstall, Secrets),
README und Roadmap fuer Schritt 2 (iOS) und Schritt 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 09:14:14 +02:00
commit 639126468f
57 changed files with 3460 additions and 0 deletions

23
.gitattributes vendored Normal file
View File

@@ -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

26
.gitignore vendored Normal file
View File

@@ -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/

98
README.md Normal file
View File

@@ -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 <dieses-repo> 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://<server-ip>: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).

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
.env
*.sqlite
*.db

21
backend/Dockerfile Normal file
View File

@@ -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"]

0
backend/app/__init__.py Normal file
View File

34
backend/app/config.py Normal file
View File

@@ -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()

34
backend/app/crud.py Normal file
View File

@@ -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

34
backend/app/database.py Normal file
View File

@@ -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()

39
backend/app/deps.py Normal file
View File

@@ -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

61
backend/app/main.py Normal file
View File

@@ -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)

147
backend/app/models.py Normal file
View File

@@ -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)

72
backend/app/off.py Normal file
View File

@@ -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],
}

View File

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

174
backend/app/schemas.py Normal file
View File

@@ -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

31
backend/app/security.py Normal file
View File

@@ -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

21
backend/app/seed.py Normal file
View File

@@ -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()

View File

View File

@@ -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

View File

@@ -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

12
backend/requirements.txt Normal file
View File

@@ -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

36
backend/tests/conftest.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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

20
deploy/.env.example Normal file
View File

@@ -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=*

41
deploy/docker-compose.yml Normal file
View File

@@ -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:

135
deploy/install.sh Normal file
View File

@@ -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/urandom | head -c "${1:-32}"; }
# --- Docker prüfen / installieren ---------------------------------------------
ensure_docker() {
if command -v docker >/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" <<EOF
WEB_PORT=$web_port
POSTGRES_USER=pantry
POSTGRES_PASSWORD=$(rand 24)
POSTGRES_DB=pantry
JWT_SECRET=$(rand 48)
ADMIN_USERNAME=$admin_user
ADMIN_PASSWORD=$admin_pass
CORS_ORIGINS=*
EOF
chmod 600 "$ENV_FILE"
GENERATED_ADMIN_USER="$admin_user"
GENERATED_ADMIN_PASS="$admin_pass"
GENERATED_WEB_PORT="$web_port"
}
# --- Start --------------------------------------------------------------------
main() {
info "Pantry-Installer startet …"
ensure_docker
create_env
info "Baue Container (das kann beim ersten Mal einige Minuten dauern) …"
( cd "$SCRIPT_DIR" && $COMPOSE --env-file "$ENV_FILE" build )
info "Starte Dienste …"
( cd "$SCRIPT_DIR" && $COMPOSE --env-file "$ENV_FILE" up -d )
# Werte aus .env lesen (auch bei bestehender .env)
# shellcheck disable=SC1090
local web_port admin_user
web_port="$(grep -E '^WEB_PORT=' "$ENV_FILE" | cut -d= -f2-)"
admin_user="$(grep -E '^ADMIN_USERNAME=' "$ENV_FILE" | cut -d= -f2-)"
echo
info "Fertig! 🎉"
echo " Web-UI: http://localhost:${web_port} (bzw. http://<server-ip>:${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 "$@"

32
docs/ROADMAP.md Normal file
View File

@@ -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.

4
web/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
npm-debug.log*

13
web/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# --- Build-Stage: React/Vite bauen ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# --- Runtime-Stage: statische Dateien via nginx, /api → Backend proxen ---
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pantry Lagerverwaltung</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

20
web/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA-Routing: unbekannte Pfade auf index.html mappen
location / {
try_files $uri $uri/ /index.html;
}
# API an das Backend weiterleiten (Service-Name "backend" aus docker-compose)
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

20
web/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "pantry-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}

78
web/src/App.jsx Normal file
View File

@@ -0,0 +1,78 @@
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { useAuth } from "./auth";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products";
import ProductForm from "./pages/ProductForm";
import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut";
import Locations from "./pages/Locations";
import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList";
function Layout({ children }) {
const { user, isAdmin, logout } = useAuth();
const navigate = useNavigate();
function handleLogout() {
logout();
navigate("/login");
}
const link = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
return (
<div className="app">
<header className="topbar">
<div className="brand">🥫 Pantry</div>
<nav className="nav">
<NavLink to="/" end className={link}>Übersicht</NavLink>
<NavLink to="/checkin" className={link}>Einlagern</NavLink>
<NavLink to="/checkout" className={link}>Auslagern</NavLink>
<NavLink to="/products" className={link}>Produkte</NavLink>
<NavLink to="/shopping" className={link}>Einkaufsliste</NavLink>
{isAdmin && <NavLink to="/locations" className={link}>Lagerorte</NavLink>}
{isAdmin && <NavLink to="/users" className={link}>Benutzer</NavLink>}
</nav>
<div className="userbox">
<span className="username">
{user?.username} {isAdmin && <span className="badge">Admin</span>}
</span>
<button className="btn ghost" onClick={handleLogout}>Abmelden</button>
</div>
</header>
<main className="content">{children}</main>
</div>
);
}
function Protected({ children, adminOnly = false }) {
const { user, isAdmin, loading } = useAuth();
if (loading) return <div className="center muted">Lädt</div>;
if (!user) return <Navigate to="/login" replace />;
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
return <Layout>{children}</Layout>;
}
export default function App() {
const { user, loading } = useAuth();
return (
<Routes>
<Route
path="/login"
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
/>
<Route path="/" element={<Protected><Dashboard /></Protected>} />
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
<Route path="/products" element={<Protected><Products /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

105
web/src/api.js Normal file
View File

@@ -0,0 +1,105 @@
// Zentraler API-Client. In Produktion und Dev läuft alles über /api
// (nginx bzw. Vite-Proxy leiten an das FastAPI-Backend weiter).
const API_BASE = "/api";
const TOKEN_KEY = "pantry_token";
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function request(path, { method = "GET", body, form } = {}) {
const headers = {};
const token = getToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
let payload;
if (form) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
payload = new URLSearchParams(form).toString();
} else if (body !== undefined) {
headers["Content-Type"] = "application/json";
payload = JSON.stringify(body);
}
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
if (resp.status === 204) return null;
let data = null;
const text = await resp.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!resp.ok) {
const detail =
(data && data.detail) ||
(typeof data === "string" ? data : null) ||
`Fehler ${resp.status}`;
throw new ApiError(
Array.isArray(detail) ? detail.map((d) => d.msg).join(", ") : detail,
resp.status
);
}
return data;
}
export const api = {
login: (username, password) =>
request("/auth/login", { method: "POST", form: { username, password } }),
me: () => request("/auth/me"),
// Produkte
listProducts: (q) => request(`/products${q ? `?q=${encodeURIComponent(q)}` : ""}`),
getProduct: (id) => request(`/products/${id}`),
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
createProduct: (body) => request("/products", { method: "POST", body }),
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
// Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
listLots: (productId) =>
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
// Views
shoppingList: () => request("/shopping-list"),
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
// Stammdaten
listLocations: () => request("/locations"),
createLocation: (body) => request("/locations", { method: "POST", body }),
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
listGroups: () => request("/groups"),
createGroup: (body) => request("/groups", { method: "POST", body }),
// Benutzer
listUsers: () => request("/users"),
createUser: (body) => request("/users", { method: "POST", body }),
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
// Einstellungen
listSettings: () => request("/settings"),
setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
};

48
web/src/auth.jsx Normal file
View File

@@ -0,0 +1,48 @@
import { createContext, useContext, useEffect, useState } from "react";
import { api, getToken, setToken } from "./api";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function boot() {
if (getToken()) {
try {
setUser(await api.me());
} catch {
setToken(null);
}
}
setLoading(false);
}
boot();
}, []);
async function login(username, password) {
const res = await api.login(username, password);
setToken(res.access_token);
const me = await api.me();
setUser(me);
return me;
}
function logout() {
setToken(null);
setUser(null);
}
const isAdmin = user?.role === "admin";
return (
<AuthContext.Provider value={{ user, isAdmin, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

16
web/src/main.jsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AuthProvider } from "./auth";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

178
web/src/pages/CheckIn.jsx Normal file
View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import { fmt, unitOptions, unitShort } from "../units";
export default function CheckIn() {
const { isAdmin } = useAuth();
const [barcode, setBarcode] = useState("");
const [product, setProduct] = useState(null);
const [results, setResults] = useState([]);
const [search, setSearch] = useState("");
const [locations, setLocations] = useState([]);
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("");
const [bestBefore, setBestBefore] = useState("");
const [locationId, setLocationId] = useState("");
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [unknownBarcode, setUnknownBarcode] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.listLocations().then(setLocations).catch(() => {});
}, []);
function selectProduct(p) {
setProduct(p);
setResults([]);
setUnknownBarcode(null);
const opts = unitOptions(p);
setUnit(opts[0].value);
}
async function doLookup() {
setError(null); setInfo(null); setUnknownBarcode(null);
if (!barcode) return;
try {
const res = await api.lookup(barcode.trim());
if (res.found && res.existing_product) {
selectProduct(res.existing_product);
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
} else {
setUnknownBarcode(barcode.trim());
setInfo(null);
}
} catch (err) {
setError(err.message);
}
}
async function doSearch(e) {
e.preventDefault();
try {
setResults(await api.listProducts(search));
} catch (err) {
setError(err.message);
}
}
async function submit(e) {
e.preventDefault();
setError(null); setInfo(null); setBusy(true);
try {
const res = await api.checkIn({
product_id: product.id,
quantity: Number(quantity),
unit,
best_before: bestBefore || null,
location_id: locationId === "" ? null : Number(locationId),
});
setInfo(`Eingelagert. Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
setQuantity("");
setBestBefore("");
setProduct(await api.getProduct(product.id));
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<div>
<div className="page-head"><h1>📥 Einlagern</h1></div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
{!product && (
<div className="grid-2">
<div className="card">
<h2>Per Barcode</h2>
<div className="row">
<input className="grow" placeholder="Barcode" value={barcode}
onChange={(e) => setBarcode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
<button className="btn primary" onClick={doLookup}>Suchen</button>
</div>
{unknownBarcode && (
<div className="alert warn">
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
{isAdmin ? (
<Link to={`/products/new`}>Produkt anlegen</Link>
) : (
"Bitte einen Administrator bitten, das Produkt anzulegen."
)}
</div>
)}
</div>
<div className="card">
<h2>Aus Produktliste</h2>
<form className="row" onSubmit={doSearch}>
<input className="grow" placeholder="Name suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} />
<button className="btn">Suchen</button>
</form>
<ul className="picklist">
{results.map((p) => (
<li key={p.id}>
<button className="link-btn" onClick={() => selectProduct(p)}>
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span>
</button>
</li>
))}
</ul>
</div>
</div>
)}
{product && (
<form className="card form-narrow" onSubmit={submit}>
<div className="selected-product">
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
<div>
<strong>{product.name}</strong>
<div className="muted">
Aktueller Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}
</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div>
<div className="row">
<label className="grow">
Menge
<input type="number" step="any" min="0" value={quantity} required
onChange={(e) => setQuantity(e.target.value)} autoFocus />
</label>
<label className="grow">
Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
</div>
<div className="row">
<label className="grow">
MHD (optional)
<input type="date" value={bestBefore} onChange={(e) => setBestBefore(e.target.value)} />
</label>
<label className="grow">
Lagerort (optional)
<select value={locationId} onChange={(e) => setLocationId(e.target.value)}>
<option value=""> keiner </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
</div>
<button className="btn primary" disabled={busy}>{busy ? "…" : "Einlagern"}</button>
</form>
)}
</div>
);
}

140
web/src/pages/CheckOut.jsx Normal file
View File

@@ -0,0 +1,140 @@
import { useState } from "react";
import { api } from "../api";
import { fmt, unitOptions, unitShort } from "../units";
export default function CheckOut() {
const [barcode, setBarcode] = useState("");
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [product, setProduct] = useState(null);
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("");
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
function selectProduct(p) {
setProduct(p);
setResults([]);
setUnit(unitOptions(p)[0].value);
}
async function doLookup() {
setError(null); setInfo(null);
if (!barcode) return;
try {
const res = await api.lookup(barcode.trim());
if (res.found && res.existing_product) {
selectProduct(res.existing_product);
} else {
setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
}
} catch (err) {
setError(err.message);
}
}
async function doSearch(e) {
e.preventDefault();
try {
setResults(await api.listProducts(search));
} catch (err) {
setError(err.message);
}
}
async function submit(e) {
e.preventDefault();
setError(null); setInfo(null); setBusy(true);
try {
const res = await api.checkOut({
product_id: product.id,
quantity: Number(quantity),
unit,
});
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n) betroffen). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
setQuantity("");
setProduct(await api.getProduct(product.id));
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<div>
<div className="page-head"><h1>📤 Auslagern</h1></div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
{!product && (
<div className="grid-2">
<div className="card">
<h2>Per Barcode</h2>
<div className="row">
<input className="grow" placeholder="Barcode" value={barcode}
onChange={(e) => setBarcode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
<button className="btn primary" onClick={doLookup}>Suchen</button>
</div>
</div>
<div className="card">
<h2>Aus Produktliste</h2>
<form className="row" onSubmit={doSearch}>
<input className="grow" placeholder="Name suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} />
<button className="btn">Suchen</button>
</form>
<ul className="picklist">
{results.map((p) => (
<li key={p.id}>
<button className="link-btn" onClick={() => selectProduct(p)}>
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span>
</button>
</li>
))}
</ul>
</div>
</div>
)}
{product && (
<form className="card form-narrow" onSubmit={submit}>
<div className="selected-product">
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
<div>
<strong>{product.name}</strong>
<div className="muted">
Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}
</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div>
<div className="row">
<label className="grow">
Menge
<input type="number" step="any" min="0" value={quantity} required
onChange={(e) => setQuantity(e.target.value)} autoFocus />
</label>
<label className="grow">
Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
</div>
<p className="muted small">
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
</p>
<button className="btn primary" disabled={busy}>{busy ? "…" : "Auslagern"}</button>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { fmt, unitShort } from "../units";
export default function Dashboard() {
const [expiring, setExpiring] = useState([]);
const [shopping, setShopping] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
async function load() {
try {
const [e, s] = await Promise.all([api.expiring(), api.shoppingList()]);
setExpiring(e);
setShopping(s);
} catch (err) {
setError(err.message);
}
}
load();
}, []);
return (
<div>
<div className="page-head">
<h1>Übersicht</h1>
<div className="actions">
<Link className="btn primary" to="/checkin">Einlagern</Link>
<Link className="btn" to="/checkout">Auslagern</Link>
</div>
</div>
{error && <div className="alert error">{error}</div>}
<div className="grid-2">
<section className="card">
<h2> Bald ablaufend</h2>
{expiring.length === 0 ? (
<p className="muted">Nichts läuft demnächst ab. 🎉</p>
) : (
<table className="table">
<thead>
<tr><th>Produkt</th><th>Menge</th><th>MHD</th><th>Tage</th></tr>
</thead>
<tbody>
{expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
<td>{it.product_name}</td>
<td>{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td>
<td>{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="card">
<h2>🛒 Einkaufsliste</h2>
{shopping.length === 0 ? (
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
) : (
<table className="table">
<thead>
<tr><th>Produkt</th><th>Bestand</th><th>Mindest</th><th>Fehlt</th></tr>
</thead>
<tbody>
{shopping.map((it) => (
<tr key={it.product_id}>
<td>{it.name}</td>
<td>{fmt(it.stock)} {unitShort(it.base_unit)}</td>
<td>{fmt(it.min_stock)}</td>
<td className="strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { api } from "../api";
export default function Locations() {
const [locations, setLocations] = useState([]);
const [name, setName] = useState("");
const [error, setError] = useState(null);
async function load() {
try {
setLocations(await api.listLocations());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createLocation({ name });
setName("");
load();
} catch (err) {
setError(err.message);
}
}
async function remove(id) {
if (!confirm("Lagerort löschen?")) return;
try {
await api.deleteLocation(id);
load();
} catch (err) {
setError(err.message);
}
}
return (
<div>
<div className="page-head"><h1>Lagerorte</h1></div>
{error && <div className="alert error">{error}</div>}
<div className="card form-narrow">
<form className="row" onSubmit={add}>
<input className="grow" placeholder="Neuer Lagerort (z.B. Speisekammer)" value={name}
onChange={(e) => setName(e.target.value)} required />
<button className="btn primary">Hinzufügen</button>
</form>
<ul className="simple-list">
{locations.map((l) => (
<li key={l.id}>
<span>{l.name}</span>
<button className="btn ghost danger" onClick={() => remove(l.id)}>Löschen</button>
</li>
))}
{locations.length === 0 && <li className="muted">Noch keine Lagerorte.</li>}
</ul>
</div>
<p className="muted small">
Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt.
</p>
</div>
);
}

47
web/src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,47 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth";
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await login(username, password);
navigate("/");
} catch (err) {
setError(err.message || "Anmeldung fehlgeschlagen");
} finally {
setBusy(false);
}
}
return (
<div className="login-wrap">
<form className="card login-card" onSubmit={onSubmit}>
<div className="brand big">🥫 Pantry</div>
<p className="muted">Lebensmittel-Lagerverwaltung</p>
{error && <div className="alert error">{error}</div>}
<label>
Benutzername
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</label>
<label>
Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</label>
<button className="btn primary" disabled={busy}>
{busy ? "Anmelden…" : "Anmelden"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import { BASE_UNITS, fmt, unitShort } from "../units";
const EMPTY = {
barcode: "",
name: "",
brand: "",
image_url: "",
base_unit: "piece",
package_size: "",
min_stock: "",
group_id: "",
};
export default function ProductForm() {
const { id } = useParams();
const isNew = !id;
const { isAdmin } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState(EMPTY);
const [product, setProduct] = useState(null);
const [lots, setLots] = useState([]);
const [groups, setGroups] = useState([]);
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
async function load() {
try {
setGroups(await api.listGroups());
if (!isNew) {
const p = await api.getProduct(id);
setProduct(p);
setForm({
barcode: p.barcode || "",
name: p.name,
brand: p.brand || "",
image_url: p.image_url || "",
base_unit: p.base_unit,
package_size: p.package_size ?? "",
min_stock: p.min_stock ?? "",
group_id: p.group_id ?? "",
});
setLots(await api.listLots(id));
}
} catch (err) {
setError(err.message);
}
}
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
function set(k, v) {
setForm((f) => ({ ...f, [k]: v }));
}
async function lookup() {
if (!form.barcode) return;
setError(null);
setInfo(null);
try {
const res = await api.lookup(form.barcode);
if (res.found && res.existing_product) {
setInfo("Dieses Produkt existiert bereits.");
navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) {
const s = res.suggestion;
setForm((f) => ({
...f,
name: s.name || f.name,
brand: s.brand || f.brand,
image_url: s.image_url || f.image_url,
base_unit: s.base_unit || f.base_unit,
}));
setInfo("Vorschlag von Open Food Facts übernommen.");
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
} catch (err) {
setError(err.message);
}
}
function buildPayload() {
return {
barcode: form.barcode || null,
name: form.name,
brand: form.brand || null,
image_url: form.image_url || null,
base_unit: form.base_unit,
package_size: form.package_size === "" ? null : Number(form.package_size),
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
group_id: form.group_id === "" ? null : Number(form.group_id),
};
}
async function save(e) {
e.preventDefault();
setError(null);
setBusy(true);
try {
if (isNew) {
const created = await api.createProduct(buildPayload());
navigate(`/products/${created.id}`);
} else {
await api.updateProduct(id, buildPayload());
setInfo("Gespeichert.");
setProduct(await api.getProduct(id));
}
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function remove() {
if (!confirm("Produkt wirklich löschen? Alle Chargen gehen verloren.")) return;
try {
await api.deleteProduct(id);
navigate("/products");
} catch (err) {
setError(err.message);
}
}
const readOnly = !isAdmin;
return (
<div>
<div className="page-head">
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
</div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
<div className="grid-2">
<form className="card" onSubmit={save}>
<div className="row">
<label className="grow">
Barcode
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={lookup} style={{ alignSelf: "flex-end" }}>
Nachschlagen
</button>
)}
</div>
<label>
Name
<input value={form.name} onChange={(e) => set("name", e.target.value)} required disabled={readOnly} />
</label>
<label>
Marke
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
</label>
<div className="row">
<label className="grow">
Basiseinheit
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
{BASE_UNITS.map((u) => (
<option key={u.value} value={u.value}>{u.label}</option>
))}
</select>
</label>
<label className="grow">
Packungsgröße (in Basiseinheit)
<input type="number" step="any" value={form.package_size}
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
placeholder="z.B. 500" />
</label>
</div>
<div className="row">
<label className="grow">
Mindestbestand (in Basiseinheit)
<input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} />
</label>
<label className="grow">
Gruppe
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
<option value=""> keine </option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
</div>
{isAdmin && (
<div className="row">
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
{!isNew && <button type="button" className="btn danger" onClick={remove}>Löschen</button>}
</div>
)}
{readOnly && <p className="muted">Nur Administratoren können Produkte bearbeiten.</p>}
</form>
{!isNew && (
<section className="card">
<h2>Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})</h2>
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
<table className="table">
<thead><tr><th>Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td>{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="muted">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import { fmt, unitShort } from "../units";
export default function Products() {
const { isAdmin } = useAuth();
const [products, setProducts] = useState([]);
const [q, setQ] = useState("");
const [error, setError] = useState(null);
async function load() {
try {
setProducts(await api.listProducts(q));
} catch (err) {
setError(err.message);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onSearch(e) {
e.preventDefault();
load();
}
return (
<div>
<div className="page-head">
<h1>Produkte</h1>
{isAdmin && <Link className="btn primary" to="/products/new">+ Neues Produkt</Link>}
</div>
{error && <div className="alert error">{error}</div>}
<form className="search" onSubmit={onSearch}>
<input placeholder="Suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn">Suchen</button>
</form>
<div className="card">
<table className="table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Marke</th>
<th>Basiseinheit</th>
<th>Bestand</th>
<th>Mindest</th>
<th></th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id}>
<td className="thumb-cell">
{p.image_url ? <img className="thumb" src={p.image_url} alt="" /> : "📦"}
</td>
<td>{p.name}</td>
<td className="muted">{p.brand || ""}</td>
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""}</td>
<td className={p.min_stock != null && p.stock < p.min_stock ? "strong row-warn-text" : ""}>
{fmt(p.stock)} {unitShort(p.base_unit)}
</td>
<td className="muted">{p.min_stock != null ? fmt(p.min_stock) : ""}</td>
<td><Link to={`/products/${p.id}`}>Details</Link></td>
</tr>
))}
{products.length === 0 && (
<tr><td colSpan={7} className="muted center">Keine Produkte.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { fmt, unitShort } from "../units";
export default function ShoppingList() {
const [items, setItems] = useState([]);
const [checked, setChecked] = useState({});
const [error, setError] = useState(null);
useEffect(() => {
api.shoppingList().then(setItems).catch((e) => setError(e.message));
}, []);
return (
<div>
<div className="page-head"><h1>🛒 Einkaufsliste</h1></div>
{error && <div className="alert error">{error}</div>}
<p className="muted">
Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt.
</p>
<div className="card">
{items.length === 0 ? (
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
) : (
<ul className="checklist">
{items.map((it) => (
<li key={it.product_id} className={checked[it.product_id] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[it.product_id]}
onChange={(e) => setChecked({ ...checked, [it.product_id]: e.target.checked })} />
<span className="item-name">{it.name}</span>
</label>
<span className="item-qty">
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>
<span className="muted"> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</span>
</span>
</li>
))}
</ul>
)}
</div>
<p className="muted small">
Das Abhaken dient hier nur der Übersicht beim Einkaufen und wird (noch) nicht gespeichert.
</p>
</div>
);
}

109
web/src/pages/Users.jsx Normal file
View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
export default function Users() {
const { user: me } = useAuth();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ username: "", password: "", role: "user" });
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
async function load() {
try {
setUsers(await api.listUsers());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null); setInfo(null);
try {
await api.createUser(form);
setForm({ username: "", password: "", role: "user" });
setInfo("Benutzer angelegt.");
load();
} catch (err) {
setError(err.message);
}
}
async function changeRole(u, role) {
try {
await api.updateUser(u.id, { role });
load();
} catch (err) {
setError(err.message);
}
}
async function remove(u) {
if (!confirm(`Benutzer "${u.username}" löschen?`)) return;
try {
await api.deleteUser(u.id);
load();
} catch (err) {
setError(err.message);
}
}
return (
<div>
<div className="page-head"><h1>Benutzer</h1></div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
<div className="grid-2">
<div className="card">
<h2>Benutzer</h2>
<table className="table">
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.username}{u.id === me.id && <span className="muted"> (du)</span>}</td>
<td>
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)} disabled={u.id === me.id}>
<option value="user">Nutzer</option>
<option value="admin">Admin</option>
</select>
</td>
<td>
{u.id !== me.id && (
<button className="btn ghost danger" onClick={() => remove(u)}>Löschen</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<form className="card form-narrow" onSubmit={add}>
<h2>Neuer Benutzer</h2>
<label>
Benutzername
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
</label>
<label>
Passwort
<input type="password" value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} />
</label>
<label>
Rolle
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="user">Nutzer (nur ein-/auslagern)</option>
<option value="admin">Admin (volle Verwaltung)</option>
</select>
</label>
<button className="btn primary">Anlegen</button>
</form>
</div>
</div>
);
}

185
web/src/styles.css Normal file
View File

@@ -0,0 +1,185 @@
:root {
--bg: #f4f6f8;
--card: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--primary: #2f855a;
--primary-dark: #276749;
--danger: #c53030;
--warn: #b7791f;
--radius: 10px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 15px;
}
a { color: var(--primary); }
.app { min-height: 100vh; }
/* Topbar */
.topbar {
display: flex;
align-items: center;
gap: 20px;
padding: 10px 20px;
background: var(--card);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
flex-wrap: wrap;
}
.brand { font-weight: 700; font-size: 20px; }
.brand.big { font-size: 30px; }
.nav { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
.nav-link {
padding: 7px 12px;
border-radius: 8px;
text-decoration: none;
color: var(--muted);
font-weight: 500;
}
.nav-link:hover { background: var(--bg); }
.nav-link.active { background: var(--primary); color: #fff; }
.userbox { display: flex; align-items: center; gap: 10px; }
.username { font-size: 14px; color: var(--muted); }
.badge {
background: var(--primary); color: #fff; font-size: 11px;
padding: 1px 6px; border-radius: 6px; font-weight: 700;
}
.content { max-width: 1000px; margin: 0 auto; padding: 24px 20px 60px; }
.page-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px; gap: 12px; flex-wrap: wrap;
}
.page-head h1 { margin: 0; font-size: 24px; }
.actions { display: flex; gap: 8px; }
/* Cards & layout */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.card h2 { margin-top: 0; font-size: 17px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-narrow { max-width: 560px; }
@media (max-width: 760px) { .grid-2 { grid-template-columns: 1fr; } }
/* Forms */
label { display: block; margin-bottom: 12px; font-size: 13px; color: var(--muted); font-weight: 600; }
input, select {
width: 100%;
margin-top: 4px;
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 15px;
background: #fff;
color: var(--text);
}
input:focus, select:focus { outline: 2px solid var(--primary); border-color: var(--primary); }
.row { display: flex; gap: 12px; align-items: flex-start; }
.row .grow { flex: 1; }
.search { display: flex; gap: 8px; margin-bottom: 16px; max-width: 480px; }
.search input { margin-top: 0; }
/* Buttons */
.btn {
display: inline-block;
padding: 9px 16px;
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
color: var(--text);
}
.btn:hover { background: var(--bg); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-dark); }
.btn.danger { color: var(--danger); border-color: var(--danger); }
.btn.ghost { border-color: transparent; background: transparent; }
.btn:disabled { opacity: 0.6; cursor: default; }
.link-btn { background: none; border: none; color: var(--primary); cursor: pointer; font-size: 15px; padding: 4px 0; text-align: left; }
/* Tables */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
.row-danger { background: #fff5f5; }
.row-warn { background: #fffaf0; }
.row-warn-text { color: var(--warn); }
.strong { font-weight: 700; }
.thumb-cell { width: 40px; }
.thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; }
.product-img { max-width: 160px; border-radius: 8px; margin-bottom: 10px; }
/* Lists */
.picklist, .simple-list, .checklist { list-style: none; padding: 0; margin: 10px 0 0; }
.simple-list li, .checklist li {
display: flex; align-items: center; justify-content: space-between;
padding: 9px 4px; border-bottom: 1px solid var(--border); gap: 10px;
}
.picklist li { padding: 6px 0; border-bottom: 1px solid var(--border); }
.checklist li.done .item-name { text-decoration: line-through; color: var(--muted); }
.checklist label { display: flex; align-items: center; gap: 8px; margin: 0; }
.checklist input[type="checkbox"] { width: auto; margin: 0; }
.item-name { font-weight: 600; color: var(--text); }
/* Alerts */
.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
.alert.error { background: #fff5f5; color: var(--danger); border: 1px solid #feb2b2; }
.alert.info { background: #ebf8ff; color: #2b6cb0; border: 1px solid #bee3f8; }
.alert.warn { background: #fffaf0; color: var(--warn); border: 1px solid #fbd38d; }
/* Selected product banner */
.selected-product {
display: flex; align-items: center; gap: 12px;
padding: 10px; background: var(--bg); border-radius: 8px; margin-bottom: 16px;
}
.selected-product > div { flex: 1; }
/* Login */
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.login-card { width: 100%; max-width: 360px; text-align: center; }
.login-card label { text-align: left; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
.center { text-align: center; padding: 40px; }
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--card: #1e293b;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
}
input, select { background: #0f172a; }
.btn { background: #0f172a; }
.btn:hover { background: #172033; }
.row-danger { background: #3b1a1a; }
.row-warn { background: #3a2f14; }
.alert.error { background: #3b1a1a; border-color: #7f1d1d; }
.alert.info { background: #16324d; border-color: #1e4e79; }
.alert.warn { background: #3a2f14; border-color: #7c5c14; }
}

38
web/src/units.js Normal file
View File

@@ -0,0 +1,38 @@
// Anzeige-Helfer für Einheiten (müssen zu backend/app/models.py::BaseUnit passen).
export const BASE_UNITS = [
{ value: "piece", label: "Stück" },
{ value: "gram", label: "Gramm (g)" },
{ value: "milliliter", label: "Milliliter (ml)" },
];
export const BASE_UNIT_SHORT = {
piece: "Stk",
gram: "g",
milliliter: "ml",
};
export function unitShort(baseUnit) {
return BASE_UNIT_SHORT[baseUnit] || baseUnit;
}
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
export function unitOptions(product) {
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
if (product.package_size) {
opts.push({ value: "package", label: `Packung (${product.package_size} ${unitShort(product.base_unit)})` });
}
return opts;
}
// Das Backend akzeptiert 'g', 'ml', 'piece'/'st' etc. als Basiseinheit-Eingabe.
export function baseUnitToInput(baseUnit) {
if (baseUnit === "gram") return "g";
if (baseUnit === "milliliter") return "ml";
return "piece";
}
export function fmt(n) {
if (n == null) return "";
return Number(n).toLocaleString("de-DE", { maximumFractionDigits: 2 });
}

18
web/vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Im Dev-Modus wird /api an das lokale Backend weitergeleitet.
// In Produktion übernimmt nginx das Proxying (siehe nginx.conf).
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});