Web-UI: professionelles Redesign (Icons statt Emojis) + neue Features
Design: - Neues Icon-Set (Inline-SVG, Feather-Stil), keine Emojis mehr - Kohaerentes Design-System (Sidebar-Layout, Palette, Spacing, Light/Dark) - Alle Seiten ueberarbeitet (Login, Dashboard, Produkte, Formular, Ein-/Auslagern, Lagerorte, Benutzer, Einkaufsliste) Neue Features: - Gruppen-Verwaltung (anlegen/bearbeiten/loeschen, Produktzahl + Bestand, Gruppen-Mindestbestand) inkl. Einkaufsliste - Einfache Gruppen-Auto-Zuordnung aus OFF-Kategorie im Produktformular - Verlauf-Seite (Bewegungen: wer/was/wann) via neuem /movements-Endpoint - Einstellungen-Seite (Ablauf-Warnfrist) - Lagerorte mit optional uebergeordnetem Ort (verschachtelbar) Backend: - groups: PATCH + Anreicherung (product_count, stock) - views: /shopping-list/groups, /movements - schemas: GroupUpdate, GroupShoppingItem, MovementOut Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,17 +3,27 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Group, User
|
||||
from ..schemas import GroupCreate, GroupOut
|
||||
from ..models import Group, Product, User
|
||||
from ..schemas import GroupCreate, GroupOut, GroupUpdate
|
||||
from ..services.stock import current_stock
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
|
||||
|
||||
def _group_to_out(db: Session, group: Group) -> GroupOut:
|
||||
out = GroupOut.model_validate(group)
|
||||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||||
out.product_count = len(products)
|
||||
out.stock = float(sum(current_stock(db, p.id) for p in products))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
def list_groups(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[Group]:
|
||||
return db.query(Group).order_by(Group.name).all()
|
||||
) -> list[GroupOut]:
|
||||
groups = db.query(Group).order_by(Group.name).all()
|
||||
return [_group_to_out(db, g) for g in groups]
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -21,14 +31,40 @@ def create_group(
|
||||
payload: GroupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> Group:
|
||||
) -> GroupOut:
|
||||
if db.query(Group).filter(Group.name == payload.name).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
|
||||
group = Group(name=payload.name, min_stock=payload.min_stock)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
return _group_to_out(db, group)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=GroupOut)
|
||||
def update_group(
|
||||
group_id: int,
|
||||
payload: GroupUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> GroupOut:
|
||||
group = db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "name" in data and data["name"]:
|
||||
clash = (
|
||||
db.query(Group)
|
||||
.filter(Group.name == data["name"], Group.id != group_id)
|
||||
.first()
|
||||
)
|
||||
if clash:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
|
||||
for field, value in data.items():
|
||||
setattr(group, field, value)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return _group_to_out(db, group)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -5,8 +5,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Lot, Product, User
|
||||
from ..schemas import ExpiringItem, ShoppingItem
|
||||
from ..models import Group, Lot, Movement, Product, User
|
||||
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
|
||||
from ..services.stock import current_stock
|
||||
from .settings import get_expiry_warning_days
|
||||
|
||||
@@ -41,6 +41,37 @@ def shopping_list(
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/shopping-list/groups", response_model=list[GroupShoppingItem])
|
||||
def group_shopping_list(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[GroupShoppingItem]:
|
||||
"""Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt.
|
||||
|
||||
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
|
||||
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
|
||||
"""
|
||||
items: list[GroupShoppingItem] = []
|
||||
groups = (
|
||||
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
|
||||
)
|
||||
for group in groups:
|
||||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||||
stock = float(sum(current_stock(db, p.id) for p in products))
|
||||
if stock < group.min_stock:
|
||||
items.append(
|
||||
GroupShoppingItem(
|
||||
group_id=group.id,
|
||||
name=group.name,
|
||||
stock=stock,
|
||||
min_stock=group.min_stock,
|
||||
deficit=group.min_stock - stock,
|
||||
product_count=len(products),
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda i: i.deficit, reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/expiring", response_model=list[ExpiringItem])
|
||||
def expiring(
|
||||
days: int | None = None,
|
||||
@@ -74,3 +105,40 @@ def expiring(
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/movements", response_model=list[MovementOut])
|
||||
def movements(
|
||||
limit: int = 100,
|
||||
product_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[MovementOut]:
|
||||
"""Bewegungsverlauf (wer hat wann was ein-/ausgelagert), neueste zuerst."""
|
||||
limit = max(1, min(limit, 500))
|
||||
query = (
|
||||
db.query(Movement, Product, User)
|
||||
.join(Product, Movement.product_id == Product.id)
|
||||
.outerjoin(User, Movement.user_id == User.id)
|
||||
)
|
||||
if product_id is not None:
|
||||
query = query.filter(Movement.product_id == product_id)
|
||||
rows = query.order_by(Movement.created_at.desc(), Movement.id.desc()).limit(limit).all()
|
||||
|
||||
result: list[MovementOut] = []
|
||||
for movement, product, user in rows:
|
||||
result.append(
|
||||
MovementOut(
|
||||
id=movement.id,
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
type=movement.type.value,
|
||||
quantity=movement.quantity,
|
||||
base_unit=product.base_unit,
|
||||
unit_used=movement.unit_used,
|
||||
username=user.username if user else None,
|
||||
note=movement.note,
|
||||
created_at=movement.created_at,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user