feat(inventory): add spare parts management

This commit is contained in:
Schubert Ferenc 2026-07-05 11:32:06 +02:00
parent abc6e308dd
commit fb5c2cc26a
43 changed files with 2950 additions and 2 deletions

View file

@ -219,6 +219,60 @@ Tabellen:
- `repair_public_access_tokens`
- `repair_notification_events`
## Lager / Ersatzteile
Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Es ist in v0.8.7 als eigenstaendige Lagerbasis umgesetzt und fuer die spaetere Verknuepfung mit Reparaturen und Kostenvoranschlaegen vorbereitet.
Datenfluss:
```text
Browser -> Athena /api/inventory... -> Hermes /inventory... -> PostgreSQL
```
Kernregeln:
- Browser greifen nie direkt auf Hermes zu.
- Artikelnummern/SKUs sind eindeutig.
- Leere SKU bei Erstellung wird automatisch im Format `ET-<jahr>-000001` vergeben.
- Preise werden intern in cents gespeichert und in Athena als Euro eingegeben.
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht.
Tabellen:
- `inventory_items`
- `inventory_categories`
- `inventory_locations`
- `inventory_suppliers`
- `inventory_stock_movements`
Bestandsbewegungen:
- `initial`
- `purchase`
- `adjustment`
- `reservation`
- `release`
- `consumption`
- `return`
- `correction`
RBAC-Permissions:
- `inventory.read`
- `inventory.create`
- `inventory.update`
- `inventory.delete`
- `inventory.stock.adjust`
- `inventory.stock.reserve`
- `inventory.stock.consume`
- `inventory.manage.masterdata`
Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
Die Integration von Lagerartikeln in Kostenvoranschlagspositionen folgt in v0.8.8. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
Hermes-Endpunkte:
- `GET /repairs`

View file

