feat(dashboard): drei fehlende Kacheln (Kennzahlen, Mängel, Qualifikationsablauf)
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:
@@ -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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -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)
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() == []
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user