diff --git a/DEVLOG.md b/DEVLOG.md index e09fee5..3c7f0b9 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -3997,3 +3997,25 @@ Keine Commits in dieser Session. - frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ --- +## 2026-09-05 13:31 – 13:36 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- 15fb8db feat(fahrzeug): Fahrzeug-Spezialfelder + 5-stufiger Status (Roadmap Phase 3) + +### Geänderte Dateien +- DEVLOG.md | 72 ++++++++++++++++++++++++++++++++++++++ +- backend/alembic/versions/0014_fahrzeugdetails.py | 49 ++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/objekte.py | 40 ++++++++++++++++++++++ +- backend/app/models/fahrzeugdetails.py | 24 +++++++++++++ +- backend/app/models/objekt.py | 1 + +- backend/app/schemas/dashboard.py | 21 +++++++----- +- backend/app/schemas/objekt.py | 23 +++++++++++++ +- backend/app/services/dashboard.py | 84 ++++++++++++++++++++++++++++++++++++++++----- +- backend/tests/test_fahrzeugdetails.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++ +- frontend/src/api/types.ts | 11 ++++++ +- frontend/src/pages/DashboardPage.tsx | 21 ++++++------ +- frontend/src/pages/admin/ObjektSection.tsx | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- + +--- diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index bb4e19f..72579b3 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -9,10 +9,12 @@ from app.schemas.dashboard import ( EinsatzbereitschaftRead, KennzahlenRead, PrueftermineintragRead, + QualifikationsablaufRead, ) from app.services.dashboard import ( bevorstehende_ablaufdaten, bevorstehende_prueftermine, + bevorstehende_qualifikationsablaeufe, einsatzbereitschaft, kennzahlen, ) @@ -54,3 +56,14 @@ async def hole_prueftermine( """Kachel 2 Leitungs-Dashboard: Geräteprüfungen (Karte 14), bewusst getrennt von /dashboard/ablaufdaten (Nutzer-Entscheidung: Prüfung ≠ Ablauf).""" return [PrueftermineintragRead(**eintrag) for eintrag in await bevorstehende_prueftermine(db)] + + +@router.get("/dashboard/qualifikationsablaeufe", response_model=list[QualifikationsablaufRead]) +async def hole_qualifikationsablaeufe( + db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> list[QualifikationsablaufRead]: + """Kachel Personal-Modul: bald ablaufende/abgelaufene Führerscheine/ + Lehrgänge/Berechtigungen.""" + return [ + QualifikationsablaufRead(**eintrag) for eintrag in await bevorstehende_qualifikationsablaeufe(db) + ] diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 65e08fb..f31d197 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -52,3 +52,17 @@ class PrueftermineintragRead(BaseModel): objektposition_id: uuid.UUID | None = None material_id: int | None = None seriennummer: str | None = None + + +class QualifikationsablaufRead(BaseModel): + """Personal-Modul-Kachel (Nutzer-Vorgabe: "wer verliert bald Führerschein/ + Lehrgang-Gültigkeit").""" + + benutzer_qualifikation_id: uuid.UUID + benutzer_id: int + benutzer_name: str + qualifikationstyp_id: int + qualifikationstyp_name: str + gueltig_bis: date + verbleibende_tage: int + status: str diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 3fdb407..0980491 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -8,8 +8,10 @@ from app.models.fehlbestand import Fehlbestand, FehlbestandStatus from app.models.geraet_instanz import GeraetInstanz from app.models.kontrolle import Kontrolle, KontrollStatus from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung +from app.models.auth import Benutzer from app.models.objekt import Objekt, ObjektStatus from app.models.objektposition import Objektposition +from app.models.personal import BenutzerQualifikation, Qualifikationstyp from app.models.stammdaten import Material from app.services.mangel import objekt_ids_mit_kritischem_offenem_mangel @@ -298,3 +300,36 @@ async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = eintraege.sort(key=lambda e: e["verbleibende_tage"]) return eintraege + + +async def bevorstehende_qualifikationsablaeufe(db: AsyncSession, warnzeitraum_tage: int = 60) -> list[dict]: + """Personal-Modul-Kachel (Nutzer-Vorgabe: "wer verliert bald Führerschein/ + Lehrgang-Gültigkeit"). Analog bevorstehende_prueftermine, aber 60 statt 30 + Tage Standard-Vorlauf (Nachweise wie Führerscheine brauchen typischerweise + mehr Vorlaufzeit zur Erneuerung als eine Geräteprüfung).""" + heute = datetime.now(timezone.utc).date() + result = await db.execute( + select(BenutzerQualifikation, Benutzer, Qualifikationstyp) + .join(Benutzer, BenutzerQualifikation.benutzer_id == Benutzer.id) + .join(Qualifikationstyp, BenutzerQualifikation.qualifikationstyp_id == Qualifikationstyp.id) + .where(BenutzerQualifikation.gueltig_bis.is_not(None)) + ) + eintraege = [] + for qualifikation, benutzer, qualifikationstyp in result.all(): + status_wert, verbleibende_tage = _ablauf_status(qualifikation.gueltig_bis, warnzeitraum_tage, heute) + if status_wert == "gueltig": + continue + eintraege.append( + { + "benutzer_qualifikation_id": qualifikation.id, + "benutzer_id": benutzer.id, + "benutzer_name": benutzer.name, + "qualifikationstyp_id": qualifikationstyp.id, + "qualifikationstyp_name": qualifikationstyp.name, + "gueltig_bis": qualifikation.gueltig_bis, + "verbleibende_tage": verbleibende_tage, + "status": status_wert, + } + ) + eintraege.sort(key=lambda e: e["verbleibende_tage"]) + return eintraege diff --git a/backend/tests/test_personal.py b/backend/tests/test_personal.py index 73228c0..59d8910 100644 --- a/backend/tests/test_personal.py +++ b/backend/tests/test_personal.py @@ -137,3 +137,62 @@ async def test_benutzer_einheit_zuordnung(client, admin_user): f"/api/v1/benutzer/{benutzer_id}", json={"einheit_id": None}, headers=auth_header(token) ) assert geloest.json()["einheit_id"] is None + + +@pytest.mark.asyncio +async def test_bald_ablaufende_qualifikation_erscheint_im_dashboard( + client, admin_user, mitarbeiter_user +): + from datetime import date, timedelta + + token = await login(client, "admin1") + quali = await client.post( + "/api/v1/qualifikationstypen", + json={"name": "Führerschein C1", "kategorie": "fuehrerschein"}, + headers=auth_header(token), + ) + quali_id = quali.json()["id"] + + await client.post( + f"/api/v1/benutzer/{mitarbeiter_user.id}/qualifikationen", + json={ + "benutzer_id": mitarbeiter_user.id, + "qualifikationstyp_id": quali_id, + "erworben_am": (date.today() - timedelta(days=365)).isoformat(), + "gueltig_bis": (date.today() + timedelta(days=10)).isoformat(), + }, + headers=auth_header(token), + ) + + response = await client.get("/api/v1/dashboard/qualifikationsablaeufe", headers=auth_header(token)) + body = response.json() + assert len(body) == 1 + assert body[0]["status"] == "bald_ablaufend" + assert body[0]["benutzer_id"] == mitarbeiter_user.id + + +@pytest.mark.asyncio +async def test_unbefristete_qualifikation_erscheint_nicht_im_dashboard(client, admin_user, mitarbeiter_user): + from datetime import date, timedelta + + token = await login(client, "admin1") + quali = await client.post( + "/api/v1/qualifikationstypen", + json={"name": "Sanitätshelfer", "kategorie": "lehrgang"}, + headers=auth_header(token), + ) + quali_id = quali.json()["id"] + + await client.post( + f"/api/v1/benutzer/{mitarbeiter_user.id}/qualifikationen", + json={ + "benutzer_id": mitarbeiter_user.id, + "qualifikationstyp_id": quali_id, + "erworben_am": (date.today() - timedelta(days=365)).isoformat(), + "gueltig_bis": None, + }, + headers=auth_header(token), + ) + + response = await client.get("/api/v1/dashboard/qualifikationsablaeufe", headers=auth_header(token)) + assert response.json() == [] diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index ed22618..ce9c92b 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -2,6 +2,27 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { apiRequest } from "../api/client"; +import type { Mangel } from "../api/types"; + +interface Kennzahlen { + anzahl_offener_fehlbestaende: number; + anzahl_genehmigter_mindermengen: number; + anzahl_kuerzlich_erledigter_fehlbestaende: number; + anzahl_bevorstehender_ablaufdaten: number; + anzahl_problematischer_objekte: number; + anzahl_objekte_gesamt: number; +} + +interface Qualifikationsablauf { + benutzer_qualifikation_id: string; + benutzer_id: number; + benutzer_name: string; + qualifikationstyp_id: number; + qualifikationstyp_name: string; + gueltig_bis: string; + verbleibende_tage: number; + status: string; +} interface ObjektMangel { objekt_id: number; @@ -54,25 +75,39 @@ export function DashboardPage() { const [bereitschaft, setBereitschaft] = useState(null); const [prueftermine, setPrueftermine] = useState([]); const [ablaufdaten, setAblaufdaten] = useState([]); + const [kennzahlen, setKennzahlen] = useState(null); + const [maengel, setMaengel] = useState([]); + const [qualifikationsablaeufe, setQualifikationsablaeufe] = useState([]); const [laedt, setLaedt] = useState(true); const [fehler, setFehler] = useState(null); const [detailsOffen, setDetailsOffen] = useState(false); + const [maengelOffen, setMaengelOffen] = useState(false); useEffect(() => { Promise.all([ apiRequest("/dashboard/einsatzbereitschaft"), apiRequest("/dashboard/prueftermine"), apiRequest("/dashboard/ablaufdaten"), + apiRequest("/dashboard/kennzahlen"), + apiRequest("/maengel"), + apiRequest("/dashboard/qualifikationsablaeufe"), ]) - .then(([b, p, a]) => { + .then(([b, p, a, k, m, q]) => { setBereitschaft(b); setPrueftermine(p); setAblaufdaten(a); + setKennzahlen(k); + setMaengel(m); + setQualifikationsablaeufe(q); }) .catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden.")) .finally(() => setLaedt(false)); }, []); + const maengelOffenAnzahl = maengel.filter((m) => m.status !== "erledigt").length; + const maengelKritischAnzahl = maengel.filter((m) => m.status !== "erledigt" && m.prioritaet === "hoch").length; + const maengelOffenListe = maengel.filter((m) => m.status !== "erledigt"); + return (