@ -214,6 +214,48 @@ Kostenvoranschlaege:
- Die oeffentliche Website zeigt diese Daten erst nach einer spaeteren Website-Version an. Olympus stellt die API dafuer bereit.
- PDF-Erzeugung, Lexoffice und Rechnungen sind Folgefeatures.
## Lager / Ersatzteile
Das Lagermodul ist ab v0.8.7 aktiv.
Migration lokal ausfuehren:
```bash
cd backend/hermes
SECRET_KEY=local-check uv run alembic upgrade head
```
Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen:
- `/api/inventory/items`
- `/api/inventory/items/[id]`
- `/api/inventory/items/[id]/stock/adjust`
- `/api/inventory/items/[id]/stock/reserve`
- `/api/inventory/items/[id]/stock/release`
- `/api/inventory/items/[id]/stock/consume`
- `/api/inventory/items/[id]/movements`
- `/api/inventory/categories`
- `/api/inventory/categories/[id]`
- `/api/inventory/locations`
- `/api/inventory/locations/[id]`
- `/api/inventory/suppliers`
- `/api/inventory/suppliers/[id]`
Hermes-Endpunkte liegen unter `/inventory/...` und sind mit `inventory.*` Permissions geschuetzt.
Bestandslogik:
- SKU optional; bei leerer SKU erzeugt Hermes `ET-<jahr>-000001`.
- `quantity_available = quantity_on_hand - quantity_reserved`.
- Bestandsanpassungen, Reservierungen, Freigaben und Verbrauch erzeugen `inventory_stock_movements`.
- Artikel mit Bewegungen werden deaktiviert statt hart geloescht.
- Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`.
Folgefeatures:
- Integration in Kostenvoranschlaege folgt in v0.8.8.
- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.7 nicht aktiv.
Vorbereitete Website-/Portal-Routen fuer spaeter:
- `/status/<token>`

View file

@ -147,7 +147,29 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Keine PDF-Erzeugung, keine Lexoffice-Anbindung und keine Rechnungserstellung in dieser Version
- Website-Anzeige/Freigabe wird spaeter in `funktechnik-schubert-website` nachgezogen
## v0.8.7 - Kundenportal, geplant
## v0.8.7 - Lager / Ersatzteile
- Ersatzteil- und Lagerartikelverwaltung
- Kategorien, Lagerorte und Lieferanten
- SKU-Format `ET-<jahr>-000001`
- Bestandslogik mit Lagerbestand, Reservierung und Verfuegbarkeit
- Bestandsbewegungen fuer Anfangsbestand, Anpassung, Reservierung, Freigabe und Verbrauch
- Preise intern in cents, UI-Eingabe in Euro
- RBAC-Permissions fuer Lagerlesen, Artikelpflege, Bestandsbuchungen und Stammdaten
- Dashboard-Kennzahlen fuer aktive Ersatzteile, niedrigen Bestand, Lagerwert und Bewegungen
- Audit Logs und Activity Feed fuer Lageraktionen
- Athena-BFF-Routen und UI fuer `/inventory` und `/inventory/items/[id]`
- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
## v0.8.8 - Lagerintegration in Kostenvoranschläge, geplant
- Lagerartikel als KV-Position ueber Suche auswaehlen
- Verkaufspreis aus `selling_price_cents` uebernehmen
- Reservierungen aus KV-/Reparaturprozess vorbereiten
- CSV-Template und Exportkonzept fuer Lagerdaten vorbereiten
## v0.8.9 - Kundenportal, geplant
- `/portal/login` fuer spaeteren Kundenlogin
- Separates Authentifizierungsmodell fuer Kunden

View file

@ -14,6 +14,7 @@ import app.models.audit
import app.models.rbac
import app.models.repair
import app.models.repair_estimate
import app.models.inventory
import app.models.system_setting
config = context.config

View file

@ -0,0 +1,214 @@
"""create inventory
Revision ID: ef8a2d5c9017
Revises: b4e8f1c7a205
Create Date: 2026-07-05 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "ef8a2d5c9017"
down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
INVENTORY_PERMISSIONS = [
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
]
def upgrade() -> None:
op.create_table(
"inventory_categories",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("slug", sa.String(length=180), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("slug"),
)
op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True)
op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True)
op.create_table(
"inventory_locations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True)
op.create_table(
"inventory_suppliers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=180), nullable=False),
sa.Column("contact_name", sa.String(length=180), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=80), nullable=True),
sa.Column("website", sa.String(length=255), nullable=True),
sa.Column("customer_number", sa.String(length=120), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True)
op.create_table(
"inventory_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("sku", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("manufacturer", sa.String(length=180), nullable=True),
sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("supplier_part_number", sa.String(length=180), nullable=True),
sa.Column("compatible_devices", sa.Text(), nullable=True),
sa.Column("location_id", sa.Integer(), nullable=True),
sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False),
sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False),
sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False),
sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False),
sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False),
sa.Column("purchase_price_cents", sa.Integer(), nullable=True),
sa.Column("selling_price_cents", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False),
sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("sku", name="uq_inventory_items_sku"),
)
op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False)
op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False)
op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False)
op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False)
op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False)
op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False)
op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False)
op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True)
op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False)
op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False)
op.create_table(
"inventory_stock_movements",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("item_id", sa.Integer(), nullable=False),
sa.Column("movement_type", sa.String(length=40), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(length=255), server_default="", nullable=False),
sa.Column("reference_type", sa.String(length=80), nullable=True),
sa.Column("reference_id", sa.Integer(), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False)
op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False)
for name, display_name, description, module in INVENTORY_PERMISSIONS:
op.execute(
sa.text(
"""
INSERT INTO permissions (name, display_name, description, module)
VALUES (:name, :display_name, :description, :module)
ON CONFLICT (name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
role_permissions = {
"administrator": [item[0] for item in INVENTORY_PERMISSIONS],
"management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
"technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"],
"support": ["inventory.read"],
"warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"],
}
for role_name, permission_names in role_permissions.items():
for permission_name in permission_names:
op.execute(
sa.text(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles, permissions
WHERE roles.name = :role_name
AND permissions.name = :permission_name
ON CONFLICT DO NOTHING
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE module = 'inventory'
)
"""
)
)
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements")
op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements")
op.drop_table("inventory_stock_movements")
op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items")
op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items")
op.drop_table("inventory_items")
op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers")
op.drop_table("inventory_suppliers")
op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations")
op.drop_table("inventory_locations")
op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories")
op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories")
op.drop_table("inventory_categories")

View file

@ -58,6 +58,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "knowledge.read" in permissions
if action.startswith("repairs."):
return "repairs.read" in permissions
if action.startswith("inventory."):
return "inventory.read" in permissions
if action.startswith("audit_logs."):
return "audit_logs.read" in permissions
if action.startswith("auth."):

View file

@ -10,6 +10,7 @@ from app.models.rbac import Role
from app.models.audit import AuditLog
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.repositories.inventory_repository import InventoryRepository
from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.user_repository import UserRepository
@ -35,6 +36,7 @@ def get_dashboard_summary(
users: list[MetricCard] = []
roles: list[MetricCard] = []
repairs: list[MetricCard] = []
inventory: list[MetricCard] = []
system_status: list[SystemStatusItem] = []
if "customers.read" in permissions:
@ -82,6 +84,14 @@ def get_dashboard_summary(
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
])
if "inventory.read" in permissions:
inventory = [
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)),
MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)),
MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))),
]
if "system_settings.manage" in permissions:
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
public_links = SystemSettingsService.get_public_links_settings(db)
@ -127,6 +137,7 @@ def get_dashboard_summary(
users=users,
roles=roles,
repairs=repairs,
inventory=inventory,
system_status=system_status,
activities=EmptyWidget(
title="Letzte Aktivitäten",

View file

@ -0,0 +1,307 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
from app.models.user import User
from app.repositories.inventory_repository import InventoryRepository
from app.schemas.inventory import (
InventoryCategoryPayload,
InventoryCategoryResponse,
InventoryItemCreate,
InventoryItemListResponse,
InventoryItemResponse,
InventoryItemUpdate,
InventoryLocationPayload,
InventoryLocationResponse,
InventoryStockAction,
InventoryStockMovementResponse,
InventorySupplierPayload,
InventorySupplierResponse,
)
from app.services.inventory_service import InventoryService
router = APIRouter(prefix="/inventory", tags=["Inventory"])
def get_item_or_404(db: Session, item_id: int) -> InventoryItem:
item = InventoryRepository.get_item(db, item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
return item
def get_category_or_404(db: Session, category_id: int) -> InventoryCategory:
category = InventoryRepository.get_category(db, category_id)
if category is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden")
return category
def get_location_or_404(db: Session, location_id: int) -> InventoryLocation:
location = InventoryRepository.get_location(db, location_id)
if location is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
return location
def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier:
supplier = InventoryRepository.get_supplier(db, supplier_id)
if supplier is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden")
return supplier
@router.get("/items", response_model=InventoryItemListResponse)
def list_items(
q: str | None = None,
category_id: int | None = None,
supplier_id: int | None = None,
location_id: int | None = None,
low_stock: bool | None = None,
active: bool | None = True,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
items, total = InventoryRepository.list_items(
db,
q=q,
category_id=category_id,
supplier_id=supplier_id,
location_id=location_id,
low_stock=low_stock,
active=active,
limit=limit,
offset=offset,
)
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
def create_item(
payload: InventoryItemCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.create")),
):
return InventoryService.create_item(db, payload, actor=current_user, request=request)
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
def get_item(
item_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return get_item_or_404(db, item_id)
@router.put("/items/{item_id}", response_model=InventoryItemResponse)
def update_item(
item_id: int,
payload: InventoryItemUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.update")),
):
item = get_item_or_404(db, item_id)
return InventoryService.update_item(db, item, payload, actor=current_user, request=request)
@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None)
def delete_item(
item_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.delete")),
):
item = get_item_or_404(db, item_id)
return InventoryService.delete_item(db, item, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse)
def adjust_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.adjust")),
):
item = get_item_or_404(db, item_id)
return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse)
def reserve_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.reserve")),
):
item = get_item_or_404(db, item_id)
return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse)
def release_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.reserve")),
):
item = get_item_or_404(db, item_id)
return InventoryService.release_stock(db, item, payload, actor=current_user, request=request)
@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse)
def consume_stock(
item_id: int,
payload: InventoryStockAction,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.stock.consume")),
):
item = get_item_or_404(db, item_id)
return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request)
@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse])
def list_movements(
item_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
get_item_or_404(db, item_id)
return InventoryRepository.list_movements(db, item_id)
@router.get("/categories", response_model=list[InventoryCategoryResponse])
def list_categories(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return InventoryRepository.list_categories(db)
@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(
payload: InventoryCategoryPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
return InventoryService.create_category(db, payload, actor=current_user, request=request)
@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse)
def update_category(
category_id: int,
payload: InventoryCategoryPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
category = get_category_or_404(db, category_id)
return InventoryService.update_category(db, category, payload, actor=current_user, request=request)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(
category_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
category = get_category_or_404(db, category_id)
InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request)
return None
@router.get("/locations", response_model=list[InventoryLocationResponse])
def list_locations(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return InventoryRepository.list_locations(db)
@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED)
def create_location(
payload: InventoryLocationPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
return InventoryService.create_location(db, payload, actor=current_user, request=request)
@router.put("/locations/{location_id}", response_model=InventoryLocationResponse)
def update_location(
location_id: int,
payload: InventoryLocationPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
location = get_location_or_404(db, location_id)
return InventoryService.update_location(db, location, payload, actor=current_user, request=request)
@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location(
location_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
location = get_location_or_404(db, location_id)
InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request)
return None
@router.get("/suppliers", response_model=list[InventorySupplierResponse])
def list_suppliers(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.read")),
):
return InventoryRepository.list_suppliers(db)
@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED)
def create_supplier(
payload: InventorySupplierPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
return InventoryService.create_supplier(db, payload, actor=current_user, request=request)
@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse)
def update_supplier(
supplier_id: int,
payload: InventorySupplierPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
supplier = get_supplier_or_404(db, supplier_id)
return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request)
@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_supplier(
supplier_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("inventory.manage.masterdata")),
):
supplier = get_supplier_or_404(db, supplier_id)
InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request)
return None

View file

@ -13,6 +13,7 @@ from app.api.auth import router as auth_router
from app.api.audit import router as audit_router
from app.api.customers import router as customers_router
from app.api.dashboard import router as dashboard_router
from app.api.inventory import router as inventory_router
from app.api.knowledge import router as knowledge_router
from app.api.permissions import router as permissions_router
from app.api.repairs import router as repairs_router
@ -41,6 +42,7 @@ app.include_router(users_router)
app.include_router(roles_router)
app.include_router(permissions_router)
app.include_router(customers_router)
app.include_router(inventory_router)
app.include_router(knowledge_router)
app.include_router(repairs_router)
app.include_router(repair_estimates_router)

View file

@ -0,0 +1,110 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class InventoryCategory(Base):
__tablename__ = "inventory_categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["InventoryItem"]] = relationship(back_populates="category")
class InventoryLocation(Base):
__tablename__ = "inventory_locations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["InventoryItem"]] = relationship(back_populates="location")
class InventorySupplier(Base):
__tablename__ = "inventory_suppliers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(80), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier")
class InventoryItem(Base):
__tablename__ = "inventory_items"
__table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),)
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str] = mapped_column(String(40), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True)
manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True)
supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True)
location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True)
quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True)
reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined")
supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined")
location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined")
movements: Mapped[list["InventoryStockMovement"]] = relationship(
back_populates="item",
cascade="all, delete-orphan",
order_by="InventoryStockMovement.created_at.desc()",
)
class InventoryStockMovement(Base):
__tablename__ = "inventory_stock_movements"
id: Mapped[int] = mapped_column(primary_key=True)
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True)
movement_type: Mapped[str] = mapped_column(String(40), index=True)
quantity: Mapped[int] = mapped_column(Integer)
reason: Mapped[str] = mapped_column(String(255), default="", server_default="")
reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
item: Mapped[InventoryItem] = relationship(back_populates="movements")
actor = relationship("User", lazy="joined")
@property
def actor_username(self) -> str:
return self.actor.username if self.actor is not None else ""

View file

@ -90,6 +90,14 @@ STANDARD_PERMISSIONS = [
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"),
("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"),
("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"),
("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"),
("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"),
("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"),
("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"),
("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"),
]
ROLE_PERMISSION_NAMES = {
@ -111,6 +119,13 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
"inventory.read",
"inventory.create",
"inventory.update",
"inventory.stock.adjust",
"inventory.stock.reserve",
"inventory.stock.consume",
"inventory.manage.masterdata",
},
"sales": {
"dashboard.read",
@ -138,6 +153,9 @@ ROLE_PERMISSION_NAMES = {
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
"inventory.read",
"inventory.stock.reserve",
"inventory.stock.consume",
},
"support": {
"dashboard.read",
@ -156,9 +174,17 @@ ROLE_PERMISSION_NAMES = {
"repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.send",
"inventory.read",
},
"warehouse": {
"dashboard.read",
"inventory.read",
"inventory.create",
"inventory.update",
"inventory.stock.adjust",
"inventory.stock.reserve",
"inventory.stock.consume",
"inventory.manage.masterdata",
},
"guest": {
"dashboard.read",

View file

@ -0,0 +1,187 @@
from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.inventory import (
InventoryCategory,
InventoryItem,
InventoryLocation,
InventoryStockMovement,
InventorySupplier,
)
class InventoryRepository:
@staticmethod
def item_query() -> Select[tuple[InventoryItem]]:
return select(InventoryItem).options(
selectinload(InventoryItem.category),
selectinload(InventoryItem.supplier),
selectinload(InventoryItem.location),
)
@staticmethod
def list_items(
db: Session,
*,
q: str | None = None,
category_id: int | None = None,
supplier_id: int | None = None,
location_id: int | None = None,
low_stock: bool | None = None,
active: bool | None = True,
limit: int = 50,
offset: int = 0,
) -> tuple[list[InventoryItem], int]:
query = InventoryRepository.item_query()
if q:
pattern = f"%{q.strip()}%"
query = query.where(or_(
InventoryItem.sku.ilike(pattern),
InventoryItem.name.ilike(pattern),
InventoryItem.manufacturer.ilike(pattern),
InventoryItem.manufacturer_part_number.ilike(pattern),
InventoryItem.supplier_part_number.ilike(pattern),
))
if category_id is not None:
query = query.where(InventoryItem.category_id == category_id)
if supplier_id is not None:
query = query.where(InventoryItem.supplier_id == supplier_id)
if location_id is not None:
query = query.where(InventoryItem.location_id == location_id)
if low_stock is not None:
if low_stock:
query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
else:
query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level)
if active is not None:
query = query.where(InventoryItem.is_active == active)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = list(
db.scalars(
query
.order_by(InventoryItem.name.asc(), InventoryItem.id.asc())
.offset(offset)
.limit(limit)
)
)
return items, total
@staticmethod
def get_item(db: Session, item_id: int) -> InventoryItem | None:
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
@staticmethod
def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None:
return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku))
@staticmethod
def get_next_sku(db: Session, year: int | None = None) -> str:
current_year = year or datetime.now(UTC).year
prefix = f"ET-{current_year}-"
latest = db.scalar(
select(InventoryItem.sku)
.where(InventoryItem.sku.like(f"{prefix}%"))
.order_by(InventoryItem.sku.desc())
.limit(1)
)
next_number = 1
if latest:
next_number = int(latest.split("-")[-1]) + 1
return f"{prefix}{next_number:06d}"
@staticmethod
def list_categories(db: Session) -> list[InventoryCategory]:
return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc())))
@staticmethod
def get_category(db: Session, category_id: int) -> InventoryCategory | None:
return db.get(InventoryCategory, category_id)
@staticmethod
def list_locations(db: Session) -> list[InventoryLocation]:
return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc())))
@staticmethod
def get_location(db: Session, location_id: int) -> InventoryLocation | None:
return db.get(InventoryLocation, location_id)
@staticmethod
def list_suppliers(db: Session) -> list[InventorySupplier]:
return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc())))
@staticmethod
def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None:
return db.get(InventorySupplier, supplier_id)
@staticmethod
def has_movements(db: Session, item_id: int) -> bool:
return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1)))
@staticmethod
def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]:
return list(
db.scalars(
select(InventoryStockMovement)
.where(InventoryStockMovement.item_id == item_id)
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
)
)
@staticmethod
def create_movement(
db: Session,
*,
item_id: int,
movement_type: str,
quantity: int,
reason: str,
reference_type: str | None,
reference_id: int | None,
note: str | None,
actor_user_id: int | None,
) -> InventoryStockMovement:
movement = InventoryStockMovement(
item_id=item_id,
movement_type=movement_type,
quantity=quantity,
reason=reason,
reference_type=reference_type,
reference_id=reference_id,
note=note,
actor_user_id=actor_user_id,
)
db.add(movement)
return movement
@staticmethod
def count_active_items(db: Session) -> int:
return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0
@staticmethod
def count_low_stock_items(db: Session) -> int:
return db.scalar(
select(func.count(InventoryItem.id))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
) or 0
@staticmethod
def total_stock_value_cents(db: Session) -> int:
return db.scalar(
select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.purchase_price_cents.is_not(None))
) or 0
@staticmethod
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
return list(
db.scalars(
select(InventoryStockMovement)
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
.limit(limit)
)
)

View file

@ -25,6 +25,7 @@ class DashboardSummary(BaseModel):
users: list[MetricCard] = []
roles: list[MetricCard] = []
repairs: list[MetricCard] = []
inventory: list[MetricCard] = []
system_status: list[SystemStatusItem] = []
activities: EmptyWidget
tasks: EmptyWidget

View file

@ -0,0 +1,231 @@
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
InventoryMovementType = Literal[
"initial",
"purchase",
"adjustment",
"reservation",
"release",
"consumption",
"return",
"correction",
]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def normalize_optional_text(value: object) -> str | None:
text = normalize_text(value)
return text or None
class InventoryCategoryPayload(BaseModel):
name: str = Field(min_length=1, max_length=160)
description: str | None = None
@field_validator("name", "description", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class InventoryLocationPayload(BaseModel):
name: str = Field(min_length=1, max_length=160)
description: str | None = None
@field_validator("name", "description", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class InventorySupplierPayload(BaseModel):
name: str = Field(min_length=1, max_length=180)
contact_name: str | None = None
email: str | None = None
phone: str | None = None
website: str | None = None
customer_number: str | None = None
notes: str | None = None
@field_validator("*", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_optional_text(value)
class InventoryItemPayload(BaseModel):
sku: str | None = Field(default=None, max_length=40)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
category_id: int | None = None
manufacturer: str | None = None
manufacturer_part_number: str | None = None
supplier_id: int | None = None
supplier_part_number: str | None = None
compatible_devices: str | None = None
location_id: int | None = None
quantity_on_hand: int = Field(default=0, ge=0)
quantity_reserved: int = Field(default=0, ge=0)
reorder_level: int = Field(default=0, ge=0)
unit: str = Field(default="Stk.", max_length=40)
purchase_price_cents: int | None = Field(default=None, ge=0)
selling_price_cents: int | None = Field(default=None, ge=0)
currency: str = Field(default="EUR", max_length=3)
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
notes: str | None = None
is_active: bool = True
@field_validator(
"sku",
"name",
"description",
"manufacturer",
"manufacturer_part_number",
"supplier_part_number",
"compatible_devices",
"unit",
"currency",
"notes",
mode="before",
)
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
@field_validator("currency")
@classmethod
def normalize_currency(cls, value: str) -> str:
return value.upper() or "EUR"
@field_validator("tax_rate_percent", mode="before")
@classmethod
def normalize_tax_rate(cls, value: object) -> object:
if isinstance(value, str):
return value.strip().replace(",", ".")
return value
@model_validator(mode="after")
def validate_reserved_quantity(self):
if self.quantity_reserved > self.quantity_on_hand:
raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten")
return self
class InventoryItemCreate(InventoryItemPayload):
pass
class InventoryItemUpdate(InventoryItemPayload):
pass
class InventoryStockAction(BaseModel):
quantity: int = Field(ge=0)
reason: str = Field(min_length=1, max_length=255)
note: str | None = None
reference_type: str | None = Field(default=None, max_length=80)
reference_id: int | None = None
@field_validator("reason", "note", "reference_type", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class InventoryCategoryResponse(InventoryCategoryPayload):
id: int
slug: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventoryLocationResponse(InventoryLocationPayload):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventorySupplierResponse(InventorySupplierPayload):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventoryItemResponse(BaseModel):
id: int
sku: str
name: str
description: str | None
category_id: int | None
manufacturer: str | None
manufacturer_part_number: str | None
supplier_id: int | None
supplier_part_number: str | None
compatible_devices: str | None
location_id: int | None
quantity_on_hand: int
quantity_reserved: int
quantity_available: int
reorder_level: int
unit: str
purchase_price_cents: int | None
selling_price_cents: int | None
currency: str
tax_rate_percent: Decimal
notes: str | None
is_active: bool
created_at: datetime
updated_at: datetime
category: InventoryCategoryResponse | None
supplier: InventorySupplierResponse | None
location: InventoryLocationResponse | None
model_config = ConfigDict(from_attributes=True)
class InventoryItemListResponse(BaseModel):
items: list[InventoryItemResponse]
total: int
limit: int
offset: int
class InventoryStockMovementResponse(BaseModel):
id: int
item_id: int
movement_type: str
quantity: int
reason: str
reference_type: str | None
reference_id: int | None
note: str | None
actor_user_id: int | None
actor_username: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View file

@ -180,6 +180,23 @@ def action_title(action: str) -> str:
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
"inventory.items.create": "Lagerartikel erstellt",
"inventory.items.update": "Lagerartikel geändert",
"inventory.items.deactivate": "Lagerartikel deaktiviert",
"inventory.items.delete": "Lagerartikel gelöscht",
"inventory.stock.adjust": "Bestand angepasst",
"inventory.stock.reserve": "Bestand reserviert",
"inventory.stock.release": "Reservierung aufgehoben",
"inventory.stock.consume": "Bestand verbraucht",
"inventory.categories.create": "Lagerkategorie erstellt",
"inventory.categories.update": "Lagerkategorie geändert",
"inventory.categories.delete": "Lagerkategorie gelöscht",
"inventory.locations.create": "Lagerort erstellt",
"inventory.locations.update": "Lagerort geändert",
"inventory.locations.delete": "Lagerort gelöscht",
"inventory.suppliers.create": "Lieferant erstellt",
"inventory.suppliers.update": "Lieferant geändert",
"inventory.suppliers.delete": "Lieferant gelöscht",
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",

View file

@ -0,0 +1,318 @@
import re
import unicodedata
from typing import TypeVar
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
from app.models.user import User
from app.repositories.inventory_repository import InventoryRepository
from app.schemas.inventory import (
InventoryCategoryPayload,
InventoryItemCreate,
InventoryItemUpdate,
InventoryLocationPayload,
InventoryStockAction,
InventorySupplierPayload,
)
from app.services.audit_service import write_audit_log
MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier)
def _slugify(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower()
return slug or "eintrag"
def _item_label(item: InventoryItem) -> str:
return f"{item.sku} · {item.name}"
def _audit_item_data(item: InventoryItem) -> dict:
return {
"id": item.id,
"sku": item.sku,
"name": item.name,
"category_id": item.category_id,
"supplier_id": item.supplier_id,
"location_id": item.location_id,
"quantity_on_hand": item.quantity_on_hand,
"quantity_reserved": item.quantity_reserved,
"quantity_available": item.quantity_available,
"reorder_level": item.reorder_level,
"selling_price_cents": item.selling_price_cents,
"currency": item.currency,
"is_active": item.is_active,
}
class InventoryService:
@staticmethod
def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem:
sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db)
InventoryService._validate_sku_available(db, sku)
item = InventoryItem(sku=sku)
InventoryService._apply_item_payload(item, payload)
try:
db.add(item)
db.flush()
if item.quantity_on_hand > 0:
InventoryRepository.create_movement(
db,
item_id=item.id,
movement_type="initial",
quantity=item.quantity_on_hand,
reason="Anfangsbestand",
reference_type=None,
reference_id=None,
note=None,
actor_user_id=actor.id,
)
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
db.refresh(item)
item = InventoryRepository.get_item(db, item.id) or item
write_audit_log(
db,
action="inventory.items.create",
entity_type="inventory_items",
entity_id=item.id,
entity_label=_item_label(item),
actor=actor,
request=request,
after_data=_audit_item_data(item),
)
return item
@staticmethod
def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem:
before_data = _audit_item_data(item)
sku = (payload.sku or "").upper() or item.sku
InventoryService._validate_sku_available(db, sku, item_id=item.id)
item.sku = sku
InventoryService._apply_item_payload(item, payload)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None
db.refresh(item)
updated = InventoryRepository.get_item(db, item.id) or item
write_audit_log(
db,
action="inventory.items.update",
entity_type="inventory_items",
entity_id=updated.id,
entity_label=_item_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_item_data(updated),
)
return updated
@staticmethod
def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None:
before_data = _audit_item_data(item)
label = _item_label(item)
if InventoryRepository.has_movements(db, item.id):
item.is_active = False
InventoryService._recalculate_available(item)
db.commit()
db.refresh(item)
write_audit_log(
db,
action="inventory.items.deactivate",
entity_type="inventory_items",
entity_id=item.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_item_data(item),
)
return InventoryRepository.get_item(db, item.id) or item
db.delete(item)
db.commit()
write_audit_log(
db,
action="inventory.items.delete",
entity_type="inventory_items",
entity_id=item.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
)
return None
@staticmethod
def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
before_data = _audit_item_data(item)
item.quantity_on_hand = payload.quantity
if item.quantity_reserved > item.quantity_on_hand:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten")
InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request)
@staticmethod
def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
InventoryService._validate_positive_stock_quantity(payload)
if payload.quantity > item.quantity_available:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand")
before_data = _audit_item_data(item)
item.quantity_reserved += payload.quantity
InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request)
@staticmethod
def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
InventoryService._validate_positive_stock_quantity(payload)
if payload.quantity > item.quantity_reserved:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich")
before_data = _audit_item_data(item)
item.quantity_reserved -= payload.quantity
InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request)
@staticmethod
def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem:
InventoryService._validate_positive_stock_quantity(payload)
if payload.quantity > item.quantity_on_hand:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand")
before_data = _audit_item_data(item)
item.quantity_on_hand -= payload.quantity
item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity)
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
@staticmethod
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
return InventoryService._save_master(db, category, "inventory.categories.create", actor, request)
@staticmethod
def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
before_data = {"id": category.id, "name": category.name, "slug": category.slug}
category.name = payload.name
category.slug = _slugify(payload.name)
category.description = payload.description
return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data)
@staticmethod
def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
location = InventoryLocation(name=payload.name, description=payload.description)
return InventoryService._save_master(db, location, "inventory.locations.create", actor, request)
@staticmethod
def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation:
before_data = {"id": location.id, "name": location.name}
location.name = payload.name
location.description = payload.description
return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data)
@staticmethod
def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
supplier = InventorySupplier(**payload.model_dump())
return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request)
@staticmethod
def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier:
before_data = {"id": supplier.id, "name": supplier.name}
for key, value in payload.model_dump().items():
setattr(supplier, key, value)
return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data)
@staticmethod
def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None:
label = getattr(entity, "name", "")
before_data = {"id": entity.id, "name": label}
db.delete(entity)
db.commit()
write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data)
@staticmethod
def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None:
for key, value in payload.model_dump(exclude={"sku"}).items():
setattr(item, key, value)
InventoryService._recalculate_available(item)
@staticmethod
def _recalculate_available(item: InventoryItem) -> None:
item.quantity_available = item.quantity_on_hand - item.quantity_reserved
@staticmethod
def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None:
existing = InventoryRepository.get_item_by_sku(db, sku)
if existing is not None and existing.id != item_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben")
@staticmethod
def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None:
if payload.quantity <= 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
@staticmethod
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
InventoryService._recalculate_available(item)
InventoryRepository.create_movement(
db,
item_id=item.id,
movement_type=movement_type,
quantity=payload.quantity,
reason=payload.reason,
reference_type=payload.reference_type,
reference_id=payload.reference_id,
note=payload.note,
actor_user_id=actor_user_id,
)
@staticmethod
def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem:
InventoryService._recalculate_available(item)
db.commit()
db.refresh(item)
updated = InventoryRepository.get_item(db, item.id) or item
write_audit_log(
db,
action=action,
entity_type="inventory_items",
entity_id=updated.id,
entity_label=_item_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_item_data(updated),
)
return updated
@staticmethod
def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel:
try:
db.add(entity)
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None
db.refresh(entity)
label = getattr(entity, "name", "")
write_audit_log(
db,
action=action,
entity_type=entity.__tablename__,
entity_id=entity.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
after_data={"id": entity.id, "name": label},
)
return entity

View file

@ -0,0 +1,33 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxyCategoryRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/categories/${id}`);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyCategoryRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyCategoryRequest(request, context);
}

