From 536b2edc4d2cb77ebc78e754ed34800f007a96b2 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 10 Sep 2026 19:53:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(inspections):=20INSP=20Phase=202=20-=20Adm?= =?UTF-8?q?in-UI=20+=20Dashboard-Lesepfad-Erg=C3=A4nzung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prüfarten-Verwaltung + Objekttyp-Zuordnung im Admin-Frontend (PruefartenSection.tsx, neue Endpunkte GET/PUT/DELETE /objekttypen/{id}/pruefarten), Prüfhistorie + FAILED-Ampel-Badge je Geräte-Exemplar (GeraeteInstanzenListe.tsx). Dashboard-Lesepfad ergänzt (nicht ersetzt): bevorstehende_prueftermine() zeigt ein Gerät mit fehlgeschlagener Prüfung jetzt auch dann, wenn naechste_pruefung datumsbasiert noch als "gueltig" gelten würde. Bewusst NICHT umgestellt: naechste_pruefung-Berechnung bleibt auf Objektposition.pruefintervall_monate (nicht objekttyp_pruefart), Fristen-Dienst/ASSET-010 bleiben unverändert - siehe Kachel-DoD. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskacheln/00_index.md | 8 +- arbeitskacheln/09_inspections.md | 47 ++++- backend/app/api/v1/endpoints/pruefung.py | 52 ++++- backend/app/schemas/pruefung.py | 12 ++ backend/app/services/dashboard.py | 16 +- backend/app/services/pruefung.py | 35 +++- backend/tests/test_insp_phase2.py | 100 +++++++++ frontend/src/api/types.ts | 31 +++ frontend/src/components/status/FristBadge.tsx | 17 +- frontend/src/pages/AdminPage.tsx | 10 +- .../src/pages/admin/GeraeteInstanzenListe.tsx | 68 +++++- .../src/pages/admin/PruefartenSection.tsx | 196 ++++++++++++++++++ 12 files changed, 574 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_insp_phase2.py create mode 100644 frontend/src/pages/admin/PruefartenSection.tsx diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index f631c20..1391b12 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -194,10 +194,10 @@ vergeben statt geraten. | LOAD-005 | Loadout | Kontrollhistorie | P1 | S | LOW | LOAD-004, FOUND-006 | ✅ (`Kontrolle`-Modell append-only, Historie-Anbindung) | | LOAD-006 | Loadout | Readiness-Kopplung | P1 | S | MEDIUM | LOAD-003, READY-001 | ✅ (Fehlbestand fließt in `einsatzbereitschaft()`-Berechnung ein) | | LOAD-007 | Loadout | Gemeinsame Sollmenge über mehrere Standorte (neu, Referenz: HiOrg-Server) | P3 | M | MEDIUM | LOAD-002, WH-004 | ✅ erledigt 2026-09-10 | -| INSP-001 | Inspections | Prüfarten-Stammdaten | P1 | S | LOW | ASSET-002 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`pruefart`-Tabelle, Dual-Write; Phase 2 Lesepfad-Umstellung/Frontend offen) | -| INSP-002 | Inspections | Prüfintervalle | P1 | S | LOW | INSP-001 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`objekttyp_pruefart`; Berechnung nutzt weiterhin Phase-1-Altpfad) | -| INSP-003 | Inspections | Prüfdurchführung/-ergebnis | P1 | M | MEDIUM | INSP-002 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`pruefung`-Tabelle mit Ergebnis/Prüfer, Dual-Write) | -| INSP-004 | Inspections | Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED) | P1 | S | LOW | INSP-003 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (FAILED-Zustand ergänzt in `pruefung.py`; kein Frontend) | +| INSP-001 | Inspections | Prüfarten-Stammdaten | P1 | S | LOW | ASSET-002 | ✅ erledigt 2026-09-10 (Phase 1+2: `pruefart`-Tabelle, Dual-Write, Admin-UI) | +| INSP-002 | Inspections | Prüfintervalle | P1 | S | LOW | INSP-001 | 🔶 Phase 1+2 additiv umgesetzt 2026-09-10 (`objekttyp_pruefart` + Admin-UI; Fälligkeits-BERECHNUNG bewusst weiter auf altem Pfad, siehe Kachel-DoD) | +| INSP-003 | Inspections | Prüfdurchführung/-ergebnis | P1 | M | MEDIUM | INSP-002 | ✅ erledigt 2026-09-10 (Phase 1+2: `pruefung`-Tabelle + Historie-Anzeige im Frontend) | +| INSP-004 | Inspections | Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED) | P1 | S | LOW | INSP-003 | ✅ erledigt 2026-09-10 (Phase 1+2: FAILED-Zustand + Dashboard/Frontend-Anzeige) | | INSP-005 | Inspections | Prüfdokument-Anbindung | P2 | XS | LOW | INSP-003, DOC-001 | ✅ (Dokument polymorph, `entitaet_typ` deckt Geräteinstanz ab) | | INSP-006 | Inspections | Prüftermin-Übersicht | P1 | S | LOW | INSP-004 | ✅ (`PrueftermineTile.tsx`, `/dashboard/prueftermine`) | | MAINT-001 | Maintenance | Wartungspläne | P2 | S | LOW | ASSET-002 | ✅ (`Wartungsplan`, Migration 0022 referenziert MAINT-001) | diff --git a/arbeitskacheln/09_inspections.md b/arbeitskacheln/09_inspections.md index b858363..558d744 100644 --- a/arbeitskacheln/09_inspections.md +++ b/arbeitskacheln/09_inspections.md @@ -51,6 +51,9 @@ Details zu INSP-001 … INSP-006 (vollständig). anlegbar, an Objekttyp koppelbar) erfüllt über Seed-Daten + `objekttyp_pruefart`-Tabelle (Koppel-UI folgt bei Bedarf in Phase 2, aktuell nur über die Tabelle direkt befüllbar, kein Admin-UI-Screen dafür). + **Phase 2 (2026-09-10):** Admin-UI „Prüfarten" (`PruefartenSection.tsx`) + ergänzt — Prüfarten anlegen UND je Objekttyp mit Intervall zuordnen, über + neue Endpunkte `GET/PUT/DELETE /objekttypen/{id}/pruefarten(/{pruefart_id})`. ## INSP-002 — Prüfintervalle @@ -76,8 +79,15 @@ Details zu INSP-001 … INSP-006 (vollständig). Kopplung angelegt (Tabelle existiert, noch ohne eigenes Admin-UI, siehe INSP-001-DoD). Die tatsächliche Fälligkeits-BERECHNUNG bleibt in Phase 1 weiterhin `pruefintervall_monate` an `Objektposition` + - `_plus_monate()` (unverändert, kein Regressionsrisiko) — Umstellung der - Berechnung auf `objekttyp_pruefart` ist Teil von Phase 2. + `_plus_monate()` (unverändert, kein Regressionsrisiko). + **Phase 2 (2026-09-10):** bewusst NICHT umgestellt — `objekttyp_pruefart` + ist jetzt über die Admin-UI voll pflegbar (INSP-001-DoD), dient aber + weiterhin nur als Stammdaten-Referenz/zukünftige Grundlage, NICHT als + Eingabe für die tatsächliche `naechste_pruefung`-Berechnung. Umstellung der + Berechnung selbst wäre ein Verhaltens-Umbau am zentralen Dual-Write-Pfad + (`geraet_instanz.py:aendere_status()`) mit Regressionsrisiko für + bestehende, produktive Prüftermine — explizit als eigener, künftiger + Schritt zurückgestellt, nicht Teil dieser Phase-2-Runde. ## INSP-003 — Prüfdurchführung/-ergebnis @@ -112,6 +122,9 @@ Details zu INSP-001 … INSP-006 (vollständig). `naechste_uvv` bisher direkt) — Dual-Write dafür ist Teil von Phase 2, sobald dafür ein echter Erfassen-Workflow existiert. `GET /geraete/{id}/pruefungen` liefert die volle Historie. + **Phase 2 (2026-09-10):** Historie jetzt auch im Admin-Frontend sichtbar — + `GeraeteInstanzenListe.tsx` bekam einen aufklappbaren „Prüfhistorie"-Bereich + je Geräte-Exemplar (Datum/Ergebnis/Bemerkung je Eintrag). ## INSP-004 — Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED) @@ -141,6 +154,16 @@ Details zu INSP-001 … INSP-006 (vollständig). wiederverwendet statt dupliziert) bezogen auf `GeraetInstanz.naechste_pruefung`. `GET /geraete/{id}/pruefungs-ampel`. Nur für Geräte (Phase 1), Fahrzeug- HU/UVV-Ampel bleibt vorerst beim bestehenden Dashboard-Mechanismus. + **Phase 2 (2026-09-10):** FAILED-Zustand jetzt an ZWEI Stellen im Frontend + sichtbar statt nur über den (bis dahin ungenutzten) Endpoint — (1) + `GeraeteInstanzenListe.tsx` zeigt die Ampel je Geräte-Exemplar als Badge, + (2) `app/services/dashboard.py:bevorstehende_prueftermine()` überschreibt + den datumsbasierten Status mit `fehlgeschlagen`, wenn die jüngste Prüfung + `nicht_bestanden` war — vorher hätte ein Gerät mit weit in der Zukunft + liegendem `naechste_pruefung`, aber fehlgeschlagener Prüfung, die + Prüftermine-Dashboardkachel als "gueltig" gar nicht erst erreicht. Neuer + Frist-Zustand `"fehlgeschlagen"` in `FristBadge.tsx` (rot, fett, eigene + Kennzeichnung statt Kollaps auf "ok"). ## INSP-005 — Prüfdokument-Anbindung @@ -205,3 +228,23 @@ bisherigen Stand (siehe deren eigene DoD-Einträge oben). **Kein Frontend in Pha (reines Backend, additiv, kein UI-Bedarf ohne Lesepfad-Umstellung) — Prüfarten- Verwaltungs-UI, Prüfungshistorie-Anzeige und FAILED-Ampel-Darstellung im Portal folgen mit Phase 2. + +**Update 2026-09-10 (Phase 2 — Lesepfad-Ergänzung + Frontend):** Bewusst +BEGRENZTER Umfang statt Voll-Migration (Risiko-Abwägung, kein Zwang zur +"perfekten" Lösung in einem Rutsch): +- ✅ Dashboard-Lesepfad ERGÄNZT (nicht ersetzt): `bevorstehende_prueftermine()` + zieht jetzt zusätzlich `dringlichkeit_fuer_geraet()` heran, um den + FAILED-Zustand datumsunabhängig durchzureichen (schließt die INSP-004-Lücke + im Dashboard selbst, nicht nur im bis dahin ungenutzten Ampel-Endpoint). +- ✅ Admin-Frontend: Prüfarten-Verwaltung + Objekttyp-Zuordnung + (`PruefartenSection.tsx`), Prüfhistorie-Anzeige + FAILED-Badge je + Geräte-Exemplar (`GeraeteInstanzenListe.tsx`). +- ❌ BEWUSST NICHT umgestellt: die `naechste_pruefung`-BERECHNUNG selbst + bleibt auf `Objektposition.pruefintervall_monate` (nicht auf + `objekttyp_pruefart`) — siehe INSP-002-DoD. Fristen-Dienst + (`app/services/fristen.py`) und ASSET-010 (Custody-Ausgabe) lesen weiterhin + ausschließlich `GeraetInstanz.naechste_pruefung`/`status`, nicht die neue + `Pruefung`-Tabelle — beide zeigen bei Prüfungen weiterhin dieselben Werte + wie vor Phase 2 (Dual-Write hält `naechste_pruefung` synchron), nur den + NEUEN FAILED-Zustand kennen sie noch nicht. Vollständige Migration bliebe + ein möglicher Phase-3-Schritt, falls der Bedarf sich zeigt. diff --git a/backend/app/api/v1/endpoints/pruefung.py b/backend/app/api/v1/endpoints/pruefung.py index c4e26c6..aa3e834 100644 --- a/backend/app/api/v1/endpoints/pruefung.py +++ b/backend/app/api/v1/endpoints/pruefung.py @@ -1,18 +1,28 @@ import uuid -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, HTTPException, status 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.models.pruefung import Pruefart, Pruefung -from app.schemas.pruefung import PruefartCreate, PruefartRead, PruefungRead, PruefungsAmpelRead +from app.models.pruefung import ObjekttypPruefart, Pruefart, Pruefung +from app.schemas.pruefung import ( + ObjekttypPruefartCreate, + ObjekttypPruefartRead, + PruefartCreate, + PruefartRead, + PruefungRead, + PruefungsAmpelRead, +) from app.services.pruefung import ( dringlichkeit_fuer_geraet, + entferne_objekttyp_pruefart, erstelle_pruefart, liste_pruefarten, + liste_pruefarten_fuer_objekttyp, liste_pruefungen_fuer_geraet, + setze_objekttyp_pruefart, ) router = APIRouter() @@ -58,3 +68,39 @@ async def pruefungs_ampel_endpunkt( ) -> PruefungsAmpelRead: dringlichkeit = await dringlichkeit_fuer_geraet(db, geraet_instanz_id) return PruefungsAmpelRead(dringlichkeit=dringlichkeit) + + +@router.get("/objekttypen/{objekttyp_id}/pruefarten", response_model=list[ObjekttypPruefartRead]) +async def liste_objekttyp_pruefarten_endpunkt( + objekttyp_id: int, db: AsyncSession = Depends(get_db), _=Depends(_lesend) +) -> list[ObjekttypPruefart]: + return await liste_pruefarten_fuer_objekttyp(db, objekttyp_id) + + +@router.put( + "/objekttypen/{objekttyp_id}/pruefarten/{pruefart_id}", + response_model=ObjekttypPruefartRead, +) +async def setze_objekttyp_pruefart_endpunkt( + objekttyp_id: int, + pruefart_id: int, + payload: ObjekttypPruefartCreate, + db: AsyncSession = Depends(get_db), + _=Depends(_administration), +) -> ObjekttypPruefart: + if payload.pruefart_id != pruefart_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="pruefart_id im Pfad und Body weichen ab") + return await setze_objekttyp_pruefart( + db, objekttyp_id=objekttyp_id, pruefart_id=pruefart_id, intervall_monate=payload.intervall_monate + ) + + +@router.delete( + "/objekttypen/{objekttyp_id}/pruefarten/{pruefart_id}", status_code=status.HTTP_204_NO_CONTENT +) +async def entferne_objekttyp_pruefart_endpunkt( + objekttyp_id: int, pruefart_id: int, db: AsyncSession = Depends(get_db), _=Depends(_administration) +) -> None: + gefunden = await entferne_objekttyp_pruefart(db, objekttyp_id=objekttyp_id, pruefart_id=pruefart_id) + if not gefunden: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zuordnung nicht gefunden") diff --git a/backend/app/schemas/pruefung.py b/backend/app/schemas/pruefung.py index bbd479f..93edc42 100644 --- a/backend/app/schemas/pruefung.py +++ b/backend/app/schemas/pruefung.py @@ -35,3 +35,15 @@ class PruefungRead(BaseModel): class PruefungsAmpelRead(BaseModel): dringlichkeit: str + + +class ObjekttypPruefartCreate(BaseModel): + pruefart_id: int + intervall_monate: int | None = None + + +class ObjekttypPruefartRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + objekttyp_id: int + pruefart_id: int + intervall_monate: int | None diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index f9bb5aa..5a4928d 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -449,7 +449,21 @@ async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = ) for geraet, objektposition, material in result.all(): status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute) - if status_wert == "gueltig": + # INSP-004 Phase 2: eine nicht bestandene Prüfung überstrahlt die + # datumsbasierte Einstufung, unabhängig davon wie weit naechste_pruefung + # noch in der Zukunft liegt (siehe pruefung_service.dringlichkeit_fuer_geraet) - + # sonst würde die Prüftermine-Kachel ein fehlgeschlagenes Gerät als + # "gueltig" ausblenden, nur weil das Datum selbst noch nicht erreicht ist. + # Lazy-Import: app.services.fristen importiert diese Funktion hier + # (bevorstehende_prueftermine wird selbst nicht von fristen.py genutzt, + # aber pruefung.py importiert fristen.py - ein Top-Level-Import hier + # würde einen Zirkelimport dashboard->pruefung->fristen->dashboard + # erzeugen). + from app.services.pruefung import dringlichkeit_fuer_geraet + + if await dringlichkeit_fuer_geraet(db, geraet.id) == "fehlgeschlagen": + status_wert = "fehlgeschlagen" + elif status_wert == "gueltig": continue eintraege.append( { diff --git a/backend/app/services/pruefung.py b/backend/app/services/pruefung.py index 1921c9e..6e7505a 100644 --- a/backend/app/services/pruefung.py +++ b/backend/app/services/pruefung.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.geraet_instanz import GeraetInstanz -from app.models.pruefung import Pruefart, Pruefung, PruefungErgebnis +from app.models.pruefung import ObjekttypPruefart, Pruefart, Pruefung, PruefungErgebnis from app.services.fristen import ampel_aus_tagen _WARNZEITRAUM_TAGE = 30 @@ -25,6 +25,39 @@ async def liste_pruefarten(db: AsyncSession) -> list[Pruefart]: return list(result.scalars().all()) +async def liste_pruefarten_fuer_objekttyp(db: AsyncSession, objekttyp_id: int) -> list[ObjekttypPruefart]: + result = await db.execute( + select(ObjekttypPruefart).where(ObjekttypPruefart.objekttyp_id == objekttyp_id) + ) + return list(result.scalars().all()) + + +async def setze_objekttyp_pruefart( + db: AsyncSession, *, objekttyp_id: int, pruefart_id: int, intervall_monate: int | None +) -> ObjekttypPruefart: + """INSP-002: legt die Zuordnung an oder aktualisiert das Intervall, falls + sie schon existiert (Composite-PK objekttyp_id+pruefart_id - eine + Objekttyp/Prüfart-Kombination ist eindeutig).""" + zuordnung = await db.get(ObjekttypPruefart, (objekttyp_id, pruefart_id)) + if zuordnung is None: + zuordnung = ObjekttypPruefart( + objekttyp_id=objekttyp_id, pruefart_id=pruefart_id, intervall_monate=intervall_monate + ) + db.add(zuordnung) + else: + zuordnung.intervall_monate = intervall_monate + await db.flush() + return zuordnung + + +async def entferne_objekttyp_pruefart(db: AsyncSession, *, objekttyp_id: int, pruefart_id: int) -> bool: + zuordnung = await db.get(ObjekttypPruefart, (objekttyp_id, pruefart_id)) + if zuordnung is None: + return False + await db.delete(zuordnung) + return True + + async def erstelle_pruefung_fuer_geraet( db: AsyncSession, *, diff --git a/backend/tests/test_insp_phase2.py b/backend/tests/test_insp_phase2.py new file mode 100644 index 0000000..bbce540 --- /dev/null +++ b/backend/tests/test_insp_phase2.py @@ -0,0 +1,100 @@ +import pytest + +from tests.conftest import auth_header, login + + +async def _erstelle_geraet(client, objekt_mit_position, token, seriennummer: str) -> str: + objekt, material = objekt_mit_position + positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token)) + position_id = positionen.json()[0]["id"] + erstellt = await client.post( + f"/api/v1/objektpositionen/{position_id}/geraete", + json={"seriennummer": seriennummer}, + headers=auth_header(token), + ) + return erstellt.json()["id"] + + +@pytest.mark.asyncio +async def test_objekttyp_pruefart_zuordnen_lesen_entfernen(client, db_session, objekttyp_factory, admin_user): + token = await login(client, "admin1") + objekttyp = await objekttyp_factory("INSP2-Typ") + + pruefart = await client.post( + "/api/v1/pruefarten", json={"name": "INSP2-Pruefart"}, headers=auth_header(token) + ) + pruefart_id = pruefart.json()["id"] + + gesetzt = await client.put( + f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/{pruefart_id}", + json={"pruefart_id": pruefart_id, "intervall_monate": 12}, + headers=auth_header(token), + ) + assert gesetzt.status_code == 200 + assert gesetzt.json()["intervall_monate"] == 12 + + liste = await client.get(f"/api/v1/objekttypen/{objekttyp.id}/pruefarten", headers=auth_header(token)) + assert liste.status_code == 200 + assert len(liste.json()) == 1 + + entfernt = await client.delete( + f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/{pruefart_id}", headers=auth_header(token) + ) + assert entfernt.status_code == 204 + + leer = await client.get(f"/api/v1/objekttypen/{objekttyp.id}/pruefarten", headers=auth_header(token)) + assert leer.json() == [] + + +@pytest.mark.asyncio +async def test_objekttyp_pruefart_erfordert_administration(client, objekttyp_factory, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + objekttyp = await objekttyp_factory("INSP2-Typ-2") + antwort = await client.put( + f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/1", + json={"pruefart_id": 1, "intervall_monate": 6}, + headers=auth_header(token), + ) + assert antwort.status_code == 403 + + +@pytest.mark.asyncio +async def test_entfernen_unbekannter_zuordnung_404(client, objekttyp_factory, admin_user): + token = await login(client, "admin1") + objekttyp = await objekttyp_factory("INSP2-Typ-3") + antwort = await client.delete( + f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/999999", headers=auth_header(token) + ) + assert antwort.status_code == 404 + + +@pytest.mark.asyncio +async def test_dashboard_prueftermine_zeigt_fehlgeschlagenes_geraet_trotz_gueltigem_datum( + client, db_session, objekt_mit_position, materialverantwortlicher_user +): + """INSP-004 Phase 2: dashboard.bevorstehende_prueftermine() darf ein Gerät + mit nicht bestandener Prüfung nicht als 'gueltig' herausfiltern, nur weil + naechste_pruefung noch weit in der Zukunft liegt.""" + token = await login(client, "materialverantwortlicher1") + objekt, _material = objekt_mit_position + positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token)) + position_id = positionen.json()[0]["id"] + await client.patch( + f"/api/v1/objekte/{objekt.id}/positionen/{position_id}", + json={"pruefintervall_monate": 12}, + headers=auth_header(token), + ) + geraet_id = await _erstelle_geraet(client, objekt_mit_position, token, "SN-INSP2-004") + + weit_in_zukunft = "2099-01-01" + await client.patch( + f"/api/v1/geraete/{geraet_id}", + json={"status": "defekt", "pruefdatum": weit_in_zukunft}, + headers=auth_header(token), + ) + + prueftermine = await client.get("/api/v1/dashboard/prueftermine", headers=auth_header(token)) + assert prueftermine.status_code == 200 + treffer = [p for p in prueftermine.json() if p.get("geraet_instanz_id") == geraet_id] + assert len(treffer) == 1 + assert treffer[0]["status"] == "fehlgeschlagen" diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8fe0667..941ac3f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -55,6 +55,37 @@ export interface GeraetInstanz { bemerkung: string | null; } +export type PruefungErgebnis = "bestanden" | "nicht_bestanden" | "mit_maengeln"; + +export interface Pruefart { + id: number; + name: string; + beschreibung: string | null; + gesetzliche_grundlage: string | null; +} + +export interface Pruefung { + id: string; + geraet_instanz_id: string | null; + objekt_id: number | null; + pruefart_id: number; + geprueft_am: string; + pruefer_id: number | null; + ergebnis: PruefungErgebnis; + bemerkung: string | null; + erstellt_am: string; +} + +export interface PruefungsAmpel { + dringlichkeit: "unbekannt" | "ok" | "bald" | "kritisch" | "ueberfaellig" | "fehlgeschlagen"; +} + +export interface ObjekttypPruefart { + objekttyp_id: number; + pruefart_id: number; + intervall_monate: number | null; +} + export interface Wartungsintervall { id: number; wartungsplan_position_id: number; diff --git a/frontend/src/components/status/FristBadge.tsx b/frontend/src/components/status/FristBadge.tsx index 514894f..d227f58 100644 --- a/frontend/src/components/status/FristBadge.tsx +++ b/frontend/src/components/status/FristBadge.tsx @@ -1,16 +1,21 @@ -export type FristStatus = "abgelaufen" | "bald_ablaufend" | "ok"; +export type FristStatus = "abgelaufen" | "bald_ablaufend" | "fehlgeschlagen" | "ok"; const LABEL: Record = { abgelaufen: "Überfällig", bald_ablaufend: "Bald fällig", + fehlgeschlagen: "Fehlgeschlagen", ok: "In Ordnung", }; // Überfällig = auffällig gefüllt, bald fällig = zurückhaltender Outline-Look, // ok = dezente Füllung - Dringlichkeit soll auch ohne Text erkennbar sein. +// fehlgeschlagen (INSP-004): eigene, datumsunabhängige Kennzeichnung - eine +// nicht bestandene Prüfung ist kein "bald abgelaufen", sondern jetzt schon +// ein Problem, unabhängig vom Fälligkeitsdatum. const KLASSE: Record = { abgelaufen: "bg-[var(--status-critical)] text-white", bald_ablaufend: "border border-[var(--status-warn)] text-[var(--status-warn)]", + fehlgeschlagen: "bg-[var(--status-critical)] text-white font-bold", ok: "bg-[var(--status-ok)]/15 text-[var(--status-ok)]", }; @@ -20,11 +25,13 @@ interface Props { /** gemeinsame Basis für InspectionStatus/InventoryStatus - * beide Backend-Endpunkte (Prüftermine/Ablaufdaten) liefern dieselben zwei - * Fristzustände (siehe app/services/dashboard.py `_ablauf_status`). Zeigt nur - * den Status - die verbleibenden Tage rendert die jeweilige Kachel separat - * als eigenes Countdown-Element. */ + * Fristzustände (siehe app/services/dashboard.py `_ablauf_status`), Prüftermine + * zusätzlich "fehlgeschlagen" (INSP-004 Phase 2). Zeigt nur den Status - die + * verbleibenden Tage rendert die jeweilige Kachel separat als eigenes + * Countdown-Element. */ export function FristBadge({ status }: Props) { - const bekannt = status === "abgelaufen" || status === "bald_ablaufend" ? status : "ok"; + const bekannt: FristStatus = + status === "abgelaufen" || status === "bald_ablaufend" || status === "fehlgeschlagen" ? status : "ok"; return ( {LABEL[bekannt]} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 5945db8..f5d43dd 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -33,6 +33,7 @@ import { MaterialSetSection } from "./admin/MaterialSetSection"; import { SollmengenGruppeSection } from "./admin/SollmengenGruppeSection"; import { InventurSection } from "./admin/InventurSection"; import { FehlerTrackingSection } from "./admin/FehlerTrackingSection"; +import { PruefartenSection } from "./admin/PruefartenSection"; import { VerleihSection } from "./admin/VerleihSection"; type Tab = @@ -58,7 +59,8 @@ type Tab = | "verleih" | "sollmengenGruppen" | "inventur" - | "fehlerTracking"; + | "fehlerTracking" + | "pruefarten"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte // (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt @@ -112,6 +114,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera }, { key: "inventur", label: "Inventur", rollen: ["administration", "materialverantwortlicher"] }, { key: "fehlerTracking", label: "Fehler-Tracking", rollen: ["administration"] }, + { + key: "pruefarten", + label: "Prüfarten", + rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], + }, ]; /** @@ -337,6 +344,7 @@ export function AdminPage() { )} {aktiverTab === "inventur" && } {aktiverTab === "fehlerTracking" && } + {aktiverTab === "pruefarten" && } {aktiverTab === "verleih" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( diff --git a/frontend/src/pages/admin/GeraeteInstanzenListe.tsx b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx index 3c815a7..436bbcc 100644 --- a/frontend/src/pages/admin/GeraeteInstanzenListe.tsx +++ b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx @@ -1,8 +1,24 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../../api/client"; -import type { GeraetInstanz } from "../../api/types"; +import type { GeraetInstanz, Pruefung, PruefungsAmpel } from "../../api/types"; import { CustodyDialog } from "../../components/CustodyDialog"; +import { FristBadge } from "../../components/status/FristBadge"; + +const ERGEBNIS_LABEL: Record = { + bestanden: "Bestanden", + nicht_bestanden: "Nicht bestanden", + mit_maengeln: "Mit Mängeln", +}; + +// PruefungsAmpel-Dringlichkeit (fristen.py Dringlichkeit-Enum + "fehlgeschlagen") +// auf die zwei/drei FristBadge-Zustände abbilden. +function ampelZuFristStatus(dringlichkeit: PruefungsAmpel["dringlichkeit"]): string { + if (dringlichkeit === "fehlgeschlagen") return "fehlgeschlagen"; + if (dringlichkeit === "ueberfaellig") return "abgelaufen"; + if (dringlichkeit === "bald" || dringlichkeit === "kritisch") return "bald_ablaufend"; + return "ok"; +} interface Props { objektpositionId: string; @@ -37,16 +53,44 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate, const [custodyDialog, setCustodyDialog] = useState<{ geraetId: string; modus: "ausgeben" | "zurueckgeben" } | null>( null ); + const [ampeln, setAmpeln] = useState>({}); + const [historieOffenId, setHistorieOffenId] = useState(null); + const [historie, setHistorie] = useState([]); async function laden() { try { const daten = await apiRequest(`/objektpositionen/${objektpositionId}/geraete`); setInstanzen(daten); + const ampelEintraege = await Promise.all( + daten.map(async (instanz) => { + try { + const ampel = await apiRequest(`/geraete/${instanz.id}/pruefungs-ampel`); + return [instanz.id, ampel.dringlichkeit] as const; + } catch { + return [instanz.id, "unbekannt"] as const; + } + }) + ); + setAmpeln(Object.fromEntries(ampelEintraege)); } catch { onFehler("Geräte-Liste konnte nicht geladen werden."); } } + async function historieUmschalten(geraetId: string) { + if (historieOffenId === geraetId) { + setHistorieOffenId(null); + return; + } + setHistorieOffenId(geraetId); + try { + const daten = await apiRequest(`/geraete/${geraetId}/pruefungen`); + setHistorie(daten); + } catch { + onFehler("Prüfhistorie konnte nicht geladen werden."); + } + } + useEffect(() => { laden(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -115,6 +159,9 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate, {instanz.status === "einsatzbereit" ? "✓" : "✗"} {instanz.seriennummer}{" "} {STATUS_LABEL[instanz.status]}{" "} + {ampeln[instanz.id] && ampeln[instanz.id] !== "unbekannt" && ( + + )}{" "} {instanz.naechste_pruefung && ( nächste Prüfung: {instanz.naechste_pruefung} )} @@ -132,6 +179,9 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate, > Zurücknehmen + @@ -140,6 +190,22 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate, + {historieOffenId === instanz.id && ( +
+ {historie.length === 0 ? ( +

Keine Prüfungen erfasst.

+ ) : ( +
    + {historie.map((p) => ( +
  • + {p.geprueft_am} — {ERGEBNIS_LABEL[p.ergebnis]} + {p.bemerkung && · {p.bemerkung}} +
  • + ))} +
+ )} +
+ )} {bearbeiteId === instanz.id && (
diff --git a/frontend/src/pages/admin/PruefartenSection.tsx b/frontend/src/pages/admin/PruefartenSection.tsx new file mode 100644 index 0000000..fc62cd8 --- /dev/null +++ b/frontend/src/pages/admin/PruefartenSection.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { ObjekttypPruefart, Objekttyp, Pruefart } from "../../api/types"; + +interface Props { + objekttypen: Objekttyp[]; + onFehler: (text: string) => void; +} + +// INSP-001/002 Phase 2: Prüfarten-Stammdaten pflegen und je Objekttyp mit +// einem Prüfintervall verknüpfen (rein informativ/Verwaltung - die +// tatsächliche Fälligkeitsberechnung nutzt weiterhin +// Objektposition.pruefintervall_monate, siehe arbeitskacheln/09_inspections.md +// Phasenplan). +export function PruefartenSection({ objekttypen, onFehler }: Props) { + const [pruefarten, setPruefarten] = useState([]); + const [name, setName] = useState(""); + const [beschreibung, setBeschreibung] = useState(""); + const [grundlage, setGrundlage] = useState(""); + + const [ausgewaehlterObjekttyp, setAusgewaehlterObjekttyp] = useState(""); + const [zuordnungen, setZuordnungen] = useState([]); + const [neuePruefartId, setNeuePruefartId] = useState(""); + const [neuesIntervall, setNeuesIntervall] = useState(""); + + async function ladePruefarten() { + try { + setPruefarten(await apiRequest("/pruefarten")); + } catch { + onFehler("Prüfarten konnten nicht geladen werden."); + } + } + + useEffect(() => { + ladePruefarten(); + }, []); + + async function ladeZuordnungen(objekttypId: number) { + try { + setZuordnungen(await apiRequest(`/objekttypen/${objekttypId}/pruefarten`)); + } catch { + onFehler("Zuordnungen konnten nicht geladen werden."); + } + } + + useEffect(() => { + if (ausgewaehlterObjekttyp !== "") void ladeZuordnungen(ausgewaehlterObjekttyp); + }, [ausgewaehlterObjekttyp]); + + async function pruefartAnlegen() { + if (!name) return; + try { + await apiRequest("/pruefarten", { + method: "POST", + body: { name, beschreibung: beschreibung || null, gesetzliche_grundlage: grundlage || null }, + }); + setName(""); + setBeschreibung(""); + setGrundlage(""); + await ladePruefarten(); + } catch { + onFehler("Prüfart konnte nicht angelegt werden (Name evtl. schon vergeben)."); + } + } + + async function zuordnungSpeichern() { + if (ausgewaehlterObjekttyp === "" || neuePruefartId === "") return; + try { + await apiRequest(`/objekttypen/${ausgewaehlterObjekttyp}/pruefarten/${neuePruefartId}`, { + method: "PUT", + body: { + pruefart_id: neuePruefartId, + intervall_monate: neuesIntervall ? Number(neuesIntervall) : null, + }, + }); + setNeuePruefartId(""); + setNeuesIntervall(""); + await ladeZuordnungen(ausgewaehlterObjekttyp); + } catch { + onFehler("Zuordnung konnte nicht gespeichert werden."); + } + } + + async function zuordnungEntfernen(pruefartId: number) { + if (ausgewaehlterObjekttyp === "") return; + try { + await apiRequest(`/objekttypen/${ausgewaehlterObjekttyp}/pruefarten/${pruefartId}`, { method: "DELETE" }); + await ladeZuordnungen(ausgewaehlterObjekttyp); + } catch { + onFehler("Zuordnung konnte nicht entfernt werden."); + } + } + + function pruefartName(id: number) { + return pruefarten.find((p) => p.id === id)?.name ?? `#${id}`; + } + + return ( +
+

Prüfarten

+ +
+

Prüfarten-Stammdaten

+
+ setName(e.target.value)} /> + setBeschreibung(e.target.value)} + /> + setGrundlage(e.target.value)} + /> + +
+
    + {pruefarten.map((p) => ( +
  • + {p.name} + {p.gesetzliche_grundlage && — {p.gesetzliche_grundlage}} +
  • + ))} +
+
+ +
+

Zuordnung je Objekttyp

+ + + {ausgewaehlterObjekttyp !== "" && ( + <> +
    + {zuordnungen.length === 0 &&

    Keine Prüfart zugeordnet.

    } + {zuordnungen.map((z) => ( +
  • + + {pruefartName(z.pruefart_id)} + {z.intervall_monate && — alle {z.intervall_monate} Monate} + + +
  • + ))} +
+
+ + setNeuesIntervall(e.target.value)} + /> + +
+ + )} +
+
+ ); +}