feat(readiness): READY-006 täglicher Bereitschafts-Snapshot mit Trend-Dashboard und Akte-Historie
CI / backend-tests (push) Successful in 2m32s
CI / frontend-build (push) Successful in 19s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 10:54:08 +02:00
1 parent bb0c601b2f
commit a1ad2d6cef
18 files changed
+550 -29

No files matched your search

+18 -5
View File
@@ -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).
---
@@ -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")
+2
View File
@@ -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"])
@@ -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]
+3
View File
@@ -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",
+37
View File
@@ -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)
+14
View File
@@ -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
+38 -23
View File
@@ -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,
@@ -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()]
@@ -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())
+71
View File
@@ -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
@@ -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<string, string> = {
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<Punkt[] | null>(null);
const berechtigt = istAdmin || istLeitungsverantwortlich;
useEffect(() => {
if (!berechtigt) return;
apiRequest<Punkt[]>(`/objekte/${objektId}/readiness-verlauf`)
.then(setPunkte)
.catch(() => setPunkte([]));
}, [objektId, berechtigt]);
if (!berechtigt) return <p className="text-muted">Nur für Administration/Leitungsverantwortliche.</p>;
if (punkte === null) return <p className="text-muted">Lade…</p>;
if (punkte.length === 0) return <p className="text-muted">Noch keine Bereitschaftshistorie vorhanden.</p>;
const tageGesamt = punkte.length;
const tageProblematisch = punkte.filter(
(p) => p.zustand === "nicht_einsatzbereit" || p.zustand === "eingeschraenkt_einsatzbereit"
).length;
return (
<div className="stack">
<p>
In den letzten {tageGesamt} erfassten Tagen: <strong>{tageProblematisch}</strong> Tag(e) nicht/eingeschränkt
einsatzbereit.
</p>
<div style={{ display: "flex", gap: "1px", flexWrap: "wrap" }}>
{punkte.map((p) => (
<span
key={p.zeitpunkt}
title={`${new Date(p.zeitpunkt).toLocaleDateString("de-DE")}: ${ZUSTAND_LABEL[p.zustand] ?? p.zustand}`}
style={{
width: "0.6rem",
height: "0.6rem",
display: "inline-block",
background:
p.zustand === "einsatzbereit"
? "var(--color-success)"
: p.zustand === "nicht_einsatzbereit"
? "var(--color-danger)"
: p.zustand === "eingeschraenkt_einsatzbereit"
? "var(--color-warning)"
: "var(--color-text-muted)",
}}
/>
))}
</div>
</div>
);
}
+6
View File
@@ -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() {
</div>
)}
<div className="akte-readiness-verlauf card">
<h3 style={{ marginTop: 0 }}>Bereitschaftshistorie</h3>
<ReadinessVerlaufPanel objektId={objekt.id} />
</div>
<div className="akte-historie card">
<h3 style={{ marginTop: 0 }}>Historie</h3>
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
@@ -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<DashboardTileId, string> = {
maengel: "Mängel",
qualifikation: "Qualifikationsablauf",
aktivitaet: "Letzte Aktivität",
readinessVerlauf: "Bereitschaft im Zeitverlauf",
};
export function DashboardPage() {
@@ -197,6 +199,8 @@ export function DashboardPage() {
return <QualifikationTile qualifikationsablaeufe={qualifikationsablaeufe} />;
case "aktivitaet":
return <AktivitaetTile aktivitaet={aktivitaet} />;
case "readinessVerlauf":
return <ReadinessVerlaufTile />;
default:
return null;
}
+4 -1
View File
@@ -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<DashboardTileId, RasterPosition> = {
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 {
@@ -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<string, string> = {
einsatzbereit: "Einsatzbereit",
eingeschraenkt_einsatzbereit: "Eingeschränkt",
unbekannt: "Unbekannt",
nicht_einsatzbereit: "Nicht einsatzbereit",
};
const ZUSTAND_FARBE: Record<string, string> = {
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<Punkt[] | null>(null);
const [fehler, setFehler] = useState(false);
const berechtigt = istAdmin || istLeitungsverantwortlich;
useEffect(() => {
if (!berechtigt) return;
apiRequest<Punkt[]>("/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 <p className="text-muted">Nur für Administration/Leitungsverantwortliche.</p>;
if (fehler) return <p className="text-muted">Verlauf konnte nicht geladen werden.</p>;
if (punkte === null) return <p className="text-muted">Lade…</p>;
if (punkte.length === 0) return <p className="text-muted">Noch keine Snapshots vorhanden.</p>;
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 (
<div className="stack">
<p className="text-xs text-muted">
Letzter Snapshot: {new Date(letzterZeitpunkt).toLocaleDateString("de-DE")}
</p>
<div style={{ display: "flex", height: "1.25rem", borderRadius: "0.25rem", overflow: "hidden" }}>
{Object.keys(ZUSTAND_LABEL).map((zustand) => {
const anzahl = letzteVerteilung.find((p) => p.zustand === zustand)?.anzahl ?? 0;
if (anzahl === 0) return null;
return (
<div
key={zustand}
title={`${ZUSTAND_LABEL[zustand]}: ${anzahl}`}
style={{ width: `${(anzahl / gesamt) * 100}%`, background: ZUSTAND_FARBE[zustand] }}
/>
);
})}
</div>
<ul>
{Object.keys(ZUSTAND_LABEL).map((zustand) => {
const anzahl = letzteVerteilung.find((p) => p.zustand === zustand)?.anzahl ?? 0;
return (
<li key={zustand} className="row-between" style={{ padding: "0.15rem 0" }}>
<span style={{ color: ZUSTAND_FARBE[zustand] }}>{ZUSTAND_LABEL[zustand]}</span>
<span className="tabular-nums">{anzahl}</span>
</li>
);
})}
</ul>
<p className="text-xs text-muted">{zeitpunkte.length} Tag(e) Verlauf verfügbar.</p>
</div>
);
}
export const ReadinessVerlaufTile = memo(ReadinessVerlaufTileKomponente);
+2
View File
@@ -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; }
@@ -21,6 +21,7 @@
"dokumente dokumente"
"notizen notizen"
"tankbuch tankbuch"
"readiness readiness"
"historie historie";
}
}