diff --git a/arbeitskacheln/13_readiness.md b/arbeitskacheln/13_readiness.md index c578a43..6038994 100644 --- a/arbeitskacheln/13_readiness.md +++ b/arbeitskacheln/13_readiness.md @@ -155,11 +155,24 @@ Details zu READY-001 … READY-006. READY-001-005 vollständig, READY-006 ergän - **Akzeptanzkriterien:** mind. eine Kennzahl (z.B. Bereitschaftsquote) über mehrere Zeitpunkte hinweg korrekt dargestellt. - **Tests:** Aggregations-Test mit synthetischen Daten über mehrere Zeitpunkte. -- **DoD:** offen — Design vollständig geklärt (2026-09-10), bereit für - Implementierung. **Referenz (HiOrg-Server-Vergleich 2026-09-09):** HiOrg bietet - dedizierte Statistiken über Dienste/Kurse — MABEA hat vergleichbare Rohdaten - (Einsatzbereitschaft, Mängel, Wartung), aber keine Verlaufs-/Trend-Ansicht, - nur den aktuellen Zustand (READY-004). +- **DoD:** ✅ umgesetzt (2026-09-10). **Datenmodell-Korrektur beim Bauen:** ein + Snapshot pro STANDORT+ZUSTAND (wie ursprünglich geplant) hätte keine + objektbezogene Akte-Historie zugelassen (kein `objekt_id`-Feld) — auf einen + Snapshot pro OBJEKT pro Tag umgestellt (`readiness_snapshot`: zeitpunkt, + objekt_id, zustand), Standort-/globale Aggregation läuft per `GROUP BY`/Join + zur Abfragezeit, dieselbe Tabelle bedient jetzt tatsächlich beide + Sichten aus einem Datenspeicher, wie ursprünglich beabsichtigt. `Objekt_kategorien()` + aus `dashboard.einsatzbereitschaft()` extrahiert (kein Big-Bang, nur + Wiederverwendung derselben Fachlogik), Snapshot-Skript + `scripts/readiness_snapshot_erstellen.py` (systemd-Timer + `mabea-readiness-snapshot.timer` serverseitig einzurichten, analog + Eskalation), `GET /readiness/verlauf` + `GET /objekte/{id}/readiness-verlauf` + (Administration/Leitungsverantwortliche), Dashboard-Kachel + „Bereitschaft im Zeitverlauf" + Akte-Abschnitt „Bereitschaftshistorie". + `backend/tests/test_readiness_snapshot.py`. **Referenz + (HiOrg-Server-Vergleich 2026-09-09):** HiOrg bietet dedizierte Statistiken + über Dienste/Kurse — MABEA hat jetzt eine vergleichbare Trend-Ansicht statt + nur des aktuellen Zustands (READY-004). --- diff --git a/backend/alembic/versions/0037_readiness_snapshot.py b/backend/alembic/versions/0037_readiness_snapshot.py new file mode 100644 index 0000000..52fb1da --- /dev/null +++ b/backend/alembic/versions/0037_readiness_snapshot.py @@ -0,0 +1,40 @@ +"""READY-006: täglicher Bereitschafts-Snapshot je Objekt. + +Revision ID: 0037_readiness_snapshot +Revises: 0036_tankbuch +Create Date: 2026-09-10 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0037_readiness_snapshot" +down_revision: Union[str, None] = "0036_tankbuch" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "CREATE TYPE readiness_zustand AS ENUM " + "('einsatzbereit', 'eingeschraenkt_einsatzbereit', 'unbekannt', 'nicht_einsatzbereit')" + ) + op.execute( + """ + CREATE TABLE readiness_snapshot ( + id UUID PRIMARY KEY, + zeitpunkt DATE NOT NULL, + objekt_id INTEGER NOT NULL REFERENCES objekt(id), + zustand readiness_zustand NOT NULL, + UNIQUE (zeitpunkt, objekt_id) + ) + """ + ) + op.execute("CREATE INDEX ix_readiness_snapshot_objekt ON readiness_snapshot (objekt_id, zeitpunkt)") + op.execute("CREATE INDEX ix_readiness_snapshot_zeitpunkt ON readiness_snapshot (zeitpunkt)") + + +def downgrade() -> None: + op.execute("DROP TABLE readiness_snapshot") + op.execute("DROP TYPE IF EXISTS readiness_zustand") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 821c3ba..164663a 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -21,6 +21,7 @@ from app.api.v1.endpoints import ( objekte, permission, personal, + readiness_snapshot, stammdaten, suche, tankbuch, @@ -62,3 +63,4 @@ api_router.include_router(ausgabe.router, tags=["ausgabe"]) api_router.include_router(wartung.router, tags=["wartung"]) api_router.include_router(suche.router, tags=["suche"]) api_router.include_router(tankbuch.router, tags=["tankbuch"]) +api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"]) diff --git a/backend/app/api/v1/endpoints/readiness_snapshot.py b/backend/app/api/v1/endpoints/readiness_snapshot.py new file mode 100644 index 0000000..9966bab --- /dev/null +++ b/backend/app/api/v1/endpoints/readiness_snapshot.py @@ -0,0 +1,39 @@ +from datetime import date, timedelta + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_roles +from app.db.session import get_db +from app.models.auth import RolleTyp +from app.schemas.readiness_snapshot import ReadinessVerlaufObjektPunkt, ReadinessVerlaufPunkt +from app.services.readiness_snapshot import verlauf_fuer_objekt, verlauf_global + +router = APIRouter() + +_leitung_plus = require_roles(RolleTyp.administration, RolleTyp.leitungsverantwortlicher) + + +@router.get("/readiness/verlauf", response_model=list[ReadinessVerlaufPunkt]) +async def readiness_verlauf( + von: date | None = None, + bis: date | None = None, + standort_id: int | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(_leitung_plus), +) -> list[ReadinessVerlaufPunkt]: + bis = bis or date.today() + von = von or bis - timedelta(days=90) + daten = await verlauf_global(db, von=von, bis=bis, standort_id=standort_id) + return [ReadinessVerlaufPunkt(**d) for d in daten] + + +@router.get("/objekte/{objekt_id}/readiness-verlauf", response_model=list[ReadinessVerlaufObjektPunkt]) +async def objekt_readiness_verlauf( + objekt_id: int, + tage: int = 180, + db: AsyncSession = Depends(get_db), + _=Depends(_leitung_plus), +) -> list[ReadinessVerlaufObjektPunkt]: + daten = await verlauf_fuer_objekt(db, objekt_id, tage=tage) + return [ReadinessVerlaufObjektPunkt(**d) for d in daten] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4c11186..51297b1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.notiz import AkteNotiz from app.models.objekt import Objekt, ObjektStatus from app.models.objektposition import Objektposition, ObjektpositionStatus from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung +from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand from app.models.personal import ( BenutzerQualifikation, Einheit, @@ -76,6 +77,8 @@ __all__ = [ "BenutzerRolleZuordnung", "Rolle", "RolleBerechtigung", + "ReadinessSnapshot", + "ReadinessZustand", "BenutzerQualifikation", "Einheit", "ObjekttypQualifikationsanforderung", diff --git a/backend/app/models/readiness_snapshot.py b/backend/app/models/readiness_snapshot.py new file mode 100644 index 0000000..98d04cd --- /dev/null +++ b/backend/app/models/readiness_snapshot.py @@ -0,0 +1,37 @@ +import enum +import uuid +from datetime import date + +from sqlalchemy import Date, ForeignKey, UniqueConstraint +from sqlalchemy.dialects.postgresql import ENUM as PgEnum, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ReadinessZustand(str, enum.Enum): + einsatzbereit = "einsatzbereit" + eingeschraenkt_einsatzbereit = "eingeschraenkt_einsatzbereit" + unbekannt = "unbekannt" + nicht_einsatzbereit = "nicht_einsatzbereit" + + +readiness_zustand_pg = PgEnum(ReadinessZustand, name="readiness_zustand", create_type=False) + + +class ReadinessSnapshot(Base): + """READY-006: EIN Snapshot-Eintrag pro Objekt pro Tag (nicht pro Standort + aggregiert) - erlaubt sowohl das globale Trend-Dashboard (GROUP BY + zeitpunkt/zustand, optional über objekt.standort_id) als auch die + Bereitschaftshistorie EINES Objekts in der Akte aus derselben Tabelle, + ohne zweiten Datenspeicher (Kachel-Vorgabe). Erzeugt durch den täglichen + systemd-Timer (scripts/readiness_snapshot_erstellen.py), nicht live + berechnet.""" + + __tablename__ = "readiness_snapshot" + __table_args__ = (UniqueConstraint("zeitpunkt", "objekt_id"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + zeitpunkt: Mapped[date] = mapped_column(Date, nullable=False) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + zustand: Mapped[ReadinessZustand] = mapped_column(readiness_zustand_pg, nullable=False) diff --git a/backend/app/schemas/readiness_snapshot.py b/backend/app/schemas/readiness_snapshot.py new file mode 100644 index 0000000..d0c5f9e --- /dev/null +++ b/backend/app/schemas/readiness_snapshot.py @@ -0,0 +1,14 @@ +from datetime import date + +from pydantic import BaseModel + + +class ReadinessVerlaufPunkt(BaseModel): + zeitpunkt: date + zustand: str + anzahl: int + + +class ReadinessVerlaufObjektPunkt(BaseModel): + zeitpunkt: date + zustand: str diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index d179f44..63e88eb 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -101,15 +101,11 @@ async def bevorstehende_ablaufdaten(db: AsyncSession) -> list[dict]: return eintraege -async def einsatzbereitschaft(db: AsyncSession) -> dict: - """Karte-Nachtrag (Leitungs-Dashboard Kachel 1): ein Objekt ist einsatzbereit, - wenn kein offener Fehlbestand (deckt auch Geräte-Instanzen ab, da - Statuswechsel defekt/in_reparatur automatisch einen Fehlbestand erzeugt, - siehe app/services/geraet_instanz.py), kein abgelaufenes ablaufdatum, keine - überfällige Geräteprüfung und keine laufende Kontrolle (Sperre) vorliegt. - Aktive Mindermengen-Genehmigung erledigt den Fehlbestand fachlich NICHT - (Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt - einsatzbereit", nicht "einsatzbereit".""" +async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[str]]]: + """Aus `einsatzbereitschaft()` extrahiert (READY-006), damit der + Snapshot-Job (app/services/readiness_snapshot.py) dieselbe Fachlogik je + Objekt nutzt statt sie zu duplizieren - liefert Kategorie+Gründe JE Objekt, + `einsatzbereitschaft()` aggregiert das nur noch zu Summen.""" heute = datetime.now(timezone.utc).date() # ausser_dienst-Objekte zählen fachlich gar nicht mehr mit (Roadmap Phase 3, # Modul Fahrzeuge: 5-stufiger Status) - anders als in_wartung, das als @@ -199,21 +195,40 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict: .all() ) + return [ + ( + objekt, + *_kategorie_und_gruende( + objekt, + letzte_kontrolle_map.get(objekt.id), + objekt_ids_mit_fehlbestand.get(objekt.id, set()), + fehlbestand_ids_genehmigt, + objekt_ids_abgelaufen, + objekt_ids_pruefung_ueberfaellig, + objekt_ids_gesperrt, + objekt_ids_kritischer_mangel, + objekt_ids_hu_uvv_ueberfaellig, + ), + ) + for objekt in objekte + ] + + +async def einsatzbereitschaft(db: AsyncSession) -> dict: + """Karte-Nachtrag (Leitungs-Dashboard Kachel 1): ein Objekt ist einsatzbereit, + wenn kein offener Fehlbestand (deckt auch Geräte-Instanzen ab, da + Statuswechsel defekt/in_reparatur automatisch einen Fehlbestand erzeugt, + siehe app/services/geraet_instanz.py), kein abgelaufenes ablaufdatum, keine + überfällige Geräteprüfung und keine laufende Kontrolle (Sperre) vorliegt. + Aktive Mindermengen-Genehmigung erledigt den Fehlbestand fachlich NICHT + (Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt + einsatzbereit", nicht "einsatzbereit".""" + kategorien = await objekt_kategorien(db) + bereit = eingeschraenkt = unbekannt = 0 nie_kontrolliert_gesamt = 0 details: list[dict] = [] - for objekt in objekte: - kategorie, gruende = _kategorie_und_gruende( - objekt, - letzte_kontrolle_map.get(objekt.id), - objekt_ids_mit_fehlbestand.get(objekt.id, set()), - fehlbestand_ids_genehmigt, - objekt_ids_abgelaufen, - objekt_ids_pruefung_ueberfaellig, - objekt_ids_gesperrt, - objekt_ids_kritischer_mangel, - objekt_ids_hu_uvv_ueberfaellig, - ) + for objekt, kategorie, gruende in kategorien: if "noch_nie_kontrolliert" in gruende: nie_kontrolliert_gesamt += 1 if kategorie == "ready": @@ -229,8 +244,8 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict: "einsatzbereit": bereit, "eingeschraenkt_einsatzbereit": eingeschraenkt, "unbekannt": unbekannt, - "nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt - unbekannt, - "gesamt": len(objekte), + "nicht_einsatzbereit": len(kategorien) - bereit - eingeschraenkt - unbekannt, + "gesamt": len(kategorien), # "unbekannt" zählt nur Objekte ohne jeden anderen Grund - diese Zahl # zählt "nie kontrolliert" unabhängig von sonstigen Problemen mit. "nie_kontrolliert_gesamt": nie_kontrolliert_gesamt, diff --git a/backend/app/services/readiness_snapshot.py b/backend/app/services/readiness_snapshot.py new file mode 100644 index 0000000..16a9ddf --- /dev/null +++ b/backend/app/services/readiness_snapshot.py @@ -0,0 +1,76 @@ +from datetime import date, timedelta + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.objekt import Objekt +from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand +from app.services.dashboard import objekt_kategorien + +_KATEGORIE_ZU_ZUSTAND = { + "ready": ReadinessZustand.einsatzbereit, + "limited": ReadinessZustand.eingeschraenkt_einsatzbereit, + "unknown": ReadinessZustand.unbekannt, + "not-ready": ReadinessZustand.nicht_einsatzbereit, +} + + +async def erstelle_snapshot(db: AsyncSession, *, zeitpunkt: date | None = None) -> int: + """READY-006: EIN Snapshot-Lauf pro Tag - ruft dieselbe Fachlogik wie das + Live-Dashboard auf (`objekt_kategorien`), schreibt aber je Objekt eine Zeile + statt nur die Summe. Erneuter Lauf am selben Tag überschreibt (UPSERT über + UNIQUE(zeitpunkt, objekt_id)) statt zu duplizieren - macht den Job + idempotent bei einem Timer-Nachlauf/manuellen Wiederholung.""" + zeitpunkt = zeitpunkt or date.today() + kategorien = await objekt_kategorien(db) + + bestehende = ( + await db.execute(select(ReadinessSnapshot).where(ReadinessSnapshot.zeitpunkt == zeitpunkt)) + ).scalars().all() + bestehend_je_objekt = {s.objekt_id: s for s in bestehende} + + anzahl = 0 + for objekt, kategorie, _gruende in kategorien: + zustand = _KATEGORIE_ZU_ZUSTAND[kategorie] + eintrag = bestehend_je_objekt.get(objekt.id) + if eintrag is not None: + eintrag.zustand = zustand + else: + db.add(ReadinessSnapshot(zeitpunkt=zeitpunkt, objekt_id=objekt.id, zustand=zustand)) + anzahl += 1 + + await db.commit() + return anzahl + + +async def verlauf_global(db: AsyncSession, *, von: date, bis: date, standort_id: int | None = None) -> list[dict]: + """Globales Dashboard: Anzahl je Zeitpunkt/Zustand, optional auf einen + Standort gefiltert (Join über objekt.standort_id - kein eigenes + Standort-Feld im Snapshot nötig, siehe Modell-Docstring).""" + query = ( + select(ReadinessSnapshot.zeitpunkt, ReadinessSnapshot.zustand, func.count()) + .join(Objekt, ReadinessSnapshot.objekt_id == Objekt.id) + .where(ReadinessSnapshot.zeitpunkt >= von, ReadinessSnapshot.zeitpunkt <= bis) + .group_by(ReadinessSnapshot.zeitpunkt, ReadinessSnapshot.zustand) + .order_by(ReadinessSnapshot.zeitpunkt) + ) + if standort_id is not None: + query = query.where(Objekt.standort_id == standort_id) + result = await db.execute(query) + return [ + {"zeitpunkt": zeitpunkt, "zustand": zustand.value, "anzahl": anzahl} + for zeitpunkt, zustand, anzahl in result.all() + ] + + +async def verlauf_fuer_objekt(db: AsyncSession, objekt_id: int, *, tage: int = 180) -> list[dict]: + """Akte-Ergänzung (2026-09-10): Bereitschaftshistorie EINES Objekts, letzte + `tage` Tage - dieselbe Tabelle wie `verlauf_global`, nur gefiltert statt + aggregiert.""" + seit = date.today() - timedelta(days=tage) + result = await db.execute( + select(ReadinessSnapshot) + .where(ReadinessSnapshot.objekt_id == objekt_id, ReadinessSnapshot.zeitpunkt >= seit) + .order_by(ReadinessSnapshot.zeitpunkt) + ) + return [{"zeitpunkt": s.zeitpunkt, "zustand": s.zustand.value} for s in result.scalars().all()] diff --git a/backend/scripts/readiness_snapshot_erstellen.py b/backend/scripts/readiness_snapshot_erstellen.py new file mode 100644 index 0000000..1f151ca --- /dev/null +++ b/backend/scripts/readiness_snapshot_erstellen.py @@ -0,0 +1,31 @@ +"""READY-006: täglicher Bereitschafts-Snapshot, für einen systemd-Timer gedacht +(gleiches Muster wie scripts/eskalation_pruefen.py) - Zeitplan über +`mabea-readiness-snapshot.timer` auf dem Server (OnCalendar), nicht hier +konfiguriert. + +Aufruf (im aktivierten venv auf dem Server): + + python -m scripts.readiness_snapshot_erstellen +""" + +from __future__ import annotations + +import asyncio +import logging + +import app.models # noqa: F401 (alle ORM-Modelle registrieren, siehe create_admin.py) +from app.db.session import SessionLocal +from app.services.readiness_snapshot import erstelle_snapshot + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main() -> None: + async with SessionLocal() as db: + anzahl = await erstelle_snapshot(db) + logger.info("Readiness-Snapshot: %d Objekt(e) erfasst.", anzahl) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/test_readiness_snapshot.py b/backend/tests/test_readiness_snapshot.py new file mode 100644 index 0000000..714bd4c --- /dev/null +++ b/backend/tests/test_readiness_snapshot.py @@ -0,0 +1,71 @@ +from datetime import date, timedelta + +import pytest +from sqlalchemy import func, select + +from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand +from app.services.readiness_snapshot import erstelle_snapshot, verlauf_fuer_objekt, verlauf_global +from tests.conftest import login + + +@pytest.mark.asyncio +async def test_snapshot_erstellt_zeile_je_objekt(db_session, standort_factory, objekt_factory): + standort = await standort_factory("Wache-Ready") + objekt = await objekt_factory(name="Rucksack Ready", code="RS-READY-1", standort=standort) + + anzahl = await erstelle_snapshot(db_session) + assert anzahl >= 1 + + treffer = ( + await db_session.execute(select(ReadinessSnapshot).where(ReadinessSnapshot.objekt_id == objekt.id)) + ).scalar_one() + # noch nie kontrolliert -> unbekannt (READY-002) + assert treffer.zustand == ReadinessZustand.unbekannt + + +@pytest.mark.asyncio +async def test_snapshot_erneuter_lauf_am_selben_tag_ueberschreibt(db_session, standort_factory, objekt_factory): + standort = await standort_factory("Wache-Ready2") + await objekt_factory(name="Rucksack Ready2", code="RS-READY-2", standort=standort) + + erste = await erstelle_snapshot(db_session) + zweite = await erstelle_snapshot(db_session) + assert erste == zweite + + anzahl_zeilen = ( + await db_session.execute(select(func.count()).select_from(ReadinessSnapshot)) + ).scalar_one() + assert anzahl_zeilen == erste + + +@pytest.mark.asyncio +async def test_verlauf_global_und_fuer_objekt(db_session, standort_factory, objekt_factory): + standort = await standort_factory("Wache-Ready3") + objekt = await objekt_factory(name="Rucksack Ready3", code="RS-READY-3", standort=standort) + + gestern = date.today() - timedelta(days=1) + await erstelle_snapshot(db_session, zeitpunkt=gestern) + await erstelle_snapshot(db_session) + + global_verlauf = await verlauf_global(db_session, von=gestern, bis=date.today()) + zeitpunkte = {v["zeitpunkt"] for v in global_verlauf} + assert gestern in zeitpunkte and date.today() in zeitpunkte + + objekt_verlauf = await verlauf_fuer_objekt(db_session, objekt.id) + assert len(objekt_verlauf) == 2 + assert all(p["zustand"] == "unbekannt" for p in objekt_verlauf) + + +@pytest.mark.asyncio +async def test_readiness_verlauf_endpunkt_erfordert_leitung(client, admin_user, mitarbeiter_user): + token_mitarbeiter = await login(client, "mitarbeiter1") + verboten = await client.get( + "/api/v1/readiness/verlauf", headers={"Authorization": f"Bearer {token_mitarbeiter}"} + ) + assert verboten.status_code == 403 + + token_admin = await login(client, "admin1") + erlaubt = await client.get( + "/api/v1/readiness/verlauf", headers={"Authorization": f"Bearer {token_admin}"} + ) + assert erlaubt.status_code == 200 diff --git a/frontend/src/components/ReadinessVerlaufPanel.tsx b/frontend/src/components/ReadinessVerlaufPanel.tsx new file mode 100644 index 0000000..959017f --- /dev/null +++ b/frontend/src/components/ReadinessVerlaufPanel.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../api/client"; +import { useAuth } from "../auth/AuthContext"; + +interface Punkt { + zeitpunkt: string; + zustand: string; +} + +const ZUSTAND_LABEL: Record = { + einsatzbereit: "Einsatzbereit", + eingeschraenkt_einsatzbereit: "Eingeschränkt einsatzbereit", + unbekannt: "Unbekannt", + nicht_einsatzbereit: "Nicht einsatzbereit", +}; + +interface Props { + objektId: number; +} + +// READY-006 (Akte-Ergänzung 2026-09-10): "in den letzten X Tagen Y Tage nicht +// einsatzbereit" aus derselben Snapshot-Tabelle wie das globale +// Dashboard-Trend-Tile, nur auf ein Objekt gefiltert. +export function ReadinessVerlaufPanel({ objektId }: Props) { + const { istAdmin, istLeitungsverantwortlich } = useAuth(); + const [punkte, setPunkte] = useState(null); + const berechtigt = istAdmin || istLeitungsverantwortlich; + + useEffect(() => { + if (!berechtigt) return; + apiRequest(`/objekte/${objektId}/readiness-verlauf`) + .then(setPunkte) + .catch(() => setPunkte([])); + }, [objektId, berechtigt]); + + if (!berechtigt) return