Dashboard

@@ -154,6 +189,75 @@ export function DashboardPage() { )} + +
+

Kennzahlen

+ {kennzahlen && ( +
    +
  • Offene Fehlbestände: {kennzahlen.anzahl_offener_fehlbestaende}
  • +
  • + Genehmigte Mindermengen: {kennzahlen.anzahl_genehmigter_mindermengen} +
  • +
  • + Kürzlich erledigt (7 Tage): {kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende} +
  • +
  • + Problematische Objekte: {kennzahlen.anzahl_problematischer_objekte} +
  • +
  • Objekte gesamt: {kennzahlen.anzahl_objekte_gesamt}
  • +
+ )} +
+ +
+

Mängel

+

{maengelOffenAnzahl} offen

+ {maengelKritischAnzahl > 0 && ( +

+ {maengelKritischAnzahl} kritisch (blockiert Einsatzbereitschaft) +

+ )} + {maengelOffenListe.length > 0 && ( + <> + + {maengelOffen && ( +
    + {maengelOffenListe.map((m) => ( +
  • + {m.beschreibung} + + {m.prioritaet} + +
  • + ))} +
+ )} + + )} + {maengelOffenListe.length === 0 &&

Keine offenen Mängel.

} +
+ +
+

Qualifikationsablauf

+ {qualifikationsablaeufe.length === 0 ? ( +

Keine bald ablaufenden Qualifikationen.

+ ) : ( +
    + {qualifikationsablaeufe.map((q) => ( +
  • + + {q.benutzer_name} ({q.qualifikationstyp_name}) + + + {q.gueltig_bis} ({q.verbleibende_tage} Tage) + +
  • + ))} +
+ )} +
)}