From 209b541220b67da19f804f876a255033549dd22f Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 6 Sep 2026 21:35:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20UI-005=20begr=C3=BCndete=20Einsatzb?= =?UTF-8?q?ereitschaft=20+=20Dokumente=20in=20der=20Akte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: objekt_readiness() in dashboard.py liefert Status+Gründe für ein einzelnes Objekt, Fallunterscheidung aus einsatzbereitschaft() extrahiert (_kategorie_und_gruende, keine Duplizierung). Neue Felder an /akte/objekt/{id}. Frontend: ReadinessBadge + Gründeliste ersetzt den rohen objekt.status-Badge (Objekt-Lebenszyklus-Status bleibt separat sichtbar). Dokumente waren in der Akte bisher nur Text ohne Interaktion - jetzt DokumentePanel eingebunden plus neuer "Ansehen"- Klick (PDF/Bild im neuen Tab statt Zwangs-Download). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskacheln/20_ui_redesign.md | 15 +- backend/app/schemas/akte.py | 2 + backend/app/services/akte.py | 5 + backend/app/services/dashboard.py | 175 +++++++++++++++--- backend/tests/test_akte.py | 15 ++ frontend/src/api/client.ts | 18 ++ frontend/src/api/types.ts | 2 + frontend/src/components/DokumentePanel.tsx | 26 ++- frontend/src/components/status/gruendeText.ts | 13 ++ frontend/src/pages/AktePage.tsx | 27 +-- frontend/src/pages/DashboardPage.tsx | 13 +- 11 files changed, 251 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/status/gruendeText.ts diff --git a/arbeitskacheln/20_ui_redesign.md b/arbeitskacheln/20_ui_redesign.md index 6c5d5cf..68bad91 100644 --- a/arbeitskacheln/20_ui_redesign.md +++ b/arbeitskacheln/20_ui_redesign.md @@ -136,7 +136,20 @@ Aktueller Stand vor Redesign: 39 Seiten-/Komponenten-Dateien, ~7.000 Zeilen, ein - **Akzeptanzkriterien:** „unbekannt/nie kontrolliert" wird nie als einsatzbereit dargestellt (harte Regel, Akzeptanzkriterium testbar). - **Tests:** Begründungslogik testen (welche Bedingungen führen zu welchem Status). -- **DoD:** offen. +- **DoD:** umgesetzt (2026-09-06). Backend: `objekt_readiness()` in + `app/services/dashboard.py` - Fallunterscheidung aus `einsatzbereitschaft()` + in `_kategorie_und_gruende()` extrahiert, keine Duplizierung, für ein + einzelnes Objekt statt aller. Neue Felder `einsatzbereitschaft_status`/ + `einsatzbereitschaft_gruende` an `/akte/objekt/{id}`. Frontend: + `ReadinessBadge` + Gründeliste (gemeinsames `GRUND_TEXT`-Mapping mit + Dashboard) statt des rohen `objekt.status`-Badges - Objekt-Lebenszyklus- + Status (aktiv/ausser_dienst/in_wartung) bleibt separat als Text sichtbar, + keine Vermischung. Getestet: kritischer Mangel -> "not-ready", nie + kontrolliert ohne sonstige Auffälligkeit -> "unknown" (nie "ready"). + Nebenbei (Nutzer-Nachfrage): Dokumente in der Akte waren bisher nur Text + ohne jede Interaktion - jetzt `DokumentePanel` eingebunden (Upload/Liste/ + Löschen wie in Mängel/Admin), plus neuer "Ansehen"-Klick (`ladeDokumentAnsehen` + öffnet PDF/Bild im neuen Tab) zusätzlich zum bisherigen Zwangs-Download. ## UI-006 — Kontrolle & Beladung modernisiert diff --git a/backend/app/schemas/akte.py b/backend/app/schemas/akte.py index 25b0f5e..069c7ac 100644 --- a/backend/app/schemas/akte.py +++ b/backend/app/schemas/akte.py @@ -24,6 +24,8 @@ class AkteRead(BaseModel): 03_digital_file.md).""" objekt: ObjektRead + einsatzbereitschaft_status: str + einsatzbereitschaft_gruende: list[str] objekttyp_name: str standort_name: str fahrzeugdetails: FahrzeugdetailsRead | None diff --git a/backend/app/services/akte.py b/backend/app/services/akte.py index c63984f..854001e 100644 --- a/backend/app/services/akte.py +++ b/backend/app/services/akte.py @@ -18,6 +18,7 @@ from app.schemas.historie import HistorieRead from app.schemas.mangel import MangelRead from app.schemas.objekt import FahrzeugdetailsRead, ObjektRead from app.schemas.zustaendigkeit import KontrollverantwortungRead +from app.services.dashboard import objekt_readiness from app.services.objekte import letzte_kontrollen_map @@ -89,12 +90,16 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None: maengel_result = await db.execute(select(Mangel).where(Mangel.objekt_id == objekt_id)) + readiness = await objekt_readiness(db, objekt_id) + objekt_read = ObjektRead.model_validate(objekt).model_copy( update={"letzte_kontrolle_am": letzte_map.get(objekt_id)} ) return AkteRead( objekt=objekt_read, + einsatzbereitschaft_status=readiness["status"], + einsatzbereitschaft_gruende=readiness["gruende"], objekttyp_name=objekttyp.name if objekttyp else "", standort_name=standort.name if standort else "", fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None, diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index dab17a9..8656abb 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -199,37 +199,24 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict: bereit = eingeschraenkt = unbekannt = 0 details: list[dict] = [] for objekt in objekte: - gruende: list[str] = [] - fehlbestand_ids = objekt_ids_mit_fehlbestand.get(objekt.id, set()) - ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt - if ungenehmigt: - gruende.append("fehlbestand_offen") - if objekt.id in objekt_ids_abgelaufen: - gruende.append("ablaufdatum_abgelaufen") - if objekt.id in objekt_ids_pruefung_ueberfaellig: - gruende.append("pruefung_ueberfaellig") - if objekt.id in objekt_ids_gesperrt: - gruende.append("kontrolle_laeuft") - if objekt.id in objekt_ids_kritischer_mangel: - gruende.append("mangel_kritisch_offen") - if objekt.status == ObjektStatus.in_wartung: - gruende.append("in_wartung") - if objekt.id in objekt_ids_hu_uvv_ueberfaellig: - gruende.append("hu_uvv_ueberfaellig") - - if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt: - eingeschraenkt += 1 - details.append( - {"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["mindermenge_genehmigt"]} - ) - elif not gruende and letzte_kontrolle_map.get(objekt.id) is None: - unbekannt += 1 - details.append( - {"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": ["noch_nie_kontrolliert"]} - ) - elif not gruende: + 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, + ) + if kategorie == "ready": bereit += 1 else: + if kategorie == "limited": + eingeschraenkt += 1 + elif kategorie == "unknown": + unbekannt += 1 details.append({"objekt_id": objekt.id, "code": objekt.code, "name": objekt.name, "gruende": gruende}) return { @@ -242,6 +229,136 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict: } +def _kategorie_und_gruende( + objekt: Objekt, + letzte_kontrolle, + fehlbestand_ids: set, + fehlbestand_ids_genehmigt: set, + objekt_ids_abgelaufen: set, + objekt_ids_pruefung_ueberfaellig: set, + objekt_ids_gesperrt: set, + objekt_ids_kritischer_mangel: set, + objekt_ids_hu_uvv_ueberfaellig: set, +) -> tuple[str, list[str]]: + """UI-005 (Epic 20): aus der `einsatzbereitschaft()`-Schleife extrahiert, damit + `objekt_readiness()` (Begründung auf der Objektakte) dieselbe Logik nutzt statt + sie zu duplizieren.""" + gruende: list[str] = [] + ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt + if ungenehmigt: + gruende.append("fehlbestand_offen") + if objekt.id in objekt_ids_abgelaufen: + gruende.append("ablaufdatum_abgelaufen") + if objekt.id in objekt_ids_pruefung_ueberfaellig: + gruende.append("pruefung_ueberfaellig") + if objekt.id in objekt_ids_gesperrt: + gruende.append("kontrolle_laeuft") + if objekt.id in objekt_ids_kritischer_mangel: + gruende.append("mangel_kritisch_offen") + if objekt.status == ObjektStatus.in_wartung: + gruende.append("in_wartung") + if objekt.id in objekt_ids_hu_uvv_ueberfaellig: + gruende.append("hu_uvv_ueberfaellig") + + if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt: + return "limited", ["mindermenge_genehmigt"] + if not gruende and letzte_kontrolle is None: + return "unknown", ["noch_nie_kontrolliert"] + if not gruende: + return "ready", [] + return "not-ready", gruende + + +async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict: + """UI-005 (Epic 20): begründete Einsatzbereitschaft für EIN Objekt (Objektakte), + dieselbe Fachlogik wie `einsatzbereitschaft()` (Dashboard-Aggregat), nur für + einen einzelnen Datensatz statt aller Objekte. `ausser_dienst` zählt hier + bewusst nicht als eigener Fall - Objektakte zeigt das Objekt so oder so an, + der Objekt-Lebenszyklus-Status wird separat dargestellt.""" + heute = datetime.now(timezone.utc).date() + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + return {"status": "unknown", "gruende": []} + + fehlbestand_ids = set( + ( + await db.execute( + select(Fehlbestand.id).where( + Fehlbestand.objekt_id == objekt_id, Fehlbestand.status.in_(OFFEN_STATI) + ) + ) + ) + .scalars() + .all() + ) + fehlbestand_ids_genehmigt = set( + ( + await db.execute( + select(func.distinct(MindermengenGenehmigung.fehlbestand_id)).where( + MindermengenGenehmigung.fehlbestand_id.in_(fehlbestand_ids), + MindermengenGenehmigung.status == MindermengeStatus.aktiv, + ) + ) + ) + .scalars() + .all() + ) + hat_ablaufdatum = ( + await db.execute( + select(Objektposition.id).where( + Objektposition.objekt_id == objekt_id, + Objektposition.ablaufdatum.is_not(None), + Objektposition.ablaufdatum < heute, + ).limit(1) + ) + ).first() is not None + hat_ueberfaellige_pruefung = ( + await db.execute( + select(GeraetInstanz.id) + .join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id) + .where( + Objektposition.objekt_id == objekt_id, + GeraetInstanz.naechste_pruefung.is_not(None), + GeraetInstanz.naechste_pruefung < heute, + ) + .limit(1) + ) + ).first() is not None + ist_gesperrt = ( + await db.execute( + select(Kontrolle.id) + .where(Kontrolle.objekt_id == objekt_id, Kontrolle.status == KontrollStatus.in_bearbeitung) + .limit(1) + ) + ).first() is not None + hat_kritischen_mangel = objekt_id in await objekt_ids_mit_kritischem_offenem_mangel(db) + hat_ueberfaellige_hu_uvv = ( + await db.execute( + select(Fahrzeugdetails.objekt_id).where( + Fahrzeugdetails.objekt_id == objekt_id, + ( + (Fahrzeugdetails.naechste_hu.is_not(None)) & (Fahrzeugdetails.naechste_hu < heute) + ) + | ((Fahrzeugdetails.naechste_uvv.is_not(None)) & (Fahrzeugdetails.naechste_uvv < heute)), + ) + ) + ).first() is not None + letzte_kontrolle_map = await letzte_kontrollen_map(db, [objekt_id]) + + kategorie, gruende = _kategorie_und_gruende( + objekt, + letzte_kontrolle_map.get(objekt_id), + fehlbestand_ids, + fehlbestand_ids_genehmigt, + {objekt_id} if hat_ablaufdatum else set(), + {objekt_id} if hat_ueberfaellige_pruefung else set(), + {objekt_id} if ist_gesperrt else set(), + {objekt_id} if hat_kritischen_mangel else set(), + {objekt_id} if hat_ueberfaellige_hu_uvv else set(), + ) + return {"status": kategorie, "gruende": gruende} + + async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = 30) -> list[dict]: """Karte-Nachtrag (Leitungs-Dashboard Kachel 2): Geräteprüfungen (Karte 14), nicht Ablaufdaten von Verbrauchsmaterial (bleibt eigene, bestehende Kachel/ diff --git a/backend/tests/test_akte.py b/backend/tests/test_akte.py index c2c6d07..32509b1 100644 --- a/backend/tests/test_akte.py +++ b/backend/tests/test_akte.py @@ -48,6 +48,21 @@ async def test_akte_buendelt_teilbereiche(client, db_session, objekt_mit_positio assert body["maengel"][0]["beschreibung"] == "Riss im Gurt" assert len(body["historie"]) == 1 assert body["historie"][0]["entitaet_typ"] == "objekt" + assert body["einsatzbereitschaft_status"] == "not-ready" + assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"] + + +@pytest.mark.asyncio +async def test_akte_readiness_unbekannt_bei_nie_kontrolliert(client, objekt_mit_position, admin_user): + """UI-005: ein Objekt ohne jede Auffälligkeit, aber noch nie kontrolliert, + gilt als "unbekannt" - NIE als "einsatzbereit" (Akzeptanzkriterium).""" + objekt, _material = objekt_mit_position + token = await login(client, "admin1") + antwort = await client.get(f"/api/v1/akte/objekt/{objekt.id}", headers=auth_header(token)) + assert antwort.status_code == 200 + body = antwort.json() + assert body["einsatzbereitschaft_status"] == "unknown" + assert body["einsatzbereitschaft_status"] != "ready" @pytest.mark.asyncio diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6a1b1d7..f80b175 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -106,6 +106,24 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string URL.revokeObjectURL(url); } +// UI-005 (Epic 20): Dokumente waren bisher nur per Zwangs-Download einsehbar, +// nicht direkt im Browser anschaubar (PDF/Bild in neuem Tab statt Datei +// speichern). Auch hier: Auth-Header nötig, daher Blob statt einfachem +// auf den Download-Endpunkt. Object-URL wird bewusst NICHT sofort +// revoked (Tab braucht sie noch); der Browser räumt sie beim Tab-Schließen auf. +export async function ladeDokumentAnsehen(dokumentId: string): Promise { + const headers: Record = {}; + if (authToken) headers["Authorization"] = `Bearer ${authToken}`; + + const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers }); + if (!response.ok) { + throw new ApiError(response.status, null); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + window.open(url, "_blank", "noopener"); +} + export async function login(username: string, password: string): Promise { const body = new URLSearchParams({ username, password }); const response = await fetch(`${BASE_URL}/auth/login`, { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1a53a94..844371b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -262,6 +262,8 @@ export interface ZustaendigerBenutzer { export interface Akte { objekt: Objekt; + einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown"; + einsatzbereitschaft_gruende: string[]; objekttyp_name: string; standort_name: string; fahrzeugdetails: Fahrzeugdetails | null; diff --git a/frontend/src/components/DokumentePanel.tsx b/frontend/src/components/DokumentePanel.tsx index 1d0d2fc..6cd1199 100644 --- a/frontend/src/components/DokumentePanel.tsx +++ b/frontend/src/components/DokumentePanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { apiRequest, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; +import { apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; import { useAuth } from "../auth/AuthContext"; import type { Dokument } from "../api/types"; @@ -63,6 +63,14 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { } } + async function ansehen(d: Dokument) { + try { + await ladeDokumentAnsehen(d.id); + } catch { + onFehler("Datei konnte nicht geöffnet werden."); + } + } + async function herunterladen(d: Dokument) { try { await ladeDokumentHerunter(d.id, d.dateiname); @@ -100,7 +108,8 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
  • - )} + {darfLoeschen && ( + + )} +
  • ))} diff --git a/frontend/src/components/status/gruendeText.ts b/frontend/src/components/status/gruendeText.ts new file mode 100644 index 0000000..537e44b --- /dev/null +++ b/frontend/src/components/status/gruendeText.ts @@ -0,0 +1,13 @@ +/** Spiegelt die Grund-Codes aus app/services/dashboard.py (einsatzbereitschaft/ + * objekt_readiness) - gemeinsam genutzt von DashboardPage und AktePage. */ +export const GRUND_TEXT: Record = { + fehlbestand_offen: "Offener Fehlbestand", + ablaufdatum_abgelaufen: "Ablaufdatum überschritten", + pruefung_ueberfaellig: "Geräteprüfung überfällig", + kontrolle_laeuft: "Kontrolle läuft (gesperrt)", + mangel_kritisch_offen: "Kritischer Mangel offen", + in_wartung: "In Wartung", + hu_uvv_ueberfaellig: "HU/UVV überfällig", + noch_nie_kontrolliert: "Noch nie kontrolliert", + mindermenge_genehmigt: "Mindermenge genehmigt", +}; diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index b8d0ece..29a5e6a 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -3,6 +3,9 @@ import { Link, useParams } from "react-router-dom"; import { apiRequest } from "../api/client"; import type { Akte } from "../api/types"; +import { DokumentePanel } from "../components/DokumentePanel"; +import { ReadinessBadge } from "../components/status/ReadinessBadge"; +import { GRUND_TEXT } from "../components/status/gruendeText"; const MANGEL_PRIORITAET_LABEL: Record = { niedrig: "Niedrig", @@ -39,11 +42,20 @@ export function AktePage() {

    {objekt.name}

    - {objekt.code} · {akte.objekttyp_name} + {objekt.code} · {akte.objekttyp_name} · Objekt-Status: {objekt.status}
    - {objekt.status} + + {akte.einsatzbereitschaft_gruende.length > 0 && ( +
      + {akte.einsatzbereitschaft_gruende.map((g) => ( +
    • + • {GRUND_TEXT[g] ?? g} +
    • + ))} +
    + )}

    Stammdaten

    @@ -118,16 +130,7 @@ export function AktePage() {

    Dokumente

    - {akte.dokumente.length === 0 &&

    Keine Dokumente hinterlegt.

    } - {akte.dokumente.length > 0 && ( -
      - {akte.dokumente.map((d) => ( -
    • - {d.dateiname} ({Math.round(d.groesse_bytes / 1024)} KB) -
    • - ))} -
    - )} +
    diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 36389b0..d378f07 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -7,6 +7,7 @@ import { InspectionStatus } from "../components/status/InspectionStatus"; import { InventoryStatus } from "../components/status/InventoryStatus"; import type { Readiness } from "../components/status/ReadinessBadge"; import { ReadinessBadge } from "../components/status/ReadinessBadge"; +import { GRUND_TEXT } from "../components/status/gruendeText"; interface Kennzahlen { anzahl_offener_fehlbestaende: number; @@ -69,18 +70,6 @@ function readinessVonGruenden(gruende: string[]): Readiness { return "not-ready"; } -const GRUND_TEXT: Record = { - fehlbestand_offen: "Offener Fehlbestand", - ablaufdatum_abgelaufen: "Ablaufdatum überschritten", - pruefung_ueberfaellig: "Geräteprüfung überfällig", - kontrolle_laeuft: "Kontrolle läuft (gesperrt)", - mangel_kritisch_offen: "Kritischer Mangel offen", - in_wartung: "In Wartung", - hu_uvv_ueberfaellig: "HU/UVV überfällig", - noch_nie_kontrolliert: "Noch nie kontrolliert", - mindermenge_genehmigt: "Mindermenge genehmigt", -}; - // Leitungs-Dashboard (Karte-Nachtrag): zwei neue Kacheln (Einsatzbereitschaft, // Prüftermine) plus die bestehende Ablaufdaten-Kachel, alle drei eigenständig - // widersprechen sich nicht, da überfällige Prüfung/Ablauf beide zusätzlich als