Nur für Administration/Leitungsverantwortliche.

; + if (punkte === null) return

Lade…

; + if (punkte.length === 0) return

Noch keine Bereitschaftshistorie vorhanden.

; + + const tageGesamt = punkte.length; + const tageProblematisch = punkte.filter( + (p) => p.zustand === "nicht_einsatzbereit" || p.zustand === "eingeschraenkt_einsatzbereit" + ).length; + + return ( +
+

+ In den letzten {tageGesamt} erfassten Tagen: {tageProblematisch} Tag(e) nicht/eingeschränkt + einsatzbereit. +

+
+ {punkte.map((p) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index 885148c..9a6c592 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -6,6 +6,7 @@ import { useAuth } from "../auth/AuthContext"; import type { Akte } from "../api/types"; import { DokumentePanel } from "../components/DokumentePanel"; import { NotizenPanel } from "../components/NotizenPanel"; +import { ReadinessVerlaufPanel } from "../components/ReadinessVerlaufPanel"; import { TankbuchPanel } from "../components/TankbuchPanel"; import { WartungSection } from "../components/WartungSection"; import { ReadinessBadge } from "../components/status/ReadinessBadge"; @@ -297,6 +298,11 @@ export function AktePage() { )} +
+

Bereitschaftshistorie

+ +
+

Historie

{akte.historie.length === 0 &&

Noch keine Änderungen protokolliert.

} diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index ee47fc8..5603c5c 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -19,6 +19,7 @@ import { KennzahlenTile } from "./tiles/KennzahlenTile"; import { MaengelTile } from "./tiles/MaengelTile"; import { PrueftermineTile } from "./tiles/PrueftermineTile"; import { QualifikationTile } from "./tiles/QualifikationTile"; +import { ReadinessVerlaufTile } from "./tiles/ReadinessVerlaufTile"; import type { Ablaufdatumeintrag, Einsatzbereitschaft, Kennzahlen, Prueftermineintrag, Qualifikationsablauf } from "./types"; const AKTUALISIERUNGS_INTERVALL_MS = 60_000; @@ -32,6 +33,7 @@ const TITEL: Record = { maengel: "Mängel", qualifikation: "Qualifikationsablauf", aktivitaet: "Letzte Aktivität", + readinessVerlauf: "Bereitschaft im Zeitverlauf", }; export function DashboardPage() { @@ -197,6 +199,8 @@ export function DashboardPage() { return ; case "aktivitaet": return ; + case "readinessVerlauf": + return ; default: return null; } diff --git a/frontend/src/pages/dashboard/layout.ts b/frontend/src/pages/dashboard/layout.ts index a4b6f4e..6fd5f63 100644 --- a/frontend/src/pages/dashboard/layout.ts +++ b/frontend/src/pages/dashboard/layout.ts @@ -6,7 +6,8 @@ export type DashboardTileId = | "kennzahlen" | "maengel" | "qualifikation" - | "aktivitaet"; + | "aktivitaet" + | "readinessVerlauf"; export const ALLE_TILE_IDS: DashboardTileId[] = [ "aufgaben", @@ -17,6 +18,7 @@ export const ALLE_TILE_IDS: DashboardTileId[] = [ "maengel", "qualifikation", "aktivitaet", + "readinessVerlauf", ]; // 24 statt 12 Spalten - feineres Raster, mehr Kontrolle beim Breiter/Schmaler @@ -44,6 +46,7 @@ export const STANDARD_RASTER: Record = { maengel: { x: 8, y: 8, w: 8, h: 5 }, qualifikation: { x: 16, y: 8, w: 8, h: 4 }, aktivitaet: { x: 0, y: 13, w: 16, h: 5 }, + readinessVerlauf: { x: 16, y: 13, w: 8, h: 5 }, }; export interface DashboardLayout { diff --git a/frontend/src/pages/dashboard/tiles/ReadinessVerlaufTile.tsx b/frontend/src/pages/dashboard/tiles/ReadinessVerlaufTile.tsx new file mode 100644 index 0000000..876de63 --- /dev/null +++ b/frontend/src/pages/dashboard/tiles/ReadinessVerlaufTile.tsx @@ -0,0 +1,89 @@ +import { memo, useEffect, useState } from "react"; + +import { apiRequest } from "../../../api/client"; +import { useAuth } from "../../../auth/AuthContext"; + +interface Punkt { + zeitpunkt: string; + zustand: string; + anzahl: number; +} + +const ZUSTAND_LABEL: Record = { + einsatzbereit: "Einsatzbereit", + eingeschraenkt_einsatzbereit: "Eingeschränkt", + unbekannt: "Unbekannt", + nicht_einsatzbereit: "Nicht einsatzbereit", +}; + +const ZUSTAND_FARBE: Record = { + einsatzbereit: "var(--color-success)", + eingeschraenkt_einsatzbereit: "var(--color-warning)", + unbekannt: "var(--color-text-muted)", + nicht_einsatzbereit: "var(--color-danger)", +}; + +// READY-006: Trend statt Momentaufnahme - täglicher Snapshot je Objekt +// (backend/app/services/readiness_snapshot.py), hier auf Zeitpunkt/Zustand +// aggregiert. Eigenständiger Fetch statt Anbindung an DashboardPage.ladeDaten, +// da diese Kachel einen anderen Endpunkt/Datenzeitraum als der Rest nutzt. +function ReadinessVerlaufTileKomponente() { + const { istAdmin, istLeitungsverantwortlich } = useAuth(); + const [punkte, setPunkte] = useState(null); + const [fehler, setFehler] = useState(false); + const berechtigt = istAdmin || istLeitungsverantwortlich; + + useEffect(() => { + if (!berechtigt) return; + apiRequest("/readiness/verlauf") + .then(setPunkte) + .catch(() => setFehler(true)); + }, [berechtigt]); + + // Backend gibt 403 für alle außer Administration/Leitungsverantwortliche + // (READY-006: Trend-Reporting ist Leitungsthema, kein Helfer-Feature). + if (!berechtigt) return

Nur für Administration/Leitungsverantwortliche.

; + if (fehler) return

Verlauf konnte nicht geladen werden.

; + if (punkte === null) return

Lade…

; + if (punkte.length === 0) return

Noch keine Snapshots vorhanden.

; + + const zeitpunkte = [...new Set(punkte.map((p) => p.zeitpunkt))].sort(); + const letzterZeitpunkt = zeitpunkte[zeitpunkte.length - 1]; + const letzteVerteilung = punkte.filter((p) => p.zeitpunkt === letzterZeitpunkt); + const gesamt = letzteVerteilung.reduce((s, p) => s + p.anzahl, 0) || 1; + + return ( +
+

+ Letzter Snapshot: {new Date(letzterZeitpunkt).toLocaleDateString("de-DE")} +

+
+ {Object.keys(ZUSTAND_LABEL).map((zustand) => { + const anzahl = letzteVerteilung.find((p) => p.zustand === zustand)?.anzahl ?? 0; + if (anzahl === 0) return null; + return ( +
+ ); + })} +
+
    + {Object.keys(ZUSTAND_LABEL).map((zustand) => { + const anzahl = letzteVerteilung.find((p) => p.zustand === zustand)?.anzahl ?? 0; + return ( +
  • + {ZUSTAND_LABEL[zustand]} + {anzahl} +
  • + ); + })} +
+

{zeitpunkte.length} Tag(e) Verlauf verfügbar.

+
+ ); +} + +export const ReadinessVerlaufTile = memo(ReadinessVerlaufTileKomponente); diff --git a/frontend/src/styles/global/akte-grid.css b/frontend/src/styles/global/akte-grid.css index 8f7abe2..e26841a 100644 --- a/frontend/src/styles/global/akte-grid.css +++ b/frontend/src/styles/global/akte-grid.css @@ -13,6 +13,7 @@ "maengel" "dokumente" "notizen" + "readiness" "historie"; } @@ -27,4 +28,5 @@ .akte-dokumente { grid-area: dokumente; } .akte-notizen { grid-area: notizen; } .akte-tankbuch { grid-area: tankbuch; } +.akte-readiness-verlauf { grid-area: readiness; } .akte-historie { grid-area: historie; } diff --git a/frontend/src/styles/global/responsive.css b/frontend/src/styles/global/responsive.css index 8672875..b30483d 100644 --- a/frontend/src/styles/global/responsive.css +++ b/frontend/src/styles/global/responsive.css @@ -21,6 +21,7 @@ "dokumente dokumente" "notizen notizen" "tankbuch tankbuch" + "readiness readiness" "historie historie"; } }