diff --git a/backend/app/main.py b/backend/app/main.py
index d948e82..57ecbc4 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,6 +11,7 @@ from .routers import (
auth,
branding,
categories,
+ dashboard,
groups,
locations,
maintenance,
@@ -110,3 +111,4 @@ app.include_router(settings_router.router)
app.include_router(branding.router)
app.include_router(categories.router)
app.include_router(maintenance.router)
+app.include_router(dashboard.router)
diff --git a/backend/app/models.py b/backend/app/models.py
index 9e04685..75eaac3 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -295,6 +295,27 @@ class BrandingAsset(Base):
)
+class DashboardLayout(Base):
+ """Anordnung der Karten auf der Startseite.
+
+ ``user_id = NULL`` ist die Vorgabe des Administrators: Sie dient neuen
+ Benutzern als Ausgangspunkt und lässt sich – falls in den Einstellungen so
+ gesetzt – auch verbindlich für alle machen.
+ """
+
+ __tablename__ = "dashboard_layouts"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ user_id: Mapped[int | None] = mapped_column(
+ ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True
+ )
+ # JSON-Text: [{"i": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}]
+ layout: Mapped[str] = mapped_column(Text, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_now, onupdate=_now
+ )
+
+
class Setting(Base):
__tablename__ = "settings"
diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py
new file mode 100644
index 0000000..a1d35bc
--- /dev/null
+++ b/backend/app/routers/dashboard.py
@@ -0,0 +1,374 @@
+"""Startseite: gespeicherte Kartenanordnung und die Auswertungen dahinter.
+
+Die Auswertungen laufen bewusst hier und nicht in der Oberfläche: Sonst müsste
+der Browser alle Chargen und Bewegungen laden, nur um ein paar Summen zu bilden.
+
+Alle Mengenangaben sind **Artikeleinheiten** (Gläser, Packungen, Stück) – siehe
+:func:`app.services.conversion.article_unit`. Nur die lassen sich über
+verschiedene Artikel hinweg addieren.
+"""
+
+from __future__ import annotations
+
+import json
+from collections import defaultdict
+from datetime import date, datetime, timedelta, timezone
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..deps import get_current_user, require_admin
+from ..models import (
+ Category,
+ DashboardLayout,
+ Group,
+ Lot,
+ Movement,
+ MovementType,
+ Product,
+ Setting,
+ User,
+)
+from ..schemas import (
+ ActivityPoint,
+ CategoryShare,
+ DashboardLayoutIn,
+ DashboardLayoutOut,
+ DashboardStats,
+ ExpirySplit,
+ TimelinePoint,
+)
+from ..services.conversion import article_unit
+from ..services.stock import current_stock
+from .settings import get_expiry_warning_days
+
+router = APIRouter(prefix="/dashboard", tags=["dashboard"])
+
+ENFORCED_KEY = "dashboard_enforced"
+
+# Eingebaute Anordnung – wird benutzt, solange weder eigene noch Vorgabe existiert.
+BUILTIN_LAYOUT: list[dict] = [
+ {"i": "actions", "x": 0, "y": 0, "w": 4, "h": 1},
+ {"i": "status", "x": 4, "y": 0, "w": 8, "h": 1},
+ {"i": "expiring", "x": 0, "y": 1, "w": 6, "h": 3},
+ {"i": "shopping", "x": 6, "y": 1, "w": 6, "h": 3},
+ {"i": "expiry-donut", "x": 0, "y": 4, "w": 4, "h": 3},
+ {"i": "category-donut", "x": 4, "y": 4, "w": 4, "h": 3},
+ {"i": "stock-timeline", "x": 8, "y": 4, "w": 4, "h": 3},
+]
+
+
+def _enforced(db: Session) -> bool:
+ row = db.get(Setting, ENFORCED_KEY)
+ return bool(row and row.value == "1")
+
+
+def _load(db: Session, user_id: int | None) -> list[dict] | None:
+ row = (
+ db.query(DashboardLayout)
+ .filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
+ .first()
+ )
+ if row is None:
+ return None
+ try:
+ data = json.loads(row.layout)
+ except ValueError:
+ return None
+ return data if isinstance(data, list) else None
+
+
+def _store(db: Session, user_id: int | None, layout: list[dict]) -> None:
+ row = (
+ db.query(DashboardLayout)
+ .filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
+ .first()
+ )
+ text = json.dumps(layout, ensure_ascii=False)
+ if row is None:
+ db.add(DashboardLayout(user_id=user_id, layout=text))
+ else:
+ row.layout = text
+ db.commit()
+
+
+# --------------------------------------------------------------------------
+# Anordnung
+# --------------------------------------------------------------------------
+@router.get("/layout", response_model=DashboardLayoutOut)
+def get_layout(
+ db: Session = Depends(get_db), user: User = Depends(get_current_user)
+) -> DashboardLayoutOut:
+ """Eigene Anordnung, sonst die Vorgabe, sonst die eingebaute."""
+ erzwungen = _enforced(db)
+ vorgabe = _load(db, None)
+
+ if erzwungen and vorgabe is not None:
+ return DashboardLayoutOut(layout=vorgabe, source="default", enforced=True, has_default=True)
+
+ eigene = _load(db, user.id)
+ if eigene is not None:
+ return DashboardLayoutOut(
+ layout=eigene, source="user", enforced=erzwungen, has_default=vorgabe is not None
+ )
+ if vorgabe is not None:
+ return DashboardLayoutOut(
+ layout=vorgabe, source="default", enforced=erzwungen, has_default=True
+ )
+ return DashboardLayoutOut(
+ layout=BUILTIN_LAYOUT, source="builtin", enforced=erzwungen, has_default=False
+ )
+
+
+@router.put("/layout", response_model=DashboardLayoutOut)
+def put_layout(
+ payload: DashboardLayoutIn,
+ db: Session = Depends(get_db),
+ user: User = Depends(get_current_user),
+) -> DashboardLayoutOut:
+ if _enforced(db) and _load(db, None) is not None:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "Die Startseite ist vom Administrator fest vorgegeben.",
+ )
+ _store(db, user.id, payload.layout)
+ return get_layout(db=db, user=user)
+
+
+@router.delete("/layout", status_code=status.HTTP_204_NO_CONTENT)
+def reset_layout(
+ db: Session = Depends(get_db), user: User = Depends(get_current_user)
+) -> None:
+ """Eigene Anordnung verwerfen – danach gilt wieder die Vorgabe."""
+ row = db.query(DashboardLayout).filter(DashboardLayout.user_id == user.id).first()
+ if row is not None:
+ db.delete(row)
+ db.commit()
+
+
+@router.put("/layout/default", response_model=DashboardLayoutOut)
+def put_default_layout(
+ payload: DashboardLayoutIn,
+ db: Session = Depends(get_db),
+ admin: User = Depends(require_admin),
+) -> DashboardLayoutOut:
+ _store(db, None, payload.layout)
+ return get_layout(db=db, user=admin)
+
+
+@router.put("/layout/enforced", response_model=DashboardLayoutOut)
+def set_enforced(
+ value: bool,
+ db: Session = Depends(get_db),
+ admin: User = Depends(require_admin),
+) -> DashboardLayoutOut:
+ row = db.get(Setting, ENFORCED_KEY)
+ if row is None:
+ db.add(Setting(key=ENFORCED_KEY, value="1" if value else "0"))
+ else:
+ row.value = "1" if value else "0"
+ db.commit()
+ return get_layout(db=db, user=admin)
+
+
+# --------------------------------------------------------------------------
+# Auswertungen
+# --------------------------------------------------------------------------
+def _zustand(best_before: date | None, heute: date, warnfrist: int) -> str:
+ """Ablaufzustand einer Charge: ohne MHD / abgelaufen / bald / in Ordnung."""
+ if best_before is None:
+ return "no_date"
+ if best_before < heute:
+ return "expired"
+ if (best_before - heute).days <= warnfrist:
+ return "soon"
+ return "ok"
+
+
+def _lots_mit_produkt(db: Session) -> list[tuple[Lot, Product]]:
+ return (
+ db.query(Lot, Product)
+ .join(Product, Lot.product_id == Product.id)
+ .filter(Lot.quantity > 0)
+ .all()
+ )
+
+
+@router.get("/stats", response_model=DashboardStats)
+def stats(
+ db: Session = Depends(get_db), _: User = Depends(get_current_user)
+) -> DashboardStats:
+ heute = date.today()
+ warnfrist = get_expiry_warning_days(db)
+
+ einheiten = 0.0
+ bestand_produkte: set[int] = set()
+ bald = abgelaufen = 0
+ for lot, product in _lots_mit_produkt(db):
+ faktor, _label = article_unit(product)
+ einheiten += lot.quantity / faktor if faktor else 0.0
+ bestand_produkte.add(product.id)
+ zustand = _zustand(lot.best_before, heute, warnfrist)
+ if zustand == "expired":
+ abgelaufen += 1
+ elif zustand == "soon":
+ bald += 1
+
+ # Einkaufsbedarf: Produkte und Gruppen unter Mindestbestand.
+ bedarf = 0
+ for product in db.query(Product).filter(Product.min_stock.isnot(None), Product.min_stock > 0):
+ if current_stock(db, product.id) < product.min_stock:
+ bedarf += 1
+ for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0):
+ unit = group.min_stock_unit
+ produkte = list(group.products)
+ summe = float(sum(current_stock(db, p.id) for p in produkte))
+ if unit is not None:
+ summe /= unit.factor or 1.0
+ if summe < group.min_stock:
+ bedarf += 1
+
+ return DashboardStats(
+ products_in_stock=len(bestand_produkte),
+ article_units=round(einheiten, 3),
+ expiring_soon=bald,
+ expired=abgelaufen,
+ shopping_items=bedarf,
+ products_total=db.query(Product).count(),
+ )
+
+
+@router.get("/expiry-split", response_model=ExpirySplit)
+def expiry_split(
+ db: Session = Depends(get_db), _: User = Depends(get_current_user)
+) -> ExpirySplit:
+ heute = date.today()
+ warnfrist = get_expiry_warning_days(db)
+ summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
+ for lot, product in _lots_mit_produkt(db):
+ faktor, _label = article_unit(product)
+ summen[_zustand(lot.best_before, heute, warnfrist)] += lot.quantity / (faktor or 1.0)
+ return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
+
+
+@router.get("/by-category", response_model=list[CategoryShare])
+def by_category(
+ db: Session = Depends(get_db), _: User = Depends(get_current_user)
+) -> list[CategoryShare]:
+ """Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt."""
+ heute = date.today()
+ warnfrist = get_expiry_warning_days(db)
+ namen = {c.id: c.name for c in db.query(Category).all()}
+
+ leer = lambda: {"article_units": 0.0, "ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
+ eimer: dict[int | None, dict] = defaultdict(leer)
+ for lot, product in _lots_mit_produkt(db):
+ faktor, _label = article_unit(product)
+ menge = lot.quantity / (faktor or 1.0)
+ topf = eimer[product.category_id]
+ topf["article_units"] += menge
+ topf[_zustand(lot.best_before, heute, warnfrist)] += menge
+
+ ergebnis = [
+ CategoryShare(
+ category_id=cid,
+ name=namen.get(cid, "Ohne Kategorie") if cid is not None else "Ohne Kategorie",
+ **{k: round(v, 3) for k, v in werte.items()},
+ )
+ for cid, werte in eimer.items()
+ ]
+ ergebnis.sort(key=lambda c: c.article_units, reverse=True)
+ return ergebnis
+
+
+def _signiert(movement: Movement) -> float:
+ """Bewegung als vorzeichenbehaftete Änderung des Bestands."""
+ if movement.type == MovementType.in_:
+ return movement.quantity
+ if movement.type == MovementType.out:
+ return -movement.quantity
+ return movement.quantity # Korrekturen sind bereits vorzeichenbehaftet
+
+
+@router.get("/timeline", response_model=list[TimelinePoint])
+def timeline(
+ days: int = 90,
+ product_id: int | None = None,
+ db: Session = Depends(get_db),
+ _: User = Depends(get_current_user),
+) -> list[TimelinePoint]:
+ """Artikeleinheiten im Bestand je Tag – rückwärts aus den Bewegungen.
+
+ Ausgangspunkt ist der heutige Bestand; für jeden Tag rückwärts wird die
+ Netto-Bewegung dieses Tages wieder herausgerechnet.
+
+ Bekannte Ungenauigkeit: Die Umrechnung in Artikeleinheiten nutzt die *heutige*
+ Packungsgröße. Wird sie später geändert, verschiebt sich auch die Historie.
+ """
+ days = max(2, min(days, 730))
+ heute = date.today()
+
+ produkte = db.query(Product)
+ if product_id is not None:
+ produkte = produkte.filter(Product.id == product_id)
+ produkte = produkte.all()
+ if not produkte:
+ return []
+
+ faktoren = {p.id: (article_unit(p)[0] or 1.0) for p in produkte}
+ bestand = {p.id: current_stock(db, p.id) for p in produkte}
+
+ beginn = datetime.combine(heute - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc)
+ bewegungen = db.query(Movement).filter(Movement.created_at >= beginn)
+ if product_id is not None:
+ bewegungen = bewegungen.filter(Movement.product_id == product_id)
+
+ delta: dict[date, dict[int, float]] = defaultdict(lambda: defaultdict(float))
+ for m in bewegungen.all():
+ if m.product_id in faktoren:
+ delta[m.created_at.date()][m.product_id] += _signiert(m)
+
+ def summe() -> float:
+ return round(sum(bestand[pid] / faktoren[pid] for pid in bestand), 3)
+
+ punkte = [TimelinePoint(date=heute, article_units=summe())]
+ tag = heute
+ for _ in range(days - 1):
+ for pid, wert in delta.get(tag, {}).items():
+ bestand[pid] = bestand.get(pid, 0.0) - wert
+ tag = tag - timedelta(days=1)
+ punkte.append(TimelinePoint(date=tag, article_units=summe()))
+
+ punkte.reverse()
+ return punkte
+
+
+@router.get("/activity", response_model=list[ActivityPoint])
+def activity(
+ days: int = 30,
+ db: Session = Depends(get_db),
+ _: User = Depends(get_current_user),
+) -> list[ActivityPoint]:
+ """Anzahl der Ein- und Auslagerungen je Tag."""
+ days = max(2, min(days, 365))
+ heute = date.today()
+ beginn = datetime.combine(heute - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc)
+
+ ein: dict[date, int] = defaultdict(int)
+ aus: dict[date, int] = defaultdict(int)
+ for m in db.query(Movement).filter(Movement.created_at >= beginn).all():
+ tag = m.created_at.date()
+ if m.type == MovementType.in_:
+ ein[tag] += 1
+ elif m.type == MovementType.out:
+ aus[tag] += 1
+
+ return [
+ ActivityPoint(
+ date=heute - timedelta(days=days - 1 - i),
+ checked_in=ein.get(heute - timedelta(days=days - 1 - i), 0),
+ checked_out=aus.get(heute - timedelta(days=days - 1 - i), 0),
+ )
+ for i in range(days)
+ ]
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 8752c19..367dbb2 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -386,3 +386,56 @@ class MovementOut(BaseModel):
class SettingOut(BaseModel):
key: str
value: str
+
+
+# ---- Startseite ----
+class DashboardLayoutIn(BaseModel):
+ """Anordnung der Karten. Die Felder entsprechen react-grid-layout."""
+ layout: list[dict]
+
+
+class DashboardLayoutOut(BaseModel):
+ layout: list[dict]
+ # Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
+ source: str
+ # Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
+ enforced: bool
+ has_default: bool
+
+
+class DashboardStats(BaseModel):
+ products_in_stock: int # Artikel mit Bestand > 0
+ article_units: float # Summe der Artikeleinheiten
+ expiring_soon: int # Chargen innerhalb der Warnfrist (nicht abgelaufen)
+ expired: int # bereits abgelaufene Chargen
+ shopping_items: int # Produkte + Gruppen unter Mindestbestand
+ products_total: int
+
+
+class ExpirySplit(BaseModel):
+ """Artikeleinheiten je Ablaufzustand – Grundlage für Ring und Säulen."""
+ ok: float
+ soon: float
+ expired: float
+ no_date: float # Chargen ohne MHD zählen separat, nicht als "ok"
+
+
+class CategoryShare(BaseModel):
+ category_id: int | None
+ name: str
+ article_units: float
+ ok: float
+ soon: float
+ expired: float
+ no_date: float
+
+
+class TimelinePoint(BaseModel):
+ date: date
+ article_units: float
+
+
+class ActivityPoint(BaseModel):
+ date: date
+ checked_in: int
+ checked_out: int
diff --git a/backend/app/services/conversion.py b/backend/app/services/conversion.py
index faee168..62800dc 100644
--- a/backend/app/services/conversion.py
+++ b/backend/app/services/conversion.py
@@ -88,3 +88,23 @@ def display_unit_info(product: Product) -> tuple[str, float]:
if product.display_unit is not None:
return product.display_unit.name, product.display_unit.factor
return BASE_LABEL[product.base_unit], 1.0
+
+
+def article_unit(product: Product) -> tuple[float, str]:
+ """Faktor und Bezeichnung der *Artikeleinheit* – das zählbare Gebinde.
+
+ Gibt es eine Packungsgröße, ist eine Einheit ein Gebinde (Glas, Packung …),
+ sonst die Anzeigeeinheit des Produkts. Nur diese Größe lässt sich über
+ verschiedene Artikel hinweg sinnvoll addieren: 3 Gläser + 2 Packungen sind
+ 5 Einheiten, während 1000 g + 2 Stück keine sinnvolle Summe ergeben.
+ """
+ if product.package_size and product.package_size > 0:
+ return product.package_size, (product.package_label or "Packung")
+ name, factor = display_unit_info(product)
+ return (factor or 1.0), name
+
+
+def article_units(product: Product, quantity_base: float) -> float:
+ """Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
+ factor, _ = article_unit(product)
+ return quantity_base / factor if factor else quantity_base
diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py
new file mode 100644
index 0000000..02516bb
--- /dev/null
+++ b/backend/tests/test_dashboard.py
@@ -0,0 +1,187 @@
+from datetime import date, timedelta
+
+import pytest
+
+from app.models import BaseUnit, Category, Product, Role, User
+from app.routers import dashboard
+from app.services.conversion import article_unit, article_units
+from app.services.stock import check_in, check_out
+
+
+@pytest.fixture()
+def user(db):
+ person = User(username="tester", password_hash="x", role=Role.admin)
+ db.add(person)
+ db.commit()
+ db.refresh(person)
+ return person
+
+
+# ---------------------------------------------------------------- Einheiten
+def test_artikeleinheit_ist_das_gebinde(rice):
+ # rice: 500 g je Packung -> eine Artikeleinheit sind 500 g
+ faktor, label = article_unit(rice)
+ assert faktor == 500
+ assert label == "Packung"
+ assert article_units(rice, 1500) == 3
+
+
+def test_artikeleinheit_ohne_gebinde_ist_die_produkteinheit(db):
+ eier = Product(name="Eier", base_unit=BaseUnit.piece)
+ db.add(eier)
+ db.commit()
+ faktor, _ = article_unit(eier)
+ assert faktor == 1
+ assert article_units(eier, 6) == 6
+
+
+def test_gebinde_bezeichnung_wird_uebernommen(db):
+ pesto = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195,
+ package_label="Glas")
+ db.add(pesto)
+ db.commit()
+ assert article_unit(pesto) == (195, "Glas")
+
+
+# ------------------------------------------------------------ Ablaufzustand
+def test_zustand_unterscheidet_die_vier_faelle():
+ heute = date(2026, 7, 1)
+ assert dashboard._zustand(None, heute, 7) == "no_date"
+ assert dashboard._zustand(heute - timedelta(days=1), heute, 7) == "expired"
+ assert dashboard._zustand(heute + timedelta(days=3), heute, 7) == "soon"
+ assert dashboard._zustand(heute, heute, 7) == "soon" # heute ist noch nicht abgelaufen
+ assert dashboard._zustand(heute + timedelta(days=30), heute, 7) == "ok"
+
+
+def test_expiry_split_zaehlt_in_artikeleinheiten(db, rice, user):
+ heute = date.today()
+ check_in(db, rice, 2, "package", heute + timedelta(days=60), None, user) # ok
+ check_in(db, rice, 1, "package", heute + timedelta(days=3), None, user) # bald
+ check_in(db, rice, 3, "package", heute - timedelta(days=2), None, user) # abgelaufen
+ check_in(db, rice, 4, "package", None, None, user) # ohne MHD
+ db.commit()
+
+ split = dashboard.expiry_split(db=db, _=user)
+ assert split.ok == 2
+ assert split.soon == 1
+ assert split.expired == 3
+ assert split.no_date == 4
+
+
+def test_by_category_bündelt_und_sortiert(db, user):
+ suess = Category(name="Süßwaren")
+ db.add(suess)
+ db.commit()
+
+ chips = Product(name="Chips", base_unit=BaseUnit.gram, package_size=175,
+ package_label="Tüte", category_id=suess.id)
+ ohne = Product(name="Salz", base_unit=BaseUnit.gram, package_size=500)
+ db.add_all([chips, ohne])
+ db.commit()
+
+ check_in(db, chips, 5, "package", None, None, user)
+ check_in(db, ohne, 2, "package", None, None, user)
+ db.commit()
+
+ anteile = dashboard.by_category(db=db, _=user)
+ assert [a.name for a in anteile] == ["Süßwaren", "Ohne Kategorie"]
+ assert anteile[0].article_units == 5
+ assert anteile[1].article_units == 2
+
+
+# ----------------------------------------------------------------- Verlauf
+def test_timeline_rechnet_bewegungen_zurueck(db, rice, user):
+ """Der Verlauf entsteht rueckwaerts aus dem heutigen Bestand."""
+ check_in(db, rice, 4, "package", None, None, user) # +4 Einheiten heute
+ check_out(db, rice, 1, "package", user) # -1 Einheit heute
+ db.commit()
+
+ punkte = dashboard.timeline(days=3, product_id=None, db=db, _=user)
+ assert len(punkte) == 3
+ # Heute: 3 Einheiten; die Tage davor lag nichts im Lager.
+ assert punkte[-1].date == date.today()
+ assert punkte[-1].article_units == 3
+ assert punkte[0].article_units == 0
+ assert punkte[1].article_units == 0
+
+
+def test_timeline_kann_auf_ein_produkt_eingegrenzt_werden(db, rice, user):
+ anderes = Product(name="Zucker", base_unit=BaseUnit.gram, package_size=1000)
+ db.add(anderes)
+ db.commit()
+ check_in(db, rice, 2, "package", None, None, user)
+ check_in(db, anderes, 5, "package", None, None, user)
+ db.commit()
+
+ gesamt = dashboard.timeline(days=2, product_id=None, db=db, _=user)
+ nur_reis = dashboard.timeline(days=2, product_id=rice.id, db=db, _=user)
+ assert gesamt[-1].article_units == 7
+ assert nur_reis[-1].article_units == 2
+
+
+def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
+ check_in(db, rice, 2, "package", None, None, user)
+ check_in(db, rice, 1, "package", None, None, user)
+ check_out(db, rice, 1, "package", user)
+ db.commit()
+
+ tage = dashboard.activity(days=3, db=db, _=user)
+ assert len(tage) == 3
+ heute = tage[-1]
+ assert heute.date == date.today()
+ assert heute.checked_in == 2
+ assert heute.checked_out == 1
+
+
+# ---------------------------------------------------------------- Anordnung
+def test_layout_faellt_auf_die_eingebaute_zurueck(db, user):
+ antwort = dashboard.get_layout(db=db, user=user)
+ assert antwort.source == "builtin"
+ assert antwort.enforced is False
+ assert antwort.has_default is False
+ assert any(k["i"] == "expiring" for k in antwort.layout)
+
+
+def test_eigene_anordnung_wird_gespeichert(db, user):
+ from app.schemas import DashboardLayoutIn
+
+ eigen = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
+ dashboard.put_layout(DashboardLayoutIn(layout=eigen), db=db, user=user)
+
+ antwort = dashboard.get_layout(db=db, user=user)
+ assert antwort.source == "user"
+ assert antwort.layout == eigen
+
+
+def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
+ from fastapi import HTTPException
+
+ from app.schemas import DashboardLayoutIn
+
+ vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
+ dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
+ dashboard.set_enforced(True, db=db, admin=user)
+
+ antwort = dashboard.get_layout(db=db, user=user)
+ assert antwort.source == "default"
+ assert antwort.enforced is True
+ assert antwort.layout == vorgabe
+
+ with pytest.raises(HTTPException) as fehler:
+ dashboard.put_layout(DashboardLayoutIn(layout=[]), db=db, user=user)
+ assert fehler.value.status_code == 403
+
+
+def test_zuruecksetzen_stellt_die_vorgabe_wieder_her(db, user):
+ from app.schemas import DashboardLayoutIn
+
+ vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
+ dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
+ dashboard.put_layout(DashboardLayoutIn(layout=[{"i": "shopping", "x": 0, "y": 0, "w": 4, "h": 3}]),
+ db=db, user=user)
+ assert dashboard.get_layout(db=db, user=user).source == "user"
+
+ dashboard.reset_layout(db=db, user=user)
+ antwort = dashboard.get_layout(db=db, user=user)
+ assert antwort.source == "default"
+ assert antwort.layout == vorgabe
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 92c38d7..569e126 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -66,6 +66,29 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
Artikel darin lagen.
- **Eigenes Logo und Favicon** je Installation (Einstellungen → Darstellung).
+## Schritt 2c – Modulare Startseite mit Auswertungen ✅
+- **Raster statt fester Seite:** Die Übersicht besteht aus Karten, die sich ziehen,
+ in der Größe ändern und einrasten lassen (react-grid-layout, 12 Spalten).
+ „Anordnen" schaltet das frei, gespeichert wird ausdrücklich.
+- **Pro Benutzer eigene Anordnung** (`dashboard_layouts`, `user_id = NULL` ist die
+ Admin-Vorgabe). Admins speichern eine Vorgabe für alle und können sie über
+ `dashboard_enforced` auch **verbindlich** setzen; Benutzer setzen jederzeit
+ auf die Vorgabe zurück.
+- **15 Karten** im Verzeichnis `web/src/dashboard/cards.jsx`: Schnellzugriff,
+ Status, zwei Kennzahlen, Bald ablaufend, Abgelaufen, Einkaufsliste, letzte
+ Bewegungen sowie sechs Auswertungen. Eine neue Karte ist ein Eintrag mehr.
+- **Diagramme als eigene SVG-Komponenten** (kein Diagramm-Paket): Ring, gestapelte
+ Balken, Linie mit Fadenkreuz. Zustandsfarben (ok/bald/abgelaufen) sind
+ Statusangaben aus den Tokens und stehen nie ohne Beschriftung; Kategorien nutzen
+ eine geprüfte Farbreihe in fester Ordnung, ab sieben Kategorien wird zu „Andere"
+ gebündelt. Hell und Dunkel haben eigene Stufen.
+- **Alle Mengen in Artikeleinheiten** (Gläser, Packungen, Stück). Gramm und Stück
+ lassen sich nicht addieren – Gebinde schon. Der Helfer dafür liegt jetzt einmal
+ zentral in `backend/app/services/conversion.py::article_unit`.
+- **Verlauf** wird aus den Bewegungen rückwärts vom heutigen Bestand rekonstruiert.
+ *Bekannte Ungenauigkeit:* Die Umrechnung nutzt die heutige Packungsgröße; wird
+ sie später geändert, verschiebt sich auch die Historie.
+
## Schritt 3 – Weitere Ideen – offen
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
- **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
diff --git a/web/package-lock.json b/web/package-lock.json
index 6b4f624..e792009 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-grid-layout": "^1.5.3",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
@@ -1337,6 +1338,15 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1421,6 +1431,12 @@
"node": ">=6"
}
},
+ "node_modules/fast-equals": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
+ "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1554,6 +1570,15 @@
"node": ">=18"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1603,6 +1628,17 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1628,6 +1664,44 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-draggable": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz",
+ "integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0",
+ "react-dom": ">= 16.3.0"
+ }
+ },
+ "node_modules/react-grid-layout": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz",
+ "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "fast-equals": "^4.0.3",
+ "prop-types": "^15.8.1",
+ "react-draggable": "^4.4.6",
+ "react-resizable": "^3.0.5",
+ "resize-observer-polyfill": "^1.5.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0",
+ "react-dom": ">= 16.3.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1638,6 +1712,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-resizable": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
+ "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "15.x",
+ "react-draggable": "^4.5.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3",
+ "react-dom": ">= 16.3"
+ }
+ },
"node_modules/react-router": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
@@ -1670,6 +1758,12 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/resize-observer-polyfill": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+ "license": "MIT"
+ },
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
diff --git a/web/package.json b/web/package.json
index df962b5..1acb134 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-grid-layout": "^1.5.3",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
diff --git a/web/src/api.js b/web/src/api.js
index 81433d3..94f1894 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -140,6 +140,21 @@ export const api = {
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
+ // Startseite: Anordnung und Auswertungen
+ dashboardLayout: () => request("/dashboard/layout"),
+ saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: { layout } }),
+ resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
+ saveDashboardDefault: (layout) =>
+ request("/dashboard/layout/default", { method: "PUT", body: { layout } }),
+ setDashboardEnforced: (value) =>
+ request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
+ dashboardStats: () => request("/dashboard/stats"),
+ dashboardExpirySplit: () => request("/dashboard/expiry-split"),
+ dashboardByCategory: () => request("/dashboard/by-category"),
+ dashboardTimeline: (days, productId) =>
+ request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
+ dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
+
// Stammdaten
listLocations: () => request("/locations"),
createLocation: (body) => request("/locations", { method: "POST", body }),
diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx
new file mode 100644
index 0000000..029510d
--- /dev/null
+++ b/web/src/dashboard/cards.jsx
@@ -0,0 +1,410 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { api } from "../api";
+import Icon from "../components/Icon";
+import { useSettings } from "../settings";
+import {
+ amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
+} from "../units";
+import Bars from "./charts/Bars";
+import Donut from "./charts/Donut";
+import Line from "./charts/Line";
+import { istDunkel, mitFarben, tokenFarbe } from "./palette";
+
+/**
+ * Verzeichnis aller Karten der Startseite.
+ *
+ * Eine Karte ist ein Eintrag hier – mehr braucht es nicht, um sie im
+ * Hinzufügen-Menü und im Raster verfügbar zu machen. Jede Karte lädt ihre
+ * eigenen Daten; so kostet eine nicht genutzte Karte auch keine Abfrage.
+ */
+
+// ---------------------------------------------------------------- Helfer
+function useDaten(laden, abhaengig = []) {
+ const [daten, setDaten] = useState(null);
+ const [fehler, setFehler] = useState(null);
+ useEffect(() => {
+ let aktiv = true;
+ laden()
+ .then((d) => aktiv && setDaten(d))
+ .catch((e) => aktiv && setFehler(e.message));
+ return () => { aktiv = false; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, abhaengig);
+ return { daten, fehler };
+}
+
+function Zustand({ fehler, daten, children, leer = "Keine Daten." }) {
+ if (fehler) return
{fehler}
;
+ if (daten == null) return Lädt…
;
+ if (Array.isArray(daten) && daten.length === 0) return {leer}
;
+ return children;
+}
+
+function kurzesDatum(iso) {
+ const d = new Date(`${iso}T00:00:00`);
+ return Number.isNaN(d.getTime())
+ ? iso
+ : d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
+}
+
+// ---------------------------------------------------------------- Aktionen
+function KarteAktionen() {
+ return (
+
+ Einlagern
+ Auslagern
+
+ );
+}
+
+// ---------------------------------------------------------------- Kennzahlen
+function Kennzahl({ wert, label, icon, ton }) {
+ return (
+
+ );
+}
+
+function KarteStatus() {
+ const { daten, fehler } = useDaten(() => api.dashboardStats());
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function KarteArtikelzahl() {
+ const { daten, fehler } = useDaten(() => api.dashboardStats());
+ return (
+
+
+
+ );
+}
+
+function KarteEinheiten() {
+ const { daten, fehler } = useDaten(() => api.dashboardStats());
+ return (
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------- Listen
+function KarteAblauf({ nurAbgelaufen = false }) {
+ const { formatBestBefore } = useSettings();
+ const { daten, fehler } = useDaten(() => api.expiring());
+ const zeilen = (daten || []).filter((e) => (nurAbgelaufen ? e.days_left < 0 : true));
+
+ return (
+
+ {zeilen.length === 0 ? (
+ {nurAbgelaufen ? "Nichts ist abgelaufen." : "Nichts läuft demnächst ab."}
+ ) : (
+
+
+
+ | Produkt | Menge | MHD | Frist |
+
+
+ {zeilen.map((it) => (
+
+ | {it.product_name} |
+
+ {articlePrimary(it)}
+ {articleSecondary(it) && {articleSecondary(it)} }
+ |
+ {formatBestBefore(it.best_before, it.best_before_precision)} |
+
+
+ {relativeExpiry(it.days_left)}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+function KarteEinkaufsliste() {
+ const { daten, fehler } = useDaten(async () => {
+ const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]);
+ return { produkte, gruppen };
+ });
+
+ if (fehler) return {fehler}
;
+ if (!daten) return Lädt…
;
+ if (!daten.produkte.length && !daten.gruppen.length) {
+ return Alle Mindestbestände erreicht.
;
+ }
+
+ return (
+
+
+
+ | Bedarf | Bestand | Fehlt |
+
+
+ {daten.gruppen.map((it) => (
+
+ | Gruppe {it.name} |
+ {fmt(it.stock)} {it.unit_name} |
+ {fmt(it.deficit)} {it.unit_name} |
+
+ ))}
+ {daten.produkte.map((it) => (
+
+ | {it.name} |
+ {amountText(it.stock, it.package_size, it.base_unit)} |
+ {amountText(it.deficit, it.package_size, it.base_unit)} |
+
+ ))}
+
+
+
+ );
+}
+
+const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
+
+function KarteBewegungen() {
+ const { daten, fehler } = useDaten(() => api.listMovements(20));
+ return (
+
+
+
+ | Zeitpunkt | Aktion | Produkt |
+
+ {(daten || []).map((m) => (
+
+ |
+ {new Date(m.created_at).toLocaleString("de-DE", {
+ day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
+ })}
+ |
+
+
+ {BEWEGUNG_LABEL[m.type] || m.type}
+
+ |
+ {m.product_name} |
+
+ ))}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------- Diagramme
+function KarteAblaufRing() {
+ const { daten, fehler } = useDaten(() => api.dashboardExpirySplit());
+ if (fehler) return {fehler}
;
+ if (!daten) return Lädt…
;
+
+ // Zustandsfarben, nie Serienfarben – und immer mit Beschriftung.
+ const segmente = [
+ { name: "In Ordnung", value: daten.ok, color: tokenFarbe("--ok", "#2e7d55") },
+ { name: "Bald ablaufend", value: daten.soon, color: tokenFarbe("--warn", "#b06f14") },
+ { name: "Abgelaufen", value: daten.expired, color: tokenFarbe("--danger", "#c0392b") },
+ { name: "Ohne MHD", value: daten.no_date, color: tokenFarbe("--muted", "#6b7480") },
+ ];
+ return ;
+}
+
+function KarteKategorienRing() {
+ const { daten, fehler } = useDaten(() => api.dashboardByCategory());
+ if (fehler) return {fehler}
;
+ if (!daten) return Lädt…
;
+
+ const segmente = mitFarben(
+ daten.map((k) => ({ name: k.name, value: k.article_units })),
+ istDunkel(),
+ );
+ return ;
+}
+
+function KarteKategorienSaeulen() {
+ const { daten, fehler } = useDaten(() => api.dashboardByCategory());
+ if (fehler) return {fehler}
;
+ if (!daten) return Lädt…
;
+
+ const zeilen = daten.slice(0, 8).map((k) => ({
+ name: k.name,
+ teile: [
+ { key: "ok", value: k.ok, label: "In Ordnung", color: tokenFarbe("--ok", "#2e7d55") },
+ { key: "soon", value: k.soon, label: "Bald ablaufend", color: tokenFarbe("--warn", "#b06f14") },
+ { key: "expired", value: k.expired, label: "Abgelaufen", color: tokenFarbe("--danger", "#c0392b") },
+ { key: "no_date", value: k.no_date, label: "Ohne MHD", color: tokenFarbe("--muted", "#6b7480") },
+ ],
+ }));
+
+ return (
+ <>
+
+ {[["In Ordnung", "--ok"], ["Bald ablaufend", "--warn"], ["Abgelaufen", "--danger"], ["Ohne MHD", "--muted"]]
+ .map(([name, token]) => (
+ -
+
+ {name}
+
+ ))}
+
+
+ >
+ );
+}
+
+function KarteBestandsverlauf({ tage = 90 }) {
+ const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
+ if (fehler) return {fehler}
;
+ if (!daten) return Lädt…
;
+
+ return (
+ kurzesDatum(p.date))}
+ serien={[{
+ name: "Artikeleinheiten",
+ color: tokenFarbe("--accent", "#3f51b5"),
+ werte: daten.map((p) => p.article_units),
+ }]}
+ einheit="Einheiten"
+ />
+ );
+}
+
+function KarteArtikelverlauf() {
+ const [produkte, setProdukte] = useState([]);
+ const [gewaehlt, setGewaehlt] = useState("");
+ const { daten, fehler } = useDaten(
+ () => (gewaehlt ? api.dashboardTimeline(90, gewaehlt) : Promise.resolve([])),
+ [gewaehlt],
+ );
+
+ useEffect(() => {
+ api.listProducts().then((liste) => {
+ setProdukte(liste);
+ if (liste.length && !gewaehlt) setGewaehlt(String(liste[0].id));
+ }).catch(() => {});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+ {fehler ? (
+
{fehler}
+ ) : (
+
kurzesDatum(p.date))}
+ serien={[{
+ name: "Bestand",
+ color: tokenFarbe("--accent", "#3f51b5"),
+ werte: (daten || []).map((p) => p.article_units),
+ }]}
+ einheit="Einheiten"
+ />
+ )}
+
+ );
+}
+
+function KarteBewegungsverlauf({ tage = 30 }) {
+ const { daten, fehler } = useDaten(() => api.dashboardActivity(tage), [tage]);
+ if (fehler) return {fehler}
;
+ if (!daten) return Lädt…
;
+
+ return (
+ kurzesDatum(p.date))}
+ serien={[
+ { name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) },
+ { name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) },
+ ]}
+ einheit="Vorgänge"
+ />
+ );
+}
+
+// ---------------------------------------------------------------- Verzeichnis
+export const KARTEN = {
+ actions: {
+ titel: "Schnellzugriff", icon: "checkin", standard: { w: 4, h: 1 }, min: { w: 3, h: 1 },
+ komponente: KarteAktionen,
+ },
+ status: {
+ titel: "Status", icon: "dashboard", standard: { w: 8, h: 1 }, min: { w: 4, h: 1 },
+ komponente: KarteStatus,
+ },
+ "kpi-products": {
+ titel: "Artikel mit Bestand", icon: "package", standard: { w: 3, h: 1 }, min: { w: 2, h: 1 },
+ komponente: KarteArtikelzahl,
+ },
+ "kpi-units": {
+ titel: "Artikeleinheiten", icon: "box", standard: { w: 3, h: 1 }, min: { w: 2, h: 1 },
+ komponente: KarteEinheiten,
+ },
+ expiring: {
+ titel: "Bald ablaufend", icon: "clock", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
+ komponente: () => ,
+ },
+ expired: {
+ titel: "Abgelaufen", icon: "alert", standard: { w: 6, h: 2 }, min: { w: 4, h: 2 },
+ komponente: () => ,
+ },
+ shopping: {
+ titel: "Einkaufsliste", icon: "cart", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
+ komponente: KarteEinkaufsliste,
+ },
+ movements: {
+ titel: "Letzte Bewegungen", icon: "history", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
+ komponente: KarteBewegungen,
+ },
+ "expiry-donut": {
+ titel: "Ablauf-Anteile", icon: "clock", standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
+ komponente: KarteAblaufRing,
+ },
+ "category-donut": {
+ titel: "Kategorien-Anteile", icon: "tag", standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
+ komponente: KarteKategorienRing,
+ },
+ "category-bars": {
+ titel: "Kategorien nach Zustand", icon: "tag", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
+ komponente: KarteKategorienSaeulen,
+ },
+ "stock-timeline": {
+ titel: "Bestandsverlauf", icon: "history", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
+ komponente: KarteBestandsverlauf,
+ },
+ "product-timeline": {
+ titel: "Verlauf eines Artikels", icon: "package", standard: { w: 8, h: 3 }, min: { w: 4, h: 3 },
+ komponente: KarteArtikelverlauf,
+ },
+ "activity-timeline": {
+ titel: "Ein- und Auslagerungen", icon: "history", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
+ komponente: KarteBewegungsverlauf,
+ },
+};
+
+export const KARTEN_IDS = Object.keys(KARTEN);
diff --git a/web/src/dashboard/charts/Bars.jsx b/web/src/dashboard/charts/Bars.jsx
new file mode 100644
index 0000000..cb3b1b4
--- /dev/null
+++ b/web/src/dashboard/charts/Bars.jsx
@@ -0,0 +1,56 @@
+import { useState } from "react";
+import { fmt } from "../../units";
+
+/**
+ * Gestapelte waagerechte Säulen (Balken).
+ *
+ * Waagerecht, weil die Beschriftungen Kategorienamen sind – die brauchen Platz
+ * und wären senkrecht gedreht kaum lesbar. Zwischen gestapelten Abschnitten
+ * bleibt eine Lücke in Hintergrundfarbe, damit angrenzende Flächen sich trennen.
+ *
+ * zeilen: [{ name, teile: [{ key, value, color, label }] }]
+ */
+export default function Bars({ zeilen = [], einheit = "" }) {
+ const [aktiv, setAktiv] = useState(null);
+
+ const summen = zeilen.map((z) => z.teile.reduce((s, t) => s + t.value, 0));
+ const groesste = Math.max(...summen, 0);
+
+ if (!zeilen.length || groesste <= 0) {
+ return Keine Daten vorhanden.
;
+ }
+
+ return (
+
+ {zeilen.map((zeile, i) => {
+ const summe = summen[i];
+ return (
+
+
{zeile.name}
+
+ {zeile.teile.filter((t) => t.value > 0).map((teil) => (
+
setAktiv({ zeile: zeile.name, ...teil })}
+ onMouseLeave={() => setAktiv(null)}
+ />
+ ))}
+
+
{fmt(summe)}
+
+ );
+ })}
+
+ {aktiv && (
+
+ {aktiv.zeile} · {aktiv.label}: {fmt(aktiv.value)} {einheit}
+
+ )}
+
+ );
+}
diff --git a/web/src/dashboard/charts/Donut.jsx b/web/src/dashboard/charts/Donut.jsx
new file mode 100644
index 0000000..3ce4696
--- /dev/null
+++ b/web/src/dashboard/charts/Donut.jsx
@@ -0,0 +1,83 @@
+import { useState } from "react";
+import { fmt } from "../../units";
+
+/**
+ * Ringdiagramm für Anteile.
+ *
+ * Ring statt Vollkreis: In der Mitte steht die Gesamtsumme, die man sonst
+ * nirgends ablesen könnte. Zwischen den Segmenten liegt eine schmale Lücke in
+ * Hintergrundfarbe – so bleiben benachbarte Flächen auch dann unterscheidbar,
+ * wenn ihre Farben ähnlich wirken.
+ *
+ * segmente: [{ name, value, color }]
+ */
+export default function Donut({ segmente = [], gesamtLabel = "gesamt", einheit = "" }) {
+ const [aktiv, setAktiv] = useState(null);
+
+ const werte = segmente.filter((s) => s.value > 0);
+ const summe = werte.reduce((s, e) => s + e.value, 0);
+
+ if (summe <= 0) {
+ return
Keine Daten vorhanden.
;
+ }
+
+ const groesse = 180;
+ const mitte = groesse / 2;
+ const radius = 74;
+ const dicke = 26;
+ const umfang = 2 * Math.PI * radius;
+ const luecke = 2; // Pixel Abstand zwischen den Segmenten
+
+ let versatz = 0;
+ const boegen = werte.map((s) => {
+ const anteil = s.value / summe;
+ const laenge = Math.max(anteil * umfang - luecke, 0.5);
+ const bogen = { ...s, laenge, versatz, anteil };
+ versatz += anteil * umfang;
+ return bogen;
+ });
+
+ const gezeigt = aktiv ?? null;
+
+ return (
+
+
+
+
+ {boegen.map((b) => (
+ - setAktiv(b)}
+ onMouseLeave={() => setAktiv(null)}>
+
+ {b.name}
+
+ {fmt(b.value)} · {Math.round(b.anteil * 100)} %
+
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/dashboard/charts/Line.jsx b/web/src/dashboard/charts/Line.jsx
new file mode 100644
index 0000000..9662e1f
--- /dev/null
+++ b/web/src/dashboard/charts/Line.jsx
@@ -0,0 +1,139 @@
+import { useEffect, useRef, useState } from "react";
+import { fmt } from "../../units";
+import { tokenFarbe } from "../palette";
+
+/** Misst die tatsächliche Breite, damit das SVG in echten Pixeln zeichnet.
+ * Ein festes viewBox mit Skalierung würde Linienstärken verzerren. */
+function useBreite(ref) {
+ const [breite, setBreite] = useState(0);
+ useEffect(() => {
+ if (!ref.current) return undefined;
+ const beobachter = new ResizeObserver(([eintrag]) => {
+ setBreite(eintrag.contentRect.width);
+ });
+ beobachter.observe(ref.current);
+ return () => beobachter.disconnect();
+ }, [ref]);
+ return breite;
+}
+
+/**
+ * Liniendiagramm über die Zeit.
+ *
+ * Bewusst nur EINE Werteachse: Zwei Skalen in einem Bild lassen sich beliebig
+ * gegeneinander verschieben und suggerieren Zusammenhänge, die es nicht gibt.
+ * Serien mit unterschiedlichen Größenordnungen gehören in getrennte Karten.
+ *
+ * labels: ["01.07.", …] – gemeinsame X-Achse
+ * serien: [{ name, color, werte: number[] }]
+ */
+export default function Line({ labels = [], serien = [], einheit = "", hoehe = 200 }) {
+ const wirt = useRef(null);
+ const breite = useBreite(wirt);
+ const [index, setIndex] = useState(null);
+
+ const hatDaten = labels.length > 1 && serien.some((s) => s.werte?.length);
+ if (!hatDaten) {
+ return
;
+ }
+
+ const rand = { oben: 12, rechts: 12, unten: 24, links: 44 };
+ const w = Math.max(breite, 240);
+ const h = hoehe;
+ const flaecheB = Math.max(w - rand.links - rand.rechts, 10);
+ const flaecheH = Math.max(h - rand.oben - rand.unten, 10);
+
+ const alleWerte = serien.flatMap((s) => s.werte);
+ const maxWert = Math.max(...alleWerte, 1);
+ // Von null aus: Bei Mengen ist eine abgeschnittene Achse irreführend.
+ const skalaMax = maxWert * 1.1;
+
+ const x = (i) => rand.links + (labels.length === 1 ? 0 : (i / (labels.length - 1)) * flaecheB);
+ const y = (wert) => rand.oben + flaecheH - (wert / skalaMax) * flaecheH;
+
+ const gitter = [0, 0.5, 1].map((teil) => ({
+ wert: skalaMax * teil,
+ y: y(skalaMax * teil),
+ }));
+
+ // Nur wenige Datumsbeschriftungen, sonst überlappen sie.
+ const schritt = Math.max(1, Math.round(labels.length / 5));
+ const achse = labels.map((l, i) => ({ l, i })).filter(({ i }) => i % schritt === 0);
+
+ const gitterFarbe = tokenFarbe("--border", "#ddd");
+ const textFarbe = tokenFarbe("--muted", "#888");
+
+ function bewegen(event) {
+ const box = event.currentTarget.getBoundingClientRect();
+ const rel = event.clientX - box.left - rand.links;
+ const i = Math.round((rel / flaecheB) * (labels.length - 1));
+ setIndex(Math.min(Math.max(i, 0), labels.length - 1));
+ }
+
+ return (
+
+ {serien.length > 1 && (
+
+ {serien.map((s) => (
+ -
+
+ {s.name}
+
+ ))}
+
+ )}
+
+
+
+
+ {index != null && (
+
+
{labels[index]}
+ {serien.map((s) => (
+
+
+ {serien.length > 1 && {s.name}}
+ {fmt(s.werte[index] ?? 0)}
+ {einheit && {einheit}}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/web/src/dashboard/palette.js b/web/src/dashboard/palette.js
new file mode 100644
index 0000000..10c5c92
--- /dev/null
+++ b/web/src/dashboard/palette.js
@@ -0,0 +1,94 @@
+/**
+ * Farben für die Diagramme.
+ *
+ * Zwei getrennte Aufgaben, zwei getrennte Paletten – das ist keine Kosmetik:
+ *
+ * 1. ZUSTAND (ok / bald ablaufend / abgelaufen) ist eine *Statusangabe*. Dafür
+ * gelten die Tokens der Anwendung (--ok, --warn, --danger). Statusfarben
+ * werden nie als Serienfarben zweckentfremdet und stehen nie allein: In der
+ * Legende steht immer auch der Text.
+ *
+ * 2. KATEGORIEN sind *Identität*. Dafür eine feste Reihenfolge geprüfter Farben.
+ * Die Reihenfolge ist Teil der Sicherheit gegen Farbfehlsichtigkeit – sie
+ * wird nicht umsortiert und nicht im Kreis wiederholt. Über sechs Kategorien
+ * hinaus wird zu „Andere“ gebündelt, statt weitere Farben zu erfinden.
+ *
+ * Dunkel ist kein Umdrehen der Hellwerte, sondern eigene Stufen für den dunklen
+ * Untergrund.
+ */
+
+// Feste Reihenfolge. Höchstens SERIES_MAX Einträge werden eingefärbt.
+export const CATEGORY_COLORS_LIGHT = [
+ "#2a78d6", // blau
+ "#eb6834", // orange
+ "#1baf7a", // aqua
+ "#eda100", // gelb
+ "#e87ba4", // magenta
+ "#008300", // grün
+];
+export const CATEGORY_COLORS_DARK = [
+ "#3987e5",
+ "#d95926",
+ "#199e70",
+ "#c98500",
+ "#d55181",
+ "#008300",
+];
+
+/** Ab hier wird gebündelt – „Andere“ bekommt einen neutralen Grauton. */
+export const SERIES_MAX = 6;
+export const OTHER_COLOR_LIGHT = "#8a8f98";
+export const OTHER_COLOR_DARK = "#6f7783";
+
+/** Erkennt das aktive Farbschema (Systemeinstellung + eigener Umschalter). */
+export function istDunkel() {
+ if (typeof document === "undefined") return false;
+ const gesetzt = document.documentElement.getAttribute("data-theme");
+ if (gesetzt === "dark") return true;
+ if (gesetzt === "light") return false;
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
+}
+
+export function kategorieFarben(dunkel) {
+ return dunkel ? CATEGORY_COLORS_DARK : CATEGORY_COLORS_LIGHT;
+}
+
+export function andereFarbe(dunkel) {
+ return dunkel ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT;
+}
+
+/**
+ * Bündelt eine sortierte Liste auf SERIES_MAX Einträge plus „Andere“ und
+ * vergibt die Farben in fester Reihenfolge.
+ * Erwartet Objekte mit { name, value }.
+ */
+export function mitFarben(eintraege, dunkel) {
+ const farben = kategorieFarben(dunkel);
+ const sortiert = [...eintraege].sort((a, b) => b.value - a.value);
+ const vorne = sortiert.slice(0, SERIES_MAX).map((e, i) => ({ ...e, color: farben[i] }));
+ const rest = sortiert.slice(SERIES_MAX);
+ if (rest.length === 0) return vorne;
+ return [
+ ...vorne,
+ {
+ name: `Andere (${rest.length})`,
+ value: rest.reduce((s, e) => s + e.value, 0),
+ color: andereFarbe(dunkel),
+ },
+ ];
+}
+
+/** Zustandsfarben kommen aus den CSS-Tokens, damit Hell/Dunkel automatisch passt. */
+export const STATUS_SERIES = [
+ { key: "ok", name: "In Ordnung", cssVar: "--ok" },
+ { key: "soon", name: "Bald ablaufend", cssVar: "--warn" },
+ { key: "expired", name: "Abgelaufen", cssVar: "--danger" },
+ { key: "no_date", name: "Ohne MHD", cssVar: "--muted" },
+];
+
+/** Liest einen CSS-Tokenwert aus – die Diagramme zeichnen in echtem SVG. */
+export function tokenFarbe(name, fallback = "#888") {
+ if (typeof document === "undefined") return fallback;
+ const wert = getComputedStyle(document.documentElement).getPropertyValue(name);
+ return wert?.trim() || fallback;
+}
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx
index 698dd84..c60ad46 100644
--- a/web/src/pages/Dashboard.jsx
+++ b/web/src/pages/Dashboard.jsx
@@ -1,142 +1,271 @@
-import { useEffect, useState } from "react";
-import { Link } from "react-router-dom";
+import { useEffect, useMemo, useState } from "react";
+import GridLayout, { WidthProvider } from "react-grid-layout";
import { api } from "../api";
+import { useAuth } from "../auth";
import Icon from "../components/Icon";
-import { useSettings } from "../settings";
-import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units";
+import { useConfirm } from "../confirm";
+import { KARTEN, KARTEN_IDS } from "../dashboard/cards";
+const Raster = WidthProvider(GridLayout);
+
+const SPALTEN = 12;
+const ZEILENHOEHE = 96;
+
+/**
+ * Startseite als frei zusammenstellbares Raster.
+ *
+ * Ansehen ist der Normalzustand. Erst „Anordnen“ schaltet Ziehen, Größe ändern
+ * und Entfernen frei – so verschiebt man nichts versehentlich beim Lesen.
+ * Gespeichert wird ausdrücklich, damit ein Probieren folgenlos bleibt.
+ */
export default function Dashboard() {
- const { formatBestBefore } = useSettings();
- const [expiring, setExpiring] = useState([]);
- const [shopping, setShopping] = useState([]);
- const [groupShopping, setGroupShopping] = useState([]);
- const [error, setError] = useState(null);
+ const { isAdmin } = useAuth();
+ const confirm = useConfirm();
- useEffect(() => {
- async function load() {
- try {
- const [e, s, g] = await Promise.all([
- api.expiring(),
- api.shoppingList(),
- api.groupShoppingList(),
- ]);
- setExpiring(e);
- setShopping(s);
- setGroupShopping(g);
- } catch (err) {
- setError(err.message);
- }
+ const [layout, setLayout] = useState([]);
+ const [entwurf, setEntwurf] = useState(null); // nur im Bearbeitungsmodus gesetzt
+ const [herkunft, setHerkunft] = useState("builtin");
+ const [erzwungen, setErzwungen] = useState(false);
+ const [hatVorgabe, setHatVorgabe] = useState(false);
+ const [fehler, setFehler] = useState(null);
+ const [hinweis, setHinweis] = useState(null);
+ const [menuOffen, setMenuOffen] = useState(false);
+
+ const bearbeiten = entwurf !== null;
+ const aktuell = entwurf ?? layout;
+
+ async function laden() {
+ try {
+ const antwort = await api.dashboardLayout();
+ setLayout(bereinige(antwort.layout));
+ setHerkunft(antwort.source);
+ setErzwungen(antwort.enforced);
+ setHatVorgabe(antwort.has_default);
+ } catch (err) {
+ setFehler(err.message);
}
- load();
- }, []);
+ }
- const overdue = expiring.filter((e) => e.days_left < 0).length;
+ useEffect(() => { laden(); }, []);
+
+ /** Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt. */
+ function bereinige(liste) {
+ return (liste || []).filter((k) => KARTEN[k.i]);
+ }
+
+ const verfuegbar = useMemo(
+ () => KARTEN_IDS.filter((id) => !aktuell.some((k) => k.i === id)),
+ [aktuell],
+ );
+
+ /** Mindestgrößen kommen aus dem Verzeichnis und gehören in die Rasterangabe –
+ * nicht zusätzlich als data-grid, sonst gibt es zwei Quellen der Wahrheit. */
+ const rasterLayout = useMemo(
+ () => aktuell.map((k) => ({
+ ...k,
+ minW: KARTEN[k.i]?.min.w ?? 2,
+ minH: KARTEN[k.i]?.min.h ?? 1,
+ })),
+ [aktuell],
+ );
+
+ function starteBearbeitung() {
+ setEntwurf(aktuell.map((k) => ({ ...k })));
+ setHinweis(null);
+ }
+
+ function hinzufuegen(id) {
+ const karte = KARTEN[id];
+ const untenY = aktuell.reduce((max, k) => Math.max(max, k.y + k.h), 0);
+ setEntwurf([...(entwurf ?? []), { i: id, x: 0, y: untenY, ...karte.standard }]);
+ setMenuOffen(false);
+ }
+
+ function entfernen(id) {
+ setEntwurf((alt) => (alt ?? []).filter((k) => k.i !== id));
+ }
+
+ async function speichern() {
+ setFehler(null);
+ try {
+ const antwort = await api.saveDashboardLayout(schlank(entwurf));
+ setLayout(bereinige(antwort.layout));
+ setHerkunft(antwort.source);
+ setEntwurf(null);
+ setHinweis("Anordnung gespeichert.");
+ } catch (err) {
+ setFehler(err.message);
+ }
+ }
+
+ async function alsVorgabe() {
+ setFehler(null);
+ try {
+ await api.saveDashboardDefault(schlank(entwurf ?? layout));
+ setHatVorgabe(true);
+ setHinweis("Als Vorgabe für alle Benutzer gespeichert.");
+ } catch (err) {
+ setFehler(err.message);
+ }
+ }
+
+ async function erzwingen(wert) {
+ setFehler(null);
+ try {
+ const antwort = await api.setDashboardEnforced(wert);
+ setErzwungen(antwort.enforced);
+ setLayout(bereinige(antwort.layout));
+ setHerkunft(antwort.source);
+ setEntwurf(null);
+ setHinweis(wert
+ ? "Die Startseite ist jetzt für alle verbindlich."
+ : "Benutzer dürfen ihre Startseite wieder selbst anordnen.");
+ } catch (err) {
+ setFehler(err.message);
+ }
+ }
+
+ async function zuruecksetzen() {
+ const ok = await confirm({
+ title: "Auf Vorgabe zurücksetzen?",
+ message: "Deine persönliche Anordnung wird verworfen.",
+ confirmLabel: "Zurücksetzen",
+ danger: true,
+ });
+ if (!ok) return;
+ try {
+ await api.resetDashboardLayout();
+ setEntwurf(null);
+ await laden();
+ setHinweis("Auf die Vorgabe zurückgesetzt.");
+ } catch (err) {
+ setFehler(err.message);
+ }
+ }
+
+ /** Nur die Rasterfelder speichern – react-grid-layout hängt viel Beiwerk an. */
+ function schlank(liste) {
+ return (liste || []).map(({ i, x, y, w, h }) => ({ i, x, y, w, h }));
+ }
return (
Übersicht
-
Bestand, Ablauf und Bedarf auf einen Blick
+
+ {erzwungen
+ ? "Die Anordnung ist vom Administrator vorgegeben."
+ : "Karten lassen sich frei anordnen und in der Größe ändern."}
+
-
Einlagern
-
Auslagern
+ {!bearbeiten && !erzwungen && (
+
+ )}
+ {bearbeiten && (
+ <>
+
+
+ {menuOffen && (
+
+ {verfuegbar.map((id) => (
+ -
+
+
+ ))}
+
+ )}
+
+
+
+ >
+ )}
- {error &&
{error}
}
+ {fehler &&
{fehler}
}
+ {hinweis &&
{hinweis}
}
- {overdue > 0 && (
-
- {overdue} Charge(n) im Lager sind bereits abgelaufen – bitte aussortieren.
+ {bearbeiten && (
+
+
+
+ Karten ziehen zum Verschieben, an der rechten unteren Ecke größer ziehen.
+ Änderungen gelten erst nach „Speichern“.
+
)}
-
-
-
Bald ablaufend
-
{expiring.length}
-
-
-
Überfällig
-
{overdue}
-
-
-
Nachzukaufen
-
{shopping.length + groupShopping.length}
-
-
+
{ if (bearbeiten) setEntwurf(schlank(neu)); }}
+ compactType="vertical"
+ >
+ {aktuell.map((eintrag) => {
+ const karte = KARTEN[eintrag.i];
+ if (!karte) return null;
+ const Inhalt = karte.komponente;
+ return (
+
+
+
+
{karte.titel}
+ {bearbeiten && (
+
+ )}
+
+
+
+ );
+ })}
+
-
-
-
-
Bald ablaufend
-
- {expiring.length === 0 ? (
- Nichts läuft demnächst ab.
- ) : (
-
-
-
- | Produkt | Menge | MHD | Frist |
-
-
- {expiring.map((it) => (
-
- | {it.product_name} |
-
- {articlePrimary(it)}
- {articleSecondary(it) && (
- {articleSecondary(it)}
- )}
- |
- {formatBestBefore(it.best_before, it.best_before_precision)} |
-
-
- {relativeExpiry(it.days_left)}
-
- |
-
- ))}
-
-
-
- )}
-
+ {aktuell.length === 0 && (
+
+ Keine Karten ausgewählt. Über „Anordnen“ lassen sich welche hinzufügen.
+
+ )}
-
-
-
Einkaufsliste
-
- {shopping.length === 0 && groupShopping.length === 0 ? (
- Alle Mindestbestände erreicht.
- ) : (
-
-
-
- | Bedarf | Bestand | Fehlt |
-
-
- {groupShopping.map((it) => (
-
- | Gruppe {it.name} |
- {fmt(it.stock)} {it.unit_name} |
- {fmt(it.deficit)} {it.unit_name} |
-
- ))}
- {shopping.map((it) => (
-
- | {it.name} |
- {amountText(it.stock, it.package_size, it.base_unit)} |
- {amountText(it.deficit, it.package_size, it.base_unit)} |
-
- ))}
-
-
-
- )}
-
+
+ {!erzwungen && herkunft === "user" && hatVorgabe && (
+
+ )}
+ {isAdmin && (
+ <>
+
+
+ >
+ )}
);
diff --git a/web/src/styles.css b/web/src/styles.css
index 3fb0c16..b6af666 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -466,6 +466,130 @@ td select { width: auto; min-width: 0; max-width: 100%; }
.stat .stat-label { font-size: 0.75rem; color: var(--muted); font-weight: 560; display: flex; align-items: center; gap: 6px; }
.stat .stat-value { font-size: 1.6rem; font-weight: 680; letter-spacing: -0.02em; margin-top: 6px; }
+/* ---------- Startseite: Raster ----------
+ react-grid-layout bringt eigenes CSS mit. Statt es einzubinden, setzen wir
+ die wenigen noetigen Regeln selbst - so sehen die Karten aus wie alle
+ anderen und die Anfasser folgen unseren Tokens. */
+.dash-grid { position: relative; }
+.react-grid-item { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
+.react-grid-item.react-draggable-dragging,
+.react-grid-item.resizing { transition: none; z-index: 5; }
+/* Platzhalter, der beim Ziehen anzeigt, wo die Karte landet. */
+.react-grid-placeholder {
+ background: var(--accent-soft);
+ border: 1px dashed var(--accent);
+ border-radius: var(--radius);
+ opacity: 0.8;
+}
+
+.dash-card {
+ margin-bottom: 0; /* Abstand macht das Raster */
+ display: flex; flex-direction: column;
+ overflow: hidden; padding: 0;
+}
+.dash-card-head {
+ display: flex; align-items: center; gap: var(--sp-2);
+ padding: var(--sp-3) var(--sp-4);
+ border-bottom: 1px solid var(--border);
+ flex: 0 0 auto;
+}
+.dash-card-head h2 { margin: 0; flex: 1; font-size: 0.9rem; }
+.dash-card-head .icon { color: var(--muted); }
+.dash-card-body { flex: 1; min-height: 0; overflow: auto; padding: var(--sp-4); }
+
+/* Anfasser nur im Bearbeitungsmodus - sonst stoert er beim Lesen. */
+.dash-grid .react-resizable-handle { display: none; }
+.dash-grid.editing .dash-card-head { cursor: grab; }
+.dash-grid.editing .dash-card { outline: 1px dashed var(--border-strong); }
+.dash-grid.editing .react-resizable-handle {
+ display: block; position: absolute; right: 3px; bottom: 3px;
+ width: 14px; height: 14px; cursor: se-resize;
+ border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
+ border-bottom-right-radius: 3px; opacity: 0.7;
+}
+
+.dash-foot {
+ display: flex; align-items: center; gap: var(--sp-3);
+ flex-wrap: wrap; margin-top: var(--sp-5);
+ padding-top: var(--sp-4); border-top: 1px solid var(--border);
+}
+.enforce-toggle {
+ display: flex; align-items: center; gap: var(--sp-2);
+ margin: 0; font-size: 0.85rem; color: var(--muted); font-weight: 500;
+}
+.enforce-toggle input { width: auto; margin: 0; accent-color: var(--accent); }
+
+/* Menue "Karte hinzufuegen" */
+.menu-wrap { position: relative; }
+.menu {
+ position: absolute; right: 0; top: calc(100% + 6px); z-index: 30;
+ list-style: none; margin: 0; padding: var(--sp-2);
+ min-width: 240px; max-height: 320px; overflow: auto;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius); box-shadow: 0 6px 20px rgba(16, 24, 40, 0.14);
+}
+.menu li { padding: 0; }
+.menu .link-btn {
+ display: flex; align-items: center; gap: var(--sp-2);
+ width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-sm);
+ color: var(--text);
+}
+.menu .link-btn:hover { background: var(--surface-2); text-decoration: none; }
+
+/* ---------- Startseite: Kartentypen ---------- */
+.card-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
+.card-stack { display: flex; flex-direction: column; gap: var(--sp-3); height: 100%; }
+
+.kpi-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
+.kpi { flex: 1 1 120px; min-width: 0; }
+.kpi-label {
+ display: flex; align-items: center; gap: 6px;
+ font-size: 0.75rem; color: var(--muted); font-weight: 560;
+}
+.kpi-value { font-size: 1.7rem; font-weight: 680; letter-spacing: -0.02em; margin-top: 2px; }
+.kpi.warn .kpi-value { color: var(--warn); }
+.kpi.danger .kpi-value { color: var(--danger); }
+
+/* ---------- Diagramme ---------- */
+.chart-donut { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; }
+.donut-svg { width: 160px; height: 160px; flex: 0 0 auto; }
+.donut-svg circle { transition: stroke-width 120ms ease; cursor: default; }
+.donut-value { font-size: 20px; font-weight: 680; fill: var(--text); }
+.donut-label { font-size: 10px; fill: var(--muted); }
+
+.chart-legend { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 140px; }
+.chart-legend li {
+ display: flex; align-items: center; gap: var(--sp-2);
+ padding: 3px 0; font-size: 0.82rem;
+}
+.chart-legend.inline { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin-bottom: var(--sp-3); }
+.chart-legend.inline li { padding: 0; }
+.chart-legend .legend-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.chart-legend .legend-value { color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
+.swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
+
+.chart-bars { display: flex; flex-direction: column; gap: 7px; }
+.bar-row { display: grid; grid-template-columns: minmax(80px, 26%) 1fr auto; align-items: center; gap: var(--sp-3); }
+.bar-name { font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bar-track { display: flex; height: 16px; border-radius: 4px; overflow: hidden; background: var(--surface-2); }
+/* 2px Luecke in Flaechenfarbe: trennt angrenzende Abschnitte zuverlaessig. */
+.bar-part + .bar-part { border-left: 2px solid var(--surface); }
+.bar-total { font-size: 0.8rem; color: var(--muted); font-variant-numeric: tabular-nums; }
+.chart-hint { font-size: 0.8rem; color: var(--muted); margin-top: var(--sp-2); }
+
+.chart-line { width: 100%; }
+.line-plot { position: relative; }
+.line-plot svg { display: block; }
+.line-tooltip {
+ position: absolute; top: 4px; transform: translateX(-50%);
+ background: var(--surface); border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm); padding: 6px 9px; pointer-events: none;
+ box-shadow: 0 4px 12px rgba(16, 24, 40, 0.12); font-size: 0.8rem; white-space: nowrap;
+}
+.line-tooltip .tip-date { color: var(--muted); font-size: 0.72rem; margin-bottom: 3px; }
+.line-tooltip .tip-row { display: flex; align-items: center; gap: 6px; }
+.line-tooltip .tip-name { color: var(--muted); }
+
/* ---------- Login ---------- */
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
.login-card { width: 100%; max-width: 360px; }