From 15fb8dbb160378eeb1e95814360de62dd5df1acb Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 5 Sep 2026 13:36:01 +0200 Subject: [PATCH] feat(fahrzeug): Fahrzeug-Spezialfelder + 5-stufiger Status (Roadmap Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD --- DEVLOG.md | 72 ++++++++++ .../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 | 86 ++++++++++-- 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 +++++++++++++++++- 12 files changed, 545 insertions(+), 31 deletions(-) create mode 100644 backend/alembic/versions/0014_fahrzeugdetails.py create mode 100644 backend/app/models/fahrzeugdetails.py create mode 100644 backend/tests/test_fahrzeugdetails.py diff --git a/DEVLOG.md b/DEVLOG.md index 1b87eb3..e09fee5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -3925,3 +3925,75 @@ Keine Commits in dieser Session. - 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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/alembic/versions/0014_fahrzeugdetails.py b/backend/alembic/versions/0014_fahrzeugdetails.py new file mode 100644 index 0000000..c54069c --- /dev/null +++ b/backend/alembic/versions/0014_fahrzeugdetails.py @@ -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). diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index 8bc819c..ad05808 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -8,12 +8,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, require_roles from app.db.session import get_db from app.models.auth import KnotenTyp, RolleTyp, Systemknoten +from app.models.fahrzeugdetails import Fahrzeugdetails from app.models.objekt import Objekt from app.models.objektposition import Objektposition from app.models.stammdaten import Material, Objekttyp from app.models.vorlage import Beladungsvorlage from app.services.label import generiere_code128_label_pdf from app.schemas.objekt import ( + FahrzeugdetailsRead, + FahrzeugdetailsUpsert, ObjektCreate, ObjektDuplizierenRequest, ObjektpositionCreate, @@ -391,3 +394,40 @@ async def fuege_zusatzposition_hinzu( code=position.code, 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 diff --git a/backend/app/models/fahrzeugdetails.py b/backend/app/models/fahrzeugdetails.py new file mode 100644 index 0000000..725d7ca --- /dev/null +++ b/backend/app/models/fahrzeugdetails.py @@ -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) diff --git a/backend/app/models/objekt.py b/backend/app/models/objekt.py index 07f0d99..477dfb7 100644 --- a/backend/app/models/objekt.py +++ b/backend/app/models/objekt.py @@ -10,6 +10,7 @@ from app.db.base import Base class ObjektStatus(str, enum.Enum): aktiv = "aktiv" ausser_dienst = "ausser_dienst" + in_wartung = "in_wartung" objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 07b3fba..65e08fb 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -3,8 +3,6 @@ from datetime import date from pydantic import BaseModel -from app.models.geraet_instanz import GeraetStatus - class KennzahlenRead(BaseModel): anzahl_offener_fehlbestaende: int @@ -39,13 +37,18 @@ class EinsatzbereitschaftRead(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 - objektposition_id: uuid.UUID - material_id: int - material_name: str - seriennummer: str - geraet_status: GeraetStatus - naechste_pruefung: date + bezeichnung: str + faelligkeitsdatum: date verbleibende_tage: int status: str + geraet_instanz_id: uuid.UUID | None = None + objektposition_id: uuid.UUID | None = None + material_id: int | None = None + seriennummer: str | None = None diff --git a/backend/app/schemas/objekt.py b/backend/app/schemas/objekt.py index e361927..542be7c 100644 --- a/backend/app/schemas/objekt.py +++ b/backend/app/schemas/objekt.py @@ -32,6 +32,29 @@ class ObjektRead(BaseModel): class ObjektUpdate(BaseModel): 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): diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 06f93cc..3fdb407 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -3,11 +3,12 @@ from datetime import date, datetime, timedelta, timezone from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.fahrzeugdetails import Fahrzeugdetails 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.objekt import Objekt +from app.models.objekt import Objekt, ObjektStatus from app.models.objektposition import Objektposition from app.models.stammdaten import Material 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 einsatzbereit", nicht "einsatzbereit".""" 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 = ( 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_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 details: list[dict] = [] for objekt in objekte: @@ -179,6 +204,10 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict: gruende.append("kontrolle_laeuft") if objekt.id in objekt_ids_kritischer_mangel: 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: eingeschraenkt += 1 @@ -205,30 +234,67 @@ async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int = Endpoint /dashboard/ablaufdaten) - Prüfung ist Wartungsintervall, Ablauf ist Verbrauchsgut-Verfall, fachlich unterschiedlich (Nutzer-Entscheidung).""" heute = datetime.now(timezone.utc).date() + eintraege = [] + result = await db.execute( select(GeraetInstanz, Objektposition, Material) .join(Objektposition, GeraetInstanz.objektposition_id == Objektposition.id) .join(Material, Objektposition.material_id == Material.id) .where(GeraetInstanz.naechste_pruefung.is_not(None)) ) - eintraege = [] for geraet, objektposition, material in result.all(): status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute) if status_wert == "gueltig": continue eintraege.append( { - "geraet_instanz_id": geraet.id, + "typ": "geraet", "objekt_id": objektposition.objekt_id, - "objektposition_id": objektposition.id, - "material_id": material.id, - "material_name": material.name, - "seriennummer": geraet.seriennummer, - "geraet_status": geraet.status, - "naechste_pruefung": geraet.naechste_pruefung, + "bezeichnung": f"{material.name} (SN {geraet.seriennummer})", + "faelligkeitsdatum": geraet.naechste_pruefung, "verbleibende_tage": verbleibende_tage, "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"]) return eintraege diff --git a/backend/tests/test_fahrzeugdetails.py b/backend/tests/test_fahrzeugdetails.py new file mode 100644 index 0000000..eb07d67 --- /dev/null +++ b/backend/tests/test_fahrzeugdetails.py @@ -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" diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index bd9d0d8..afaa39f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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 { id: number; code: string; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 8d1a16a..ed22618 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -19,11 +19,10 @@ interface Einsatzbereitschaft { } interface Prueftermineintrag { - geraet_instanz_id: string; + typ: string; objekt_id: number; - material_name: string; - seriennummer: string; - naechste_pruefung: string; + bezeichnung: string; + faelligkeitsdatum: string; verbleibende_tage: number; status: string; } @@ -42,6 +41,8 @@ const GRUND_TEXT: Record = { pruefung_ueberfaellig: "Geräteprüfung überfällig", kontrolle_laeuft: "Kontrolle läuft (gesperrt)", mangel_kritisch_offen: "Kritischer Mangel offen", + in_wartung: "In Wartung", + hu_uvv_ueberfaellig: "HU/UVV überfällig", mindermenge_genehmigt: "Mindermenge genehmigt", }; @@ -124,13 +125,11 @@ export function DashboardPage() {

Keine anstehenden oder überfälligen Geräteprüfungen.

) : (
    - {prueftermine.map((p) => ( -
  • - - {p.material_name} (SN {p.seriennummer}) - - - {p.naechste_pruefung} ({p.verbleibende_tage} Tage) + {prueftermine.map((p, i) => ( +
  • + {p.bezeichnung} + + {p.faelligkeitsdatum} ({p.verbleibende_tage} Tage)
  • ))} diff --git a/frontend/src/pages/admin/ObjektSection.tsx b/frontend/src/pages/admin/ObjektSection.tsx index 481a9d7..2d929e0 100644 --- a/frontend/src/pages/admin/ObjektSection.tsx +++ b/frontend/src/pages/admin/ObjektSection.tsx @@ -1,9 +1,15 @@ import { useState } from "react"; 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"; +const STATUS_OPTIONEN = [ + { wert: "aktiv", label: "Aktiv" }, + { wert: "in_wartung", label: "In Wartung" }, + { wert: "ausser_dienst", label: "Außer Dienst" }, +]; + interface Props { objekte: Objekt[]; objekttypen: Objekttyp[]; @@ -46,6 +52,52 @@ export function ObjektSection({ const [fahrzeugBearbeitenId, setFahrzeugBearbeitenId] = useState(null); const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState(""); const [suche, setSuche] = useState(""); + const [detailsOffenId, setDetailsOffenId] = useState(null); + const [details, setDetails] = useState>({}); + + 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(`/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- // Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per @@ -286,6 +338,24 @@ export function ObjektSection({
    + {!nurPflegen && ( + + )} + {objekttypen.find((t) => t.id === o.objekttyp_id)?.ist_zugfahrzeug && ( + + )}
    + {detailsOffenId === o.id && ( +
    + setDetails((d) => ({ ...d, kennzeichen: e.target.value }))} + /> + setDetails((d) => ({ ...d, funkrufname: e.target.value }))} + /> +
    + setDetails((d) => ({ ...d, baujahr: Number(e.target.value) || undefined }))} + /> + + setDetails((d) => ({ ...d, kilometerstand: Number(e.target.value) || undefined })) + } + /> +
    +
    +
    + + setDetails((d) => ({ ...d, naechste_hu: e.target.value }))} + /> +
    +
    + + setDetails((d) => ({ ...d, naechste_uvv: e.target.value }))} + /> +
    +
    + +
    + )} {fahrzeugBearbeitenId === o.id && (