Compare commits

..

3 Commits

Author SHA1 Message Date
Scarriffle
2ad197df42 iOS zeigt dieselben Dashboards und kann direkt buchen
Die Uebersicht in der App war fest verdrahtet: vier Kacheln, unabhaengig
davon, was am Rechner zusammengestellt wurde. Jetzt liest sie dieselben
Dashboards wie die Web-Oberflaeche und stellt sie nativ dar.

Das Raster laesst sich auf einem Telefon nicht sinnvoll nachbilden, deshalb
stehen die Karten untereinander - in der Lesereihenfolge des Rasters, erst
Zeile, dann Spalte. Zusammengestellt wird weiterhin am Rechner; gibt es
mehrere Dashboards, schaltet oben ein Menue um.

Neu ist die Buchungskarte: Artikel suchen, Menge, ein- oder auslagern, ohne
den Scan-Ablauf zu oeffnen. Nach dem Buchen zieht die Karte den Bestand nach
und laesst die uebrigen Karten neu laden - sonst stuende dort weiter die alte
Zahl.

Diagrammkarten bekommen bewusst keinen halbgaren Nachbau, sondern den
ehrlichen Hinweis, dass es sie in der Web-Oberflaeche gibt.

Geprueft mit der echten Antwort eines laufenden Servers: Sie decodiert in die
Modelle der App, samt zweier Karten derselben Art mit verschiedenen Artikeln.
OverviewView faellt weg, DashboardView tritt an ihre Stelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:27:01 +02:00
Scarriffle
89598147ed Web: Dashboards in der Seitenleiste, Karten mehrfach, Buchen ohne Seitenwechsel
Drei Dinge in einem, weil sie dieselbe Umstellung brauchen.

Karten sind jetzt Instanzen: Die Anordnung merkt sich neben der Kennung auch
die Art und ein "props" je Karte. Dadurch laesst sich dieselbe Art mehrfach
auflegen, und "Verlauf eines Artikels" merkt sich endlich, welcher Artikel
gemeint ist - vorher lag diese Wahl nur in useState und war nach jedem
Neuladen wieder weg, was die Karte zum Beobachten unbrauchbar machte. Wer
mehrere Artikel im Blick haben will, legt jetzt einfach mehrere Karten an.

Mehrere Dashboards stehen als eingerueckte Eintraege unter "Übersicht" in der
Seitenleiste, anlegen und umbenennen laeuft ueber die Kopfzeile. Ab dem
zweiten werden sie aufgezaehlt; bei einem einzigen waere die Liste nur eine
Verdopplung.

Neu sind die Karten "Einlagern" und "Auslagern": Artikel suchen, Menge
eintragen, gebucht. MHD und Lagerort bleiben bewusst weg - wer die braucht,
ist auf der vollen Seite besser aufgehoben. Die alte Karte "Schnellzugriff"
bleibt als reine Verlinkung bestehen.

Der Bestaetigungsdialog kann jetzt auch nach Text fragen (prompt: true). Das
war noetig, weil Dashboards benannt werden - und window.prompt sieht aus wie
der Browser, nicht wie diese Anwendung; derselbe Grund, aus dem es hier schon
kein window.confirm gibt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:18:51 +02:00
Scarriffle
f62289f975 Mehrere Dashboards und dieselbe Karte mehrfach
Zwei Grenzen sassen tief im Datenmodell. Erstens war "i" im gespeicherten
Layout zugleich die Kartenart; da react-grid-layout eindeutige Kennungen
verlangt, ging jede Art genau einmal - zwei Artikel gleichzeitig beobachten war
damit unmoeglich. Zweitens war dashboard_layouts.user_id unique, also genau ein
Dashboard je Benutzer.

Jetzt ist "i" die Kennung dieser einen Karte und "type" ihre Art, dazu kommt
"props" fuer das, was nur diese Karte angeht (etwa welcher Artikel). Aeltere
Anordnungen haben kein "type"; fuer sie gilt die alte Kennung als Art, sodass
gespeicherte Startseiten unveraendert weiterlaufen.

Die Tabelle bekommt Name und Reihenfolge, das unique faellt. Sein Name haengt
davon ab, wie die Tabelle entstanden ist, deshalb wird er in pg_constraint
nachgeschlagen statt geraten.

Eine Feinheit beim Anlegen: Wer noch auf der Admin-Vorgabe sitzt und sein
erstes eigenes Dashboard anlegt, wuerde die Vorgabe schlagartig verlieren -
sobald eigene Zeilen da sind, zaehlen nur noch die. Deshalb wird sie vorher
uebernommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:11:49 +02:00
18 changed files with 1420 additions and 300 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)
# --------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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)"

View 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
}
}
}

View 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)
}
}
}
}

View File

@@ -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.

View File

@@ -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))
}
}

View File

@@ -43,7 +43,7 @@ struct HomeView: View {
var body: some View {
TabView {
OverviewView()
DashboardView()
.tabItem { Label("Start", systemImage: "house") }
ScanTabView()

View File

@@ -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 */,

View File

@@ -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>} />

View File

@@ -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"),

View File

@@ -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>

View File

@@ -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": {

View File

@@ -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>
);

View File

@@ -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; }