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:
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal 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
26
.gitignore
vendored
Normal 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
98
README.md
Normal 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
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
34
backend/app/config.py
Normal file
34
backend/app/config.py
Normal 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
34
backend/app/crud.py
Normal 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
34
backend/app/database.py
Normal 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
39
backend/app/deps.py
Normal 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
61
backend/app/main.py
Normal 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
147
backend/app/models.py
Normal 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
72
backend/app/off.py
Normal 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],
|
||||||
|
}
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
30
backend/app/routers/auth.py
Normal file
30
backend/app/routers/auth.py
Normal 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
|
||||||
44
backend/app/routers/groups.py
Normal file
44
backend/app/routers/groups.py
Normal 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()
|
||||||
42
backend/app/routers/locations.py
Normal file
42
backend/app/routers/locations.py
Normal 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()
|
||||||
125
backend/app/routers/products.py
Normal file
125
backend/app/routers/products.py
Normal 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()
|
||||||
49
backend/app/routers/settings.py
Normal file
49
backend/app/routers/settings.py
Normal 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)
|
||||||
83
backend/app/routers/stock.py
Normal file
83
backend/app/routers/stock.py
Normal 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()
|
||||||
68
backend/app/routers/users.py
Normal file
68
backend/app/routers/users.py
Normal 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()
|
||||||
76
backend/app/routers/views.py
Normal file
76
backend/app/routers/views.py
Normal 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
174
backend/app/schemas.py
Normal 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
31
backend/app/security.py
Normal 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
21
backend/app/seed.py
Normal 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()
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
125
backend/app/services/stock.py
Normal file
125
backend/app/services/stock.py
Normal 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
|
||||||
64
backend/app/services/units.py
Normal file
64
backend/app/services/units.py
Normal 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
12
backend/requirements.txt
Normal 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
36
backend/tests/conftest.py
Normal 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
|
||||||
81
backend/tests/test_fefo.py
Normal file
81
backend/tests/test_fefo.py
Normal 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)
|
||||||
37
backend/tests/test_units.py
Normal file
37
backend/tests/test_units.py
Normal 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
20
deploy/.env.example
Normal 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
41
deploy/docker-compose.yml
Normal 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
135
deploy/install.sh
Normal 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
32
docs/ROADMAP.md
Normal 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
4
web/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
npm-debug.log*
|
||||||
13
web/Dockerfile
Normal file
13
web/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# --- Build-Stage: React/Vite bauen ---
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Runtime-Stage: statische Dateien via nginx, /api → Backend proxen ---
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Pantry – Lagerverwaltung</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
web/nginx.conf
Normal file
20
web/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA-Routing: unbekannte Pfade auf index.html mappen
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API an das Backend weiterleiten (Service-Name "backend" aus docker-compose)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
web/package.json
Normal file
20
web/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "pantry-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
web/src/App.jsx
Normal file
78
web/src/App.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "./auth";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import Products from "./pages/Products";
|
||||||
|
import ProductForm from "./pages/ProductForm";
|
||||||
|
import CheckIn from "./pages/CheckIn";
|
||||||
|
import CheckOut from "./pages/CheckOut";
|
||||||
|
import Locations from "./pages/Locations";
|
||||||
|
import Users from "./pages/Users";
|
||||||
|
import ShoppingList from "./pages/ShoppingList";
|
||||||
|
|
||||||
|
function Layout({ children }) {
|
||||||
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">🥫 Pantry</div>
|
||||||
|
<nav className="nav">
|
||||||
|
<NavLink to="/" end className={link}>Übersicht</NavLink>
|
||||||
|
<NavLink to="/checkin" className={link}>Einlagern</NavLink>
|
||||||
|
<NavLink to="/checkout" className={link}>Auslagern</NavLink>
|
||||||
|
<NavLink to="/products" className={link}>Produkte</NavLink>
|
||||||
|
<NavLink to="/shopping" className={link}>Einkaufsliste</NavLink>
|
||||||
|
{isAdmin && <NavLink to="/locations" className={link}>Lagerorte</NavLink>}
|
||||||
|
{isAdmin && <NavLink to="/users" className={link}>Benutzer</NavLink>}
|
||||||
|
</nav>
|
||||||
|
<div className="userbox">
|
||||||
|
<span className="username">
|
||||||
|
{user?.username} {isAdmin && <span className="badge">Admin</span>}
|
||||||
|
</span>
|
||||||
|
<button className="btn ghost" onClick={handleLogout}>Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="content">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Protected({ children, adminOnly = false }) {
|
||||||
|
const { user, isAdmin, loading } = useAuth();
|
||||||
|
if (loading) return <div className="center muted">Lädt…</div>;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
||||||
|
return <Layout>{children}</Layout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
|
||||||
|
/>
|
||||||
|
<Route path="/" element={<Protected><Dashboard /></Protected>} />
|
||||||
|
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
|
||||||
|
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
||||||
|
<Route path="/products" element={<Protected><Products /></Protected>} />
|
||||||
|
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||||||
|
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
||||||
|
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||||
|
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||||
|
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
web/src/api.js
Normal file
105
web/src/api.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Zentraler API-Client. In Produktion und Dev läuft alles über /api
|
||||||
|
// (nginx bzw. Vite-Proxy leiten an das FastAPI-Backend weiter).
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "pantry_token";
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
export function setToken(token) {
|
||||||
|
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message, status) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { method = "GET", body, form } = {}) {
|
||||||
|
const headers = {};
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
if (form) {
|
||||||
|
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||||
|
payload = new URLSearchParams(form).toString();
|
||||||
|
} else if (body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
payload = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
|
||||||
|
|
||||||
|
if (resp.status === 204) return null;
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
const text = await resp.text();
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const detail =
|
||||||
|
(data && data.detail) ||
|
||||||
|
(typeof data === "string" ? data : null) ||
|
||||||
|
`Fehler ${resp.status}`;
|
||||||
|
throw new ApiError(
|
||||||
|
Array.isArray(detail) ? detail.map((d) => d.msg).join(", ") : detail,
|
||||||
|
resp.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (username, password) =>
|
||||||
|
request("/auth/login", { method: "POST", form: { username, password } }),
|
||||||
|
me: () => request("/auth/me"),
|
||||||
|
|
||||||
|
// Produkte
|
||||||
|
listProducts: (q) => request(`/products${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||||
|
getProduct: (id) => request(`/products/${id}`),
|
||||||
|
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
|
||||||
|
createProduct: (body) => request("/products", { method: "POST", body }),
|
||||||
|
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Bestand
|
||||||
|
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||||||
|
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
|
||||||
|
listLots: (productId) =>
|
||||||
|
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
|
||||||
|
|
||||||
|
// Views
|
||||||
|
shoppingList: () => request("/shopping-list"),
|
||||||
|
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||||||
|
|
||||||
|
// Stammdaten
|
||||||
|
listLocations: () => request("/locations"),
|
||||||
|
createLocation: (body) => request("/locations", { method: "POST", body }),
|
||||||
|
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listGroups: () => request("/groups"),
|
||||||
|
createGroup: (body) => request("/groups", { method: "POST", body }),
|
||||||
|
|
||||||
|
// Benutzer
|
||||||
|
listUsers: () => request("/users"),
|
||||||
|
createUser: (body) => request("/users", { method: "POST", body }),
|
||||||
|
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Einstellungen
|
||||||
|
listSettings: () => request("/settings"),
|
||||||
|
setSetting: (key, value) =>
|
||||||
|
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||||||
|
};
|
||||||
48
web/src/auth.jsx
Normal file
48
web/src/auth.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { api, getToken, setToken } from "./api";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function boot() {
|
||||||
|
if (getToken()) {
|
||||||
|
try {
|
||||||
|
setUser(await api.me());
|
||||||
|
} catch {
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
boot();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await api.login(username, password);
|
||||||
|
setToken(res.access_token);
|
||||||
|
const me = await api.me();
|
||||||
|
setUser(me);
|
||||||
|
return me;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, isAdmin, loading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
16
web/src/main.jsx
Normal file
16
web/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import { AuthProvider } from "./auth";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
178
web/src/pages/CheckIn.jsx
Normal file
178
web/src/pages/CheckIn.jsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
import { fmt, unitOptions, unitShort } from "../units";
|
||||||
|
|
||||||
|
export default function CheckIn() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [barcode, setBarcode] = useState("");
|
||||||
|
const [product, setProduct] = useState(null);
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [locations, setLocations] = useState([]);
|
||||||
|
|
||||||
|
const [quantity, setQuantity] = useState("");
|
||||||
|
const [unit, setUnit] = useState("");
|
||||||
|
const [bestBefore, setBestBefore] = useState("");
|
||||||
|
const [locationId, setLocationId] = useState("");
|
||||||
|
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [unknownBarcode, setUnknownBarcode] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listLocations().then(setLocations).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function selectProduct(p) {
|
||||||
|
setProduct(p);
|
||||||
|
setResults([]);
|
||||||
|
setUnknownBarcode(null);
|
||||||
|
const opts = unitOptions(p);
|
||||||
|
setUnit(opts[0].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLookup() {
|
||||||
|
setError(null); setInfo(null); setUnknownBarcode(null);
|
||||||
|
if (!barcode) return;
|
||||||
|
try {
|
||||||
|
const res = await api.lookup(barcode.trim());
|
||||||
|
if (res.found && res.existing_product) {
|
||||||
|
selectProduct(res.existing_product);
|
||||||
|
setInfo(`Produkt erkannt: ${res.existing_product.name}`);
|
||||||
|
} else {
|
||||||
|
setUnknownBarcode(barcode.trim());
|
||||||
|
setInfo(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
setResults(await api.listProducts(search));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null); setInfo(null); setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.checkIn({
|
||||||
|
product_id: product.id,
|
||||||
|
quantity: Number(quantity),
|
||||||
|
unit,
|
||||||
|
best_before: bestBefore || null,
|
||||||
|
location_id: locationId === "" ? null : Number(locationId),
|
||||||
|
});
|
||||||
|
setInfo(`Eingelagert. Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
|
||||||
|
setQuantity("");
|
||||||
|
setBestBefore("");
|
||||||
|
setProduct(await api.getProduct(product.id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head"><h1>📥 Einlagern</h1></div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
{info && <div className="alert info">{info}</div>}
|
||||||
|
|
||||||
|
{!product && (
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Per Barcode</h2>
|
||||||
|
<div className="row">
|
||||||
|
<input className="grow" placeholder="Barcode" value={barcode}
|
||||||
|
onChange={(e) => setBarcode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
|
||||||
|
<button className="btn primary" onClick={doLookup}>Suchen</button>
|
||||||
|
</div>
|
||||||
|
{unknownBarcode && (
|
||||||
|
<div className="alert warn">
|
||||||
|
Barcode <strong>{unknownBarcode}</strong> ist unbekannt.{" "}
|
||||||
|
{isAdmin ? (
|
||||||
|
<Link to={`/products/new`}>Produkt anlegen</Link>
|
||||||
|
) : (
|
||||||
|
"Bitte einen Administrator bitten, das Produkt anzulegen."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Aus Produktliste</h2>
|
||||||
|
<form className="row" onSubmit={doSearch}>
|
||||||
|
<input className="grow" placeholder="Name suchen…" value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
<button className="btn">Suchen</button>
|
||||||
|
</form>
|
||||||
|
<ul className="picklist">
|
||||||
|
{results.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button className="link-btn" onClick={() => selectProduct(p)}>
|
||||||
|
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product && (
|
||||||
|
<form className="card form-narrow" onSubmit={submit}>
|
||||||
|
<div className="selected-product">
|
||||||
|
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
|
||||||
|
<div>
|
||||||
|
<strong>{product.name}</strong>
|
||||||
|
<div className="muted">
|
||||||
|
Aktueller Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Menge
|
||||||
|
<input type="number" step="any" min="0" value={quantity} required
|
||||||
|
onChange={(e) => setQuantity(e.target.value)} autoFocus />
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Einheit
|
||||||
|
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||||||
|
{unitOptions(product).map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
MHD (optional)
|
||||||
|
<input type="date" value={bestBefore} onChange={(e) => setBestBefore(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Lagerort (optional)
|
||||||
|
<select value={locationId} onChange={(e) => setLocationId(e.target.value)}>
|
||||||
|
<option value="">– keiner –</option>
|
||||||
|
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button className="btn primary" disabled={busy}>{busy ? "…" : "Einlagern"}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
web/src/pages/CheckOut.jsx
Normal file
140
web/src/pages/CheckOut.jsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { fmt, unitOptions, unitShort } from "../units";
|
||||||
|
|
||||||
|
export default function CheckOut() {
|
||||||
|
const [barcode, setBarcode] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [product, setProduct] = useState(null);
|
||||||
|
|
||||||
|
const [quantity, setQuantity] = useState("");
|
||||||
|
const [unit, setUnit] = useState("");
|
||||||
|
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
function selectProduct(p) {
|
||||||
|
setProduct(p);
|
||||||
|
setResults([]);
|
||||||
|
setUnit(unitOptions(p)[0].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLookup() {
|
||||||
|
setError(null); setInfo(null);
|
||||||
|
if (!barcode) return;
|
||||||
|
try {
|
||||||
|
const res = await api.lookup(barcode.trim());
|
||||||
|
if (res.found && res.existing_product) {
|
||||||
|
selectProduct(res.existing_product);
|
||||||
|
} else {
|
||||||
|
setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
setResults(await api.listProducts(search));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null); setInfo(null); setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.checkOut({
|
||||||
|
product_id: product.id,
|
||||||
|
quantity: Number(quantity),
|
||||||
|
unit,
|
||||||
|
});
|
||||||
|
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n) betroffen). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
|
||||||
|
setQuantity("");
|
||||||
|
setProduct(await api.getProduct(product.id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head"><h1>📤 Auslagern</h1></div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
{info && <div className="alert info">{info}</div>}
|
||||||
|
|
||||||
|
{!product && (
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Per Barcode</h2>
|
||||||
|
<div className="row">
|
||||||
|
<input className="grow" placeholder="Barcode" value={barcode}
|
||||||
|
onChange={(e) => setBarcode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && doLookup()} />
|
||||||
|
<button className="btn primary" onClick={doLookup}>Suchen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Aus Produktliste</h2>
|
||||||
|
<form className="row" onSubmit={doSearch}>
|
||||||
|
<input className="grow" placeholder="Name suchen…" value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
<button className="btn">Suchen</button>
|
||||||
|
</form>
|
||||||
|
<ul className="picklist">
|
||||||
|
{results.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button className="link-btn" onClick={() => selectProduct(p)}>
|
||||||
|
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product && (
|
||||||
|
<form className="card form-narrow" onSubmit={submit}>
|
||||||
|
<div className="selected-product">
|
||||||
|
{product.image_url && <img className="thumb" src={product.image_url} alt="" />}
|
||||||
|
<div>
|
||||||
|
<strong>{product.name}</strong>
|
||||||
|
<div className="muted">
|
||||||
|
Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Menge
|
||||||
|
<input type="number" step="any" min="0" value={quantity} required
|
||||||
|
onChange={(e) => setQuantity(e.target.value)} autoFocus />
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Einheit
|
||||||
|
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||||||
|
{unitOptions(product).map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="muted small">
|
||||||
|
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||||||
|
</p>
|
||||||
|
<button className="btn primary" disabled={busy}>{busy ? "…" : "Auslagern"}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
web/src/pages/Dashboard.jsx
Normal file
84
web/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { fmt, unitShort } from "../units";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [expiring, setExpiring] = useState([]);
|
||||||
|
const [shopping, setShopping] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [e, s] = await Promise.all([api.expiring(), api.shoppingList()]);
|
||||||
|
setExpiring(e);
|
||||||
|
setShopping(s);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Übersicht</h1>
|
||||||
|
<div className="actions">
|
||||||
|
<Link className="btn primary" to="/checkin">Einlagern</Link>
|
||||||
|
<Link className="btn" to="/checkout">Auslagern</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="grid-2">
|
||||||
|
<section className="card">
|
||||||
|
<h2>⏰ Bald ablaufend</h2>
|
||||||
|
{expiring.length === 0 ? (
|
||||||
|
<p className="muted">Nichts läuft demnächst ab. 🎉</p>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Produkt</th><th>Menge</th><th>MHD</th><th>Tage</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{expiring.map((it) => (
|
||||||
|
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
|
||||||
|
<td>{it.product_name}</td>
|
||||||
|
<td>{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
|
||||||
|
<td>{it.best_before}</td>
|
||||||
|
<td>{it.days_left < 0 ? `${-it.days_left} überf.` : it.days_left}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>🛒 Einkaufsliste</h2>
|
||||||
|
{shopping.length === 0 ? (
|
||||||
|
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Produkt</th><th>Bestand</th><th>Mindest</th><th>Fehlt</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shopping.map((it) => (
|
||||||
|
<tr key={it.product_id}>
|
||||||
|
<td>{it.name}</td>
|
||||||
|
<td>{fmt(it.stock)} {unitShort(it.base_unit)}</td>
|
||||||
|
<td>{fmt(it.min_stock)}</td>
|
||||||
|
<td className="strong">{fmt(it.deficit)} {unitShort(it.base_unit)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
web/src/pages/Locations.jsx
Normal file
66
web/src/pages/Locations.jsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
export default function Locations() {
|
||||||
|
const [locations, setLocations] = useState([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLocations(await api.listLocations());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function add(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.createLocation({ name });
|
||||||
|
setName("");
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
if (!confirm("Lagerort löschen?")) return;
|
||||||
|
try {
|
||||||
|
await api.deleteLocation(id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head"><h1>Lagerorte</h1></div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
<div className="card form-narrow">
|
||||||
|
<form className="row" onSubmit={add}>
|
||||||
|
<input className="grow" placeholder="Neuer Lagerort (z.B. Speisekammer)" value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)} required />
|
||||||
|
<button className="btn primary">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
<ul className="simple-list">
|
||||||
|
{locations.map((l) => (
|
||||||
|
<li key={l.id}>
|
||||||
|
<span>{l.name}</span>
|
||||||
|
<button className="btn ghost danger" onClick={() => remove(l.id)}>Löschen</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{locations.length === 0 && <li className="muted">Noch keine Lagerorte.</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="muted small">
|
||||||
|
Unterlagerorte (Regal / Fach) folgen in einem späteren Ausbauschritt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
web/src/pages/Login.jsx
Normal file
47
web/src/pages/Login.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Anmeldung fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-wrap">
|
||||||
|
<form className="card login-card" onSubmit={onSubmit}>
|
||||||
|
<div className="brand big">🥫 Pantry</div>
|
||||||
|
<p className="muted">Lebensmittel-Lagerverwaltung</p>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<button className="btn primary" disabled={busy}>
|
||||||
|
{busy ? "Anmelden…" : "Anmelden"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
web/src/pages/ProductForm.jsx
Normal file
228
web/src/pages/ProductForm.jsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
import { BASE_UNITS, fmt, unitShort } from "../units";
|
||||||
|
|
||||||
|
const EMPTY = {
|
||||||
|
barcode: "",
|
||||||
|
name: "",
|
||||||
|
brand: "",
|
||||||
|
image_url: "",
|
||||||
|
base_unit: "piece",
|
||||||
|
package_size: "",
|
||||||
|
min_stock: "",
|
||||||
|
group_id: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isNew = !id;
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [form, setForm] = useState(EMPTY);
|
||||||
|
const [product, setProduct] = useState(null);
|
||||||
|
const [lots, setLots] = useState([]);
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setGroups(await api.listGroups());
|
||||||
|
if (!isNew) {
|
||||||
|
const p = await api.getProduct(id);
|
||||||
|
setProduct(p);
|
||||||
|
setForm({
|
||||||
|
barcode: p.barcode || "",
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand || "",
|
||||||
|
image_url: p.image_url || "",
|
||||||
|
base_unit: p.base_unit,
|
||||||
|
package_size: p.package_size ?? "",
|
||||||
|
min_stock: p.min_stock ?? "",
|
||||||
|
group_id: p.group_id ?? "",
|
||||||
|
});
|
||||||
|
setLots(await api.listLots(id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
function set(k, v) {
|
||||||
|
setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookup() {
|
||||||
|
if (!form.barcode) return;
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
try {
|
||||||
|
const res = await api.lookup(form.barcode);
|
||||||
|
if (res.found && res.existing_product) {
|
||||||
|
setInfo("Dieses Produkt existiert bereits.");
|
||||||
|
navigate(`/products/${res.existing_product.id}`);
|
||||||
|
} else if (res.found && res.suggestion) {
|
||||||
|
const s = res.suggestion;
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
name: s.name || f.name,
|
||||||
|
brand: s.brand || f.brand,
|
||||||
|
image_url: s.image_url || f.image_url,
|
||||||
|
base_unit: s.base_unit || f.base_unit,
|
||||||
|
}));
|
||||||
|
setInfo("Vorschlag von Open Food Facts übernommen.");
|
||||||
|
} else {
|
||||||
|
setInfo("Barcode unbekannt – bitte Daten selbst eingeben.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
return {
|
||||||
|
barcode: form.barcode || null,
|
||||||
|
name: form.name,
|
||||||
|
brand: form.brand || null,
|
||||||
|
image_url: form.image_url || null,
|
||||||
|
base_unit: form.base_unit,
|
||||||
|
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||||
|
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
|
||||||
|
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
const created = await api.createProduct(buildPayload());
|
||||||
|
navigate(`/products/${created.id}`);
|
||||||
|
} else {
|
||||||
|
await api.updateProduct(id, buildPayload());
|
||||||
|
setInfo("Gespeichert.");
|
||||||
|
setProduct(await api.getProduct(id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!confirm("Produkt wirklich löschen? Alle Chargen gehen verloren.")) return;
|
||||||
|
try {
|
||||||
|
await api.deleteProduct(id);
|
||||||
|
navigate("/products");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOnly = !isAdmin;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
{info && <div className="alert info">{info}</div>}
|
||||||
|
|
||||||
|
<div className="grid-2">
|
||||||
|
<form className="card" onSubmit={save}>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Barcode
|
||||||
|
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
||||||
|
</label>
|
||||||
|
{isAdmin && (
|
||||||
|
<button type="button" className="btn" onClick={lookup} style={{ alignSelf: "flex-end" }}>
|
||||||
|
Nachschlagen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input value={form.name} onChange={(e) => set("name", e.target.value)} required disabled={readOnly} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Marke
|
||||||
|
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
|
||||||
|
</label>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Basiseinheit
|
||||||
|
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
|
||||||
|
{BASE_UNITS.map((u) => (
|
||||||
|
<option key={u.value} value={u.value}>{u.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Packungsgröße (in Basiseinheit)
|
||||||
|
<input type="number" step="any" value={form.package_size}
|
||||||
|
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
|
||||||
|
placeholder="z.B. 500" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Mindestbestand (in Basiseinheit)
|
||||||
|
<input type="number" step="any" value={form.min_stock}
|
||||||
|
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} />
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Gruppe
|
||||||
|
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||||||
|
{!isNew && <button type="button" className="btn danger" onClick={remove}>Löschen</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{readOnly && <p className="muted">Nur Administratoren können Produkte bearbeiten.</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})</h2>
|
||||||
|
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{lots.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
|
||||||
|
<td>{l.best_before || "–"}</td>
|
||||||
|
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{lots.length === 0 && <tr><td colSpan={3} className="muted">Keine Chargen im Bestand.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
web/src/pages/Products.jsx
Normal file
81
web/src/pages/Products.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
import { fmt, unitShort } from "../units";
|
||||||
|
|
||||||
|
export default function Products() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setProducts(await api.listProducts(q));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function onSearch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Produkte</h1>
|
||||||
|
{isAdmin && <Link className="btn primary" to="/products/new">+ Neues Produkt</Link>}
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
|
||||||
|
<form className="search" onSubmit={onSearch}>
|
||||||
|
<input placeholder="Suchen…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
<button className="btn">Suchen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Marke</th>
|
||||||
|
<th>Basiseinheit</th>
|
||||||
|
<th>Bestand</th>
|
||||||
|
<th>Mindest</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{products.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td className="thumb-cell">
|
||||||
|
{p.image_url ? <img className="thumb" src={p.image_url} alt="" /> : "📦"}
|
||||||
|
</td>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td className="muted">{p.brand || "–"}</td>
|
||||||
|
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${p.package_size}` : ""}</td>
|
||||||
|
<td className={p.min_stock != null && p.stock < p.min_stock ? "strong row-warn-text" : ""}>
|
||||||
|
{fmt(p.stock)} {unitShort(p.base_unit)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{p.min_stock != null ? fmt(p.min_stock) : "–"}</td>
|
||||||
|
<td><Link to={`/products/${p.id}`}>Details</Link></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{products.length === 0 && (
|
||||||
|
<tr><td colSpan={7} className="muted center">Keine Produkte.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
web/src/pages/ShoppingList.jsx
Normal file
47
web/src/pages/ShoppingList.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { fmt, unitShort } from "../units";
|
||||||
|
|
||||||
|
export default function ShoppingList() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [checked, setChecked] = useState({});
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.shoppingList().then(setItems).catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head"><h1>🛒 Einkaufsliste</h1></div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
<p className="muted">
|
||||||
|
Produkte, deren Bestand unter dem hinterlegten Mindestbestand liegt.
|
||||||
|
</p>
|
||||||
|
<div className="card">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="muted">Alle Mindestbestände erreicht. 👍</p>
|
||||||
|
) : (
|
||||||
|
<ul className="checklist">
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.product_id} className={checked[it.product_id] ? "done" : ""}>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={!!checked[it.product_id]}
|
||||||
|
onChange={(e) => setChecked({ ...checked, [it.product_id]: e.target.checked })} />
|
||||||
|
<span className="item-name">{it.name}</span>
|
||||||
|
</label>
|
||||||
|
<span className="item-qty">
|
||||||
|
fehlt <strong>{fmt(it.deficit)} {unitShort(it.base_unit)}</strong>
|
||||||
|
<span className="muted"> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="muted small">
|
||||||
|
Das Abhaken dient hier nur der Übersicht beim Einkaufen und wird (noch) nicht gespeichert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
web/src/pages/Users.jsx
Normal file
109
web/src/pages/Users.jsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [form, setForm] = useState({ username: "", password: "", role: "user" });
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setUsers(await api.listUsers());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function add(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null); setInfo(null);
|
||||||
|
try {
|
||||||
|
await api.createUser(form);
|
||||||
|
setForm({ username: "", password: "", role: "user" });
|
||||||
|
setInfo("Benutzer angelegt.");
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRole(u, role) {
|
||||||
|
try {
|
||||||
|
await api.updateUser(u.id, { role });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(u) {
|
||||||
|
if (!confirm(`Benutzer "${u.username}" löschen?`)) return;
|
||||||
|
try {
|
||||||
|
await api.deleteUser(u.id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head"><h1>Benutzer</h1></div>
|
||||||
|
{error && <div className="alert error">{error}</div>}
|
||||||
|
{info && <div className="alert info">{info}</div>}
|
||||||
|
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Benutzer</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Name</th><th>Rolle</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.username}{u.id === me.id && <span className="muted"> (du)</span>}</td>
|
||||||
|
<td>
|
||||||
|
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)} disabled={u.id === me.id}>
|
||||||
|
<option value="user">Nutzer</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.id !== me.id && (
|
||||||
|
<button className="btn ghost danger" onClick={() => remove(u)}>Löschen</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="card form-narrow" onSubmit={add}>
|
||||||
|
<h2>Neuer Benutzer</h2>
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input type="password" value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={4} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rolle
|
||||||
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||||
|
<option value="user">Nutzer (nur ein-/auslagern)</option>
|
||||||
|
<option value="admin">Admin (volle Verwaltung)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="btn primary">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
web/src/styles.css
Normal file
185
web/src/styles.css
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4f6f8;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--text: #1e293b;
|
||||||
|
--muted: #64748b;
|
||||||
|
--primary: #2f855a;
|
||||||
|
--primary-dark: #276749;
|
||||||
|
--danger: #c53030;
|
||||||
|
--warn: #b7791f;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--primary); }
|
||||||
|
|
||||||
|
.app { min-height: 100vh; }
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; font-size: 20px; }
|
||||||
|
.brand.big { font-size: 30px; }
|
||||||
|
.nav { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
|
||||||
|
.nav-link {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav-link:hover { background: var(--bg); }
|
||||||
|
.nav-link.active { background: var(--primary); color: #fff; }
|
||||||
|
.userbox { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.username { font-size: 14px; color: var(--muted); }
|
||||||
|
.badge {
|
||||||
|
background: var(--primary); color: #fff; font-size: 11px;
|
||||||
|
padding: 1px 6px; border-radius: 6px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content { max-width: 1000px; margin: 0 auto; padding: 24px 20px 60px; }
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 18px; gap: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page-head h1 { margin: 0; font-size: 24px; }
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* Cards & layout */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card h2 { margin-top: 0; font-size: 17px; }
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.form-narrow { max-width: 560px; }
|
||||||
|
@media (max-width: 760px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
label { display: block; margin-bottom: 12px; font-size: 13px; color: var(--muted); font-weight: 600; }
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: 2px solid var(--primary); border-color: var(--primary); }
|
||||||
|
.row { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
|
.row .grow { flex: 1; }
|
||||||
|
.search { display: flex; gap: 8px; margin-bottom: 16px; max-width: 480px; }
|
||||||
|
.search input { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--bg); }
|
||||||
|
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
|
.btn.primary:hover { background: var(--primary-dark); }
|
||||||
|
.btn.danger { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.btn.ghost { border-color: transparent; background: transparent; }
|
||||||
|
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
.link-btn { background: none; border: none; color: var(--primary); cursor: pointer; font-size: 15px; padding: 4px 0; text-align: left; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
.row-danger { background: #fff5f5; }
|
||||||
|
.row-warn { background: #fffaf0; }
|
||||||
|
.row-warn-text { color: var(--warn); }
|
||||||
|
.strong { font-weight: 700; }
|
||||||
|
.thumb-cell { width: 40px; }
|
||||||
|
.thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; }
|
||||||
|
.product-img { max-width: 160px; border-radius: 8px; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.picklist, .simple-list, .checklist { list-style: none; padding: 0; margin: 10px 0 0; }
|
||||||
|
.simple-list li, .checklist li {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 9px 4px; border-bottom: 1px solid var(--border); gap: 10px;
|
||||||
|
}
|
||||||
|
.picklist li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.checklist li.done .item-name { text-decoration: line-through; color: var(--muted); }
|
||||||
|
.checklist label { display: flex; align-items: center; gap: 8px; margin: 0; }
|
||||||
|
.checklist input[type="checkbox"] { width: auto; margin: 0; }
|
||||||
|
.item-name { font-weight: 600; color: var(--text); }
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||||
|
.alert.error { background: #fff5f5; color: var(--danger); border: 1px solid #feb2b2; }
|
||||||
|
.alert.info { background: #ebf8ff; color: #2b6cb0; border: 1px solid #bee3f8; }
|
||||||
|
.alert.warn { background: #fffaf0; color: var(--warn); border: 1px solid #fbd38d; }
|
||||||
|
|
||||||
|
/* Selected product banner */
|
||||||
|
.selected-product {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px; background: var(--bg); border-radius: 8px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.selected-product > div { flex: 1; }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||||
|
.login-card { width: 100%; max-width: 360px; text-align: center; }
|
||||||
|
.login-card label { text-align: left; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 13px; }
|
||||||
|
.center { text-align: center; padding: 40px; }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--card: #1e293b;
|
||||||
|
--border: #334155;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
}
|
||||||
|
input, select { background: #0f172a; }
|
||||||
|
.btn { background: #0f172a; }
|
||||||
|
.btn:hover { background: #172033; }
|
||||||
|
.row-danger { background: #3b1a1a; }
|
||||||
|
.row-warn { background: #3a2f14; }
|
||||||
|
.alert.error { background: #3b1a1a; border-color: #7f1d1d; }
|
||||||
|
.alert.info { background: #16324d; border-color: #1e4e79; }
|
||||||
|
.alert.warn { background: #3a2f14; border-color: #7c5c14; }
|
||||||
|
}
|
||||||
38
web/src/units.js
Normal file
38
web/src/units.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Anzeige-Helfer für Einheiten (müssen zu backend/app/models.py::BaseUnit passen).
|
||||||
|
|
||||||
|
export const BASE_UNITS = [
|
||||||
|
{ value: "piece", label: "Stück" },
|
||||||
|
{ value: "gram", label: "Gramm (g)" },
|
||||||
|
{ value: "milliliter", label: "Milliliter (ml)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BASE_UNIT_SHORT = {
|
||||||
|
piece: "Stk",
|
||||||
|
gram: "g",
|
||||||
|
milliliter: "ml",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unitShort(baseUnit) {
|
||||||
|
return BASE_UNIT_SHORT[baseUnit] || baseUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
|
||||||
|
export function unitOptions(product) {
|
||||||
|
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
|
||||||
|
if (product.package_size) {
|
||||||
|
opts.push({ value: "package", label: `Packung (${product.package_size} ${unitShort(product.base_unit)})` });
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Backend akzeptiert 'g', 'ml', 'piece'/'st' etc. als Basiseinheit-Eingabe.
|
||||||
|
export function baseUnitToInput(baseUnit) {
|
||||||
|
if (baseUnit === "gram") return "g";
|
||||||
|
if (baseUnit === "milliliter") return "ml";
|
||||||
|
return "piece";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmt(n) {
|
||||||
|
if (n == null) return "–";
|
||||||
|
return Number(n).toLocaleString("de-DE", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
18
web/vite.config.js
Normal file
18
web/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Im Dev-Modus wird /api an das lokale Backend weitergeleitet.
|
||||||
|
// In Produktion übernimmt nginx das Proxying (siehe nginx.conf).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user