feat(fahrzeug): Fahrzeug-Spezialfelder + 5-stufiger Status (Roadmap Phase 3)
CI / backend-tests (push) Failing after 1m40s
CI / frontend-build (push) Successful in 17s

Neue Tabelle fahrzeugdetails (1:1 zu Objekt, additiv/optional): Kennzeichen,
Funkrufname, Baujahr, Kilometerstand, Betriebsstunden, nächste HU/UVV.
objekt_status um 'in_wartung' erweitert (Nutzer-Vorgabe: einsatzbereit/
eingeschränkt/nicht einsatzbereit/in Wartung/außer Dienst - die ersten drei
bleiben weiterhin rein rechnerisch über dashboard.einsatzbereitschaft(),
in_wartung/ausser_dienst sind die beiden manuell gesetzten Zustände).

dashboard.einsatzbereitschaft(): ausser_dienst-Objekte zählen nicht mehr mit,
in_wartung und überfällige HU/UVV sind neue Gründe für "nicht einsatzbereit".

dashboard.bevorstehende_prueftermine() (Kachel 2) zeigt jetzt Geräte- UND
Fahrzeugprüfungen (HU/UVV) in einer Liste (Nutzer-Vorgabe: ein Konzept für
"welche Prüfungen bald fällig sind") - Schema dafür verallgemeinert
(typ/bezeichnung/faelligkeitsdatum statt geräte-spezifischer Felder).

