feat(inspections): INSP Phase 2 - Admin-UI + Dashboard-Lesepfad-Ergänzung
CI / backend-tests (push) Successful in 3m16s
CI / frontend-build (push) Successful in 20s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 19:53:38 +02:00
1 parent e5dc80c3a6
commit 536b2edc4d
12 files changed
+574 -18

No files matched your search

+4 -4
View File
@@ -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) |
+45 -2
View File
@@ -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.
+49 -3
View File
@@ -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")
+12
View File
@@ -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
+15 -1
View File
@@ -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(
{
+34 -1
View File
@@ -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,
*,
+100
View File
@@ -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"
+31
View File
@@ -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;
+12 -5
View File
@@ -1,16 +1,21 @@
export type FristStatus = "abgelaufen" | "bald_ablaufend" | "ok";
export type FristStatus = "abgelaufen" | "bald_ablaufend" | "fehlgeschlagen" | "ok";
const LABEL: Record<FristStatus, string> = {
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<FristStatus, string> = {
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 (
<span className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${KLASSE[bekannt]}`}>
{LABEL[bekannt]}
+9 -1
View File
@@ -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" && <InventurSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "fehlerTracking" && <FehlerTrackingSection onFehler={setFehler} />}
{aktiverTab === "pruefarten" && <PruefartenSection objekttypen={objekttypen} onFehler={setFehler} />}
{aktiverTab === "verleih" && <VerleihSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
@@ -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<Pruefung["ergebnis"], string> = {
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<Record<string, PruefungsAmpel["dringlichkeit"]>>({});
const [historieOffenId, setHistorieOffenId] = useState<string | null>(null);
const [historie, setHistorie] = useState<Pruefung[]>([]);
async function laden() {
try {
const daten = await apiRequest<GeraetInstanz[]>(`/objektpositionen/${objektpositionId}/geraete`);
setInstanzen(daten);
const ampelEintraege = await Promise.all(
daten.map(async (instanz) => {
try {
const ampel = await apiRequest<PruefungsAmpel>(`/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<Pruefung[]>(`/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,
<span>
{instanz.status === "einsatzbereit" ? "✓" : "✗"} {instanz.seriennummer}{" "}
<span className={`badge ${STATUS_BADGE[instanz.status]}`}>{STATUS_LABEL[instanz.status]}</span>{" "}
{ampeln[instanz.id] && ampeln[instanz.id] !== "unbekannt" && (
<FristBadge status={ampelZuFristStatus(ampeln[instanz.id])} />
)}{" "}
{instanz.naechste_pruefung && (
<span className="text-muted">nächste Prüfung: {instanz.naechste_pruefung}</span>
)}
@@ -132,6 +179,9 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
>
Zurücknehmen
</button>
<button className="btn btn-secondary" onClick={() => historieUmschalten(instanz.id)}>
{historieOffenId === instanz.id ? "Historie schließen" : "Prüfhistorie"}
</button>
<button className="btn btn-secondary" onClick={() => bearbeiten(instanz)}>
{bearbeiteId === instanz.id ? "Schließen" : "Bearbeiten"}
</button>
@@ -140,6 +190,22 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
</button>
</div>
</div>
{historieOffenId === instanz.id && (
<div style={{ marginTop: "0.4rem", paddingLeft: "1rem" }}>
{historie.length === 0 ? (
<p className="text-muted" style={{ fontSize: "0.85rem" }}>Keine Prüfungen erfasst.</p>
) : (
<ul className="card-list">
{historie.map((p) => (
<li key={p.id} style={{ padding: "0.2rem 0", fontSize: "0.85rem" }}>
{p.geprueft_am} — {ERGEBNIS_LABEL[p.ergebnis]}
{p.bemerkung && <span className="text-muted"> · {p.bemerkung}</span>}
</li>
))}
</ul>
)}
</div>
)}
{bearbeiteId === instanz.id && (
<div className="stack" style={{ marginTop: "0.4rem", paddingLeft: "1rem" }}>
<div className="row" style={{ flexWrap: "wrap" }}>
@@ -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<Pruefart[]>([]);
const [name, setName] = useState("");
const [beschreibung, setBeschreibung] = useState("");
const [grundlage, setGrundlage] = useState("");
const [ausgewaehlterObjekttyp, setAusgewaehlterObjekttyp] = useState<number | "">("");
const [zuordnungen, setZuordnungen] = useState<ObjekttypPruefart[]>([]);
const [neuePruefartId, setNeuePruefartId] = useState<number | "">("");
const [neuesIntervall, setNeuesIntervall] = useState("");
async function ladePruefarten() {
try {
setPruefarten(await apiRequest<Pruefart[]>("/pruefarten"));
} catch {
onFehler("Prüfarten konnten nicht geladen werden.");
}
}
useEffect(() => {
ladePruefarten();
}, []);
async function ladeZuordnungen(objekttypId: number) {
try {
setZuordnungen(await apiRequest<ObjekttypPruefart[]>(`/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 (
<section className="section">
<h2>Prüfarten</h2>
<div className="card">
<h3 style={{ marginTop: 0 }}>Prüfarten-Stammdaten</h3>
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.75rem" }}>
<input className="input" placeholder="Name (z.B. UVV-Prüfung)" value={name} onChange={(e) => setName(e.target.value)} />
<input
className="input"
placeholder="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
/>
<input
className="input"
placeholder="Gesetzliche Grundlage (optional)"
value={grundlage}
onChange={(e) => setGrundlage(e.target.value)}
/>
<button className="btn btn-primary" onClick={pruefartAnlegen} disabled={!name}>
Anlegen
</button>
</div>
<ul className="card-list">
{pruefarten.map((p) => (
<li key={p.id}>
<strong>{p.name}</strong>
{p.gesetzliche_grundlage && <span className="text-muted"> — {p.gesetzliche_grundlage}</span>}
</li>
))}
</ul>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Zuordnung je Objekttyp</h3>
<select
className="input"
style={{ marginBottom: "0.75rem" }}
value={ausgewaehlterObjekttyp}
onChange={(e) => setAusgewaehlterObjekttyp(Number(e.target.value) || "")}
>
<option value="">Objekttyp wählen…</option>
{objekttypen.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
{ausgewaehlterObjekttyp !== "" && (
<>
<ul className="card-list" style={{ marginBottom: "0.75rem" }}>
{zuordnungen.length === 0 && <p className="text-muted">Keine Prüfart zugeordnet.</p>}
{zuordnungen.map((z) => (
<li key={z.pruefart_id} className="row-between">
<span>
{pruefartName(z.pruefart_id)}
{z.intervall_monate && <span className="text-muted"> — alle {z.intervall_monate} Monate</span>}
</span>
<button className="btn btn-secondary" onClick={() => zuordnungEntfernen(z.pruefart_id)}>
Entfernen
</button>
</li>
))}
</ul>
<div className="row" style={{ flexWrap: "wrap" }}>
<select
className="input"
value={neuePruefartId}
onChange={(e) => setNeuePruefartId(Number(e.target.value) || "")}
>
<option value="">Prüfart wählen…</option>
{pruefarten.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<input
type="number"
min="0"
className="input"
style={{ maxWidth: "10rem" }}
placeholder="Intervall (Monate)"
value={neuesIntervall}
onChange={(e) => setNeuesIntervall(e.target.value)}
/>
<button className="btn btn-primary" onClick={zuordnungSpeichern} disabled={neuePruefartId === ""}>
Zuordnen
</button>
</div>
</>
)}
</div>
</section>
);
}