View file

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/inventory/categories");
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/categories");
}

View file

@ -0,0 +1,12 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/movements`);
}

View file

@ -0,0 +1,37 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxyItemRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}`);
}
export async function GET(request: NextRequest, context: Params) {
return proxyItemRequest(request, context);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyItemRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyItemRequest(request, context);
}

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`);
}

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`);
}

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`);
}

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id } = await params;
return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`);
}

View file

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`);
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/items");
}

View file

@ -0,0 +1,33 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxyLocationRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/locations/${id}`);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyLocationRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyLocationRequest(request, context);
}

View file

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/inventory/locations");
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/locations");
}

View file

@ -0,0 +1,33 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string }>;
};
async function proxySupplierRequest(request: NextRequest, { params }: Params) {
const { id } = await params;
return proxyHermesRequest(request, `/inventory/suppliers/${id}`);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxySupplierRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxySupplierRequest(request, context);
}

View file

@ -0,0 +1,18 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function GET(request: NextRequest) {
return proxyHermesRequest(request, "/inventory/suppliers");
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/inventory/suppliers");
}

View file

@ -62,7 +62,7 @@ export default function DashboardPage() {
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
}
const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles];
const cards = [...summary.customers, ...summary.repairs, ...summary.inventory, ...summary.users, ...summary.roles];
return (
<div className="space-y-6">

View file

@ -0,0 +1,153 @@
"use client";
import { use, useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { formatDate, formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { InventoryItem, InventoryStockMovement } from "@/types/inventory";
type PageProps = {
params: Promise<{
id: string;
}>;
};
const movementLabels: Record<string, string> = {
initial: "Anfangsbestand",
purchase: "Einkauf",
adjustment: "Anpassung",
reservation: "Reservierung",
release: "Freigabe",
consumption: "Verbrauch",
return: "Rückgabe",
correction: "Korrektur",
};
export default function InventoryItemDetailPage({ params }: PageProps) {
const { id } = use(params);
const [item, setItem] = useState<InventoryItem | null>(null);
const [movements, setMovements] = useState<InventoryStockMovement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const loadItem = useCallback(async () => {
setLoading(true);
setError("");
try {
const [itemResponse, movementResponse] = await Promise.all([
api.get<InventoryItem>(`/inventory/items/${id}`),
api.get<InventoryStockMovement[]>(`/inventory/items/${id}/movements`),
]);
setItem(itemResponse.data);
setMovements(movementResponse.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
queueMicrotask(() => {
void loadItem();
});
}, [loadItem]);
if (loading) {
return <div className="rounded-lg border bg-white p-8 text-slate-500">Artikel wird geladen...</div>;
}
if (error || !item) {
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Artikel nicht gefunden"}</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-medium text-slate-500">{item.sku}</p>
<h1 className="text-3xl font-bold text-slate-950">{item.name}</h1>
<p className="mt-1 text-sm text-slate-500">{item.category?.name ?? "Keine Kategorie"} · {item.is_active ? "Aktiv" : "Inaktiv"}</p>
</div>
<Link href="/inventory"><Button type="button" variant="outline">Zurück zum Lager</Button></Link>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
<h2 className="font-semibold text-slate-950">Stammdaten</h2>
<dl className="mt-4 grid gap-4 md:grid-cols-2">
<Info label="Beschreibung" value={item.description ?? "Keine Angabe"} />
<Info label="Hersteller" value={item.manufacturer ?? "Keine Angabe"} />
<Info label="Hersteller-Teilenummer" value={item.manufacturer_part_number ?? "Keine Angabe"} />
<Info label="Lieferanten-Teilenummer" value={item.supplier_part_number ?? "Keine Angabe"} />
<Info label="Lieferant" value={item.supplier?.name ?? "Kein Lieferant"} />
<Info label="Lagerort" value={item.location?.name ?? "Kein Lagerort"} />
<Info label="Kompatible Geräte" value={item.compatible_devices ?? "Keine Angabe"} />
<Info label="Notizen" value={item.notes ?? "Keine Notizen"} />
</dl>
</section>
<section className="rounded-lg border bg-white p-5">
<h2 className="font-semibold text-slate-950">Bestand</h2>
<dl className="mt-4 grid gap-3">
<Metric label="Bestand" value={`${item.quantity_on_hand} ${item.unit}`} />
<Metric label="Reserviert" value={`${item.quantity_reserved} ${item.unit}`} />
<Metric label="Verfügbar" value={`${item.quantity_available} ${item.unit}`} />
<Metric label="Mindestbestand" value={`${item.reorder_level} ${item.unit}`} />
</dl>
</section>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<section className="rounded-lg border bg-white p-5">
<h2 className="font-semibold text-slate-950">Preise</h2>
<dl className="mt-4 grid gap-3">
<Metric label="Einkaufspreis" value={formatMoney(item.purchase_price_cents, item.currency)} />
<Metric label="Verkaufspreis" value={formatMoney(item.selling_price_cents, item.currency)} />
<Metric label="MwSt." value={`${item.tax_rate_percent} %`} />
</dl>
</section>
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
<h2 className="font-semibold text-slate-950">Bewegungen</h2>
{movements.length === 0 ? (
<p className="mt-4 text-sm text-slate-500">Noch keine Bewegungen vorhanden.</p>
) : (
<div className="mt-4 divide-y">
{movements.map((movement) => (
<div key={movement.id} className="py-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium text-slate-950">{movementLabels[movement.movement_type] ?? movement.movement_type} · {movement.quantity}</p>
<time className="text-xs text-slate-500" dateTime={movement.created_at}>{formatDate(movement.created_at)}</time>
</div>
<p className="mt-1 text-sm text-slate-500">{movement.reason}{movement.note ? ` · ${movement.note}` : ""}</p>
{movement.actor_username && <p className="mt-1 text-xs text-slate-400">Gebucht von {movement.actor_username}</p>}
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-950">{value}</dd>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-slate-50 p-3">
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
<dd className="mt-1 text-lg font-semibold text-slate-950">{value}</dd>
</div>
);
}

View file

@ -0,0 +1,322 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Edit, Eye, PackagePlus, Plus, RotateCcw, Trash2, Wrench } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import EmptyState from "@/components/common/EmptyState";
import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import InventoryItemDialog from "@/components/inventory/InventoryItemDialog";
import MasterDataSection from "@/components/inventory/MasterDataSection";
import StockActionDialog, { type StockAction } from "@/components/inventory/StockActionDialog";
import { formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils";
import { Button, buttonVariants } from "@/components/ui/button";
import { api } from "@/lib/api";
import { hasPermission } from "@/lib/permissions";
import type {
InventoryCategory,
InventoryItem,
InventoryItemListResponse,
InventoryItemPayload,
InventoryLocation,
InventoryStockPayload,
InventorySupplier,
} from "@/types/inventory";
import type { CurrentUser } from "@/types/rbac";
const pageSize = 10;
export default function InventoryPage() {
const { showToast } = useToast();
const [items, setItems] = useState<InventoryItem[]>([]);
const [categories, setCategories] = useState<InventoryCategory[]>([]);
const [locations, setLocations] = useState<InventoryLocation[]>([]);
const [suppliers, setSuppliers] = useState<InventorySupplier[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [stockError, setStockError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [pending, setPending] = useState(false);
const [tab, setTab] = useState<"items" | "categories" | "locations" | "suppliers">("items");
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [locationFilter, setLocationFilter] = useState("all");
const [supplierFilter, setSupplierFilter] = useState("all");
const [stockFilter, setStockFilter] = useState<"all" | "low">("all");
const [activeFilter, setActiveFilter] = useState<"true" | "false" | "all">("true");
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [formOpen, setFormOpen] = useState(false);
const [stockOpen, setStockOpen] = useState(false);
const [stockAction, setStockAction] = useState<StockAction>("adjust");
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [deleteItem, setDeleteItem] = useState<InventoryItem | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
params.set("limit", String(pageSize));
params.set("offset", String((page - 1) * pageSize));
if (search.trim()) params.set("q", search.trim());
if (categoryFilter !== "all") params.set("category_id", categoryFilter);
if (locationFilter !== "all") params.set("location_id", locationFilter);
if (supplierFilter !== "all") params.set("supplier_id", supplierFilter);
if (stockFilter === "low") params.set("low_stock", "true");
if (activeFilter !== "all") params.set("active", activeFilter);
const [itemResponse, categoryResponse, locationResponse, supplierResponse, meResponse] = await Promise.all([
api.get<InventoryItemListResponse>(`/inventory/items?${params.toString()}`),
api.get<InventoryCategory[]>("/inventory/categories"),
api.get<InventoryLocation[]>("/inventory/locations"),
api.get<InventorySupplier[]>("/inventory/suppliers"),
api.get<CurrentUser>("/me"),
]);
setItems(itemResponse.data.items);
setTotal(itemResponse.data.total);
setCategories(categoryResponse.data);
setLocations(locationResponse.data);
setSuppliers(supplierResponse.data);
setCurrentUser(meResponse.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, [activeFilter, categoryFilter, locationFilter, page, search, stockFilter, supplierFilter]);
useEffect(() => {
queueMicrotask(() => {
void loadData();
});
}, [loadData]);
const canCreate = hasPermission(currentUser, "inventory.create");
const canUpdate = hasPermission(currentUser, "inventory.update");
const canDelete = hasPermission(currentUser, "inventory.delete");
const canAdjust = hasPermission(currentUser, "inventory.stock.adjust");
const canReserve = hasPermission(currentUser, "inventory.stock.reserve");
const canConsume = hasPermission(currentUser, "inventory.stock.consume");
const canManageMasterdata = hasPermission(currentUser, "inventory.manage.masterdata");
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, pageCount);
const sortedItems = useMemo(() => [...items].sort((first, second) => {
const firstValue = String(first[sortKey as keyof InventoryItem] ?? "").toLowerCase();
const secondValue = String(second[sortKey as keyof InventoryItem] ?? "").toLowerCase();
if (firstValue === secondValue) return 0;
const result = firstValue > secondValue ? 1 : -1;
return sortDirection === "asc" ? result : -result;
}), [items, sortDirection, sortKey]);
const columns: DataTableColumn<InventoryItem>[] = [
{ key: "sku", label: "SKU", sortable: true, render: (item) => <span className="font-medium text-slate-950">{item.sku}</span> },
{ key: "name", label: "Name", sortable: true, render: (item) => item.name },
{ key: "category", label: "Kategorie", render: (item) => item.category?.name ?? "Keine" },
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer ?? "Keine Angabe" },
{ key: "quantity_on_hand", label: "Bestand", sortable: true, render: (item) => item.quantity_on_hand },
{ key: "quantity_reserved", label: "Reserviert", sortable: true, render: (item) => item.quantity_reserved },
{ key: "quantity_available", label: "Verfügbar", sortable: true, render: (item) => <span className={item.quantity_available <= item.reorder_level ? "font-semibold text-amber-700" : ""}>{item.quantity_available}</span> },
{ key: "reorder_level", label: "Mindestbestand", sortable: true, render: (item) => item.reorder_level },
{ key: "location", label: "Lagerort", render: (item) => item.location?.name ?? "Kein Lagerort" },
{ key: "selling_price_cents", label: "VK", sortable: true, render: (item) => formatMoney(item.selling_price_cents, item.currency) },
{ key: "is_active", label: "Status", sortable: true, render: (item) => item.is_active ? "Aktiv" : "Inaktiv" },
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (item) => (
<div className="flex justify-end gap-2">
<Link href={`/inventory/items/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
{canUpdate && <Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelectedItem(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>}
{canAdjust && <Button type="button" variant="ghost" size="icon-sm" title="Bestand anpassen" onClick={() => openStock(item, "adjust")}><Wrench size={16} /></Button>}
{canReserve && <Button type="button" variant="ghost" size="icon-sm" title="Reservieren" onClick={() => openStock(item, "reserve")}><PackagePlus size={16} /></Button>}
{canConsume && <Button type="button" variant="ghost" size="icon-sm" title="Verbrauch buchen" onClick={() => openStock(item, "consume")}><RotateCcw size={16} /></Button>}
{canDelete && <Button type="button" variant="destructive" size="icon-sm" title="Deaktivieren" onClick={() => { setDeleteItem(item); setDeleteError(""); }}><Trash2 size={16} /></Button>}
</div>
),
},
];
function openStock(item: InventoryItem, action: StockAction) {
setSelectedItem(item);
setStockAction(action);
setStockError("");
setStockOpen(true);
}
function handleSort(key: string) {
if (sortKey === key) {
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortKey(key);
setSortDirection("asc");
}
async function saveItem(payload: InventoryItemPayload) {
setPending(true);
setFormError("");
try {
if (selectedItem) {
await api.put<InventoryItem>(`/inventory/items/${selectedItem.id}`, payload);
showToast({ type: "success", title: "Artikel aktualisiert", description: selectedItem.sku });
} else {
const response = await api.post<InventoryItem>("/inventory/items", payload);
showToast({ type: "success", title: "Artikel angelegt", description: response.data.sku });
}
setFormOpen(false);
await loadData();
} catch (err) {
const message = getErrorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Artikel konnte nicht gespeichert werden", description: message });
} finally {
setPending(false);
}
}
async function saveStock(payload: InventoryStockPayload) {
if (!selectedItem) return;
setPending(true);
setStockError("");
try {
await api.post<InventoryItem>(`/inventory/items/${selectedItem.id}/stock/${stockAction}`, payload);
showToast({ type: "success", title: "Bestand gebucht", description: selectedItem.sku });
setStockOpen(false);
await loadData();
} catch (err) {
const message = getErrorMessage(err);
setStockError(message);
showToast({ type: "error", title: "Bestand konnte nicht gebucht werden", description: message });
} finally {
setPending(false);
}
}
async function confirmDelete() {
if (!deleteItem) return;
setPending(true);
setDeleteError("");
try {
await api.delete(`/inventory/items/${deleteItem.id}`);
showToast({ type: "success", title: "Artikel deaktiviert", description: deleteItem.sku });
setDeleteItem(null);
await loadData();
} catch (err) {
const message = getErrorMessage(err);
setDeleteError(message);
showToast({ type: "error", title: "Artikel konnte nicht deaktiviert werden", description: message });
} finally {
setPending(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-950">Lager / Ersatzteile</h1>
<p className="mt-1 text-sm text-slate-500">{total} Artikel im aktuellen Filter</p>
</div>
{canCreate && <Button type="button" onClick={() => { setSelectedItem(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Artikel anlegen</Button>}
</div>
<div className="flex flex-wrap gap-2">
{(["items", "categories", "locations", "suppliers"] as const).map((value) => (
<Button key={value} type="button" variant={tab === value ? "default" : "outline"} onClick={() => setTab(value)}>
{{ items: "Artikel", categories: "Kategorien", locations: "Lagerorte", suppliers: "Lieferanten" }[value]}
</Button>
))}
</div>
{tab === "items" ? (
<>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 xl:flex-row xl:items-center">
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Artikel suchen" />
<select value={categoryFilter} onChange={(event) => { setCategoryFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
<option value="all">Alle Kategorien</option>
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
</select>
<select value={locationFilter} onChange={(event) => { setLocationFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
<option value="all">Alle Lagerorte</option>
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
</select>
<select value={supplierFilter} onChange={(event) => { setSupplierFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
<option value="all">Alle Lieferanten</option>
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
</select>
<select value={stockFilter} onChange={(event) => { setStockFilter(event.target.value as "all" | "low"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
<option value="all">Alle Bestände</option>
<option value="low">Niedriger Bestand</option>
</select>
<select value={activeFilter} onChange={(event) => { setActiveFilter(event.target.value as "true" | "false" | "all"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
<option value="true">Aktiv</option>
<option value="false">Inaktiv</option>
<option value="all">Alle</option>
</select>
</div>
{!loading && !error && total === 0 && !search ? (
<EmptyState
title="Noch keine Ersatzteile vorhanden."
description="Lege den ersten Artikel an, um Bestand, Preise und Lieferanten zu pflegen."
action={canCreate ? <Button type="button" onClick={() => setFormOpen(true)}><Plus size={16} />Artikel anlegen</Button> : undefined}
/>
) : (
<DataTable
columns={columns}
rows={sortedItems}
rowKey={(item) => item.id}
sortKey={sortKey}
sortDirection={sortDirection}
loading={loading}
error={error}
emptyTitle="Keine Artikel gefunden."
emptyDescription="Passe Suche oder Filter an."
onSort={handleSort}
/>
)}
<div className="flex items-center justify-between text-sm text-slate-600">
<span>Seite {currentPage} von {pageCount}</span>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
</div>
</div>
</>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{tab === "categories" && <MasterDataSection title="Kategorien" kind="categories" entries={categories} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
{tab === "locations" && <MasterDataSection title="Lagerorte" kind="locations" entries={locations} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
{tab === "suppliers" && <MasterDataSection title="Lieferanten" kind="suppliers" entries={suppliers} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
</div>
)}
<InventoryItemDialog key={`item-${selectedItem?.id ?? "new"}-${formOpen}`} open={formOpen} item={selectedItem} categories={categories} locations={locations} suppliers={suppliers} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveItem} />
<StockActionDialog key={`stock-${selectedItem?.id ?? "none"}-${stockAction}-${stockOpen}`} open={stockOpen} item={selectedItem} action={stockAction} pending={pending} serverError={stockError} onOpenChange={setStockOpen} onSubmit={saveStock} />
<ConfirmDialog
open={Boolean(deleteItem)}
title="Artikel deaktivieren"
description="Artikel mit Bewegungen werden nicht hart gelöscht, sondern deaktiviert."
confirmLabel="Deaktivieren"
pending={pending}
onOpenChange={(open) => !open && setDeleteItem(null)}
onConfirm={confirmDelete}
>
<div className="space-y-2">
<p>{deleteItem?.sku} · {deleteItem?.name}</p>
{deleteError && <p className="text-sm text-red-600">{deleteError}</p>}
</div>
</ConfirmDialog>
</div>
);
}

View file

@ -66,6 +66,7 @@ const menu = [
icon: Package,
name: "Lager",
href: "/inventory",
permission: "inventory.read",
},
{
icon: ShoppingCart,

View file

@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type {
InventoryCategory,
InventoryItem,
InventoryItemPayload,
InventoryLocation,
InventorySupplier,
} from "@/types/inventory";
import { centsToEuroInput, emptyItemPayload, euroToCents } from "./inventory-utils";
type Props = {
open: boolean;
item: InventoryItem | null;
categories: InventoryCategory[];
locations: InventoryLocation[];
suppliers: InventorySupplier[];
pending: boolean;
serverError: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: InventoryItemPayload) => void;
};
function payloadFromItem(item: InventoryItem | null): InventoryItemPayload {
if (!item) {
return emptyItemPayload;
}
return {
sku: item.sku,
name: item.name,
description: item.description,
category_id: item.category_id,
manufacturer: item.manufacturer,
manufacturer_part_number: item.manufacturer_part_number,
supplier_id: item.supplier_id,
supplier_part_number: item.supplier_part_number,
compatible_devices: item.compatible_devices,
location_id: item.location_id,
quantity_on_hand: item.quantity_on_hand,
quantity_reserved: item.quantity_reserved,
reorder_level: item.reorder_level,
unit: item.unit,
purchase_price_cents: item.purchase_price_cents,
selling_price_cents: item.selling_price_cents,
currency: item.currency,
tax_rate_percent: item.tax_rate_percent,
notes: item.notes,
is_active: item.is_active,
};
}
export default function InventoryItemDialog({
open,
item,
categories,
locations,
suppliers,
pending,
serverError,
onOpenChange,
onSubmit,
}: Props) {
const initialPayload = payloadFromItem(item);
const [payload, setPayload] = useState<InventoryItemPayload>(initialPayload);
const [purchasePrice, setPurchasePrice] = useState(centsToEuroInput(initialPayload.purchase_price_cents));
const [sellingPrice, setSellingPrice] = useState(centsToEuroInput(initialPayload.selling_price_cents));
const [clientError, setClientError] = useState("");
function update<K extends keyof InventoryItemPayload>(key: K, value: InventoryItemPayload[K]) {
setPayload((current) => ({ ...current, [key]: value }));
}
function submit() {
const purchaseCents = euroToCents(purchasePrice);
const sellingCents = euroToCents(sellingPrice);
if (!payload.name.trim()) {
setClientError("Bitte einen Artikelnamen angeben.");
return;
}
if (payload.quantity_reserved > payload.quantity_on_hand) {
setClientError("Reservierter Bestand darf den Lagerbestand nicht überschreiten.");
return;
}
if ((purchasePrice.trim() && purchaseCents === null) || (sellingPrice.trim() && sellingCents === null)) {
setClientError("Bitte Preise als Eurobetrag eingeben, z. B. 1,50.");
return;
}
setClientError("");
onSubmit({
...payload,
sku: payload.sku?.trim() || null,
name: payload.name.trim(),
purchase_price_cents: purchaseCents,
selling_price_cents: sellingCents,
description: payload.description?.trim() || null,
manufacturer: payload.manufacturer?.trim() || null,
manufacturer_part_number: payload.manufacturer_part_number?.trim() || null,
supplier_part_number: payload.supplier_part_number?.trim() || null,
compatible_devices: payload.compatible_devices?.trim() || null,
notes: payload.notes?.trim() || null,
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{item ? "Artikel bearbeiten" : "Artikel anlegen"}</DialogTitle>
<DialogDescription>Pflege Stammdaten, Lagerbestand, Preise und Bezugsdaten.</DialogDescription>
</DialogHeader>
<div className="grid gap-5">
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Stammdaten</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">SKU optional<Input value={payload.sku ?? ""} onChange={(event) => update("sku", event.target.value || null)} placeholder="ET-2026-000001" /></label>
<label className="grid gap-1 text-sm md:col-span-2">Name<Input value={payload.name} onChange={(event) => update("name", event.target.value)} /></label>
<label className="grid gap-1 text-sm md:col-span-3">Beschreibung<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Zuordnung</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">Kategorie<select className="h-8 rounded-lg border px-2 text-sm" value={payload.category_id ?? ""} onChange={(event) => update("category_id", event.target.value ? Number(event.target.value) : null)}>
<option value="">Keine Kategorie</option>
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Lagerort<select className="h-8 rounded-lg border px-2 text-sm" value={payload.location_id ?? ""} onChange={(event) => update("location_id", event.target.value ? Number(event.target.value) : null)}>
<option value="">Kein Lagerort</option>
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
</select></label>
<label className="grid gap-1 text-sm">Lieferant<select className="h-8 rounded-lg border px-2 text-sm" value={payload.supplier_id ?? ""} onChange={(event) => update("supplier_id", event.target.value ? Number(event.target.value) : null)}>
<option value="">Kein Lieferant</option>
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
</select></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Hersteller und Kompatibilität</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="grid gap-1 text-sm">Hersteller<Input value={payload.manufacturer ?? ""} onChange={(event) => update("manufacturer", event.target.value || null)} /></label>
<label className="grid gap-1 text-sm">Hersteller-Teilenummer<Input value={payload.manufacturer_part_number ?? ""} onChange={(event) => update("manufacturer_part_number", event.target.value || null)} /></label>
<label className="grid gap-1 text-sm">Lieferanten-Teilenummer<Input value={payload.supplier_part_number ?? ""} onChange={(event) => update("supplier_part_number", event.target.value || null)} /></label>
<label className="grid gap-1 text-sm md:col-span-3">Kompatible Geräte<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.compatible_devices ?? ""} onChange={(event) => update("compatible_devices", event.target.value || null)} /></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Bestand und Preise</h3>
<div className="grid gap-3 md:grid-cols-4">
<label className="grid gap-1 text-sm">Bestand<Input type="number" min={0} value={payload.quantity_on_hand} onChange={(event) => update("quantity_on_hand", Number(event.target.value))} /></label>
<label className="grid gap-1 text-sm">Reserviert<Input type="number" min={0} value={payload.quantity_reserved} onChange={(event) => update("quantity_reserved", Number(event.target.value))} /></label>
<label className="grid gap-1 text-sm">Mindestbestand<Input type="number" min={0} value={payload.reorder_level} onChange={(event) => update("reorder_level", Number(event.target.value))} /></label>
<label className="grid gap-1 text-sm">Einheit<Input value={payload.unit} onChange={(event) => update("unit", event.target.value)} /></label>
<label className="grid gap-1 text-sm">Einkaufspreis <Input inputMode="decimal" value={purchasePrice} onChange={(event) => setPurchasePrice(event.target.value)} /></label>
<label className="grid gap-1 text-sm">Verkaufspreis <Input inputMode="decimal" value={sellingPrice} onChange={(event) => setSellingPrice(event.target.value)} /></label>
<label className="grid gap-1 text-sm">Währung<Input value={payload.currency} onChange={(event) => update("currency", event.target.value.toUpperCase())} /></label>
<label className="grid gap-1 text-sm">MwSt. %<Input inputMode="decimal" value={payload.tax_rate_percent} onChange={(event) => update("tax_rate_percent", event.target.value.replace(",", "."))} /></label>
</div>
</section>
<section className="grid gap-3">
<h3 className="font-semibold text-slate-950">Notizen</h3>
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} />
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={payload.is_active} onChange={(event) => update("is_active", event.target.checked)} /> Artikel ist aktiv</label>
</section>
</div>
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { Edit, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/common/ToastProvider";
import { api } from "@/lib/api";
import type { InventoryCategory, InventoryLocation, InventorySupplier } from "@/types/inventory";
import { getErrorMessage } from "./inventory-utils";
type MasterKind = "categories" | "locations" | "suppliers";
type MasterEntry = InventoryCategory | InventoryLocation | InventorySupplier;
type Props = {
title: string;
kind: MasterKind;
entries: MasterEntry[];
canManage: boolean;
onChanged: () => void;
};
export default function MasterDataSection({ title, kind, entries, canManage, onChanged }: Props) {
const { showToast } = useToast();
const [editing, setEditing] = useState<MasterEntry | null>(null);
const [deleteEntry, setDeleteEntry] = useState<MasterEntry | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
function startCreate() {
setEditing({ id: 0, name: "", description: null, created_at: "", updated_at: "" } as MasterEntry);
setName("");
setDescription("");
setError("");
}
function startEdit(entry: MasterEntry) {
setEditing(entry);
setName(entry.name);
setDescription("description" in entry && entry.description ? entry.description : "");
setError("");
}
async function save() {
if (!editing) {
return;
}
if (!name.trim()) {
setError("Bitte einen Namen angeben.");
return;
}
setPending(true);
setError("");
const payload = kind === "suppliers"
? { name: name.trim(), notes: description.trim() || null }
: { name: name.trim(), description: description.trim() || null };
try {
if (editing.id) {
await api.put(`/inventory/${kind}/${editing.id}`, payload);
} else {
await api.post(`/inventory/${kind}`, payload);
}
showToast({ type: "success", title: `${title} gespeichert` });
setEditing(null);
onChanged();
} catch (err) {
const message = getErrorMessage(err);
setError(message);
showToast({ type: "error", title: `${title} konnte nicht gespeichert werden`, description: message });
} finally {
setPending(false);
}
}
async function confirmDelete() {
if (!deleteEntry) {
return;
}
setPending(true);
try {
await api.delete(`/inventory/${kind}/${deleteEntry.id}`);
showToast({ type: "success", title: `${title} gelöscht`, description: deleteEntry.name });
setDeleteEntry(null);
onChanged();
} catch (err) {
const message = getErrorMessage(err);
showToast({ type: "error", title: `${title} konnte nicht gelöscht werden`, description: message });
} finally {
setPending(false);
}
}
return (
<section className="rounded-lg border bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="font-semibold text-slate-950">{title}</h2>
{canManage && <Button type="button" size="sm" onClick={startCreate}><Plus size={14} />Neu</Button>}
</div>
<div className="divide-y">
{entries.length === 0 ? (
<p className="py-4 text-sm text-slate-500">Noch keine Einträge vorhanden.</p>
) : entries.map((entry) => (
<div key={entry.id} className="flex items-center justify-between gap-3 py-3">
<div>
<p className="font-medium text-slate-950">{entry.name}</p>
{"description" in entry && entry.description && <p className="text-sm text-slate-500">{entry.description}</p>}
{"notes" in entry && entry.notes && <p className="text-sm text-slate-500">{entry.notes}</p>}
</div>
{canManage && (
<div className="flex gap-2">
<Button type="button" variant="ghost" size="icon-sm" onClick={() => startEdit(entry)} title="Bearbeiten"><Edit size={16} /></Button>
<Button type="button" variant="destructive" size="icon-sm" onClick={() => setDeleteEntry(entry)} title="Löschen"><Trash2 size={16} /></Button>
</div>
)}
</div>
))}
</div>
{editing && (
<div className="mt-4 grid gap-3 rounded-lg border bg-slate-50 p-4">
<Input value={name} placeholder="Name" onChange={(event) => setName(event.target.value)} />
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={description} placeholder={kind === "suppliers" ? "Notizen" : "Beschreibung"} onChange={(event) => setDescription(event.target.value)} />
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<Button type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
<Button type="button" variant="outline" onClick={() => setEditing(null)} disabled={pending}>Abbrechen</Button>
</div>
</div>
)}
<ConfirmDialog
open={Boolean(deleteEntry)}
title={`${title} löschen`}
description="Der Eintrag wird entfernt. Verknüpfte Artikel behalten ihre sonstigen Daten."
pending={pending}
onOpenChange={(open) => !open && setDeleteEntry(null)}
onConfirm={confirmDelete}
>
{deleteEntry?.name}
</ConfirmDialog>
</section>
);
}

View file

@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { InventoryItem, InventoryStockPayload } from "@/types/inventory";
export type StockAction = "adjust" | "reserve" | "release" | "consume";
type Props = {
open: boolean;
item: InventoryItem | null;
action: StockAction;
pending: boolean;
serverError: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: InventoryStockPayload) => void;
};
const labels: Record<StockAction, { title: string; description: string; reason: string }> = {
adjust: {
title: "Bestand anpassen",
description: "Setzt den physischen Bestand auf den angegebenen Wert.",
reason: "Inventurkorrektur",
},
reserve: {
title: "Bestand reservieren",
description: "Reserviert verfügbaren Bestand für einen späteren Vorgang.",
reason: "Reservierung",
},
release: {
title: "Reservierung aufheben",
description: "Gibt reservierten Bestand wieder frei.",
reason: "Reservierung aufgehoben",
},
consume: {
title: "Verbrauch buchen",
description: "Bucht Bestand als verbraucht aus.",
reason: "Verbrauch",
},
};
export default function StockActionDialog({ open, item, action, pending, serverError, onOpenChange, onSubmit }: Props) {
const [quantity, setQuantity] = useState(action === "adjust" ? item?.quantity_on_hand ?? 0 : 1);
const [reason, setReason] = useState(labels[action].reason);
const [note, setNote] = useState("");
const [clientError, setClientError] = useState("");
function submit() {
if (quantity <= 0 && action !== "adjust") {
setClientError("Bitte eine Menge größer 0 angeben.");
return;
}
if (quantity < 0) {
setClientError("Bitte keine negative Menge eingeben.");
return;
}
if (!reason.trim()) {
setClientError("Bitte einen Grund angeben.");
return;
}
setClientError("");
onSubmit({
quantity,
reason: reason.trim(),
note: note.trim() || null,
reference_type: null,
reference_id: null,
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{labels[action].title}</DialogTitle>
<DialogDescription>{item ? `${item.sku} · ${item.name}` : labels[action].description}</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-600">
Bestand {item?.quantity_on_hand ?? 0} · Reserviert {item?.quantity_reserved ?? 0} · Verfügbar {item?.quantity_available ?? 0}
</p>
<label className="grid gap-1 text-sm">Menge<Input type="number" min={action === "adjust" ? 0 : 1} value={quantity} onChange={(event) => setQuantity(Number(event.target.value))} /></label>
<label className="grid gap-1 text-sm">Grund<Input value={reason} onChange={(event) => setReason(event.target.value)} /></label>
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
</div>
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Buchen"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,62 @@
import type { InventoryItemPayload } from "@/types/inventory";
export function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden";
}
return "Aktion konnte nicht abgeschlossen werden";
}
export function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
}
export function formatMoney(cents: number | null, currency = "EUR") {
if (cents === null) {
return "Keine Angabe";
}
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(cents / 100);
}
export function euroToCents(value: string) {
const normalized = value.trim().replace(",", ".");
if (!normalized) {
return null;
}
const amount = Number(normalized);
if (!Number.isFinite(amount) || amount < 0) {
return null;
}
return Math.round(amount * 100);
}
export function centsToEuroInput(cents: number | null) {
if (cents === null) {
return "";
}
return (cents / 100).toFixed(2).replace(".", ",");
}
export const emptyItemPayload: InventoryItemPayload = {
sku: null,
name: "",
description: null,
category_id: null,
manufacturer: null,
manufacturer_part_number: null,
supplier_id: null,
supplier_part_number: null,
compatible_devices: null,
location_id: null,
quantity_on_hand: 0,
quantity_reserved: 0,
reorder_level: 0,
unit: "Stk.",
purchase_price_cents: null,
selling_price_cents: null,
currency: "EUR",
tax_rate_percent: "19.00",
notes: null,
is_active: true,
};

View file

@ -17,6 +17,7 @@ export interface DashboardSummary {
users: MetricCard[];
roles: MetricCard[];
repairs: MetricCard[];
inventory: MetricCard[];
system_status: SystemStatusItem[];
activities: EmptyWidget;
tasks: EmptyWidget;

View file

@ -0,0 +1,121 @@
export type InventoryMovementType =
| "initial"
| "purchase"
| "adjustment"
| "reservation"
| "release"
| "consumption"
| "return"
| "correction";
export interface InventoryCategory {
id: number;
name: string;
slug: string;
description: string | null;
created_at: string;
updated_at: string;
}
export interface InventoryLocation {
id: number;
name: string;
description: string | null;
created_at: string;
updated_at: string;
}
export interface InventorySupplier {
id: number;
name: string;
contact_name: string | null;
email: string | null;
phone: string | null;
website: string | null;
customer_number: string | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface InventoryItem {
id: number;
sku: string;
name: string;
description: string | null;
category_id: number | null;
manufacturer: string | null;
manufacturer_part_number: string | null;
supplier_id: number | null;
supplier_part_number: string | null;
compatible_devices: string | null;
location_id: number | null;
quantity_on_hand: number;
quantity_reserved: number;
quantity_available: number;
reorder_level: number;
unit: string;
purchase_price_cents: number | null;
selling_price_cents: number | null;
currency: string;
tax_rate_percent: string;
notes: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
category: InventoryCategory | null;
supplier: InventorySupplier | null;
location: InventoryLocation | null;
}
export interface InventoryItemListResponse {
items: InventoryItem[];
total: number;
limit: number;
offset: number;
}
export interface InventoryStockMovement {
id: number;
item_id: number;
movement_type: InventoryMovementType;
quantity: number;
reason: string;
reference_type: string | null;
reference_id: number | null;
note: string | null;
actor_user_id: number | null;
actor_username: string;
created_at: string;
}
export type InventoryItemPayload = {
sku: string | null;
name: string;
description: string | null;
category_id: number | null;
manufacturer: string | null;
manufacturer_part_number: string | null;
supplier_id: number | null;
supplier_part_number: string | null;
compatible_devices: string | null;
location_id: number | null;
quantity_on_hand: number;
quantity_reserved: number;
reorder_level: number;
unit: string;
purchase_price_cents: number | null;
selling_price_cents: number | null;
currency: string;
tax_rate_percent: string;
notes: string | null;
is_active: boolean;
};
export type InventoryStockPayload = {
quantity: number;
reason: string;
note: string | null;
reference_type: string | null;
reference_id: number | null;
};