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 ( +
+
{label}
+
{wert}
+
+ ); +} + +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."}
+ ) : ( +
+ + + + + + {zeilen.map((it) => ( + + + + + + + ))} + +
ProduktMengeMHDFrist
{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 ( +
+ + + + + + {daten.gruppen.map((it) => ( + + + + + + ))} + {daten.produkte.map((it) => ( + + + + + + ))} + +
BedarfBestandFehlt
Gruppe {it.name}{fmt(it.stock)} {it.unit_name}{fmt(it.deficit)} {it.unit_name}
{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 ( + +
+ + + + {(daten || []).map((m) => ( + + + + + + ))} + +
ZeitpunktAktionProdukt
+ {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 ( + <> + + + + ); +} + +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)} + /> + ))} + + + {fmt(gezeigt ? gezeigt.value : summe)} + + + {gezeigt ? gezeigt.name : `${gesamtLabel}${einheit ? ` (${einheit})` : ""}`} + + + +
    + {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
Keine Daten vorhanden.
; + } + + 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} +
  • + ))} +
+ )} + +
+ setIndex(null)}> + {gitter.map((g) => ( + + + {fmt(g.wert)} + + ))} + + {achse.map(({ l, i }) => ( + {l} + ))} + + {serien.map((s) => ( + `${i === 0 ? "M" : "L"} ${x(i)} ${y(wert)}`).join(" ")} /> + ))} + + {index != null && ( + <> + + {serien.map((s) => ( + + ))} + + )} + + + {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.
- ) : ( -
- - - - - - {expiring.map((it) => ( - - - - - - - ))} - -
ProduktMengeMHDFrist
{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.
- ) : ( -
- - - - - - {groupShopping.map((it) => ( - - - - - - ))} - {shopping.map((it) => ( - - - - - - ))} - -
BedarfBestandFehlt
Gruppe {it.name}{fmt(it.stock)} {it.unit_name}{fmt(it.deficit)} {it.unit_name}
{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; }