feat(dashboard): drei fehlende Kacheln (Kennzahlen, Mängel, Qualifikationsablauf)
CI / backend-tests (push) Failing after 1m40s
CI / frontend-build (push) Successful in 17s

Kennzahlen: bestehender Endpoint /dashboard/kennzahlen war seit Prompt 12.1
nie im Frontend verdrahtet - jetzt eigene Kachel (offene Fehlbestände,
genehmigte Mindermengen, kürzlich erledigt, problematische Objekte, Objekte
gesamt).

Mängel: eigene Übersicht (offen/kritisch-Zahl + aufklappbare Liste) zusätzlich
zur bisherigen Erwähnung als Grund in Kachel 1 - nutzt bestehendes GET
/maengel, kein neuer Backend-Code.

Qualifikationsablauf: neuer Endpoint /dashboard/qualifikationsablaeufe
(bevorstehende_qualifikationsablaeufe(), 60 Tage Vorlauf) - analog
bevorstehende_prueftermine, aber für BenutzerQualifikation.gueltig_bis
(Nutzer-Vorgabe: "wer verliert bald Führerschein/Lehrgang-Gültigkeit").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 13:38:42 +02:00
co-authored by Claude Sonnet 5
parent 15fb8dbb16
commit e9904d5bea
6 changed files with 248 additions and 1 deletions
+22
View File
@@ -3997,3 +3997,25 @@ Keine Commits in dieser Session.
- frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - 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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
---
+13
View File
@@ -9,10 +9,12 @@ from app.schemas.dashboard import (
EinsatzbereitschaftRead, EinsatzbereitschaftRead,
KennzahlenRead, KennzahlenRead,
PrueftermineintragRead, PrueftermineintragRead,
QualifikationsablaufRead,
) )
from app.services.dashboard import ( from app.services.dashboard import (
bevorstehende_ablaufdaten, bevorstehende_ablaufdaten,
bevorstehende_prueftermine, bevorstehende_prueftermine,
bevorstehende_qualifikationsablaeufe,
einsatzbereitschaft, einsatzbereitschaft,
kennzahlen, kennzahlen,
) )
@@ -54,3 +56,14 @@ async def hole_prueftermine(
"""Kachel 2 Leitungs-Dashboard: Geräteprüfungen (Karte 14), bewusst getrennt """Kachel 2 Leitungs-Dashboard: Geräteprüfungen (Karte 14), bewusst getrennt
von /dashboard/ablaufdaten (Nutzer-Entscheidung: Prüfung ≠ Ablauf).""" von /dashboard/ablaufdaten (Nutzer-Entscheidung: Prüfung ≠ Ablauf)."""
return [PrueftermineintragRead(**eintrag) for eintrag in await bevorstehende_prueftermine(db)] 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)
]
+14
View File
@@ -52,3 +52,17 @@ class PrueftermineintragRead(BaseModel):
objektposition_id: uuid.UUID | None = None objektposition_id: uuid.UUID | None = None
material_id: int | None = None material_id: int | None = None
seriennummer: str | 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
+35
View File
@@ -8,8 +8,10 @@ from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.geraet_instanz import GeraetInstanz from app.models.geraet_instanz import GeraetInstanz
from app.models.kontrolle import Kontrolle, KontrollStatus from app.models.kontrolle import Kontrolle, KontrollStatus
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.auth import Benutzer
from app.models.objekt import Objekt, ObjektStatus from app.models.objekt import Objekt, ObjektStatus
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
from app.models.personal import BenutzerQualifikation, Qualifikationstyp
from app.models.stammdaten import Material from app.models.stammdaten import Material
from app.services.mangel import objekt_ids_mit_kritischem_offenem_mangel 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"]) eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege 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
+59
View File
@@ -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) f"/api/v1/benutzer/{benutzer_id}", json={"einheit_id": None}, headers=auth_header(token)
) )
assert geloest.json()["einheit_id"] is None 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() == []
+105 -1
View File
@@ -2,6 +2,27 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { apiRequest } from "../api/client"; 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 { interface ObjektMangel {
objekt_id: number; objekt_id: number;
@@ -54,25 +75,39 @@ export function DashboardPage() {
const [bereitschaft, setBereitschaft] = useState<Einsatzbereitschaft | null>(null); const [bereitschaft, setBereitschaft] = useState<Einsatzbereitschaft | null>(null);
const [prueftermine, setPrueftermine] = useState<Prueftermineintrag[]>([]); const [prueftermine, setPrueftermine] = useState<Prueftermineintrag[]>([]);
const [ablaufdaten, setAblaufdaten] = useState<Ablaufdatumeintrag[]>([]); const [ablaufdaten, setAblaufdaten] = useState<Ablaufdatumeintrag[]>([]);
const [kennzahlen, setKennzahlen] = useState<Kennzahlen | null>(null);
const [maengel, setMaengel] = useState<Mangel[]>([]);
const [qualifikationsablaeufe, setQualifikationsablaeufe] = useState<Qualifikationsablauf[]>([]);
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null); const [fehler, setFehler] = useState<string | null>(null);
const [detailsOffen, setDetailsOffen] = useState(false); const [detailsOffen, setDetailsOffen] = useState(false);
const [maengelOffen, setMaengelOffen] = useState(false);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
apiRequest<Einsatzbereitschaft>("/dashboard/einsatzbereitschaft"), apiRequest<Einsatzbereitschaft>("/dashboard/einsatzbereitschaft"),
apiRequest<Prueftermineintrag[]>("/dashboard/prueftermine"), apiRequest<Prueftermineintrag[]>("/dashboard/prueftermine"),
apiRequest<Ablaufdatumeintrag[]>("/dashboard/ablaufdaten"), apiRequest<Ablaufdatumeintrag[]>("/dashboard/ablaufdaten"),
apiRequest<Kennzahlen>("/dashboard/kennzahlen"),
apiRequest<Mangel[]>("/maengel"),
apiRequest<Qualifikationsablauf[]>("/dashboard/qualifikationsablaeufe"),
]) ])
.then(([b, p, a]) => { .then(([b, p, a, k, m, q]) => {
setBereitschaft(b); setBereitschaft(b);
setPrueftermine(p); setPrueftermine(p);
setAblaufdaten(a); setAblaufdaten(a);
setKennzahlen(k);
setMaengel(m);
setQualifikationsablaeufe(q);
}) })
.catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden.")) .catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden."))
.finally(() => setLaedt(false)); .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 ( return (
<main className="page"> <main className="page">
<h1>Dashboard</h1> <h1>Dashboard</h1>
@@ -154,6 +189,75 @@ export function DashboardPage() {
</ul> </ul>
)} )}
</div> </div>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Kennzahlen</h3>
{kennzahlen && (
<ul className="card-list">
<li style={{ padding: "0.3rem 0" }}>Offene Fehlbestände: {kennzahlen.anzahl_offener_fehlbestaende}</li>
<li style={{ padding: "0.3rem 0" }}>
Genehmigte Mindermengen: {kennzahlen.anzahl_genehmigter_mindermengen}
</li>
<li style={{ padding: "0.3rem 0" }}>
Kürzlich erledigt (7 Tage): {kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende}
</li>
<li style={{ padding: "0.3rem 0" }}>
Problematische Objekte: {kennzahlen.anzahl_problematischer_objekte}
</li>
<li style={{ padding: "0.3rem 0" }}>Objekte gesamt: {kennzahlen.anzahl_objekte_gesamt}</li>
</ul>
)}
</div>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Mängel</h3>
<p style={{ fontSize: "1.5rem", margin: "0.25rem 0" }}>{maengelOffenAnzahl} offen</p>
{maengelKritischAnzahl > 0 && (
<p className="text-muted" style={{ color: "#b00020" }}>
{maengelKritischAnzahl} kritisch (blockiert Einsatzbereitschaft)
</p>
)}
{maengelOffenListe.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setMaengelOffen(!maengelOffen)}>
{maengelOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{maengelOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{maengelOffenListe.map((m) => (
<li key={m.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<Link to="/maengel">{m.beschreibung}</Link>
<span className="text-muted" style={{ color: m.prioritaet === "hoch" ? "#b00020" : undefined }}>
{m.prioritaet}
</span>
</li>
))}
</ul>
)}
</>
)}
{maengelOffenListe.length === 0 && <p className="text-muted">Keine offenen Mängel.</p>}
</div>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Qualifikationsablauf</h3>
{qualifikationsablaeufe.length === 0 ? (
<p className="text-muted">Keine bald ablaufenden Qualifikationen.</p>
) : (
<ul className="card-list">
{qualifikationsablaeufe.map((q) => (
<li key={q.benutzer_qualifikation_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{q.benutzer_name} <span className="text-muted">({q.qualifikationstyp_name})</span>
</span>
<span style={{ color: q.status === "abgelaufen" ? "#b00020" : undefined }}>
{q.gueltig_bis} ({q.verbleibende_tage} Tage)
</span>
</li>
))}
</ul>
)}
</div>
</div> </div>
)} )}
</main> </main>