Compare commits
3 Commits
b2a38501d2
...
2ad197df42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad197df42 | ||
|
|
89598147ed | ||
|
|
f62289f975 |
@@ -61,10 +61,26 @@ def _ensure_schema() -> None:
|
||||
"NOT NULL DEFAULT 'day'",
|
||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
|
||||
"REFERENCES categories(id) ON DELETE SET NULL",
|
||||
# Mehrere Dashboards je Benutzer: Name und Reihenfolge kommen dazu.
|
||||
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS name VARCHAR(80) "
|
||||
"NOT NULL DEFAULT 'Übersicht'",
|
||||
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
|
||||
"NOT NULL DEFAULT 0",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
conn.execute(text(stmt))
|
||||
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
|
||||
# Der Name der Beschraenkung haengt davon ab, wie die Tabelle entstanden
|
||||
# ist, deshalb wird er nachgeschlagen statt geraten.
|
||||
namen = conn.execute(
|
||||
text(
|
||||
"SELECT conname FROM pg_constraint "
|
||||
"WHERE conrelid = 'dashboard_layouts'::regclass AND contype = 'u'"
|
||||
)
|
||||
).scalars().all()
|
||||
for name in namen:
|
||||
conn.execute(text(f'ALTER TABLE dashboard_layouts DROP CONSTRAINT "{name}"'))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -336,20 +336,28 @@ class BrandingAsset(Base):
|
||||
|
||||
|
||||
class DashboardLayout(Base):
|
||||
"""Anordnung der Karten auf der Startseite.
|
||||
"""Ein Dashboard: benannte Anordnung von Karten.
|
||||
|
||||
``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.
|
||||
|
||||
Jeder Benutzer darf **mehrere** Dashboards haben (deshalb steht auf
|
||||
``user_id`` kein unique mehr); ``position`` bestimmt ihre Reihenfolge in der
|
||||
Seitenleiste.
|
||||
"""
|
||||
|
||||
__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
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# JSON-Text: [{"i": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}]
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False, default="Übersicht")
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# JSON-Text. "i" ist die Kennung *dieser Karte*, "type" ihre Art - erst
|
||||
# dadurch laesst sich dieselbe Art mehrfach auf ein Dashboard legen:
|
||||
# [{"i": "k3", "type": "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
|
||||
|
||||
@@ -34,9 +34,11 @@ from ..models import (
|
||||
from ..schemas import (
|
||||
ActivityPoint,
|
||||
CategoryShare,
|
||||
DashboardLayoutIn,
|
||||
DashboardLayoutOut,
|
||||
DashboardCreate,
|
||||
DashboardListOut,
|
||||
DashboardOut,
|
||||
DashboardStats,
|
||||
DashboardUpdate,
|
||||
ExpirySplit,
|
||||
FlowPoint,
|
||||
TimelinePoint,
|
||||
@@ -53,8 +55,8 @@ ENFORCED_KEY = "dashboard_enforced"
|
||||
# Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten
|
||||
# zusätzlich auf ihre Mindesthöhe an.
|
||||
BUILTIN_LAYOUT: list[dict] = [
|
||||
{"i": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
||||
{"i": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
||||
{"i": "actions", "type": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
||||
{"i": "status", "type": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
||||
# Bewusst die kombinierte Karte: "expiring" zeigt nur noch Laufendes, in der
|
||||
# Vorgabe soll aber auch Ueberfaelliges ohne Zutun sichtbar sein.
|
||||
{"i": "expiry-all", "x": 0, "y": 3, "w": 6, "h": 6},
|
||||
@@ -70,119 +72,209 @@ def _enforced(db: Session) -> bool:
|
||||
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
|
||||
def _normalize(layout: list[dict]) -> list[dict]:
|
||||
"""Karten auf die Instanz-Form bringen.
|
||||
|
||||
Frueher war ``i`` zugleich die Kartenart – dadurch ging jede Art genau
|
||||
einmal. Jetzt ist ``i`` die Kennung dieser einen Karte und ``type`` ihre
|
||||
Art. Aeltere gespeicherte Anordnungen haben kein ``type``; fuer sie ist die
|
||||
alte Kennung die Art.
|
||||
"""
|
||||
result: list[dict] = []
|
||||
for eintrag in layout:
|
||||
if not isinstance(eintrag, dict) or "i" not in eintrag:
|
||||
continue
|
||||
karte = dict(eintrag)
|
||||
karte.setdefault("type", karte["i"])
|
||||
karte.setdefault("props", {})
|
||||
result.append(karte)
|
||||
return result
|
||||
|
||||
|
||||
def _parse(row: DashboardLayout) -> list[dict]:
|
||||
try:
|
||||
data = json.loads(row.layout)
|
||||
except ValueError:
|
||||
return None
|
||||
return data if isinstance(data, list) else None
|
||||
return []
|
||||
return _normalize(data) if isinstance(data, list) else []
|
||||
|
||||
|
||||
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()
|
||||
def _rows(db: Session, user_id: int | None) -> list[DashboardLayout]:
|
||||
query = db.query(DashboardLayout)
|
||||
query = query.filter(
|
||||
DashboardLayout.user_id.is_(None)
|
||||
if user_id is None
|
||||
else DashboardLayout.user_id == user_id
|
||||
)
|
||||
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()
|
||||
return query.order_by(DashboardLayout.position, DashboardLayout.id).all()
|
||||
|
||||
|
||||
def _as_out(rows: list[DashboardLayout]) -> list[DashboardOut]:
|
||||
return [
|
||||
DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _owned(db: Session, dashboard_id: int, user: User) -> DashboardLayout:
|
||||
row = db.get(DashboardLayout, dashboard_id)
|
||||
if row is None or row.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dashboard nicht gefunden")
|
||||
return row
|
||||
|
||||
|
||||
def _guard_enforced(db: Session) -> None:
|
||||
if _enforced(db) and _rows(db, None):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Die Startseite ist vom Administrator fest vorgegeben.",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Anordnung
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/layout", response_model=DashboardLayoutOut)
|
||||
def get_layout(
|
||||
@router.get("/layouts", response_model=DashboardListOut)
|
||||
def list_dashboards(
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
) -> DashboardLayoutOut:
|
||||
"""Eigene Anordnung, sonst die Vorgabe, sonst die eingebaute."""
|
||||
) -> DashboardListOut:
|
||||
"""Eigene Dashboards, sonst die Vorgabe, sonst das eingebaute."""
|
||||
erzwungen = _enforced(db)
|
||||
vorgabe = _load(db, None)
|
||||
vorgabe = _rows(db, None)
|
||||
|
||||
if erzwungen and vorgabe is not None:
|
||||
return DashboardLayoutOut(layout=vorgabe, source="default", enforced=True, has_default=True)
|
||||
if erzwungen and vorgabe:
|
||||
return DashboardListOut(
|
||||
dashboards=_as_out(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
|
||||
eigene = _rows(db, user.id)
|
||||
if eigene:
|
||||
return DashboardListOut(
|
||||
dashboards=_as_out(eigene), source="user",
|
||||
enforced=erzwungen, has_default=bool(vorgabe),
|
||||
)
|
||||
if vorgabe is not None:
|
||||
return DashboardLayoutOut(
|
||||
layout=vorgabe, source="default", enforced=erzwungen, has_default=True
|
||||
if vorgabe:
|
||||
return DashboardListOut(
|
||||
dashboards=_as_out(vorgabe), source="default",
|
||||
enforced=erzwungen, has_default=True,
|
||||
)
|
||||
return DashboardLayoutOut(
|
||||
layout=BUILTIN_LAYOUT, source="builtin", enforced=erzwungen, has_default=False
|
||||
# Ohne alles das eingebaute Dashboard - mit id 0, weil es keine Zeile hat.
|
||||
return DashboardListOut(
|
||||
dashboards=[DashboardOut(id=0, name="Übersicht", position=0,
|
||||
layout=_normalize(BUILTIN_LAYOUT))],
|
||||
source="builtin", enforced=erzwungen, has_default=False,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/layout", response_model=DashboardLayoutOut)
|
||||
def put_layout(
|
||||
payload: DashboardLayoutIn,
|
||||
@router.post("/layouts", response_model=DashboardOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_dashboard(
|
||||
payload: DashboardCreate,
|
||||
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)
|
||||
) -> DashboardOut:
|
||||
_guard_enforced(db)
|
||||
bestehende = _rows(db, user.id)
|
||||
# Legt der Benutzer sein erstes eigenes Dashboard an, waehrend er noch auf
|
||||
# der Vorgabe sitzt, wuerde diese sonst schlagartig verschwinden. Deshalb
|
||||
# wird sie vorher als eigene Dashboards uebernommen.
|
||||
if not bestehende:
|
||||
for row in _rows(db, None):
|
||||
db.add(DashboardLayout(user_id=user.id, name=row.name,
|
||||
position=row.position, layout=row.layout))
|
||||
db.flush()
|
||||
bestehende = _rows(db, user.id)
|
||||
|
||||
position = max((row.position for row in bestehende), default=-1) + 1
|
||||
neu = DashboardLayout(
|
||||
user_id=user.id, name=payload.name.strip(), position=position,
|
||||
layout=json.dumps(_normalize(payload.layout), ensure_ascii=False),
|
||||
)
|
||||
db.add(neu)
|
||||
db.commit()
|
||||
db.refresh(neu)
|
||||
return DashboardOut(id=neu.id, name=neu.name, position=neu.position, layout=_parse(neu))
|
||||
|
||||
|
||||
@router.delete("/layout")
|
||||
def reset_layout(
|
||||
@router.patch("/layouts/{dashboard_id}", response_model=DashboardOut)
|
||||
def update_dashboard(
|
||||
dashboard_id: int,
|
||||
payload: DashboardUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> DashboardOut:
|
||||
_guard_enforced(db)
|
||||
row = _owned(db, dashboard_id, user)
|
||||
|
||||
daten = payload.model_dump(exclude_unset=True)
|
||||
if daten.get("name"):
|
||||
row.name = daten["name"].strip()
|
||||
if daten.get("layout") is not None:
|
||||
row.layout = json.dumps(_normalize(daten["layout"]), ensure_ascii=False)
|
||||
if daten.get("position") is not None:
|
||||
row.position = daten["position"]
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||||
|
||||
|
||||
@router.delete("/layouts/{dashboard_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_dashboard(
|
||||
dashboard_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
_guard_enforced(db)
|
||||
row = _owned(db, dashboard_id, user)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/layouts")
|
||||
def reset_dashboards(
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
) -> Response:
|
||||
"""Eigene Anordnung verwerfen – danach gilt wieder die Vorgabe.
|
||||
"""Alle eigenen Dashboards verwerfen – danach gilt wieder die Vorgabe.
|
||||
|
||||
Der Statuscode steht bewusst am Response und nicht im Dekorator: Durch
|
||||
``from __future__ import annotations`` wird ``-> None`` zu einer Zeichenkette,
|
||||
die FastAPI zu ``NoneType`` auflöst und als Antwortmodell wertet – zusammen
|
||||
mit 204 (das keinen Rumpf haben darf) bricht der Start dann ab.
|
||||
"""
|
||||
row = db.query(DashboardLayout).filter(DashboardLayout.user_id == user.id).first()
|
||||
if row is not None:
|
||||
for row in _rows(db, user.id):
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.put("/layout/default", response_model=DashboardLayoutOut)
|
||||
def put_default_layout(
|
||||
payload: DashboardLayoutIn,
|
||||
@router.put("/layouts/default", response_model=DashboardListOut)
|
||||
def put_default_dashboards(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> DashboardLayoutOut:
|
||||
_store(db, None, payload.layout)
|
||||
return get_layout(db=db, user=admin)
|
||||
) -> DashboardListOut:
|
||||
"""Die eigenen Dashboards zur Vorgabe für alle machen."""
|
||||
for row in _rows(db, None):
|
||||
db.delete(row)
|
||||
for row in _rows(db, admin.id):
|
||||
db.add(DashboardLayout(user_id=None, name=row.name,
|
||||
position=row.position, layout=row.layout))
|
||||
db.commit()
|
||||
return list_dashboards(db=db, user=admin)
|
||||
|
||||
|
||||
@router.put("/layout/enforced", response_model=DashboardLayoutOut)
|
||||
@router.put("/layout/enforced", response_model=DashboardListOut)
|
||||
def set_enforced(
|
||||
value: bool,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> DashboardLayoutOut:
|
||||
) -> DashboardListOut:
|
||||
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)
|
||||
return list_dashboards(db=db, user=admin)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -424,8 +424,27 @@ class DashboardLayoutIn(BaseModel):
|
||||
layout: list[dict]
|
||||
|
||||
|
||||
class DashboardLayoutOut(BaseModel):
|
||||
class DashboardCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
layout: list[dict] = []
|
||||
|
||||
|
||||
class DashboardUpdate(BaseModel):
|
||||
"""Umbenennen und/oder umbauen – nur mitgeschickte Felder zaehlen."""
|
||||
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
layout: list[dict] | None = None
|
||||
position: int | None = None
|
||||
|
||||
|
||||
class DashboardOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
position: int
|
||||
layout: list[dict]
|
||||
|
||||
|
||||
class DashboardListOut(BaseModel):
|
||||
dashboards: list[DashboardOut]
|
||||
# Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
|
||||
source: str
|
||||
# Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
|
||||
@@ -433,6 +452,13 @@ class DashboardLayoutOut(BaseModel):
|
||||
has_default: bool
|
||||
|
||||
|
||||
class DashboardLayoutOut(BaseModel):
|
||||
layout: list[dict]
|
||||
source: str
|
||||
enforced: bool
|
||||
has_default: bool
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
products_in_stock: int # Artikel mit Bestand > 0
|
||||
article_units: float # Summe der Artikeleinheiten
|
||||
|
||||
@@ -188,54 +188,120 @@ def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Anordnung
|
||||
def _erstes(antwort):
|
||||
return antwort.dashboards[0]
|
||||
|
||||
|
||||
def test_layout_faellt_auf_die_eingebaute_zurueck(db, user):
|
||||
antwort = dashboard.get_layout(db=db, user=user)
|
||||
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||
assert antwort.source == "builtin"
|
||||
assert antwort.enforced is False
|
||||
assert antwort.has_default is False
|
||||
assert any(k["i"] == "expiry-all" for k in antwort.layout)
|
||||
assert len(antwort.dashboards) == 1
|
||||
assert any(k["type"] == "expiry-all" for k in _erstes(antwort).layout)
|
||||
|
||||
|
||||
def test_eigene_anordnung_wird_gespeichert(db, user):
|
||||
from app.schemas import DashboardLayoutIn
|
||||
from app.schemas import DashboardCreate, DashboardUpdate
|
||||
|
||||
eigen = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
|
||||
dashboard.put_layout(DashboardLayoutIn(layout=eigen), db=db, user=user)
|
||||
neu = dashboard.create_dashboard(
|
||||
DashboardCreate(name="Vorräte", layout=[]), db=db, user=user
|
||||
)
|
||||
eigen = [{"i": "k1", "type": "shopping", "x": 0, "y": 0, "w": 6, "h": 3, "props": {}}]
|
||||
dashboard.update_dashboard(neu.id, DashboardUpdate(layout=eigen), db=db, user=user)
|
||||
|
||||
antwort = dashboard.get_layout(db=db, user=user)
|
||||
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||
assert antwort.source == "user"
|
||||
assert antwort.layout == eigen
|
||||
assert _erstes(antwort).name == "Vorräte"
|
||||
assert _erstes(antwort).layout == eigen
|
||||
|
||||
|
||||
def test_dieselbe_kartenart_mehrfach(db, user):
|
||||
"""Der eigentliche Zweck der Instanz-Kennung: zwei Karten derselben Art,
|
||||
jede auf einen anderen Artikel eingestellt."""
|
||||
from app.schemas import DashboardCreate
|
||||
|
||||
layout = [
|
||||
{"i": "a", "type": "product-timeline", "x": 0, "y": 0, "w": 6, "h": 6,
|
||||
"props": {"product_id": 1}},
|
||||
{"i": "b", "type": "product-timeline", "x": 6, "y": 0, "w": 6, "h": 6,
|
||||
"props": {"product_id": 2}},
|
||||
]
|
||||
neu = dashboard.create_dashboard(
|
||||
DashboardCreate(name="Beobachtung", layout=layout), db=db, user=user
|
||||
)
|
||||
assert len(neu.layout) == 2
|
||||
assert {k["props"]["product_id"] for k in neu.layout} == {1, 2}
|
||||
|
||||
|
||||
def test_alte_anordnung_bekommt_die_art_aus_der_kennung(db):
|
||||
"""Gespeicherte Anordnungen von frueher haben kein "type" - dort war die
|
||||
Kennung die Art. Ohne das Nachziehen waere jede alte Karte unbekannt."""
|
||||
alt = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
|
||||
normalisiert = dashboard._normalize(alt)
|
||||
assert normalisiert[0]["type"] == "shopping"
|
||||
assert normalisiert[0]["props"] == {}
|
||||
|
||||
|
||||
def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.schemas import DashboardLayoutIn
|
||||
from app.schemas import DashboardCreate
|
||||
|
||||
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
|
||||
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
|
||||
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
|
||||
dashboard.put_default_dashboards(db=db, admin=user)
|
||||
dashboard.set_enforced(True, db=db, admin=user)
|
||||
|
||||
antwort = dashboard.get_layout(db=db, user=user)
|
||||
antwort = dashboard.list_dashboards(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)
|
||||
dashboard.create_dashboard(DashboardCreate(name="X", 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
|
||||
from app.schemas import DashboardCreate
|
||||
|
||||
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.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
|
||||
dashboard.put_default_dashboards(db=db, admin=user)
|
||||
dashboard.create_dashboard(DashboardCreate(name="Eigenes", layout=[]), db=db, user=user)
|
||||
assert dashboard.list_dashboards(db=db, user=user).source == "user"
|
||||
|
||||
dashboard.reset_layout(db=db, user=user)
|
||||
antwort = dashboard.get_layout(db=db, user=user)
|
||||
dashboard.reset_dashboards(db=db, user=user)
|
||||
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||
assert antwort.source == "default"
|
||||
assert antwort.layout == vorgabe
|
||||
assert _erstes(antwort).name == "Vorgabe"
|
||||
|
||||
|
||||
def test_erstes_eigenes_uebernimmt_die_vorgabe(db, user):
|
||||
"""Sonst verschwaende das Anlegen eines zweiten Dashboards die Vorgabe:
|
||||
Sobald eigene Zeilen existieren, zaehlen nur noch die."""
|
||||
from app.schemas import DashboardCreate
|
||||
|
||||
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
|
||||
dashboard.put_default_dashboards(db=db, admin=user)
|
||||
dashboard.reset_dashboards(db=db, user=user)
|
||||
assert dashboard.list_dashboards(db=db, user=user).source == "default"
|
||||
|
||||
dashboard.create_dashboard(DashboardCreate(name="Getränke", layout=[]), db=db, user=user)
|
||||
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||
assert [d.name for d in antwort.dashboards] == ["Vorgabe", "Getränke"]
|
||||
|
||||
|
||||
def test_fremdes_dashboard_ist_unsichtbar(db, user):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import Role, User
|
||||
from app.schemas import DashboardCreate, DashboardUpdate
|
||||
|
||||
fremd = User(username="andere", password_hash="x", role=Role.user)
|
||||
db.add(fremd)
|
||||
db.commit()
|
||||
db.refresh(fremd)
|
||||
|
||||
meins = dashboard.create_dashboard(DashboardCreate(name="Meins", layout=[]), db=db, user=user)
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
dashboard.update_dashboard(meins.id, DashboardUpdate(name="Geklaut"), db=db, user=fremd)
|
||||
assert fehler.value.status_code == 404
|
||||
|
||||
@@ -194,6 +194,11 @@ actor APIClient {
|
||||
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self)
|
||||
}
|
||||
|
||||
/// Die Dashboards des Benutzers - dieselben wie in der Web-Oberflaeche.
|
||||
func dashboards() async throws -> DashboardList {
|
||||
try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self)
|
||||
}
|
||||
|
||||
/// Neueste zuerst. `productId` filtert auf einen Artikel.
|
||||
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
|
||||
var path = "/movements?limit=\(limit)"
|
||||
|
||||
403
ios/Sources/DashboardCards.swift
Normal file
403
ios/Sources/DashboardCards.swift
Normal file
@@ -0,0 +1,403 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Ein- und Auslagern ohne Umweg
|
||||
|
||||
/// Buchen direkt aus einer Karte heraus: Artikel suchen, Menge, fertig.
|
||||
///
|
||||
/// MHD und Lagerort bleiben bewusst weg - wer die braucht, ist im Scan-Ablauf
|
||||
/// besser aufgehoben. Hier zaehlt der schnelle Alltagsfall.
|
||||
struct QuickBookingCard: View {
|
||||
enum Richtung { case ein, aus }
|
||||
|
||||
let richtung: Richtung
|
||||
let onChange: () -> Void
|
||||
|
||||
@State private var suche = ""
|
||||
@State private var treffer: [Product] = []
|
||||
@State private var artikel: Product?
|
||||
@State private var menge = "1"
|
||||
@State private var meldung: String?
|
||||
@State private var fehler: String?
|
||||
@State private var laeuft = false
|
||||
|
||||
private var einlagern: Bool { richtung == .ein }
|
||||
private var zahl: Double? {
|
||||
Double(menge.replacingOccurrences(of: ",", with: "."))
|
||||
}
|
||||
private var bereit: Bool {
|
||||
artikel != nil && (zahl ?? 0) > 0 && !laeuft
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let artikel {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(artikel.name).font(.subheadline).bold()
|
||||
Text("\(formatAmount(artikel.stockInArticleUnits)) \(artikel.articleUnitLabel) im Bestand")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
self.artikel = nil
|
||||
suche = ""
|
||||
meldung = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} else {
|
||||
TextField("Artikel suchen…", text: $suche)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: suche) { _ in Task { await suchen() } }
|
||||
ForEach(treffer.prefix(4)) { produkt in
|
||||
Button {
|
||||
artikel = produkt
|
||||
treffer = []
|
||||
meldung = nil
|
||||
} label: {
|
||||
HStack {
|
||||
Text(produkt.name).font(.callout)
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
TextField("1", text: $menge)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.decimalPad)
|
||||
.frame(width: 80)
|
||||
Button {
|
||||
Task { await buchen() }
|
||||
} label: {
|
||||
Label(einlagern ? "Einlagern" : "Auslagern",
|
||||
systemImage: einlagern ? "arrow.down.to.line" : "arrow.up.to.line")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(einlagern ? Color.accentColor : .orange)
|
||||
.disabled(!bereit)
|
||||
}
|
||||
|
||||
if let meldung {
|
||||
Text(meldung).font(.caption).foregroundStyle(.green)
|
||||
}
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func suchen() async {
|
||||
let text = suche.trimmingCharacters(in: .whitespaces)
|
||||
guard text.count >= 2 else { treffer = []; return }
|
||||
treffer = (try? await APIClient.shared.searchProducts(text)) ?? []
|
||||
}
|
||||
|
||||
private func buchen() async {
|
||||
guard let artikel, let wert = zahl else { return }
|
||||
laeuft = true
|
||||
defer { laeuft = false }
|
||||
fehler = nil
|
||||
meldung = nil
|
||||
do {
|
||||
// "article" ist die Einheit, in der der Artikel gefuehrt wird -
|
||||
// genau die, die in der Karte steht.
|
||||
if einlagern {
|
||||
let zeile = CheckInLine(quantity: wert, bestBefore: nil,
|
||||
bestBeforePrecision: "day", locationId: nil)
|
||||
_ = try await APIClient.shared.checkInBatch(
|
||||
BatchCheckInRequest(productId: artikel.id, unit: "article", lines: [zeile]))
|
||||
} else {
|
||||
_ = try await APIClient.shared.checkOut(
|
||||
CheckOutRequest(productId: artikel.id, quantity: wert,
|
||||
unit: "article", lotId: nil))
|
||||
}
|
||||
meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")."
|
||||
menge = "1"
|
||||
// Bestand in der Karte nachziehen, sonst steht dort die alte Zahl.
|
||||
self.artikel = try? await APIClient.shared.product(id: artikel.id)
|
||||
onChange()
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schnellzugriff
|
||||
|
||||
struct ActionsCard: View {
|
||||
@EnvironmentObject private var router: Router
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button { router.route = .checkin } label: {
|
||||
Label("Einlagern", systemImage: "arrow.down.to.line")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button { router.route = .checkout } label: {
|
||||
Label("Auslagern", systemImage: "arrow.up.to.line")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Kennzahlen
|
||||
|
||||
struct StatusCard: View {
|
||||
let art: String
|
||||
let stand: Int
|
||||
|
||||
@State private var stats: DashboardStats?
|
||||
@State private var fehler: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if let stats {
|
||||
switch art {
|
||||
case "kpi-products":
|
||||
Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand",
|
||||
detail: "von \(stats.productsTotal)", tint: .accentColor)
|
||||
case "kpi-units":
|
||||
Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten",
|
||||
detail: "", tint: .accentColor)
|
||||
default:
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend",
|
||||
detail: "", tint: stats.expiringSoon > 0 ? .orange : .secondary)
|
||||
Kennzahl(wert: String(stats.expired), label: "Abgelaufen",
|
||||
detail: "", tint: stats.expired > 0 ? .red : .secondary)
|
||||
Kennzahl(wert: String(stats.shoppingItems), label: "Einzukaufen",
|
||||
detail: "", tint: stats.shoppingItems > 0 ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Lädt…").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task(id: stand) {
|
||||
do {
|
||||
stats = try await APIClient.shared.dashboardStats()
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Kennzahl: View {
|
||||
let wert: String
|
||||
let label: String
|
||||
let detail: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.caption2).foregroundStyle(.secondary)
|
||||
.lineLimit(1).minimumScaleFactor(0.8)
|
||||
Text(wert).font(.title2).bold().foregroundStyle(tint)
|
||||
if !detail.isEmpty {
|
||||
Text(detail).font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Listen
|
||||
|
||||
struct ShoppingCard: View {
|
||||
@EnvironmentObject private var display: DisplaySettings
|
||||
let stand: Int
|
||||
|
||||
@State private var items: [ShoppingItem] = []
|
||||
@State private var fehler: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if items.isEmpty {
|
||||
Text("Alle Mindestbestände sind gedeckt.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(items.prefix(5)) { item in
|
||||
HStack {
|
||||
Text(item.name).font(.callout)
|
||||
Spacer()
|
||||
Text("fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
NavigationLink("Ganze Liste") { ShoppingListView() }
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: stand) {
|
||||
do {
|
||||
items = try await APIClient.shared.shoppingList()
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExpiryCard: View {
|
||||
@EnvironmentObject private var display: DisplaySettings
|
||||
let modus: String
|
||||
let stand: Int
|
||||
|
||||
@State private var items: [ExpiringItem] = []
|
||||
@State private var fehler: String?
|
||||
|
||||
/// "expiring" zeigt nur Laufendes, "expired" nur Ueberfaelliges,
|
||||
/// "expiry-all" beides - wie in der Web-Oberflaeche.
|
||||
private var gefiltert: [ExpiringItem] {
|
||||
switch modus {
|
||||
case "expiring": return items.filter { $0.daysLeft >= 0 }
|
||||
case "expired": return items.filter { $0.daysLeft < 0 }
|
||||
default: return items
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if gefiltert.isEmpty {
|
||||
Text("Nichts läuft demnächst ab.").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(gefiltert.prefix(5)) { item in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.productName).font(.callout)
|
||||
Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision))")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(item.daysLeft < 0 ? "abgelaufen" : "\(item.daysLeft) Tage")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
||||
}
|
||||
}
|
||||
NavigationLink("Ganze Liste") { ExpiringView() }.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: stand) {
|
||||
do {
|
||||
items = try await APIClient.shared.expiring()
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MovementsCard: View {
|
||||
let stand: Int
|
||||
|
||||
@State private var movements: [Movement] = []
|
||||
@State private var fehler: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if movements.isEmpty {
|
||||
Text("Noch keine Bewegungen.").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(movements.prefix(5)) { movement in
|
||||
MovementRow(movement: movement)
|
||||
}
|
||||
NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: stand) {
|
||||
do {
|
||||
movements = try await APIClient.shared.movements(limit: 5)
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ein fester Artikel
|
||||
|
||||
struct ProductCard: View {
|
||||
let productId: Int?
|
||||
let stand: Int
|
||||
|
||||
@State private var produkt: Product?
|
||||
@State private var fehler: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if productId == nil {
|
||||
Text("Kein Artikel gewählt – einstellen in der Web-Oberfläche.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if let produkt {
|
||||
NavigationLink {
|
||||
ProductDetailView(product: produkt)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(produkt.name).font(.callout)
|
||||
if let hinweis = mindestbestand(produkt) {
|
||||
Text(hinweis).font(.caption2).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(formatAmount(produkt.stockInArticleUnits)) \(produkt.articleUnitLabel)")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Lädt…").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task(id: "\(productId ?? 0)-\(stand)") {
|
||||
guard let productId else { return }
|
||||
do {
|
||||
produkt = try await APIClient.shared.product(id: productId)
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mindestbestand(_ produkt: Product) -> String? {
|
||||
switch produkt.stockLevel {
|
||||
case .below: return "unter Mindestbestand"
|
||||
case .close: return "bald nachkaufen"
|
||||
case .ok, .none: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
167
ios/Sources/DashboardView.swift
Normal file
167
ios/Sources/DashboardView.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Zeigt die Dashboards, die auch die Web-Oberflaeche kennt.
|
||||
///
|
||||
/// Das Raster (x/y/w/h) laesst sich auf einem Telefon nicht sinnvoll
|
||||
/// nachbilden - hier stehen die Karten deshalb untereinander, in der
|
||||
/// Lesereihenfolge des Rasters (erst Zeile, dann Spalte). Zusammengestellt
|
||||
/// werden Dashboards weiterhin am Rechner; die App zeigt sie.
|
||||
struct DashboardView: View {
|
||||
@EnvironmentObject private var session: Session
|
||||
|
||||
@State private var dashboards: [Dashboard] = []
|
||||
@State private var auswahl: Int?
|
||||
@State private var busy = true
|
||||
@State private var fehler: String?
|
||||
@State private var manageShown = false
|
||||
/// Zaehler, der die Karten nach einer Buchung neu laden laesst.
|
||||
@State private var stand = 0
|
||||
|
||||
private var aktives: Dashboard? {
|
||||
dashboards.first { $0.id == auswahl } ?? dashboards.first
|
||||
}
|
||||
|
||||
/// Rasterreihenfolge: erst von oben nach unten, dann von links nach rechts.
|
||||
private var karten: [DashboardCard] {
|
||||
(aktives?.layout ?? []).sorted {
|
||||
$0.y == $1.y ? $0.x < $1.x : $0.y < $1.y
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let fehler {
|
||||
Section { Text(fehler).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
// Mehrere Dashboards: oben umschalten. Bei einem einzigen waere
|
||||
// die Auswahl nur Beiwerk.
|
||||
if dashboards.count > 1 {
|
||||
Section {
|
||||
Picker("Dashboard", selection: Binding(
|
||||
get: { aktives?.id ?? 0 },
|
||||
set: { auswahl = $0 }
|
||||
)) {
|
||||
ForEach(dashboards) { d in
|
||||
Text(d.name).tag(d.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(karten) { karte in
|
||||
Section {
|
||||
DashboardCardView(karte: karte, stand: stand) { stand += 1 }
|
||||
} header: {
|
||||
Text(titel(fuer: karte))
|
||||
}
|
||||
}
|
||||
|
||||
if karten.isEmpty && !busy {
|
||||
Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(aktives?.name ?? session.activeProfile?.name ?? "Übersicht")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ServerMenu(manageShown: $manageShown)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||
.refreshable { await laden() }
|
||||
.task(id: session.activeProfileID) { await laden() }
|
||||
}
|
||||
}
|
||||
|
||||
private func titel(fuer karte: DashboardCard) -> String {
|
||||
CardCatalog.titel(karte.type)
|
||||
}
|
||||
|
||||
private func laden() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let antwort = try await APIClient.shared.dashboards()
|
||||
dashboards = antwort.dashboards.sorted { $0.position < $1.position }
|
||||
if auswahl == nil || !dashboards.contains(where: { $0.id == auswahl }) {
|
||||
auswahl = dashboards.first?.id
|
||||
}
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Beschriftungen der Kartenarten - dieselben wie in web/src/dashboard/cards.jsx.
|
||||
enum CardCatalog {
|
||||
static func titel(_ type: String) -> String {
|
||||
switch type {
|
||||
case "actions": return "Schnellzugriff"
|
||||
case "checkin-quick": return "Einlagern"
|
||||
case "checkout-quick": return "Auslagern"
|
||||
case "status": return "Status"
|
||||
case "kpi-products": return "Artikel mit Bestand"
|
||||
case "kpi-units": return "Artikeleinheiten"
|
||||
case "expiring": return "Bald ablaufend"
|
||||
case "expired": return "Abgelaufen"
|
||||
case "expiry-all": return "Ablauf-Übersicht"
|
||||
case "shopping": return "Einkaufsliste"
|
||||
case "movements": return "Letzte Bewegungen"
|
||||
case "product-stock": return "Artikel im Blick"
|
||||
case "product-timeline": return "Verlauf eines Artikels"
|
||||
case "expiry-donut": return "Ablauf-Anteile"
|
||||
case "category-donut": return "Kategorien-Anteile"
|
||||
case "category-bars": return "Kategorien nach Zustand"
|
||||
case "stock-timeline": return "Bestandsverlauf"
|
||||
case "activity-timeline": return "Ein- und Auslagerungen"
|
||||
default: return "Karte"
|
||||
}
|
||||
}
|
||||
|
||||
/// Karten, die nur als Diagramm Sinn ergeben. Die App zeigt statt eines
|
||||
/// halbgaren Nachbaus einen ehrlichen Hinweis.
|
||||
static func istDiagramm(_ type: String) -> Bool {
|
||||
["expiry-donut", "category-donut", "category-bars",
|
||||
"stock-timeline", "activity-timeline"].contains(type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Waehlt anhand der Kartenart den passenden Inhalt.
|
||||
struct DashboardCardView: View {
|
||||
let karte: DashboardCard
|
||||
let stand: Int
|
||||
let onChange: () -> Void
|
||||
|
||||
var body: some View {
|
||||
switch karte.type {
|
||||
case "checkin-quick":
|
||||
QuickBookingCard(richtung: .ein, onChange: onChange)
|
||||
case "checkout-quick":
|
||||
QuickBookingCard(richtung: .aus, onChange: onChange)
|
||||
case "actions":
|
||||
ActionsCard()
|
||||
case "status", "kpi-products", "kpi-units":
|
||||
StatusCard(art: karte.type, stand: stand)
|
||||
case "shopping":
|
||||
ShoppingCard(stand: stand)
|
||||
case "expiring", "expired", "expiry-all":
|
||||
ExpiryCard(modus: karte.type, stand: stand)
|
||||
case "movements":
|
||||
MovementsCard(stand: stand)
|
||||
case "product-stock", "product-timeline":
|
||||
ProductCard(productId: karte.props?.productId, stand: stand)
|
||||
default:
|
||||
if CardCatalog.istDiagramm(karte.type) {
|
||||
Label("Diagramme gibt es in der Web-Oberfläche.", systemImage: "chart.xyaxis.line")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Unbekannte Karte „\(karte.type)“.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,55 @@ struct Movement: Codable, Identifiable, Hashable {
|
||||
var isIncoming: Bool { type == "in" }
|
||||
}
|
||||
|
||||
// MARK: - Dashboards
|
||||
|
||||
/// Eine Karte auf einem Dashboard.
|
||||
///
|
||||
/// `i` ist die Kennung **dieser** Karte, `type` ihre Art - erst dadurch kann
|
||||
/// dieselbe Art mehrfach auf einem Dashboard liegen. `props` traegt, was nur
|
||||
/// diese eine Karte angeht (etwa welcher Artikel gemeint ist).
|
||||
struct DashboardCard: Codable, Identifiable, Hashable {
|
||||
let i: String
|
||||
let type: String
|
||||
let x: Int
|
||||
let y: Int
|
||||
let w: Int
|
||||
let h: Int
|
||||
let tage: Int?
|
||||
let props: CardProps?
|
||||
|
||||
var id: String { i }
|
||||
|
||||
/// Nur die Felder, die die App auswertet. Die Rasterangaben kommen aus der
|
||||
/// Web-Oberflaeche; hier bestimmen sie allein die Reihenfolge.
|
||||
struct CardProps: Codable, Hashable {
|
||||
let productId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case productId = "product_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Dashboard: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let position: Int
|
||||
let layout: [DashboardCard]
|
||||
}
|
||||
|
||||
struct DashboardList: Codable {
|
||||
let dashboards: [Dashboard]
|
||||
let source: String
|
||||
let enforced: Bool
|
||||
let hasDefault: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case dashboards, source, enforced
|
||||
case hasDefault = "has_default"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stammdaten
|
||||
|
||||
/// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl.
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Startseite: Kennzahlen des Servers und die letzten Bewegungen.
|
||||
///
|
||||
/// Bewusst ohne Diagramme - die Kennzahl selbst ist die Antwort, und jede
|
||||
/// fuehrt per Tipp dorthin, wo man etwas tun kann.
|
||||
struct OverviewView: View {
|
||||
@EnvironmentObject private var session: Session
|
||||
@EnvironmentObject private var display: DisplaySettings
|
||||
|
||||
@State private var stats: DashboardStats?
|
||||
@State private var letzte: [Movement] = []
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
@State private var manageShown = false
|
||||
|
||||
private let spalten = [GridItem(.flexible()), GridItem(.flexible())]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
Section {
|
||||
LazyVGrid(columns: spalten, spacing: 12) {
|
||||
NavigationLink { ProductListView() } label: {
|
||||
StatTile(title: "Artikel mit Bestand",
|
||||
value: stats.map { String($0.productsInStock) } ?? "–",
|
||||
detail: stats.map { "von \($0.productsTotal)" } ?? "",
|
||||
systemImage: "shippingbox", tint: .accentColor)
|
||||
}
|
||||
NavigationLink { ShoppingListView() } label: {
|
||||
StatTile(title: "Einzukaufen",
|
||||
value: stats.map { String($0.shoppingItems) } ?? "–",
|
||||
detail: "unter Mindestbestand",
|
||||
systemImage: "cart",
|
||||
tint: (stats?.shoppingItems ?? 0) > 0 ? .orange : .secondary)
|
||||
}
|
||||
NavigationLink { ExpiringView() } label: {
|
||||
StatTile(title: "Läuft bald ab",
|
||||
value: stats.map { String($0.expiringSoon) } ?? "–",
|
||||
detail: "Chargen",
|
||||
systemImage: "clock",
|
||||
tint: (stats?.expiringSoon ?? 0) > 0 ? .orange : .secondary)
|
||||
}
|
||||
NavigationLink { ExpiringView() } label: {
|
||||
StatTile(title: "Abgelaufen",
|
||||
value: stats.map { String($0.expired) } ?? "–",
|
||||
detail: "Chargen",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
tint: (stats?.expired ?? 0) > 0 ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(letzte) { movement in
|
||||
MovementRow(movement: movement)
|
||||
}
|
||||
if letzte.isEmpty && !busy {
|
||||
Text("Noch keine Bewegungen.").foregroundStyle(.secondary)
|
||||
}
|
||||
NavigationLink("Ganzen Verlauf ansehen") { HistoryView() }
|
||||
} header: {
|
||||
Text("Letzte Bewegungen")
|
||||
}
|
||||
}
|
||||
.navigationTitle(session.activeProfile?.name ?? "Vorrania")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ServerMenu(manageShown: $manageShown)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||
.refreshable { await load() }
|
||||
.task(id: session.activeProfileID) { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
stats = try await APIClient.shared.dashboardStats()
|
||||
letzte = try await APIClient.shared.movements(limit: 10)
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Kennzahl als Kachel.
|
||||
struct StatTile: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage).font(.caption).foregroundStyle(tint)
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
.lineLimit(1).minimumScaleFactor(0.8)
|
||||
}
|
||||
Text(value).font(.title).bold().foregroundStyle(tint)
|
||||
if !detail.isEmpty {
|
||||
Text(detail).font(.caption2).foregroundStyle(.tertiary)
|
||||
.lineLimit(1).minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ struct HomeView: View {
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
OverviewView()
|
||||
DashboardView()
|
||||
.tabItem { Label("Start", systemImage: "house") }
|
||||
|
||||
ScanTabView()
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
|
||||
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
|
||||
191427830389D44062F0A622 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BDE41825692274409DDCAC7 /* OverviewView.swift */; };
|
||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
|
||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
|
||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
|
||||
@@ -20,7 +19,9 @@
|
||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
||||
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
||||
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; };
|
||||
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
|
||||
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
|
||||
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; };
|
||||
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
|
||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
||||
@@ -34,8 +35,8 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
|
||||
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
|
||||
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||
0BDE41825692274409DDCAC7 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = "<group>"; };
|
||||
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
|
||||
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
|
||||
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
@@ -56,6 +57,7 @@
|
||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
|
||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
|
||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
|
||||
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -69,6 +71,8 @@
|
||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */,
|
||||
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */,
|
||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
||||
090485CC54558EB4433376A9 /* DashboardCards.swift */,
|
||||
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
|
||||
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||
@@ -76,7 +80,6 @@
|
||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
||||
314B1BB7220691A7423E8929 /* Models.swift */,
|
||||
0BDE41825692274409DDCAC7 /* OverviewView.swift */,
|
||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||
4741D0E95875919C921945CF /* RootView.swift */,
|
||||
@@ -183,6 +186,8 @@
|
||||
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */,
|
||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */,
|
||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
||||
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */,
|
||||
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */,
|
||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
||||
@@ -190,7 +195,6 @@
|
||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
||||
191427830389D44062F0A622 /* OverviewView.swift in Sources */,
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import Icon from "./components/Icon";
|
||||
import BrandMark from "./components/BrandMark";
|
||||
@@ -33,6 +35,19 @@ function NavItem({ to, icon, label, end }) {
|
||||
function Sidebar() {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [dashboards, setDashboards] = useState([]);
|
||||
|
||||
// Die Dashboards des Benutzers stehen als eigene Eintraege in der Leiste.
|
||||
// Sie aendern sich beim Anlegen und Loeschen, deshalb horcht die Leiste auf
|
||||
// das Ereignis, das die Dashboard-Seite dabei ausloest.
|
||||
useEffect(() => {
|
||||
function laden() {
|
||||
api.dashboards().then((a) => setDashboards(a.dashboards)).catch(() => {});
|
||||
}
|
||||
laden();
|
||||
window.addEventListener("dashboards-geaendert", laden);
|
||||
return () => window.removeEventListener("dashboards-geaendert", laden);
|
||||
}, []);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
@@ -46,6 +61,14 @@ function Sidebar() {
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
<NavItem to="/" end icon="dashboard" label="Übersicht" />
|
||||
{/* Ab dem zweiten Dashboard lohnt die Aufzaehlung; bei einem einzigen
|
||||
waere sie nur eine Verdopplung von "Übersicht". */}
|
||||
{dashboards.length > 1 && dashboards.map((d, index) => (
|
||||
<NavLink key={d.id} to={index === 0 ? "/" : `/d/${d.id}`} end
|
||||
className={({ isActive }) => (isActive ? "nav-link sub active" : "nav-link sub")}>
|
||||
<span className="label">{d.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
||||
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
||||
<NavItem to="/products" icon="package" label="Produkte" />
|
||||
@@ -103,6 +126,7 @@ export default function App() {
|
||||
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
<Route path="/" element={<Protected><Dashboard /></Protected>} />
|
||||
<Route path="/d/:id" element={<Protected><Dashboard /></Protected>} />
|
||||
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
|
||||
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
||||
<Route path="/products" element={<Protected><Products /></Protected>} />
|
||||
|
||||
@@ -163,12 +163,14 @@ export const api = {
|
||||
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||||
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
|
||||
|
||||
// Startseite: Anordnung und Auswertungen
|
||||
dashboardLayout: () => request("/dashboard/layout"),
|
||||
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: { layout } }),
|
||||
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
||||
saveDashboardDefault: (layout) =>
|
||||
request("/dashboard/layout/default", { method: "PUT", body: { layout } }),
|
||||
// Startseite: Dashboards und Auswertungen
|
||||
dashboards: () => request("/dashboard/layouts"),
|
||||
createDashboard: (body) => request("/dashboard/layouts", { method: "POST", body }),
|
||||
updateDashboard: (id, body) =>
|
||||
request(`/dashboard/layouts/${id}`, { method: "PATCH", body }),
|
||||
deleteDashboard: (id) => request(`/dashboard/layouts/${id}`, { method: "DELETE" }),
|
||||
resetDashboards: () => request("/dashboard/layouts", { method: "DELETE" }),
|
||||
saveDashboardDefault: () => request("/dashboard/layouts/default", { method: "PUT" }),
|
||||
setDashboardEnforced: (value) =>
|
||||
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
|
||||
dashboardStats: () => request("/dashboard/stats"),
|
||||
|
||||
@@ -13,16 +13,25 @@ import Icon from "./components/Icon";
|
||||
*
|
||||
* const confirm = useConfirm();
|
||||
* if (!(await confirm({ title: "Gruppe löschen?", message: "…" }))) return;
|
||||
*
|
||||
* Mit `prompt: true` wird daraus eine Eingabe; aufgeloest wird dann der
|
||||
* eingetippte Text (oder `null` beim Abbrechen). Auch dafuer gilt der Grund von
|
||||
* oben: `window.prompt` sieht aus wie der Browser, nicht wie diese Anwendung.
|
||||
*
|
||||
* const name = await confirm({ title: "Name?", prompt: true, defaultValue: "…" });
|
||||
*/
|
||||
const ConfirmContext = createContext(async () => false);
|
||||
|
||||
export function ConfirmProvider({ children }) {
|
||||
const [frage, setFrage] = useState(null);
|
||||
const [eingabe, setEingabe] = useState("");
|
||||
// Die Zusage des offenen Dialogs, damit `await confirm(...)` funktioniert.
|
||||
const antwort = useRef(null);
|
||||
|
||||
const confirm = useCallback((optionen) => {
|
||||
setFrage(typeof optionen === "string" ? { message: optionen } : optionen);
|
||||
const gestellt = typeof optionen === "string" ? { message: optionen } : optionen;
|
||||
setFrage(gestellt);
|
||||
setEingabe(gestellt.defaultValue ?? "");
|
||||
return new Promise((resolve) => {
|
||||
antwort.current = resolve;
|
||||
});
|
||||
@@ -34,25 +43,46 @@ export function ConfirmProvider({ children }) {
|
||||
antwort.current = null;
|
||||
}
|
||||
|
||||
/** Bei einer Eingabe zaehlt der Text, sonst wie bisher true/false. */
|
||||
function bestaetigen() {
|
||||
if (!frage?.prompt) { schliessen(true); return; }
|
||||
const text = eingabe.trim();
|
||||
if (!text) return; // Leer bestaetigen ergibt keinen Sinn
|
||||
schliessen(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
{frage && (
|
||||
<div className="modal-backdrop"
|
||||
onClick={(e) => e.target === e.currentTarget && schliessen(false)}>
|
||||
onClick={(e) => e.target === e.currentTarget && schliessen(frage.prompt ? null : false)}>
|
||||
<div className="modal" role="dialog" aria-modal="true">
|
||||
<div className="modal-head">
|
||||
<Icon name="alert" size={18} className={frage.danger ? "icon-danger" : ""} />
|
||||
<Icon name={frage.prompt ? "edit" : "alert"} size={18}
|
||||
className={frage.danger ? "icon-danger" : ""} />
|
||||
<h2>{frage.title || "Bitte bestätigen"}</h2>
|
||||
</div>
|
||||
<p className="modal-body">{frage.message}</p>
|
||||
{frage.message && <p className="modal-body">{frage.message}</p>}
|
||||
{frage.prompt && (
|
||||
<div className="modal-body">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input autoFocus value={eingabe} onChange={(e) => setEingabe(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") bestaetigen();
|
||||
if (e.key === "Escape") schliessen(null);
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
<div className="btn-pair">
|
||||
<button type="button" className="btn" autoFocus onClick={() => schliessen(false)}>
|
||||
<button type="button" className="btn" autoFocus={!frage.prompt}
|
||||
onClick={() => schliessen(frage.prompt ? null : false)}>
|
||||
{frage.cancelLabel || "Abbrechen"}
|
||||
</button>
|
||||
<button type="button"
|
||||
className={frage.danger ? "btn danger-solid" : "btn primary"}
|
||||
onClick={() => schliessen(true)}>
|
||||
disabled={frage.prompt && !eingabe.trim()}
|
||||
onClick={bestaetigen}>
|
||||
{frage.confirmLabel || "Fortfahren"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,145 @@ function zeitLabel(iso, tage) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Aktionen
|
||||
/**
|
||||
* Artikelsuche mit Tastaturbedienung, wie sie die Aktionskarten brauchen.
|
||||
* Erst ab zwei Zeichen wird gesucht – sonst laedt jede Karte beim Oeffnen die
|
||||
* halbe Artikelliste.
|
||||
*/
|
||||
function Artikelsuche({ gewaehlt, onWaehlen }) {
|
||||
const [text, setText] = useState("");
|
||||
const [treffer, setTreffer] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gewaehlt || text.trim().length < 2) { setTreffer([]); return undefined; }
|
||||
let aktiv = true;
|
||||
const zeit = setTimeout(() => {
|
||||
api.listProducts(text.trim())
|
||||
.then((liste) => aktiv && setTreffer(liste.slice(0, 6)))
|
||||
.catch(() => aktiv && setTreffer([]));
|
||||
}, 250);
|
||||
return () => { aktiv = false; clearTimeout(zeit); };
|
||||
}, [text, gewaehlt]);
|
||||
|
||||
if (gewaehlt) {
|
||||
return (
|
||||
<div className="aktion-gewaehlt">
|
||||
<span className="name">{gewaehlt.name}</span>
|
||||
<button className="btn-icon" title="Andere wählen"
|
||||
onClick={() => { onWaehlen(null); setText(""); }}>
|
||||
<Icon name="close" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="aktion-suche">
|
||||
<input value={text} onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Artikel suchen…" onMouseDown={(e) => e.stopPropagation()} />
|
||||
{treffer.length > 0 && (
|
||||
<ul className="aktion-treffer">
|
||||
{treffer.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button onClick={() => { onWaehlen(p); setText(""); }}>{p.name}</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein- und Auslagern ohne Seitenwechsel: Artikel suchen, Menge eintragen,
|
||||
* fertig. MHD und Lagerort bleiben bewusst weg – wer die braucht, ist auf der
|
||||
* vollen Seite besser aufgehoben; hier zaehlt der schnelle Alltagsfall.
|
||||
*/
|
||||
function KarteSchnellbuchung({ richtung }) {
|
||||
const einlagern = richtung === "in";
|
||||
const [artikel, setArtikel] = useState(null);
|
||||
const [menge, setMenge] = useState("1");
|
||||
const [meldung, setMeldung] = useState(null);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
const zahl = Number(String(menge).replace(",", "."));
|
||||
const bereit = artikel && zahl > 0 && !laeuft;
|
||||
|
||||
async function buchen() {
|
||||
if (!bereit) return;
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
setMeldung(null);
|
||||
try {
|
||||
// "article" ist die Einheit, in der der Artikel gefuehrt wird (Gebinde
|
||||
// oder Basiseinheit) - genau das, was in der Karte steht.
|
||||
if (einlagern) {
|
||||
await api.checkInBatch({
|
||||
product_id: artikel.id, unit: "article", lines: [{ quantity: zahl }],
|
||||
});
|
||||
} else {
|
||||
await api.checkOut({ product_id: artikel.id, quantity: zahl, unit: "article" });
|
||||
}
|
||||
setMeldung(`${fmt(zahl)} ${einlagern ? "eingelagert" : "ausgelagert"}.`);
|
||||
setMenge("1");
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-stack aktion-karte">
|
||||
<Artikelsuche gewaehlt={artikel} onWaehlen={(p) => {
|
||||
setArtikel(p); setMeldung(null); setFehler(null);
|
||||
}} />
|
||||
<div className="aktion-zeile">
|
||||
<input className="aktion-menge" value={menge} inputMode="decimal"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setMenge(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") buchen(); }} />
|
||||
<button className={`btn ${einlagern ? "primary" : ""}`} disabled={!bereit} onClick={buchen}>
|
||||
<Icon name={einlagern ? "checkin" : "checkout"} size={16} />
|
||||
{einlagern ? "Einlagern" : "Auslagern"}
|
||||
</button>
|
||||
</div>
|
||||
{meldung && <div className="alert ok">{meldung}</div>}
|
||||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein fest eingestellter Artikel im Blick: Bestand jetzt, mit Hinweis auf den
|
||||
* Mindestbestand. Welcher Artikel gemeint ist, steht in den Karten-Props und
|
||||
* wird deshalb mitgespeichert.
|
||||
*/
|
||||
function KarteArtikel({ props: karteProps }) {
|
||||
const produktId = karteProps?.product_id;
|
||||
const { daten, fehler } = useDaten(
|
||||
() => (produktId ? api.getProduct(produktId) : Promise.resolve(null)),
|
||||
[produktId],
|
||||
);
|
||||
|
||||
if (!produktId) {
|
||||
return <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
||||
}
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
{daten && (
|
||||
<div className="card-stack">
|
||||
<div className="kpi">
|
||||
<div className="kpi-label"><Icon name="package" size={14} />{daten.name}</div>
|
||||
<div className="kpi-value">{articlePrimary(daten)}</div>
|
||||
</div>
|
||||
<div className="muted small">{articleSecondary(daten)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Zustand>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteAktionen() {
|
||||
return (
|
||||
<div className="card-actions">
|
||||
@@ -330,29 +469,24 @@ function KarteBestandsverlauf({ tage = 90 }) {
|
||||
);
|
||||
}
|
||||
|
||||
function KarteArtikelverlauf({ tage = 90 }) {
|
||||
const [produkte, setProdukte] = useState([]);
|
||||
const [gewaehlt, setGewaehlt] = useState("");
|
||||
/**
|
||||
* Verlauf eines Artikels. Welcher gemeint ist, steht in den Karten-Props und
|
||||
* ist damit gespeichert - frueher lag die Wahl nur in useState und war nach
|
||||
* jedem Neuladen wieder weg, weshalb sich damit nichts beobachten liess.
|
||||
*/
|
||||
function KarteArtikelverlauf({ tage = 90, props: karteProps }) {
|
||||
const gewaehlt = karteProps?.product_id ?? "";
|
||||
const { daten, fehler } = useDaten(
|
||||
() => (gewaehlt ? api.dashboardTimeline(tage, gewaehlt) : Promise.resolve([])),
|
||||
[gewaehlt, tage],
|
||||
);
|
||||
|
||||
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
|
||||
}, []);
|
||||
if (!gewaehlt) {
|
||||
return <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-stack">
|
||||
<select value={gewaehlt} onChange={(e) => setGewaehlt(e.target.value)}
|
||||
style={{ marginTop: 0 }}>
|
||||
<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>
|
||||
) : (
|
||||
@@ -405,6 +539,25 @@ export const KARTEN = {
|
||||
standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
|
||||
komponente: KarteAktionen,
|
||||
},
|
||||
"checkin-quick": {
|
||||
titel: "Einlagern", icon: "checkin", vorschau: "aktion",
|
||||
beschreibung: "Artikel suchen, Menge eintragen – ohne Seitenwechsel",
|
||||
standard: { w: 4, h: 4 }, min: { w: 3, h: 4 },
|
||||
komponente: () => <KarteSchnellbuchung richtung="in" />,
|
||||
},
|
||||
"checkout-quick": {
|
||||
titel: "Auslagern", icon: "checkout", vorschau: "aktion",
|
||||
beschreibung: "Artikel suchen, Menge entnehmen – ohne Seitenwechsel",
|
||||
standard: { w: 4, h: 4 }, min: { w: 3, h: 4 },
|
||||
komponente: () => <KarteSchnellbuchung richtung="out" />,
|
||||
},
|
||||
"product-stock": {
|
||||
titel: "Artikel im Blick", icon: "package", vorschau: "kennzahl",
|
||||
beschreibung: "Bestand eines festen Artikels – mehrfach verwendbar",
|
||||
standard: { w: 3, h: 3 }, min: { w: 2, h: 3 },
|
||||
artikel: true,
|
||||
komponente: KarteArtikel,
|
||||
},
|
||||
status: {
|
||||
titel: "Status", icon: "dashboard", vorschau: "kennzahlen",
|
||||
beschreibung: "Bald ablaufend, abgelaufen, nachzukaufen",
|
||||
@@ -479,8 +632,9 @@ export const KARTEN = {
|
||||
},
|
||||
"product-timeline": {
|
||||
titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
|
||||
beschreibung: "Ein wählbarer Artikel über 90 Tage",
|
||||
beschreibung: "Ein fester Artikel über die Zeit – mehrfach verwendbar",
|
||||
zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 },
|
||||
artikel: true,
|
||||
komponente: KarteArtikelverlauf,
|
||||
},
|
||||
"activity-timeline": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import GridLayout, { WidthProvider } from "react-grid-layout";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
@@ -16,33 +17,54 @@ const SPALTEN = 12;
|
||||
// oder unnötig hoch.
|
||||
const ZEILENHOEHE = 40;
|
||||
|
||||
/** Kennung einer einzelnen Karte. Sie muss im Dashboard eindeutig sein, damit
|
||||
* dieselbe Kartenart mehrfach darauf liegen kann. */
|
||||
function neueKennung(art, vorhanden) {
|
||||
let nummer = 1;
|
||||
let kennung = `${art}-${nummer}`;
|
||||
while (vorhanden.some((k) => k.i === kennung)) {
|
||||
nummer += 1;
|
||||
kennung = `${art}-${nummer}`;
|
||||
}
|
||||
return kennung;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Eine Karte ist eine *Instanz*: „i“ ist ihre Kennung, „type“ ihre Art. Erst
|
||||
* dadurch lassen sich zwei Verlaufskarten für zwei Artikel nebeneinander legen.
|
||||
*/
|
||||
export default function Dashboard() {
|
||||
const { isAdmin } = useAuth();
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { id: routeId } = useParams();
|
||||
|
||||
const [layout, setLayout] = useState([]);
|
||||
const [dashboards, setDashboards] = useState([]);
|
||||
const [entwurf, setEntwurf] = useState(null); // nur im Bearbeitungsmodus gesetzt
|
||||
const [herkunft, setHerkunft] = useState("builtin");
|
||||
const [erzwungen, setErzwungen] = useState(false);
|
||||
const [hatVorgabe, setHatVorgabe] = useState(false);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [menuOffen, setMenuOffen] = useState(false);
|
||||
const [produkte, setProdukte] = useState([]);
|
||||
|
||||
const aktivesId = routeId ? Number(routeId) : dashboards[0]?.id;
|
||||
const aktives = dashboards.find((d) => d.id === aktivesId) ?? dashboards[0];
|
||||
|
||||
const bearbeiten = entwurf !== null;
|
||||
const aktuell = entwurf ?? layout;
|
||||
const aktuell = entwurf ?? bereinige(aktives?.layout);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const antwort = await api.dashboardLayout();
|
||||
setLayout(bereinige(antwort.layout));
|
||||
const antwort = await api.dashboards();
|
||||
setDashboards(antwort.dashboards);
|
||||
setHerkunft(antwort.source);
|
||||
setErzwungen(antwort.enforced);
|
||||
setHatVorgabe(antwort.has_default);
|
||||
@@ -51,8 +73,22 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Seitenleiste fuehrt dieselbe Liste und muss von Aenderungen erfahren. */
|
||||
function meldeAenderung() {
|
||||
window.dispatchEvent(new Event("dashboards-geaendert"));
|
||||
}
|
||||
|
||||
useEffect(() => { laden(); }, []);
|
||||
|
||||
// Artikelliste nur einmal für die Einstellung der artikelbezogenen Karten.
|
||||
useEffect(() => {
|
||||
api.listProducts().then(setProdukte).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Beim Wechsel des Dashboards einen offenen Entwurf verwerfen – sonst
|
||||
// landete er beim Speichern auf dem falschen.
|
||||
useEffect(() => { setEntwurf(null); setMenuOffen(false); }, [aktivesId]);
|
||||
|
||||
/**
|
||||
* Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt.
|
||||
* Zusätzlich zu kleine Karten auf ihre Mindestgröße heben: Gespeicherte
|
||||
@@ -61,26 +97,22 @@ export default function Dashboard() {
|
||||
*/
|
||||
function bereinige(liste) {
|
||||
return (liste || [])
|
||||
.filter((k) => KARTEN[k.i])
|
||||
.map((k) => ({ ...k, type: k.type ?? k.i, props: k.props ?? {} }))
|
||||
.filter((k) => KARTEN[k.type])
|
||||
.map((k) => ({
|
||||
...k,
|
||||
w: Math.max(k.w, KARTEN[k.i].min.w),
|
||||
h: Math.max(k.h, KARTEN[k.i].min.h),
|
||||
w: Math.max(k.w, KARTEN[k.type].min.w),
|
||||
h: Math.max(k.h, KARTEN[k.type].min.h),
|
||||
}));
|
||||
}
|
||||
|
||||
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,
|
||||
minW: KARTEN[k.type]?.min.w ?? 2,
|
||||
minH: KARTEN[k.type]?.min.h ?? 1,
|
||||
})),
|
||||
[aktuell],
|
||||
);
|
||||
@@ -89,23 +121,33 @@ export default function Dashboard() {
|
||||
setEntwurf(aktuell.map((k) => ({ ...k })));
|
||||
}
|
||||
|
||||
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 }]);
|
||||
function hinzufuegen(art) {
|
||||
const karte = KARTEN[art];
|
||||
const liste = entwurf ?? aktuell;
|
||||
const untenY = liste.reduce((max, k) => Math.max(max, k.y + k.h), 0);
|
||||
setEntwurf([
|
||||
...liste,
|
||||
{ i: neueKennung(art, liste), type: art, x: 0, y: untenY, props: {}, ...karte.standard },
|
||||
]);
|
||||
setMenuOffen(false);
|
||||
}
|
||||
|
||||
function entfernen(id) {
|
||||
setEntwurf((alt) => (alt ?? []).filter((k) => k.i !== id));
|
||||
function entfernen(kennung) {
|
||||
setEntwurf((alt) => (alt ?? []).filter((k) => k.i !== kennung));
|
||||
}
|
||||
|
||||
/** Einstellung einer einzelnen Karte ändern (z.B. welcher Artikel). */
|
||||
function setzeProps(kennung, props) {
|
||||
setEntwurf((alt) => (alt ?? []).map((k) => (
|
||||
k.i === kennung ? { ...k, props: { ...k.props, ...props } } : k
|
||||
)));
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
setFehler(null);
|
||||
try {
|
||||
const antwort = await api.saveDashboardLayout(schlank(entwurf));
|
||||
setLayout(bereinige(antwort.layout));
|
||||
setHerkunft(antwort.source);
|
||||
const gespeichert = await api.updateDashboard(aktives.id, { layout: schlank(entwurf) });
|
||||
setDashboards((alt) => alt.map((d) => (d.id === gespeichert.id ? gespeichert : d)));
|
||||
setEntwurf(null);
|
||||
toast("Anordnung gespeichert.");
|
||||
} catch (err) {
|
||||
@@ -113,10 +155,67 @@ export default function Dashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function anlegen() {
|
||||
const name = await confirm({
|
||||
title: "Neues Dashboard",
|
||||
prompt: true,
|
||||
defaultValue: "Neues Dashboard",
|
||||
confirmLabel: "Anlegen",
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
const neu = await api.createDashboard({ name, layout: [] });
|
||||
await laden();
|
||||
meldeAenderung();
|
||||
navigate(`/d/${neu.id}`);
|
||||
toast(`„${neu.name}“ angelegt.`);
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function umbenennen() {
|
||||
const name = await confirm({
|
||||
title: "Dashboard umbenennen",
|
||||
prompt: true,
|
||||
defaultValue: aktives?.name ?? "",
|
||||
confirmLabel: "Umbenennen",
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
const geaendert = await api.updateDashboard(aktives.id, { name });
|
||||
setDashboards((alt) => alt.map((d) => (d.id === geaendert.id ? geaendert : d)));
|
||||
meldeAenderung();
|
||||
toast("Umbenannt.");
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen() {
|
||||
const ok = await confirm({
|
||||
title: `„${aktives?.name}“ löschen?`,
|
||||
message: "Das Dashboard und seine Karten werden entfernt.",
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteDashboard(aktives.id);
|
||||
setEntwurf(null);
|
||||
await laden();
|
||||
meldeAenderung();
|
||||
navigate("/");
|
||||
toast("Dashboard gelöscht.");
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function alsVorgabe() {
|
||||
setFehler(null);
|
||||
try {
|
||||
await api.saveDashboardDefault(schlank(entwurf ?? layout));
|
||||
await api.saveDashboardDefault();
|
||||
setHatVorgabe(true);
|
||||
toast("Als Vorgabe für alle Benutzer gespeichert.");
|
||||
} catch (err) {
|
||||
@@ -129,7 +228,7 @@ export default function Dashboard() {
|
||||
try {
|
||||
const antwort = await api.setDashboardEnforced(wert);
|
||||
setErzwungen(antwort.enforced);
|
||||
setLayout(bereinige(antwort.layout));
|
||||
setDashboards(antwort.dashboards);
|
||||
setHerkunft(antwort.source);
|
||||
setEntwurf(null);
|
||||
toast(wert
|
||||
@@ -143,15 +242,17 @@ export default function Dashboard() {
|
||||
async function zuruecksetzen() {
|
||||
const ok = await confirm({
|
||||
title: "Auf Vorgabe zurücksetzen?",
|
||||
message: "Deine persönliche Anordnung wird verworfen.",
|
||||
message: "Deine persönlichen Dashboards werden verworfen.",
|
||||
confirmLabel: "Zurücksetzen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.resetDashboardLayout();
|
||||
await api.resetDashboards();
|
||||
setEntwurf(null);
|
||||
await laden();
|
||||
meldeAenderung();
|
||||
navigate("/");
|
||||
toast("Auf die Vorgabe zurückgesetzt.");
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
@@ -159,11 +260,15 @@ export default function Dashboard() {
|
||||
}
|
||||
|
||||
/** Nur die Rasterfelder speichern – react-grid-layout hängt viel Beiwerk an.
|
||||
* Der gewählte Zeitraum gehört zur Karte und wird mitgespeichert. */
|
||||
* Art, Einstellungen und Zeitraum gehören zur Karte und bleiben erhalten. */
|
||||
function schlank(liste) {
|
||||
return (liste || []).map(({ i, x, y, w, h, tage }) => (
|
||||
tage ? { i, x, y, w, h, tage } : { i, x, y, w, h }
|
||||
));
|
||||
const vorhanden = new Map((entwurf ?? aktuell).map((k) => [k.i, k]));
|
||||
return (liste || []).map(({ i, x, y, w, h }) => {
|
||||
const alt = vorhanden.get(i) ?? {};
|
||||
const eintrag = { i, type: alt.type ?? i, x, y, w, h, props: alt.props ?? {} };
|
||||
if (alt.tage) eintrag.tage = alt.tage;
|
||||
return eintrag;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,26 +276,30 @@ export default function Dashboard() {
|
||||
* die Wahl still gespeichert – sonst wäre sie nach dem nächsten Laden wieder
|
||||
* weg, ohne dass es einen sichtbaren Speichern-Knopf gäbe.
|
||||
*/
|
||||
async function setzeZeitraum(id, tage) {
|
||||
const geaendert = aktuell.map((k) => (k.i === id ? { ...k, tage } : k));
|
||||
async function setzeZeitraum(kennung, tage) {
|
||||
const geaendert = aktuell.map((k) => (k.i === kennung ? { ...k, tage } : k));
|
||||
if (bearbeiten) {
|
||||
setEntwurf(geaendert);
|
||||
return;
|
||||
}
|
||||
setLayout(geaendert);
|
||||
if (erzwungen) return;
|
||||
setDashboards((alt) => alt.map((d) => (
|
||||
d.id === aktives.id ? { ...d, layout: geaendert } : d
|
||||
)));
|
||||
if (erzwungen || !aktives?.id) return;
|
||||
try {
|
||||
await api.saveDashboardLayout(schlank(geaendert));
|
||||
await api.updateDashboard(aktives.id, { layout: geaendert });
|
||||
} catch {
|
||||
/* Nicht speichern zu können ist hier kein Grund, die Ansicht zu stören. */
|
||||
}
|
||||
}
|
||||
|
||||
const eigenes = herkunft === "user" && aktives?.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Übersicht</h1>
|
||||
<h1>{aktives?.name ?? "Übersicht"}</h1>
|
||||
<div className="sub">
|
||||
{erzwungen
|
||||
? "Die Anordnung ist vom Administrator vorgegeben."
|
||||
@@ -199,25 +308,39 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="actions">
|
||||
{!bearbeiten && !erzwungen && (
|
||||
<button className="btn" onClick={starteBearbeitung}>
|
||||
<Icon name="edit" size={16} />Anordnen
|
||||
</button>
|
||||
<>
|
||||
<button className="btn" onClick={starteBearbeitung}>
|
||||
<Icon name="edit" size={16} />Anordnen
|
||||
</button>
|
||||
<button className="btn ghost" onClick={anlegen}>
|
||||
<Icon name="plus" size={16} />Neues Dashboard
|
||||
</button>
|
||||
{eigenes && (
|
||||
<>
|
||||
<button className="btn ghost" onClick={umbenennen}>Umbenennen</button>
|
||||
{dashboards.length > 1 && (
|
||||
<button className="btn ghost danger" onClick={loeschen}>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{bearbeiten && (
|
||||
<>
|
||||
<div className="menu-wrap">
|
||||
<button className="btn" onClick={() => setMenuOffen((o) => !o)}
|
||||
disabled={verfuegbar.length === 0}>
|
||||
<button className="btn" onClick={() => setMenuOffen((o) => !o)}>
|
||||
<Icon name="plus" size={16} />Karte hinzufügen
|
||||
</button>
|
||||
{menuOffen && (
|
||||
<div className="menu karten-menu">
|
||||
{verfuegbar.map((id) => (
|
||||
<button key={id} className="karten-wahl" onClick={() => hinzufuegen(id)}>
|
||||
<Vorschau art={KARTEN[id].vorschau} />
|
||||
{KARTEN_IDS.map((art) => (
|
||||
<button key={art} className="karten-wahl" onClick={() => hinzufuegen(art)}>
|
||||
<Vorschau art={KARTEN[art].vorschau} />
|
||||
<span className="karten-text">
|
||||
<span className="karten-titel">{KARTEN[id].titel}</span>
|
||||
<span className="muted small">{KARTEN[id].beschreibung}</span>
|
||||
<span className="karten-titel">{KARTEN[art].titel}</span>
|
||||
<span className="muted small">{KARTEN[art].beschreibung}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -240,7 +363,8 @@ export default function Dashboard() {
|
||||
<Icon name="edit" size={16} />
|
||||
<span>
|
||||
Karten ziehen zum Verschieben, an der rechten unteren Ecke größer ziehen.
|
||||
Änderungen gelten erst nach „Speichern“.
|
||||
Dieselbe Karte lässt sich mehrfach hinzufügen. Änderungen gelten erst
|
||||
nach „Speichern“.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -259,14 +383,15 @@ export default function Dashboard() {
|
||||
compactType="vertical"
|
||||
>
|
||||
{aktuell.map((eintrag) => {
|
||||
const karte = KARTEN[eintrag.i];
|
||||
const karte = KARTEN[eintrag.type];
|
||||
if (!karte) return null;
|
||||
const Inhalt = karte.komponente;
|
||||
const artikel = produkte.find((p) => p.id === eintrag.props?.product_id);
|
||||
return (
|
||||
<section key={eintrag.i} className="card dash-card">
|
||||
<div className="dash-card-head">
|
||||
<Icon name={karte.icon} size={16} />
|
||||
<h2>{karte.titel}</h2>
|
||||
<h2>{artikel ? `${karte.titel}: ${artikel.name}` : karte.titel}</h2>
|
||||
{/* Zeitraum gehört in den Kopf: So bleibt die volle Kartenhöhe
|
||||
dem Diagramm. onMouseDown stoppt das Ziehen der Karte. */}
|
||||
{karte.zeitraum && (
|
||||
@@ -278,6 +403,18 @@ export default function Dashboard() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* Artikelbezogene Karten werden beim Anordnen eingestellt -
|
||||
so kann dieselbe Art mehrfach auf verschiedene Artikel zeigen. */}
|
||||
{bearbeiten && karte.artikel && (
|
||||
<select className="zeitraum" value={eintrag.props?.product_id ?? ""}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setzeProps(eintrag.i, {
|
||||
product_id: e.target.value ? Number(e.target.value) : undefined,
|
||||
})}>
|
||||
<option value="">– Artikel wählen –</option>
|
||||
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{bearbeiten && (
|
||||
<button className="btn-icon danger" title="Karte entfernen"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
@@ -287,7 +424,7 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
<div className="dash-card-body">
|
||||
<Inhalt tage={eintrag.tage ?? karte.zeitraum} />
|
||||
<Inhalt tage={eintrag.tage ?? karte.zeitraum} props={eintrag.props} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -779,3 +779,63 @@ select.zeitraum:hover { color: var(--text); }
|
||||
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
|
||||
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
|
||||
}
|
||||
|
||||
/* ---- Dashboards: Unterpunkte in der Seitenleiste ---- */
|
||||
/* Eingerückt und kleiner, damit die Dashboards sichtbar zu "Übersicht"
|
||||
gehören und nicht wie eigene Hauptbereiche wirken. */
|
||||
.nav-link.sub {
|
||||
padding-left: 34px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nav-link.sub.active { font-weight: 600; }
|
||||
|
||||
/* ---- Aktionskarten: Ein- und Auslagern ohne Seitenwechsel ---- */
|
||||
.aktion-karte { gap: 10px; }
|
||||
|
||||
.aktion-suche { position: relative; }
|
||||
.aktion-suche input { width: 100%; }
|
||||
|
||||
/* Trefferliste schwebt über der Karte - im Raster ist darunter kein Platz. */
|
||||
.aktion-treffer {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 2px 0 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 20px rgb(0 0 0 / 12%);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.aktion-treffer li button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.aktion-treffer li button:hover { background: var(--surface-2); }
|
||||
|
||||
.aktion-gewaehlt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.aktion-gewaehlt .name { flex: 1; font-weight: 600; font-size: 14px; }
|
||||
|
||||
.aktion-zeile { display: flex; gap: 8px; align-items: center; }
|
||||
.aktion-zeile .btn { flex: 1; justify-content: center; }
|
||||
.aktion-menge { width: 72px; text-align: right; }
|
||||
|
||||
Reference in New Issue
Block a user