Modulare Startseite: Kartenraster, eigene Anordnung je Benutzer, Auswertungen
Die Uebersicht war fest verdrahtet und fuer alle gleich. Jetzt besteht sie aus Karten in einem 12-Spalten-Raster, die sich ziehen, in der Groesse aendern und einrasten lassen (react-grid-layout). "Anordnen" schaltet das frei, gespeichert wird ausdruecklich - Probieren bleibt folgenlos. Anordnung: - Neue Tabelle dashboard_layouts; user_id = NULL ist die Admin-Vorgabe. - Jeder Benutzer hat seine eigene Anordnung. Admins speichern eine Vorgabe fuer alle und koennen sie ueber die Einstellung dashboard_enforced verbindlich machen; dann lehnt das Backend das Speichern eigener Anordnungen ab. - "Auf Vorgabe zuruecksetzen" verwirft die persoenliche Anordnung. 15 Karten (Verzeichnis web/src/dashboard/cards.jsx, neue Karte = ein Eintrag): Schnellzugriff, Status, Artikelzahl, Artikeleinheiten, Bald ablaufend, Abgelaufen, Einkaufsliste, letzte Bewegungen sowie sechs Auswertungen (Ablauf-Ring, Kategorien-Ring, Kategorien nach Zustand, Bestandsverlauf, Verlauf eines Artikels, Ein-/Auslagerungen). Diagramme als eigene SVG-Komponenten statt Diagramm-Paket: - Zustandsfarben (ok/bald/abgelaufen) sind Statusangaben aus den Tokens und stehen nie ohne Beschriftung; Kategorien nutzen eine gepruefte Farbreihe in fester Ordnung, ab sieben Kategorien wird zu "Andere" gebuendelt. - Eine Werteachse, duenne Marken, zurueckhaltendes Gitter, Fadenkreuz mit Kurzinfo. Hell und Dunkel haben eigene Farbstufen. Mengen durchgehend in Artikeleinheiten (Glaeser, Packungen, Stueck): Gramm und Stueck lassen sich nicht addieren, Gebinde schon. Der bisher dreifach vorhandene Helfer liegt jetzt einmal in services/conversion.py::article_unit. Der Verlauf wird rueckwaerts vom heutigen Bestand aus den Bewegungen rekonstruiert. Bekannte Ungenauigkeit, im Code und in der Roadmap vermerkt: Die Umrechnung nutzt die heutige Packungsgroesse. Geprueft: "npm run build" laeuft durch. Die neuen pytest-Tests (backend/tests/test_dashboard.py) konnten hier nicht laufen - auf diesem Rechner ist kein Python installiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ from .routers import (
|
|||||||
auth,
|
auth,
|
||||||
branding,
|
branding,
|
||||||
categories,
|
categories,
|
||||||
|
dashboard,
|
||||||
groups,
|
groups,
|
||||||
locations,
|
locations,
|
||||||
maintenance,
|
maintenance,
|
||||||
@@ -110,3 +111,4 @@ app.include_router(settings_router.router)
|
|||||||
app.include_router(branding.router)
|
app.include_router(branding.router)
|
||||||
app.include_router(categories.router)
|
app.include_router(categories.router)
|
||||||
app.include_router(maintenance.router)
|
app.include_router(maintenance.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
|||||||
@@ -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):
|
class Setting(Base):
|
||||||
__tablename__ = "settings"
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
|||||||
374
backend/app/routers/dashboard.py
Normal file
374
backend/app/routers/dashboard.py
Normal file
@@ -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)
|
||||||
|
]
|
||||||
@@ -386,3 +386,56 @@ class MovementOut(BaseModel):
|
|||||||
class SettingOut(BaseModel):
|
class SettingOut(BaseModel):
|
||||||
key: str
|
key: str
|
||||||
value: 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
|
||||||
|
|||||||
@@ -88,3 +88,23 @@ def display_unit_info(product: Product) -> tuple[str, float]:
|
|||||||
if product.display_unit is not None:
|
if product.display_unit is not None:
|
||||||
return product.display_unit.name, product.display_unit.factor
|
return product.display_unit.name, product.display_unit.factor
|
||||||
return BASE_LABEL[product.base_unit], 1.0
|
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
|
||||||
|
|||||||
187
backend/tests/test_dashboard.py
Normal file
187
backend/tests/test_dashboard.py
Normal file
@@ -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
|
||||||
@@ -66,6 +66,29 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
|
|||||||
Artikel darin lagen.
|
Artikel darin lagen.
|
||||||
- **Eigenes Logo und Favicon** je Installation (Einstellungen → Darstellung).
|
- **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
|
## Schritt 3 – Weitere Ideen – offen
|
||||||
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
|
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
|
||||||
- **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
|
- **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
|
||||||
|
|||||||
94
web/package-lock.json
generated
94
web/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-grid-layout": "^1.5.3",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1337,6 +1338,15 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1421,6 +1431,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1554,6 +1570,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1603,6 +1628,17 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -1628,6 +1664,44 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -1638,6 +1712,20 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.4",
|
"version": "6.30.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||||
@@ -1670,6 +1758,12 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-grid-layout": "^1.5.3",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -140,6 +140,21 @@ export const api = {
|
|||||||
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||||||
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
|
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
|
// Stammdaten
|
||||||
listLocations: () => request("/locations"),
|
listLocations: () => request("/locations"),
|
||||||
createLocation: (body) => request("/locations", { method: "POST", body }),
|
createLocation: (body) => request("/locations", { method: "POST", body }),
|
||||||
|
|||||||
410
web/src/dashboard/cards.jsx
Normal file
410
web/src/dashboard/cards.jsx
Normal file
@@ -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 <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
|
if (daten == null) return <div className="empty">Lädt…</div>;
|
||||||
|
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
|
||||||
|
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 (
|
||||||
|
<div className="card-actions">
|
||||||
|
<Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
|
||||||
|
<Link className="btn" to="/checkout"><Icon name="checkout" size={16} />Auslagern</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Kennzahlen
|
||||||
|
function Kennzahl({ wert, label, icon, ton }) {
|
||||||
|
return (
|
||||||
|
<div className={`kpi ${ton || ""}`}>
|
||||||
|
<div className="kpi-label"><Icon name={icon} size={14} />{label}</div>
|
||||||
|
<div className="kpi-value">{wert}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteStatus() {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
|
return (
|
||||||
|
<Zustand fehler={fehler} daten={daten}>
|
||||||
|
<div className="kpi-row">
|
||||||
|
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
||||||
|
ton={daten?.expiring_soon ? "warn" : ""} />
|
||||||
|
<Kennzahl icon="alert" label="Abgelaufen" wert={daten?.expired ?? 0}
|
||||||
|
ton={daten?.expired ? "danger" : ""} />
|
||||||
|
<Kennzahl icon="cart" label="Nachzukaufen" wert={daten?.shopping_items ?? 0} />
|
||||||
|
</div>
|
||||||
|
</Zustand>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteArtikelzahl() {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
|
return (
|
||||||
|
<Zustand fehler={fehler} daten={daten}>
|
||||||
|
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
|
||||||
|
</Zustand>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteEinheiten() {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
|
return (
|
||||||
|
<Zustand fehler={fehler} daten={daten}>
|
||||||
|
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
|
||||||
|
</Zustand>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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 (
|
||||||
|
<Zustand fehler={fehler} daten={daten}
|
||||||
|
leer={nurAbgelaufen ? "Nichts ist abgelaufen." : "Nichts läuft demnächst ab."}>
|
||||||
|
{zeilen.length === 0 ? (
|
||||||
|
<div className="empty">{nurAbgelaufen ? "Nichts ist abgelaufen." : "Nichts läuft demnächst ab."}</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th>Frist</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilen.map((it) => (
|
||||||
|
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
|
||||||
|
<td data-label="Produkt">{it.product_name}</td>
|
||||||
|
<td data-label="Menge" className="num">
|
||||||
|
{articlePrimary(it)}
|
||||||
|
{articleSecondary(it) && <div className="muted small">{articleSecondary(it)}</div>}
|
||||||
|
</td>
|
||||||
|
<td data-label="MHD">{formatBestBefore(it.best_before, it.best_before_precision)}</td>
|
||||||
|
<td data-label="Frist">
|
||||||
|
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
|
||||||
|
{relativeExpiry(it.days_left)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Zustand>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteEinkaufsliste() {
|
||||||
|
const { daten, fehler } = useDaten(async () => {
|
||||||
|
const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]);
|
||||||
|
return { produkte, gruppen };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
if (!daten.produkte.length && !daten.gruppen.length) {
|
||||||
|
return <div className="empty">Alle Mindestbestände erreicht.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{daten.gruppen.map((it) => (
|
||||||
|
<tr key={`g${it.group_id}`}>
|
||||||
|
<td data-label="Bedarf"><span className="badge accent">Gruppe</span> {it.name}</td>
|
||||||
|
<td data-label="Bestand" className="num">{fmt(it.stock)} {it.unit_name}</td>
|
||||||
|
<td data-label="Fehlt" className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{daten.produkte.map((it) => (
|
||||||
|
<tr key={`p${it.product_id}`}>
|
||||||
|
<td data-label="Bedarf">{it.name}</td>
|
||||||
|
<td data-label="Bestand" className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
|
||||||
|
<td data-label="Fehlt" className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
|
||||||
|
|
||||||
|
function KarteBewegungen() {
|
||||||
|
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
||||||
|
return (
|
||||||
|
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Zeitpunkt</th><th>Aktion</th><th>Produkt</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{(daten || []).map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td data-label="Zeitpunkt" className="muted">
|
||||||
|
{new Date(m.created_at).toLocaleString("de-DE", {
|
||||||
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td data-label="Aktion">
|
||||||
|
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
|
||||||
|
{BEWEGUNG_LABEL[m.type] || m.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Produkt">{m.product_name}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Zustand>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Diagramme
|
||||||
|
function KarteAblaufRing() {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardExpirySplit());
|
||||||
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
|
||||||
|
// 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 <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteKategorienRing() {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||||||
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
|
||||||
|
const segmente = mitFarben(
|
||||||
|
daten.map((k) => ({ name: k.name, value: k.article_units })),
|
||||||
|
istDunkel(),
|
||||||
|
);
|
||||||
|
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteKategorienSaeulen() {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||||||
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<ul className="chart-legend inline">
|
||||||
|
{[["In Ordnung", "--ok"], ["Bald ablaufend", "--warn"], ["Abgelaufen", "--danger"], ["Ohne MHD", "--muted"]]
|
||||||
|
.map(([name, token]) => (
|
||||||
|
<li key={name}>
|
||||||
|
<span className="swatch" style={{ background: tokenFarbe(token) }} />
|
||||||
|
<span className="legend-name">{name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Bars zeilen={zeilen} einheit="Einheiten" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteBestandsverlauf({ tage = 90 }) {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
|
||||||
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
labels={daten.map((p) => 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 (
|
||||||
|
<div className="card-stack">
|
||||||
|
<select value={gewaehlt} onChange={(e) => setGewaehlt(e.target.value)}
|
||||||
|
style={{ marginTop: 0, maxWidth: 280 }}>
|
||||||
|
<option value="">– Artikel wählen –</option>
|
||||||
|
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{fehler ? (
|
||||||
|
<div className="alert error"><Icon name="alert" size={16} />{fehler}</div>
|
||||||
|
) : (
|
||||||
|
<Line
|
||||||
|
labels={(daten || []).map((p) => kurzesDatum(p.date))}
|
||||||
|
serien={[{
|
||||||
|
name: "Bestand",
|
||||||
|
color: tokenFarbe("--accent", "#3f51b5"),
|
||||||
|
werte: (daten || []).map((p) => p.article_units),
|
||||||
|
}]}
|
||||||
|
einheit="Einheiten"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KarteBewegungsverlauf({ tage = 30 }) {
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardActivity(tage), [tage]);
|
||||||
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
labels={daten.map((p) => 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: () => <KarteAblauf />,
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
titel: "Abgelaufen", icon: "alert", standard: { w: 6, h: 2 }, min: { w: 4, h: 2 },
|
||||||
|
komponente: () => <KarteAblauf nurAbgelaufen />,
|
||||||
|
},
|
||||||
|
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);
|
||||||
56
web/src/dashboard/charts/Bars.jsx
Normal file
56
web/src/dashboard/charts/Bars.jsx
Normal file
@@ -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 <div className="empty">Keine Daten vorhanden.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-bars">
|
||||||
|
{zeilen.map((zeile, i) => {
|
||||||
|
const summe = summen[i];
|
||||||
|
return (
|
||||||
|
<div className="bar-row" key={zeile.name}>
|
||||||
|
<div className="bar-name" title={zeile.name}>{zeile.name}</div>
|
||||||
|
<div className="bar-track">
|
||||||
|
{zeile.teile.filter((t) => t.value > 0).map((teil) => (
|
||||||
|
<div
|
||||||
|
key={teil.key}
|
||||||
|
className="bar-part"
|
||||||
|
style={{
|
||||||
|
width: `${(teil.value / groesste) * 100}%`,
|
||||||
|
background: teil.color,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setAktiv({ zeile: zeile.name, ...teil })}
|
||||||
|
onMouseLeave={() => setAktiv(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bar-total">{fmt(summe)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{aktiv && (
|
||||||
|
<div className="chart-hint">
|
||||||
|
<strong>{aktiv.zeile}</strong> · {aktiv.label}: {fmt(aktiv.value)} {einheit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
web/src/dashboard/charts/Donut.jsx
Normal file
83
web/src/dashboard/charts/Donut.jsx
Normal file
@@ -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 <div className="empty">Keine Daten vorhanden.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="chart-donut">
|
||||||
|
<svg viewBox={`0 0 ${groesse} ${groesse}`} className="donut-svg" role="img"
|
||||||
|
aria-label={`Ringdiagramm, ${werte.length} Anteile`}>
|
||||||
|
<g transform={`rotate(-90 ${mitte} ${mitte})`}>
|
||||||
|
{boegen.map((b) => (
|
||||||
|
<circle key={b.name}
|
||||||
|
cx={mitte} cy={mitte} r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={b.color}
|
||||||
|
strokeWidth={gezeigt?.name === b.name ? dicke + 4 : dicke}
|
||||||
|
strokeDasharray={`${b.laenge} ${umfang - b.laenge}`}
|
||||||
|
strokeDashoffset={-b.versatz}
|
||||||
|
onMouseEnter={() => setAktiv(b)}
|
||||||
|
onMouseLeave={() => setAktiv(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value">
|
||||||
|
{fmt(gezeigt ? gezeigt.value : summe)}
|
||||||
|
</text>
|
||||||
|
<text x={mitte} y={mitte + 14} textAnchor="middle" className="donut-label">
|
||||||
|
{gezeigt ? gezeigt.name : `${gesamtLabel}${einheit ? ` (${einheit})` : ""}`}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<ul className="chart-legend">
|
||||||
|
{boegen.map((b) => (
|
||||||
|
<li key={b.name}
|
||||||
|
onMouseEnter={() => setAktiv(b)}
|
||||||
|
onMouseLeave={() => setAktiv(null)}>
|
||||||
|
<span className="swatch" style={{ background: b.color }} />
|
||||||
|
<span className="legend-name">{b.name}</span>
|
||||||
|
<span className="legend-value">
|
||||||
|
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
web/src/dashboard/charts/Line.jsx
Normal file
139
web/src/dashboard/charts/Line.jsx
Normal file
@@ -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 <div className="chart-line" ref={wirt}><div className="empty">Keine Daten vorhanden.</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="chart-line" ref={wirt}>
|
||||||
|
{serien.length > 1 && (
|
||||||
|
<ul className="chart-legend inline">
|
||||||
|
{serien.map((s) => (
|
||||||
|
<li key={s.name}>
|
||||||
|
<span className="swatch" style={{ background: s.color }} />
|
||||||
|
<span className="legend-name">{s.name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="line-plot">
|
||||||
|
<svg width={w} height={h} role="img"
|
||||||
|
aria-label={`Liniendiagramm über ${labels.length} Tage`}
|
||||||
|
onMouseMove={bewegen} onMouseLeave={() => setIndex(null)}>
|
||||||
|
{gitter.map((g) => (
|
||||||
|
<g key={g.wert}>
|
||||||
|
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y}
|
||||||
|
stroke={gitterFarbe} strokeWidth="1" />
|
||||||
|
<text x={rand.links - 8} y={g.y + 4} textAnchor="end"
|
||||||
|
fontSize="11" fill={textFarbe}>{fmt(g.wert)}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{achse.map(({ l, i }) => (
|
||||||
|
<text key={l + i} x={x(i)} y={h - 6} textAnchor="middle"
|
||||||
|
fontSize="11" fill={textFarbe}>{l}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{serien.map((s) => (
|
||||||
|
<path key={s.name} fill="none" stroke={s.color} strokeWidth="2"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round"
|
||||||
|
d={s.werte.map((wert, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(wert)}`).join(" ")} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{index != null && (
|
||||||
|
<>
|
||||||
|
<line x1={x(index)} x2={x(index)} y1={rand.oben} y2={rand.oben + flaecheH}
|
||||||
|
stroke={gitterFarbe} strokeWidth="1" />
|
||||||
|
{serien.map((s) => (
|
||||||
|
<circle key={s.name} cx={x(index)} cy={y(s.werte[index] ?? 0)} r="4.5"
|
||||||
|
fill={s.color} stroke={tokenFarbe("--surface", "#fff")} strokeWidth="2" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{index != null && (
|
||||||
|
<div className="line-tooltip"
|
||||||
|
style={{ left: Math.min(Math.max(x(index), 60), w - 60) }}>
|
||||||
|
<div className="tip-date">{labels[index]}</div>
|
||||||
|
{serien.map((s) => (
|
||||||
|
<div key={s.name} className="tip-row">
|
||||||
|
<span className="swatch" style={{ background: s.color }} />
|
||||||
|
{serien.length > 1 && <span className="tip-name">{s.name}</span>}
|
||||||
|
<strong>{fmt(s.werte[index] ?? 0)}</strong>
|
||||||
|
{einheit && <span className="muted small"> {einheit}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
web/src/dashboard/palette.js
Normal file
94
web/src/dashboard/palette.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,142 +1,271 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import GridLayout, { WidthProvider } from "react-grid-layout";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { useSettings } from "../settings";
|
import { useConfirm } from "../confirm";
|
||||||
import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units";
|
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() {
|
export default function Dashboard() {
|
||||||
const { formatBestBefore } = useSettings();
|
const { isAdmin } = useAuth();
|
||||||
const [expiring, setExpiring] = useState([]);
|
const confirm = useConfirm();
|
||||||
const [shopping, setShopping] = useState([]);
|
|
||||||
const [groupShopping, setGroupShopping] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [layout, setLayout] = useState([]);
|
||||||
async function load() {
|
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 {
|
try {
|
||||||
const [e, s, g] = await Promise.all([
|
const antwort = await api.dashboardLayout();
|
||||||
api.expiring(),
|
setLayout(bereinige(antwort.layout));
|
||||||
api.shoppingList(),
|
setHerkunft(antwort.source);
|
||||||
api.groupShoppingList(),
|
setErzwungen(antwort.enforced);
|
||||||
]);
|
setHatVorgabe(antwort.has_default);
|
||||||
setExpiring(e);
|
|
||||||
setShopping(s);
|
|
||||||
setGroupShopping(g);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Übersicht</h1>
|
<h1>Übersicht</h1>
|
||||||
<div className="sub">Bestand, Ablauf und Bedarf auf einen Blick</div>
|
<div className="sub">
|
||||||
|
{erzwungen
|
||||||
|
? "Die Anordnung ist vom Administrator vorgegeben."
|
||||||
|
: "Karten lassen sich frei anordnen und in der Größe ändern."}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
|
{!bearbeiten && !erzwungen && (
|
||||||
<Link className="btn" to="/checkout"><Icon name="checkout" size={16} />Auslagern</Link>
|
<button className="btn" onClick={starteBearbeitung}>
|
||||||
</div>
|
<Icon name="edit" size={16} />Anordnen
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
|
||||||
|
|
||||||
{overdue > 0 && (
|
|
||||||
<div className="alert error"><Icon name="alert" size={16} />
|
|
||||||
{overdue} Charge(n) im Lager sind bereits abgelaufen – bitte aussortieren.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{bearbeiten && (
|
||||||
<div className="stat-row">
|
<>
|
||||||
<div className="stat">
|
<div className="menu-wrap">
|
||||||
<div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div>
|
<button className="btn" onClick={() => setMenuOffen((o) => !o)}
|
||||||
<div className="stat-value">{expiring.length}</div>
|
disabled={verfuegbar.length === 0}>
|
||||||
</div>
|
<Icon name="plus" size={16} />Karte hinzufügen
|
||||||
<div className="stat">
|
</button>
|
||||||
<div className="stat-label"><Icon name="alert" size={14} />Überfällig</div>
|
{menuOffen && (
|
||||||
<div className="stat-value">{overdue}</div>
|
<ul className="menu">
|
||||||
</div>
|
{verfuegbar.map((id) => (
|
||||||
<div className="stat">
|
<li key={id}>
|
||||||
<div className="stat-label"><Icon name="cart" size={14} />Nachzukaufen</div>
|
<button className="link-btn" onClick={() => hinzufuegen(id)}>
|
||||||
<div className="stat-value">{shopping.length + groupShopping.length}</div>
|
<Icon name={KARTEN[id].icon} size={15} />{KARTEN[id].titel}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
|
))}
|
||||||
<div className="grid-2">
|
</ul>
|
||||||
<section className="card">
|
|
||||||
<div className="card-head"><Icon name="clock" />
|
|
||||||
<h2>Bald ablaufend</h2>
|
|
||||||
</div>
|
|
||||||
{expiring.length === 0 ? (
|
|
||||||
<div className="empty">Nichts läuft demnächst ab.</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrap">
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th>Frist</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{expiring.map((it) => (
|
|
||||||
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
|
|
||||||
<td data-label="Produkt">{it.product_name}</td>
|
|
||||||
<td data-label="Menge" className="num">
|
|
||||||
{articlePrimary(it)}
|
|
||||||
{articleSecondary(it) && (
|
|
||||||
<div className="muted small">{articleSecondary(it)}</div>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td data-label="MHD">{formatBestBefore(it.best_before, it.best_before_precision)}</td>
|
<button className="btn primary" onClick={speichern}>Speichern</button>
|
||||||
<td data-label="Frist">
|
<button className="btn ghost" onClick={() => { setEntwurf(null); setMenuOffen(false); }}>
|
||||||
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
|
Abbrechen
|
||||||
{relativeExpiry(it.days_left)}
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||||
|
{hinweis && <div className="alert ok"><Icon name="check" size={16} />{hinweis}</div>}
|
||||||
|
|
||||||
|
{bearbeiten && (
|
||||||
|
<div className="alert info">
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
<span>
|
||||||
|
Karten ziehen zum Verschieben, an der rechten unteren Ecke größer ziehen.
|
||||||
|
Änderungen gelten erst nach „Speichern“.
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
<Raster
|
||||||
<div className="card-head"><Icon name="cart" />
|
className={`dash-grid ${bearbeiten ? "editing" : ""}`}
|
||||||
<h2>Einkaufsliste</h2>
|
layout={rasterLayout}
|
||||||
</div>
|
cols={SPALTEN}
|
||||||
{shopping.length === 0 && groupShopping.length === 0 ? (
|
rowHeight={ZEILENHOEHE}
|
||||||
<div className="empty">Alle Mindestbestände erreicht.</div>
|
margin={[16, 16]}
|
||||||
) : (
|
containerPadding={[0, 0]}
|
||||||
<div className="table-wrap">
|
isDraggable={bearbeiten}
|
||||||
<table className="table">
|
isResizable={bearbeiten}
|
||||||
<thead>
|
draggableHandle=".dash-card-head"
|
||||||
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
|
onLayoutChange={(neu) => { if (bearbeiten) setEntwurf(schlank(neu)); }}
|
||||||
</thead>
|
compactType="vertical"
|
||||||
<tbody>
|
>
|
||||||
{groupShopping.map((it) => (
|
{aktuell.map((eintrag) => {
|
||||||
<tr key={`g${it.group_id}`}>
|
const karte = KARTEN[eintrag.i];
|
||||||
<td data-label="Bedarf"><span className="badge accent">Gruppe</span> {it.name}</td>
|
if (!karte) return null;
|
||||||
<td data-label="Bestand" className="num">{fmt(it.stock)} {it.unit_name}</td>
|
const Inhalt = karte.komponente;
|
||||||
<td data-label="Fehlt" className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
return (
|
||||||
</tr>
|
<section key={eintrag.i} className="card dash-card">
|
||||||
))}
|
<div className="dash-card-head">
|
||||||
{shopping.map((it) => (
|
<Icon name={karte.icon} size={16} />
|
||||||
<tr key={`p${it.product_id}`}>
|
<h2>{karte.titel}</h2>
|
||||||
<td data-label="Bedarf">{it.name}</td>
|
{bearbeiten && (
|
||||||
<td data-label="Bestand" className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
|
<button className="btn-icon danger" title="Karte entfernen"
|
||||||
<td data-label="Fehlt" className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
</tr>
|
onClick={() => entfernen(eintrag.i)}>
|
||||||
))}
|
<Icon name="trash" size={15} />
|
||||||
</tbody>
|
</button>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="dash-card-body"><Inhalt /></div>
|
||||||
</section>
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Raster>
|
||||||
|
|
||||||
|
{aktuell.length === 0 && (
|
||||||
|
<div className="card"><div className="empty">
|
||||||
|
Keine Karten ausgewählt. Über „Anordnen“ lassen sich welche hinzufügen.
|
||||||
|
</div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dash-foot">
|
||||||
|
{!erzwungen && herkunft === "user" && hatVorgabe && (
|
||||||
|
<button className="btn ghost" onClick={zuruecksetzen}>
|
||||||
|
<Icon name="history" size={15} />Auf Vorgabe zurücksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button className="btn ghost" onClick={alsVorgabe}>
|
||||||
|
<Icon name="users" size={15} />Als Vorgabe für alle speichern
|
||||||
|
</button>
|
||||||
|
<label className="enforce-toggle">
|
||||||
|
<input type="checkbox" checked={erzwungen}
|
||||||
|
onChange={(e) => erzwingen(e.target.checked)} />
|
||||||
|
Vorgabe verbindlich (Benutzer können nicht umbauen)
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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-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; }
|
.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 ---------- */
|
||||||
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
||||||
.login-card { width: 100%; max-width: 360px; }
|
.login-card { width: 100%; max-width: 360px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user