Frontend: Status-Dropdown + Fahrzeugdetails-Formular in ObjektSection
(nur für Objekttypen mit ist_zugfahrzeug=True), Dashboard-Kachel 2 zeigt
gemischte Liste.

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:36:01 +02:00
co-authored by Claude Sonnet 5
parent eb20affd5d
commit 15fb8dbb16
12 changed files with 545 additions and 31 deletions
+72
View File
@@ -3925,3 +3925,75 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/QualifikationSection.tsx | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - frontend/src/pages/admin/QualifikationSection.tsx | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
--- ---
## 2026-09-05 13:20 13:24 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- eb20aff feat(mangel): Mangel-Ticket-System (Roadmap Phase 2)
### Geänderte Dateien
- DEVLOG.md | 27 +++++++++++++
- backend/alembic/versions/0013_mangel.py | 62 +++++++++++++++++++++++++++++
- backend/app/api/v1/api.py | 2 +
- backend/app/api/v1/endpoints/mangel.py | 85 +++++++++++++++++++++++++++++++++++++++
- backend/app/models/mangel.py | 61 ++++++++++++++++++++++++++++
- backend/app/schemas/mangel.py | 42 ++++++++++++++++++++
- backend/app/services/dashboard.py | 5 +++
- backend/app/services/mangel.py | 33 ++++++++++++++++
- backend/tests/test_mangel.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/App.tsx | 9 +++++
- frontend/src/api/types.ts | 17 ++++++++
- frontend/src/components/AppShell.tsx | 3 ++
- frontend/src/pages/DashboardPage.tsx | 1 +
- frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-05 13:26 13:28 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 27 +++++++++++++
- backend/alembic/versions/0013_mangel.py | 62 +++++++++++++++++++++++++++++
- backend/app/api/v1/api.py | 2 +
- backend/app/api/v1/endpoints/mangel.py | 85 +++++++++++++++++++++++++++++++++++++++
- backend/app/models/mangel.py | 61 ++++++++++++++++++++++++++++
- backend/app/schemas/mangel.py | 42 ++++++++++++++++++++
- backend/app/services/dashboard.py | 5 +++
- backend/app/services/mangel.py | 33 ++++++++++++++++
- backend/tests/test_mangel.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/App.tsx | 9 +++++
- frontend/src/api/types.ts | 17 ++++++++
- frontend/src/components/AppShell.tsx | 3 ++
- frontend/src/pages/DashboardPage.tsx | 1 +
- frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-05 13:29 13:30 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 27 +++++++++++++
- backend/alembic/versions/0013_mangel.py | 62 +++++++++++++++++++++++++++++
- backend/app/api/v1/api.py | 2 +
- backend/app/api/v1/endpoints/mangel.py | 85 +++++++++++++++++++++++++++++++++++++++
- backend/app/models/mangel.py | 61 ++++++++++++++++++++++++++++
- backend/app/schemas/mangel.py | 42 ++++++++++++++++++++
- backend/app/services/dashboard.py | 5 +++
- backend/app/services/mangel.py | 33 ++++++++++++++++
- backend/tests/test_mangel.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/App.tsx | 9 +++++
- frontend/src/api/types.ts | 17 ++++++++
- frontend/src/components/AppShell.tsx | 3 ++
- frontend/src/pages/DashboardPage.tsx | 1 +
- frontend/src/pages/MangelListePage.tsx | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
@@ -0,0 +1,49 @@
"""Fahrzeug-Spezialfelder (Roadmap Phase 3: BOS-Ressourcenplattform-Anforderung
Modul 2 "Fahrzeuge"). Kennzeichen/Funkrufname/Kilometerstand/Betriebsstunden/
Baujahr/HU/UVV als optionale 1:1-Erweiterung zu Objekt (nicht jeder Objekttyp
braucht das, nur Zugfahrzeuge - additiv, kein eigener Fahrzeug-Objekttyp
erzwungen, Karte 11 bleibt gültig).
Zusätzlich objekt_status um 'in_wartung' erweitert (Nutzer-Vorgabe: 5-stufiger
Fahrzeug-Status). ALTER TYPE ... ADD VALUE muss außerhalb der laufenden
Transaktion committen (Postgres-Einschränkung), daher AUTOCOMMIT-Block.
Revision ID: 0014_fahrzeugdetails
Revises: 0013_mangel
Create Date: 2026-09-05
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0014_fahrzeugdetails"
down_revision: Union[str, None] = "0013_mangel"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute("ALTER TYPE objekt_status ADD VALUE IF NOT EXISTS 'in_wartung'")
op.execute(
"""
CREATE TABLE fahrzeugdetails (
objekt_id INTEGER PRIMARY KEY REFERENCES objekt(id),
kennzeichen TEXT,
funkrufname TEXT,
baujahr INTEGER,
kilometerstand INTEGER,
betriebsstunden NUMERIC,
naechste_hu DATE,
naechste_uvv DATE
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE fahrzeugdetails")
# 'in_wartung' aus dem ENUM zu entfernen ist in Postgres nicht direkt möglich
# (kein ALTER TYPE ... DROP VALUE) - bewusst nicht rückgängig gemacht, siehe
# Muster in 0001_initial_schema (dort ebenfalls nur DROP TYPE als Ganzes).
+40
View File
@@ -8,12 +8,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.db.session import get_db from app.db.session import get_db
from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
from app.models.fahrzeugdetails import Fahrzeugdetails
from app.models.objekt import Objekt from app.models.objekt import Objekt
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
from app.models.stammdaten import Material, Objekttyp from app.models.stammdaten import Material, Objekttyp
from app.models.vorlage import Beladungsvorlage from app.models.vorlage import Beladungsvorlage
from app.services.label import generiere_code128_label_pdf from app.services.label import generiere_code128_label_pdf
from app.schemas.objekt import ( from app.schemas.objekt import (
FahrzeugdetailsRead,
FahrzeugdetailsUpsert,
ObjektCreate, ObjektCreate,
ObjektDuplizierenRequest, ObjektDuplizierenRequest,
ObjektpositionCreate, ObjektpositionCreate,
@@ -391,3 +394,40 @@ async def fuege_zusatzposition_hinzu(
code=position.code, code=position.code,
pruefintervall_monate=position.pruefintervall_monate, pruefintervall_monate=position.pruefintervall_monate,
) )
@router.get("/objekte/{objekt_id}/fahrzeugdetails", response_model=FahrzeugdetailsRead)
async def hole_fahrzeugdetails(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> Fahrzeugdetails:
"""Roadmap Phase 3 (Modul Fahrzeuge): 404 statt leerem Default, wenn noch
keine Fahrzeugdetails erfasst sind - Frontend zeigt dann ein leeres
Formular statt scheinbar vorhandener Nullwerte."""
details = await db.get(Fahrzeugdetails, objekt_id)
if details is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Fahrzeugdetails erfasst")
return details
@router.put("/objekte/{objekt_id}/fahrzeugdetails", response_model=FahrzeugdetailsRead)
async def setze_fahrzeugdetails(
objekt_id: int,
payload: FahrzeugdetailsUpsert,
db: AsyncSession = Depends(get_db),
_=Depends(_materialverantwortliche),
) -> Fahrzeugdetails:
"""Upsert (PUT statt PATCH): Fahrzeugdetails existieren typischerweise
genau einmal je Objekt, ein Formular ersetzt komplett statt Teil-Update -
einfacher als Anlegen/Ändern zu unterscheiden."""
objekt = await db.get(Objekt, objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
details = await db.get(Fahrzeugdetails, objekt_id)
if details is None:
details = Fahrzeugdetails(objekt_id=objekt_id)
db.add(details)
for feld, wert in payload.model_dump().items():
setattr(details, feld, wert)
await db.flush()
return details
+24
View File
@@ -0,0 +1,24 @@
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Fahrzeugdetails(Base):
"""Roadmap Phase 3 (Modul "Fahrzeuge"): optionale 1:1-Erweiterung zu Objekt
für Fahrzeug-Spezialfelder. Objekt bleibt generisch (Karte 11) - nicht jedes
Objekt braucht das, daher eigene Tabelle statt Spalten direkt an Objekt."""
__tablename__ = "fahrzeugdetails"
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), primary_key=True)
kennzeichen: Mapped[str | None] = mapped_column(String)
funkrufname: Mapped[str | None] = mapped_column(String)
baujahr: Mapped[int | None] = mapped_column(Integer)
kilometerstand: Mapped[int | None] = mapped_column(Integer)
betriebsstunden: Mapped[Decimal | None] = mapped_column(Numeric)
naechste_hu: Mapped[date | None] = mapped_column(Date)
naechste_uvv: Mapped[date | None] = mapped_column(Date)
+1
View File
@@ -10,6 +10,7 @@ from app.db.base import Base
class ObjektStatus(str, enum.Enum): class ObjektStatus(str, enum.Enum):
aktiv = "aktiv" aktiv = "aktiv"
ausser_dienst = "ausser_dienst" ausser_dienst = "ausser_dienst"
in_wartung = "in_wartung"
objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False) objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False)
+12 -9
View File
@@ -3,8 +3,6 @@ from datetime import date
from pydantic import BaseModel from pydantic import BaseModel
from app.models.geraet_instanz import GeraetStatus
class KennzahlenRead(BaseModel): class KennzahlenRead(BaseModel):
anzahl_offener_fehlbestaende: int anzahl_offener_fehlbestaende: int
@@ -39,13 +37,18 @@ class EinsatzbereitschaftRead(BaseModel):
class PrueftermineintragRead(BaseModel): class PrueftermineintragRead(BaseModel):
geraet_instanz_id: uuid.UUID """typ='geraet' (Karte 14) oder 'fahrzeug_hu'/'fahrzeug_uvv' (Roadmap Phase
3, Modul Fahrzeuge) - eine Kachel für beide (Nutzer-Vorgabe: "welche
Prüfungen bald fällig sind" ist ein Konzept). Die geraet-spezifischen
Felder sind bei Fahrzeug-Einträgen None."""
typ: str
objekt_id: int objekt_id: int
objektposition_id: uuid.UUID bezeichnung: str
material_id: int faelligkeitsdatum: date
material_name: str
seriennummer: str
geraet_status: GeraetStatus
naechste_pruefung: date
verbleibende_tage: int verbleibende_tage: int
status: str status: str
geraet_instanz_id: uuid.UUID | None = None
objektposition_id: uuid.UUID | None = None
material_id: int | None = None
seriennummer: str | None = None
+23
View File
@@ -32,6 +32,29 @@ class ObjektRead(BaseModel):
class ObjektUpdate(BaseModel): class ObjektUpdate(BaseModel):
fahrzeug_id: int | None = None fahrzeug_id: int | None = None
status: ObjektStatus | None = None
class FahrzeugdetailsUpsert(BaseModel):
kennzeichen: str | None = None
funkrufname: str | None = None
baujahr: int | None = None
kilometerstand: int | None = None
betriebsstunden: Decimal | None = None
naechste_hu: date | None = None
naechste_uvv: date | None = None
class FahrzeugdetailsRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
objekt_id: int
kennzeichen: str | None
funkrufname: str | None
baujahr: int | None
kilometerstand: int | None
betriebsstunden: Decimal | None
naechste_hu: date | None
naechste_uvv: date | None
class ObjektDuplizierenRequest(BaseModel): class ObjektDuplizierenRequest(BaseModel):
+76 -10
View File
@@ -3,11 +3,12 @@ from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fahrzeugdetails import Fahrzeugdetails
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus 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.objekt import Objekt from app.models.objekt import Objekt, ObjektStatus
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
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
@@ -104,7 +105,12 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
(Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt (Leitplanke U2/Karte 08) - das Objekt bleibt daher "eingeschränkt
einsatzbereit", nicht "einsatzbereit".""" einsatzbereit", nicht "einsatzbereit"."""
heute = datetime.now(timezone.utc).date() heute = datetime.now(timezone.utc).date()
objekte = (await db.execute(select(Objekt))).scalars().all() # ausser_dienst-Objekte zählen fachlich gar nicht mehr mit (Roadmap Phase 3,
# Modul Fahrzeuge: 5-stufiger Status) - anders als in_wartung, das als
# aktiver Grund weiter unten erscheint statt das Objekt komplett auszublenden.
objekte = (
(await db.execute(select(Objekt).where(Objekt.status != ObjektStatus.ausser_dienst))).scalars().all()
)
offene_fehlbestaende = ( offene_fehlbestaende = (
await db.execute( await db.execute(
@@ -163,6 +169,25 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
objekt_ids_kritischer_mangel = await objekt_ids_mit_kritischem_offenem_mangel(db) objekt_ids_kritischer_mangel = await objekt_ids_mit_kritischem_offenem_mangel(db)
objekt_ids_hu_uvv_ueberfaellig = set(
(
await db.execute(
select(func.distinct(Fahrzeugdetails.objekt_id)).where(
(
(Fahrzeugdetails.naechste_hu.is_not(None))
& (Fahrzeugdetails.naechste_hu < heute)
)
| (
(Fahrzeugdetails.naechste_uvv.is_not(None))
& (Fahrzeugdetails.naechste_uvv < heute)
)
)
)
)
.scalars()
.all()
)
bereit = eingeschraenkt = 0 bereit = eingeschraenkt = 0
details: list[dict] = [] details: list[dict] = []
for objekt in objekte: for objekt in objekte:
@@ -179,6 +204,10 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict:
gruende.append("kontrolle_laeuft") gruende.append("kontrolle_laeuft")
if objekt.id in objekt_ids_kritischer_mangel: if objekt.id in objekt_ids_kritischer_mangel:
gruende.append("mangel_kritisch_offen") gruende.append("mangel_kritisch_offen")
if objekt.status == ObjektStatus.in_wartung:
gruende.append("in_wartung")
if objekt.id in objekt_ids_hu_uvv_ueberfaellig:
gruende.append("hu_uvv_ueberfaellig")
if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt: if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt:
eingeschraenkt += 1 eingeschraenkt += 1
@@ -205,30 +234,67 @@ async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int =
Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist
Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung).""" Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung)."""
heute = datetime.now(timezone.utc).date() heute = datetime.now(timezone.utc).date()
eintraege = []
result = await db.execute( result = await db.execute(
select(GeraetInstanz, Objektposition, Material) select(GeraetInstanz, Objektposition, Material)
.join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id) .join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id)
.join(Material, Objektposition.material_id == Material.id) .join(Material, Objektposition.material_id == Material.id)
.where(GeraetInstanz.naechste_pruefung.is_not(None)) .where(GeraetInstanz.naechste_pruefung.is_not(None))
) )
eintraege = []
for geraet, objektposition, material in result.all(): for geraet, objektposition, material in result.all():
status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute) status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute)
if status_wert == "gueltig": if status_wert == "gueltig":
continue continue
eintraege.append( eintraege.append(
{ {
"geraet_instanz_id": geraet.id, "typ": "geraet",
"objekt_id": objektposition.objekt_id, "objekt_id": objektposition.objekt_id,
"objektposition_id": objektposition.id, "bezeichnung": f"{material.name} (SN {geraet.seriennummer})",
"material_id": material.id, "faelligkeitsdatum": geraet.naechste_pruefung,
"material_name": material.name,
"seriennummer": geraet.seriennummer,
"geraet_status": geraet.status,
"naechste_pruefung": geraet.naechste_pruefung,
"verbleibende_tage": verbleibende_tage, "verbleibende_tage": verbleibende_tage,
"status": status_wert, "status": status_wert,
"geraet_instanz_id": geraet.id,
"objektposition_id": objektposition.id,
"material_id": material.id,
"seriennummer": geraet.seriennummer,
} }
) )
# Fahrzeug-HU/UVV (Roadmap Phase 3, Modul Fahrzeuge) - gleiche Kachel wie
# Geräteprüfungen (Nutzer-Vorgabe: "welche Prüfungen bald fällig sind" ist
# EIN Konzept, egal ob Gerät oder Fahrzeug).
result = await db.execute(
select(Fahrzeugdetails, Objekt)
.join(Objekt, Fahrzeugdetails.objekt_id == Objekt.id)
.where(
(Fahrzeugdetails.naechste_hu.is_not(None)) | (Fahrzeugdetails.naechste_uvv.is_not(None))
)
)
for details, objekt in result.all():
for feld, typ, label in (
(details.naechste_hu, "fahrzeug_hu", "HU"),
(details.naechste_uvv, "fahrzeug_uvv", "UVV"),
):
if feld is None:
continue
status_wert, verbleibende_tage = _ablauf_status(feld, warnzeitraum_tage, heute)
if status_wert == "gueltig":
continue
eintraege.append(
{
"typ": typ,
"objekt_id": objekt.id,
"bezeichnung": f"{label} {objekt.name} ({details.kennzeichen or objekt.code})",
"faelligkeitsdatum": feld,
"verbleibende_tage": verbleibende_tage,
"status": status_wert,
"geraet_instanz_id": None,
"objektposition_id": None,
"material_id": None,
"seriennummer": None,
}
)
eintraege.sort(key=lambda e: e["verbleibende_tage"]) eintraege.sort(key=lambda e: e["verbleibende_tage"])
return eintraege return eintraege
+99
View File
@@ -0,0 +1,99 @@
from datetime import date, timedelta
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_fahrzeugdetails_upsert(client, objekt_mit_position, admin_user):
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
fehlend = await client.get(f"/api/v1/objekte/{objekt.id}/fahrzeugdetails", headers=auth_header(token))
assert fehlend.status_code == 404
gesetzt = await client.put(
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails",
json={"kennzeichen": "XX-YY 123", "kilometerstand": 42315, "naechste_hu": "2027-05-01"},
headers=auth_header(token),
)
assert gesetzt.status_code == 200
assert gesetzt.json()["kennzeichen"] == "XX-YY 123"
assert gesetzt.json()["kilometerstand"] == 42315
aktualisiert = await client.put(
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails",
json={"kilometerstand": 42500},
headers=auth_header(token),
)
assert aktualisiert.json()["kilometerstand"] == 42500
assert aktualisiert.json()["kennzeichen"] is None # PUT ersetzt komplett, kein Merge
@pytest.mark.asyncio
async def test_mitarbeiter_darf_fahrzeugdetails_nicht_setzen(client, objekt_mit_position, mitarbeiter_user):
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
response = await client.put(
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails", json={"kennzeichen": "X"}, headers=auth_header(token)
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_ausser_dienst_objekt_zaehlt_nicht_in_einsatzbereitschaft(
client, objekt_mit_position, admin_user, materialverantwortlicher_user
):
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "ausser_dienst"}, headers=auth_header(token))
verantwortlicher_token = await login(client, "materialverantwortlicher1")
response = await client.get(
"/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token)
)
body = response.json()
assert body["gesamt"] == 0
@pytest.mark.asyncio
async def test_in_wartung_macht_objekt_nicht_einsatzbereit(
client, objekt_mit_position, admin_user, materialverantwortlicher_user
):
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
await client.patch(f"/api/v1/objekte/{objekt.id}", json={"status": "in_wartung"}, headers=auth_header(token))
verantwortlicher_token = await login(client, "materialverantwortlicher1")
response = await client.get(
"/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token)
)
body = response.json()
assert body["nicht_einsatzbereit"] == 1
assert body["details"][0]["gruende"] == ["in_wartung"]
@pytest.mark.asyncio
async def test_ueberfaellige_hu_macht_objekt_nicht_einsatzbereit_und_erscheint_in_prueftermine(
client, objekt_mit_position, admin_user, materialverantwortlicher_user
):
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
await client.put(
f"/api/v1/objekte/{objekt.id}/fahrzeugdetails",
json={"naechste_hu": (date.today() - timedelta(days=10)).isoformat()},
headers=auth_header(token),
)
verantwortlicher_token = await login(client, "materialverantwortlicher1")
bereitschaft = await client.get(
"/api/v1/dashboard/einsatzbereitschaft", headers=auth_header(verantwortlicher_token)
)
assert bereitschaft.json()["details"][0]["gruende"] == ["hu_uvv_ueberfaellig"]
prueftermine = await client.get("/api/v1/dashboard/prueftermine", headers=auth_header(verantwortlicher_token))
body = prueftermine.json()
assert len(body) == 1
assert body[0]["typ"] == "fahrzeug_hu"
assert body[0]["status"] == "abgelaufen"
+11
View File
@@ -1,3 +1,14 @@
export interface Fahrzeugdetails {
objekt_id: number;
kennzeichen: string | null;
funkrufname: string | null;
baujahr: number | null;
kilometerstand: number | null;
betriebsstunden: string | null;
naechste_hu: string | null;
naechste_uvv: string | null;
}
export interface Objekt { export interface Objekt {
id: number; id: number;
code: string; code: string;
+10 -11
View File
@@ -19,11 +19,10 @@ interface Einsatzbereitschaft {
} }
interface Prueftermineintrag { interface Prueftermineintrag {
geraet_instanz_id: string; typ: string;
objekt_id: number; objekt_id: number;
material_name: string; bezeichnung: string;
seriennummer: string; faelligkeitsdatum: string;
naechste_pruefung: string;
verbleibende_tage: number; verbleibende_tage: number;
status: string; status: string;
} }
@@ -42,6 +41,8 @@ const GRUND_TEXT: Record<string, string> = {
pruefung_ueberfaellig: "Geräteprüfung überfällig", pruefung_ueberfaellig: "Geräteprüfung überfällig",
kontrolle_laeuft: "Kontrolle läuft (gesperrt)", kontrolle_laeuft: "Kontrolle läuft (gesperrt)",
mangel_kritisch_offen: "Kritischer Mangel offen", mangel_kritisch_offen: "Kritischer Mangel offen",
in_wartung: "In Wartung",
hu_uvv_ueberfaellig: "HU/UVV überfällig",
mindermenge_genehmigt: "Mindermenge genehmigt", mindermenge_genehmigt: "Mindermenge genehmigt",
}; };
@@ -124,13 +125,11 @@ export function DashboardPage() {
<p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p> <p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>
) : ( ) : (
<ul className="card-list"> <ul className="card-list">
{prueftermine.map((p) => ( {prueftermine.map((p, i) => (
<li key={p.geraet_instanz_id} className="row-between" style={{ padding: "0.3rem 0" }}> <li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
<span> <span>{p.bezeichnung}</span>
{p.material_name} <span className="text-muted">(SN {p.seriennummer})</span> <span style={{ color: p.status === "abgelaufen" ? "#b00020" : undefined }}>
</span> {p.faelligkeitsdatum} ({p.verbleibende_tage} Tage)
<span className={p.status === "abgelaufen" ? "text-muted" : "text-muted"} style={{ color: p.status === "abgelaufen" ? "#b00020" : undefined }}>
{p.naechste_pruefung} ({p.verbleibende_tage} Tage)
</span> </span>
</li> </li>
))} ))}
+128 -1
View File
@@ -1,9 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { apiRequest } from "../../api/client"; import { apiRequest } from "../../api/client";
import type { Beladungsvorlage, Material, Objekt, Objekttyp, Standort } from "../../api/types"; import type { Beladungsvorlage, Fahrzeugdetails, Material, Objekt, Objekttyp, Standort } from "../../api/types";
import { ObjektPositionenPanel } from "./ObjektPositionenPanel"; import { ObjektPositionenPanel } from "./ObjektPositionenPanel";
const STATUS_OPTIONEN = [
{ wert: "aktiv", label: "Aktiv" },
{ wert: "in_wartung", label: "In Wartung" },
{ wert: "ausser_dienst", label: "Außer Dienst" },
];
interface Props { interface Props {
objekte: Objekt[]; objekte: Objekt[];
objekttypen: Objekttyp[]; objekttypen: Objekttyp[];
@@ -46,6 +52,52 @@ export function ObjektSection({
const [fahrzeugBearbeitenId, setFahrzeugBearbeitenId] = useState<number | null>(null); const [fahrzeugBearbeitenId, setFahrzeugBearbeitenId] = useState<number | null>(null);
const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState<number | "">(""); const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState<number | "">("");
const [suche, setSuche] = useState(""); const [suche, setSuche] = useState("");
const [detailsOffenId, setDetailsOffenId] = useState<number | null>(null);
const [details, setDetails] = useState<Partial<Fahrzeugdetails>>({});
async function statusAendern(o: Objekt, status: string) {
try {
await apiRequest(`/objekte/${o.id}`, { method: "PATCH", body: { status } });
await onGeaendert();
} catch {
onFehler("Status konnte nicht geändert werden.");
}
}
async function fahrzeugdetailsOeffnen(o: Objekt) {
if (detailsOffenId === o.id) {
setDetailsOffenId(null);
return;
}
setDetailsOffenId(o.id);
try {
const daten = await apiRequest<Fahrzeugdetails>(`/objekte/${o.id}/fahrzeugdetails`);
setDetails(daten);
} catch {
setDetails({});
}
}
async function fahrzeugdetailsSpeichern(objektId: number) {
try {
await apiRequest(`/objekte/${objektId}/fahrzeugdetails`, {
method: "PUT",
body: {
kennzeichen: details.kennzeichen || null,
funkrufname: details.funkrufname || null,
baujahr: details.baujahr || null,
kilometerstand: details.kilometerstand || null,
betriebsstunden: details.betriebsstunden || null,
naechste_hu: details.naechste_hu || null,
naechste_uvv: details.naechste_uvv || null,
},
});
setDetailsOffenId(null);
onFehler("");
} catch {
onFehler("Fahrzeugdetails konnten nicht gespeichert werden.");
}
}
// Option A+C (Karte 11 Nachtrag): Dropdown zeigt nur Zugfahrzeuge (Objekttyp- // Option A+C (Karte 11 Nachtrag): Dropdown zeigt nur Zugfahrzeuge (Objekttyp-
// Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per // Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per
@@ -286,6 +338,24 @@ export function ObjektSection({
</span> </span>
</span> </span>
<div className="row"> <div className="row">
{!nurPflegen && (
<select
className="input"
value={o.status}
onChange={(e) => statusAendern(o, e.target.value)}
>
{STATUS_OPTIONEN.map((s) => (
<option key={s.wert} value={s.wert}>
{s.label}
</option>
))}
</select>
)}
{objekttypen.find((t) => t.id === o.objekttyp_id)?.ist_zugfahrzeug && (
<button className="btn btn-secondary" onClick={() => fahrzeugdetailsOeffnen(o)}>
{detailsOffenId === o.id ? "Schließen" : "Fahrzeugdetails"}
</button>
)}
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)} onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
@@ -304,6 +374,63 @@ export function ObjektSection({
)} )}
</div> </div>
</div> </div>
{detailsOffenId === o.id && (
<div className="stack" style={{ marginTop: "0.5rem", maxWidth: "24rem" }}>
<input
className="input"
placeholder="Kennzeichen"
value={details.kennzeichen ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, kennzeichen: e.target.value }))}
/>
<input
className="input"
placeholder="Funkrufname"
value={details.funkrufname ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, funkrufname: e.target.value }))}
/>
<div className="row">
<input
className="input"
type="number"
placeholder="Baujahr"
value={details.baujahr ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, baujahr: Number(e.target.value) || undefined }))}
/>
<input
className="input"
type="number"
placeholder="Kilometerstand"
value={details.kilometerstand ?? ""}
onChange={(e) =>
setDetails((d) => ({ ...d, kilometerstand: Number(e.target.value) || undefined }))
}
/>
</div>
<div className="row">
<div className="field">
<label>Nächste HU</label>
<input
className="input"
type="date"
value={details.naechste_hu ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, naechste_hu: e.target.value }))}
/>
</div>
<div className="field">
<label>Nächste UVV</label>
<input
className="input"
type="date"
value={details.naechste_uvv ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, naechste_uvv: e.target.value }))}
/>
</div>
</div>
<button className="btn btn-primary" onClick={() => fahrzeugdetailsSpeichern(o.id)}>
Speichern
</button>
</div>
)}
{fahrzeugBearbeitenId === o.id && ( {fahrzeugBearbeitenId === o.id && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}> <div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<select <select