feat(maintenance): MAINT-Epic (Wartungspläne/-aufträge, Werkstatt) + Akte-Redesign
Neues Wartungskonzept: Wartungsplan+Position+Intervall (zeit/km/ Betriebsstunden, kombinierbar - fällig sobald ein Kriterium erreicht ist), Wartungsauftrag mit intern/externer Werkstatt, Status offen/ in_arbeit/erledigt, Kosten, Historie, Dokument-Anhang. Ad-hoc-Aufträge ohne Plan möglich (z.B. kleinere Reparatur). Admin-Tab "Wartungspläne". Objektakte-Redesign (Epic 21, Nutzer-Feedback "wirkt nicht modern"): CSS-Grid statt Karten-Stapel, einspaltig mobil, zweispaltig ab 900px; Fahrzeug-Objekte zeigen Wartung/Fahrzeugdaten prominent, Geräte/ Rucksäcke die Beladung. Dabei gefundene Lücke: Beladung (Soll/Ist je Position) fehlte in der Akte komplett, nur über eine laufende Kontrolle sichtbar - jetzt eigene Karte. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -23,8 +23,8 @@ Details zu MAINT-001 … MAINT-006 (vollständig).
|
|||||||
- **Audit:** Änderung geloggt
|
- **Audit:** Änderung geloggt
|
||||||
- **Akzeptanzkriterien:** Wartungsplan mit mehreren Positionen anlegen.
|
- **Akzeptanzkriterien:** Wartungsplan mit mehreren Positionen anlegen.
|
||||||
- **Tests:** CRUD-Test.
|
- **Tests:** CRUD-Test.
|
||||||
- **DoD:** mind. ein Plan für einen Fahrzeugtyp angelegt. **Lücke:** MABEA hat KEIN
|
- **DoD:** umgesetzt (2026-09-06). `wartungsplan`/`wartungsplan_position` je
|
||||||
Wartungs-Modul, nur Prüfung (Inspections-Epic) — dieses ganze Epic ist neu.
|
Objekttyp, `POST/GET /wartungsplaene`, Admin-Tab "Wartungspläne".
|
||||||
|
|
||||||
## MAINT-002 — Wartungsintervalle
|
## MAINT-002 — Wartungsintervalle
|
||||||
|
|
||||||
@@ -49,7 +49,14 @@ Details zu MAINT-001 … MAINT-006 (vollständig).
|
|||||||
berechnet.
|
berechnet.
|
||||||
- **Tests:** Berechnungstest je Typ + Kombinationstest („je nachdem was zuerst
|
- **Tests:** Berechnungstest je Typ + Kombinationstest („je nachdem was zuerst
|
||||||
eintritt").
|
eintritt").
|
||||||
- **DoD:** funktioniert mit ASSET-009-Zählerstand verzahnt. Komplett neu in MABEA.
|
- **DoD:** umgesetzt (2026-09-06), abgewandelt: `wartungsintervall` (zeit/km/
|
||||||
|
betriebsstunden je Position, mehrere kombinierbar). Statt eines eigenen
|
||||||
|
Zähler-Abgleich-Konzepts (ASSET-009 existiert in MABEA nicht) werden die
|
||||||
|
bereits vorhandenen `Fahrzeugdetails.kilometerstand`/`betriebsstunden`
|
||||||
|
genutzt — für Geräte ohne Fahrzeugdetails wirkt nur das Zeit-Intervall.
|
||||||
|
`GET /objekte/{id}/faellige-wartungen` berechnet je Position, welches
|
||||||
|
Kriterium zuerst eintritt; nie durchgeführt gilt immer als fällig. Getestet:
|
||||||
|
alle drei Typen einzeln + Kombination ("km vor Zeit fällig").
|
||||||
|
|
||||||
## MAINT-003 — Wartungsauftrag
|
## MAINT-003 — Wartungsauftrag
|
||||||
|
|
||||||
@@ -70,7 +77,12 @@ Details zu MAINT-001 … MAINT-006 (vollständig).
|
|||||||
- **Audit:** Statuswechsel geloggt
|
- **Audit:** Statuswechsel geloggt
|
||||||
- **Akzeptanzkriterien:** Auftrag anlegen, Status durchlaufen bis erledigt.
|
- **Akzeptanzkriterien:** Auftrag anlegen, Status durchlaufen bis erledigt.
|
||||||
- **Tests:** Statuswechsel-Test.
|
- **Tests:** Statuswechsel-Test.
|
||||||
- **DoD:** kompletter Auftragszyklus getestet. Komplett neu in MABEA.
|
- **DoD:** umgesetzt (2026-09-06). `wartungsauftrag` (Objekt + optional
|
||||||
|
Geräte-Instanz + optional Wartungsplan-Position — auch ad-hoc ohne Plan,
|
||||||
|
z.B. "kleinere Reparatur"), Status offen/in_arbeit/erledigt, **werkstatt_intern
|
||||||
|
+ werkstatt_name** (beantwortet die Nutzerfrage nach externer Werkstatt),
|
||||||
|
Kosten optional. Zyklus offen→in_arbeit→erledigt getestet, inkl. ad-hoc-
|
||||||
|
Auftrag ohne Plan.
|
||||||
|
|
||||||
## MAINT-004 — Wartungshistorie
|
## MAINT-004 — Wartungshistorie
|
||||||
|
|
||||||
@@ -88,7 +100,9 @@ Details zu MAINT-001 … MAINT-006 (vollständig).
|
|||||||
- **Audit:** zeigt Audit-relevante Daten
|
- **Audit:** zeigt Audit-relevante Daten
|
||||||
- **Akzeptanzkriterien:** chronologische Liste korrekt.
|
- **Akzeptanzkriterien:** chronologische Liste korrekt.
|
||||||
- **Tests:** Anzeigetest.
|
- **Tests:** Anzeigetest.
|
||||||
- **DoD:** funktioniert mit mind. 2 erledigten Aufträgen. Komplett neu in MABEA.
|
- **DoD:** umgesetzt (2026-09-06). Kein neuer Endpunkt nötig -
|
||||||
|
`GET /wartungsauftraege?objekt_id=` liefert offene+erledigte, Akte zeigt
|
||||||
|
beide getrennt in der neuen "Wartung"-Sektion.
|
||||||
|
|
||||||
## MAINT-005 — Ersatzteile/Kosten (P3)
|
## MAINT-005 — Ersatzteile/Kosten (P3)
|
||||||
|
|
||||||
@@ -124,15 +138,13 @@ Details zu MAINT-001 … MAINT-006 (vollständig).
|
|||||||
- **Audit:** wie DOC-001
|
- **Audit:** wie DOC-001
|
||||||
- **Akzeptanzkriterien:** Dokument anhängen.
|
- **Akzeptanzkriterien:** Dokument anhängen.
|
||||||
- **Tests:** Integrationstest.
|
- **Tests:** Integrationstest.
|
||||||
- **DoD:** analog INSP-005 — kleine Whitelist-Erweiterung im Dokumente-Modul
|
- **DoD:** umgesetzt (2026-09-06). `entitaet_typ=wartungsauftrag` zur Whitelist
|
||||||
(`entitaet_typ=wartungsauftrag` ergänzen), sonst nichts Neues.
|
ergänzt, `DokumentePanel` in der Wartung-Sektion der Akte je Auftrag.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**MABEA-Ist-Stand-Abgleich:** Das komplette Maintenance-Epic **fehlt in MABEA** — es gibt
|
**MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-06):** MAINT-001..004+006
|
||||||
keinerlei Wartungskonzept, nur Prüfung (Inspections). Anders als bei den meisten bisherigen
|
vollständig umgesetzt — echtes Wartungskonzept existiert jetzt (Wartungspläne,
|
||||||
Epics ist hier praktisch nichts wiederverwendbar außer der generellen Berechnungsmechanik
|
Intervalle, Werkstattaufträge intern/extern, Historie, Dokument-Anhang).
|
||||||
aus Inspections (Intervall→Fälligkeit) und dem Dokumente-Modul (DOC-001, per
|
MAINT-005 (Ersatzteile/Kosten-Detailliste) bleibt bewusst zurückgestellt (P3) —
|
||||||
Whitelist-Erweiterung). Zusammen mit Warehouse und Digital File eines der drei Epics mit
|
Kosten als einzelnes Feld am Auftrag reicht für den MVP.
|
||||||
dem größten Umsetzungsaufwand gegenüber dem MABEA-Ist-Stand — bewusst als P2 eingestuft
|
|
||||||
(nicht MVP-kritisch).
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Epic 21 — Objektakte-Redesign (Fahrzeug/Gerät getrennt, Mobile/Desktop getrennt)
|
||||||
|
|
||||||
|
Nutzer-Feedback 2026-09-06: Die Akte wirkt wie ein gestapelter Formular-Stoß, nicht wie
|
||||||
|
eine moderne Verwaltungsansicht. Drei konkrete Punkte:
|
||||||
|
|
||||||
|
1. Kompakte Mobile/Tablet-Kontroll-Ansicht vs. reichhaltige Desktop-Verwaltungsansicht
|
||||||
|
fehlten bisher als Unterscheidung — eine Ansicht für beides.
|
||||||
|
2. Fahrzeug-Akte und Geräte/Rucksack-Akte brauchen unterschiedliche Schwerpunkte
|
||||||
|
(Fahrzeug: Kennzeichen/HU/UVV/Wartung vorn; Gerät/Rucksack: Beladung/Material vorn).
|
||||||
|
3. Echte Lücke dabei entdeckt: die Beladung (Material-Ist/Soll je Position) fehlte in
|
||||||
|
der Akte komplett — nur über eine laufende Kontrolle sichtbar, nicht als reine
|
||||||
|
Verwaltungsübersicht.
|
||||||
|
|
||||||
|
## AKTE-001 — Beladung/Material in der Akte sichtbar
|
||||||
|
|
||||||
|
- **Ziel:** Aktuelle Soll-/Ist-Menge je Position ohne Kontrolle zu starten einsehbar.
|
||||||
|
- **Backend:** `AkteRead.beladung` (Liste: material_name, fach, sollmenge_effektiv,
|
||||||
|
istmenge, einheit) aus bereits vorhandenen Objektposition/Material-Daten.
|
||||||
|
- **Frontend:** neue Karte "Beladung" in der Akte.
|
||||||
|
- **DoD:** umgesetzt.
|
||||||
|
|
||||||
|
## AKTE-002 — Responsive Grid statt Karten-Stapel
|
||||||
|
|
||||||
|
- **Ziel:** Desktop nutzt die Breite (mehrspaltig), Mobile bleibt eine Spalte - ohne
|
||||||
|
zwei getrennte Komponenten/Datenladungen pflegen zu müssen.
|
||||||
|
- **Beschreibung:** Ein CSS-Grid mit `grid-template-areas`, Mobile-first (eine Spalte
|
||||||
|
in natürlicher DOM-Reihenfolge), ab ~900px zweispaltig. Kein JS-Breakpoint-Hook nötig.
|
||||||
|
- **DoD:** umgesetzt.
|
||||||
|
|
||||||
|
## AKTE-003 — Fahrzeug- vs. Geräte-Schwerpunkt
|
||||||
|
|
||||||
|
- **Ziel:** Fahrzeug-Objekte zeigen Wartung/Fahrzeugdaten prominent, Geräte/Rucksäcke
|
||||||
|
die Beladung.
|
||||||
|
- **Beschreibung:** Reihenfolge/Grid-Zuordnung hängt an `akte.fahrzeugdetails !== null`
|
||||||
|
(bereits vorhandene Unterscheidung, keine neue Datenmodellierung nötig).
|
||||||
|
- **DoD:** umgesetzt.
|
||||||
|
|
||||||
|
**Referenz:** baut auf Epic 20 (UI-Redesign, insb. UI-005) auf, ersetzt dessen
|
||||||
|
Akte-Layout nicht, sondern erweitert es strukturell.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""MAINT-001..006: Wartungsplaene, Intervalle, Wartungsauftraege.
|
||||||
|
|
||||||
|
Revision ID: 0022_wartung
|
||||||
|
Revises: 0021_ausgabe
|
||||||
|
Create Date: 2026-09-06
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0022_wartung"
|
||||||
|
down_revision: Union[str, None] = "0021_ausgabe"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE TYPE intervall_typ AS ENUM ('zeit', 'km', 'betriebsstunden')")
|
||||||
|
op.execute("CREATE TYPE wartungsauftrag_status AS ENUM ('offen', 'in_arbeit', 'erledigt')")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE wartungsplan (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
objekttyp_id INTEGER NOT NULL REFERENCES objekttyp(id),
|
||||||
|
name TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE wartungsplan_position (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
wartungsplan_id INTEGER NOT NULL REFERENCES wartungsplan(id),
|
||||||
|
wartungsart TEXT NOT NULL,
|
||||||
|
beschreibung TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE wartungsintervall (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
wartungsplan_position_id INTEGER NOT NULL REFERENCES wartungsplan_position(id),
|
||||||
|
typ intervall_typ NOT NULL,
|
||||||
|
wert INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE wartungsauftrag (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||||
|
geraet_instanz_id UUID REFERENCES geraet_instanz(id),
|
||||||
|
wartungsplan_position_id INTEGER REFERENCES wartungsplan_position(id),
|
||||||
|
wartungsart TEXT NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
status wartungsauftrag_status NOT NULL DEFAULT 'offen',
|
||||||
|
werkstatt_intern BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
werkstatt_name TEXT,
|
||||||
|
faelligkeit_am DATE,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL,
|
||||||
|
erledigt_am TIMESTAMPTZ,
|
||||||
|
durchgefuehrt_von INTEGER REFERENCES benutzer(id),
|
||||||
|
kosten NUMERIC,
|
||||||
|
kilometerstand_bei_erledigung INTEGER,
|
||||||
|
betriebsstunden_bei_erledigung NUMERIC
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX idx_wartungsauftrag_objekt ON wartungsauftrag (objekt_id)")
|
||||||
|
op.execute("CREATE INDEX idx_wartungsauftrag_status ON wartungsauftrag (status)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE wartungsauftrag")
|
||||||
|
op.execute("DROP TABLE wartungsintervall")
|
||||||
|
op.execute("DROP TABLE wartungsplan_position")
|
||||||
|
op.execute("DROP TABLE wartungsplan")
|
||||||
|
op.execute("DROP TYPE wartungsauftrag_status")
|
||||||
|
op.execute("DROP TYPE intervall_typ")
|
||||||
@@ -21,6 +21,7 @@ from app.api.v1.endpoints import (
|
|||||||
personal,
|
personal,
|
||||||
stammdaten,
|
stammdaten,
|
||||||
vorlagen,
|
vorlagen,
|
||||||
|
wartung,
|
||||||
zustaendigkeit,
|
zustaendigkeit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,3 +47,4 @@ api_router.include_router(permission.router, tags=["permission"])
|
|||||||
api_router.include_router(akte.router, tags=["akte"])
|
api_router.include_router(akte.router, tags=["akte"])
|
||||||
api_router.include_router(lager.router, tags=["lager"])
|
api_router.include_router(lager.router, tags=["lager"])
|
||||||
api_router.include_router(ausgabe.router, tags=["ausgabe"])
|
api_router.include_router(ausgabe.router, tags=["ausgabe"])
|
||||||
|
api_router.include_router(wartung.router, tags=["wartung"])
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
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 Benutzer, RolleTyp
|
||||||
|
from app.models.objekt import Objekt
|
||||||
|
from app.models.wartung import Wartungsauftrag, Wartungsplan, WartungsauftragStatus
|
||||||
|
from app.schemas.wartung import (
|
||||||
|
FaelligeWartungRead,
|
||||||
|
WartungsauftragCreate,
|
||||||
|
WartungsauftragErledigen,
|
||||||
|
WartungsauftragRead,
|
||||||
|
WartungsauftragStatusUpdate,
|
||||||
|
WartungsplanCreate,
|
||||||
|
WartungsplanPositionRead,
|
||||||
|
WartungsplanRead,
|
||||||
|
)
|
||||||
|
from app.services.wartung import (
|
||||||
|
aendere_status,
|
||||||
|
erledige_wartungsauftrag,
|
||||||
|
erstelle_wartungsauftrag,
|
||||||
|
erstelle_wartungsplan,
|
||||||
|
faellige_wartungen_fuer_objekt,
|
||||||
|
hole_intervalle,
|
||||||
|
hole_positionen_mit_intervallen,
|
||||||
|
liste_wartungsauftraege,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_verantwortliche = require_roles(
|
||||||
|
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wartungsplan_read(db: AsyncSession, plan: Wartungsplan) -> WartungsplanRead:
|
||||||
|
positionen = await hole_positionen_mit_intervallen(db, plan.id)
|
||||||
|
positionen_read = []
|
||||||
|
for position in positionen:
|
||||||
|
intervalle = await hole_intervalle(db, position.id)
|
||||||
|
positionen_read.append(
|
||||||
|
WartungsplanPositionRead(
|
||||||
|
id=position.id,
|
||||||
|
wartungsplan_id=position.wartungsplan_id,
|
||||||
|
wartungsart=position.wartungsart,
|
||||||
|
beschreibung=position.beschreibung,
|
||||||
|
intervalle=list(intervalle),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return WartungsplanRead(
|
||||||
|
id=plan.id, objekttyp_id=plan.objekttyp_id, name=plan.name, positionen=positionen_read
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wartungsplaene", response_model=list[WartungsplanRead])
|
||||||
|
async def liste_wartungsplaene(
|
||||||
|
objekttyp_id: int | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
) -> list[WartungsplanRead]:
|
||||||
|
stmt = select(Wartungsplan)
|
||||||
|
if objekttyp_id is not None:
|
||||||
|
stmt = stmt.where(Wartungsplan.objekttyp_id == objekttyp_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [await _wartungsplan_read(db, p) for p in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wartungsplaene", response_model=WartungsplanRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def erstelle_neuer_wartungsplan(
|
||||||
|
payload: WartungsplanCreate, db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
|
||||||
|
) -> WartungsplanRead:
|
||||||
|
plan = await erstelle_wartungsplan(
|
||||||
|
db, objekttyp_id=payload.objekttyp_id, name=payload.name, positionen=payload.positionen
|
||||||
|
)
|
||||||
|
return await _wartungsplan_read(db, plan)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/objekte/{objekt_id}/faellige-wartungen", response_model=list[FaelligeWartungRead])
|
||||||
|
async def hole_faellige_wartungen(
|
||||||
|
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||||
|
) -> list[dict]:
|
||||||
|
objekt = await db.get(Objekt, objekt_id)
|
||||||
|
if objekt is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
|
||||||
|
return await faellige_wartungen_fuer_objekt(db, objekt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wartungsauftraege", response_model=list[WartungsauftragRead])
|
||||||
|
async def liste_auftraege(
|
||||||
|
objekt_id: int | None = None,
|
||||||
|
status_filter: WartungsauftragStatus | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
) -> list[Wartungsauftrag]:
|
||||||
|
return await liste_wartungsauftraege(db, objekt_id=objekt_id, status=status_filter)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wartungsauftraege", response_model=WartungsauftragRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def erstelle_auftrag(
|
||||||
|
payload: WartungsauftragCreate, db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche)
|
||||||
|
) -> Wartungsauftrag:
|
||||||
|
objekt = await db.get(Objekt, payload.objekt_id)
|
||||||
|
if objekt is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
|
||||||
|
return await erstelle_wartungsauftrag(
|
||||||
|
db,
|
||||||
|
objekt_id=payload.objekt_id,
|
||||||
|
geraet_instanz_id=payload.geraet_instanz_id,
|
||||||
|
wartungsplan_position_id=payload.wartungsplan_position_id,
|
||||||
|
wartungsart=payload.wartungsart,
|
||||||
|
beschreibung=payload.beschreibung,
|
||||||
|
werkstatt_intern=payload.werkstatt_intern,
|
||||||
|
werkstatt_name=payload.werkstatt_name,
|
||||||
|
faelligkeit_am=payload.faelligkeit_am,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/wartungsauftraege/{auftrag_id}/status", response_model=WartungsauftragRead)
|
||||||
|
async def route_status_aendern(
|
||||||
|
auftrag_id: uuid.UUID,
|
||||||
|
payload: WartungsauftragStatusUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(_verantwortliche),
|
||||||
|
) -> Wartungsauftrag:
|
||||||
|
auftrag = await db.get(Wartungsauftrag, auftrag_id)
|
||||||
|
if auftrag is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wartungsauftrag nicht gefunden")
|
||||||
|
return await aendere_status(db, auftrag=auftrag, status=payload.status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wartungsauftraege/{auftrag_id}/erledigen", response_model=WartungsauftragRead)
|
||||||
|
async def route_erledigen(
|
||||||
|
auftrag_id: uuid.UUID,
|
||||||
|
payload: WartungsauftragErledigen,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Benutzer = Depends(_verantwortliche),
|
||||||
|
) -> Wartungsauftrag:
|
||||||
|
auftrag = await db.get(Wartungsauftrag, auftrag_id)
|
||||||
|
if auftrag is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wartungsauftrag nicht gefunden")
|
||||||
|
return await erledige_wartungsauftrag(
|
||||||
|
db,
|
||||||
|
auftrag=auftrag,
|
||||||
|
benutzer_id=current_user.id,
|
||||||
|
erledigt_am=payload.erledigt_am,
|
||||||
|
kosten=payload.kosten,
|
||||||
|
kilometerstand_bei_erledigung=payload.kilometerstand_bei_erledigung,
|
||||||
|
betriebsstunden_bei_erledigung=payload.betriebsstunden_bei_erledigung,
|
||||||
|
)
|
||||||
@@ -24,6 +24,14 @@ from app.models.personal import (
|
|||||||
)
|
)
|
||||||
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
|
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
|
||||||
from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
|
from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
|
||||||
|
from app.models.wartung import (
|
||||||
|
IntervallTyp,
|
||||||
|
Wartungsauftrag,
|
||||||
|
WartungsauftragStatus,
|
||||||
|
Wartungsintervall,
|
||||||
|
Wartungsplan,
|
||||||
|
WartungsplanPosition,
|
||||||
|
)
|
||||||
from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit
|
from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -81,4 +89,10 @@ __all__ = [
|
|||||||
"Vorlagenposition",
|
"Vorlagenposition",
|
||||||
"Kontrollverantwortung",
|
"Kontrollverantwortung",
|
||||||
"Zustaendigkeit",
|
"Zustaendigkeit",
|
||||||
|
"IntervallTyp",
|
||||||
|
"Wartungsauftrag",
|
||||||
|
"WartungsauftragStatus",
|
||||||
|
"Wartungsintervall",
|
||||||
|
"Wartungsplan",
|
||||||
|
"WartungsplanPosition",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class IntervallTyp(str, enum.Enum):
|
||||||
|
zeit = "zeit"
|
||||||
|
km = "km"
|
||||||
|
betriebsstunden = "betriebsstunden"
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsauftragStatus(str, enum.Enum):
|
||||||
|
offen = "offen"
|
||||||
|
in_arbeit = "in_arbeit"
|
||||||
|
erledigt = "erledigt"
|
||||||
|
|
||||||
|
|
||||||
|
intervall_typ_pg = PgEnum(IntervallTyp, name="intervall_typ", create_type=False)
|
||||||
|
wartungsauftrag_status_pg = PgEnum(WartungsauftragStatus, name="wartungsauftrag_status", create_type=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Wartungsplan(Base):
|
||||||
|
__tablename__ = "wartungsplan"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsplanPosition(Base):
|
||||||
|
__tablename__ = "wartungsplan_position"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
wartungsplan_id: Mapped[int] = mapped_column(ForeignKey("wartungsplan.id"), nullable=False)
|
||||||
|
wartungsart: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
beschreibung: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class Wartungsintervall(Base):
|
||||||
|
__tablename__ = "wartungsintervall"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
wartungsplan_position_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("wartungsplan_position.id"), nullable=False
|
||||||
|
)
|
||||||
|
typ: Mapped[IntervallTyp] = mapped_column(intervall_typ_pg, nullable=False)
|
||||||
|
# zeit: Monate. km/betriebsstunden: Einheit des jeweiligen Fahrzeugdetails-Feldes.
|
||||||
|
wert: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Wartungsauftrag(Base):
|
||||||
|
__tablename__ = "wartungsauftrag"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
|
||||||
|
geraet_instanz_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("geraet_instanz.id")
|
||||||
|
)
|
||||||
|
wartungsplan_position_id: Mapped[int | None] = mapped_column(ForeignKey("wartungsplan_position.id"))
|
||||||
|
wartungsart: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
beschreibung: Mapped[str | None] = mapped_column(Text)
|
||||||
|
status: Mapped[WartungsauftragStatus] = mapped_column(
|
||||||
|
wartungsauftrag_status_pg, nullable=False, default=WartungsauftragStatus.offen
|
||||||
|
)
|
||||||
|
werkstatt_intern: Mapped[bool] = mapped_column(nullable=False, default=True)
|
||||||
|
werkstatt_name: Mapped[str | None] = mapped_column(String)
|
||||||
|
faelligkeit_am: Mapped[date | None] = mapped_column()
|
||||||
|
erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
erledigt_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||||
|
durchgefuehrt_von: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id"))
|
||||||
|
kosten: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||||
|
kilometerstand_bei_erledigung: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
betriebsstunden_bei_erledigung: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
@@ -17,6 +19,16 @@ class ZustaendigerBenutzer(BaseModel):
|
|||||||
quelle: str # "standort" oder "objekt" (Vereinigungslogik Karte 04)
|
quelle: str # "standort" oder "objekt" (Vereinigungslogik Karte 04)
|
||||||
|
|
||||||
|
|
||||||
|
class BeladungsPosition(BaseModel):
|
||||||
|
objektposition_id: uuid.UUID
|
||||||
|
material_id: int
|
||||||
|
material_name: str
|
||||||
|
einheit: str
|
||||||
|
fach: str | None
|
||||||
|
sollmenge: Decimal
|
||||||
|
istmenge: Decimal
|
||||||
|
|
||||||
|
|
||||||
class AkteRead(BaseModel):
|
class AkteRead(BaseModel):
|
||||||
"""FILE-001..006: gebündelte Aggregations-Sicht über alle bereits existierenden
|
"""FILE-001..006: gebündelte Aggregations-Sicht über alle bereits existierenden
|
||||||
Teilbereiche einer Ressource (aktuell nur Objekttyp "objekt" - Prompt 24/Backlog-
|
Teilbereiche einer Ressource (aktuell nur Objekttyp "objekt" - Prompt 24/Backlog-
|
||||||
@@ -36,3 +48,4 @@ class AkteRead(BaseModel):
|
|||||||
dokumente: list[DokumentRead]
|
dokumente: list[DokumentRead]
|
||||||
geraete: list[GeraetInstanzRead]
|
geraete: list[GeraetInstanzRead]
|
||||||
maengel: list[MangelRead]
|
maengel: list[MangelRead]
|
||||||
|
beladung: list[BeladungsPosition]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
# Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen,
|
# Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen,
|
||||||
# muss hier explizit freigeschaltet werden.
|
# muss hier explizit freigeschaltet werden.
|
||||||
EntitaetTyp = Literal[
|
EntitaetTyp = Literal[
|
||||||
"objekt", "objektposition", "geraet_instanz", "mangel", "fahrzeugdetails", "benutzer"
|
"objekt", "objektposition", "geraet_instanz", "mangel", "fahrzeugdetails", "benutzer", "wartungsauftrag"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.wartung import IntervallTyp, WartungsauftragStatus
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsintervallCreate(BaseModel):
|
||||||
|
typ: IntervallTyp
|
||||||
|
wert: int
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsintervallRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
wartungsplan_position_id: int
|
||||||
|
typ: IntervallTyp
|
||||||
|
wert: int
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsplanPositionCreate(BaseModel):
|
||||||
|
wartungsart: str
|
||||||
|
beschreibung: str | None = None
|
||||||
|
intervalle: list[WartungsintervallCreate] = []
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsplanPositionRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
wartungsplan_id: int
|
||||||
|
wartungsart: str
|
||||||
|
beschreibung: str | None
|
||||||
|
intervalle: list[WartungsintervallRead] = []
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsplanCreate(BaseModel):
|
||||||
|
objekttyp_id: int
|
||||||
|
name: str
|
||||||
|
positionen: list[WartungsplanPositionCreate] = []
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsplanRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
objekttyp_id: int
|
||||||
|
name: str
|
||||||
|
positionen: list[WartungsplanPositionRead] = []
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsauftragCreate(BaseModel):
|
||||||
|
objekt_id: int
|
||||||
|
geraet_instanz_id: uuid.UUID | None = None
|
||||||
|
wartungsplan_position_id: int | None = None
|
||||||
|
wartungsart: str
|
||||||
|
beschreibung: str | None = None
|
||||||
|
werkstatt_intern: bool = True
|
||||||
|
werkstatt_name: str | None = None
|
||||||
|
faelligkeit_am: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsauftragErledigen(BaseModel):
|
||||||
|
erledigt_am: date | None = None
|
||||||
|
kosten: Decimal | None = None
|
||||||
|
kilometerstand_bei_erledigung: int | None = None
|
||||||
|
betriebsstunden_bei_erledigung: Decimal | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsauftragStatusUpdate(BaseModel):
|
||||||
|
status: WartungsauftragStatus
|
||||||
|
|
||||||
|
|
||||||
|
class WartungsauftragRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: uuid.UUID
|
||||||
|
objekt_id: int
|
||||||
|
geraet_instanz_id: uuid.UUID | None
|
||||||
|
wartungsplan_position_id: int | None
|
||||||
|
wartungsart: str
|
||||||
|
beschreibung: str | None
|
||||||
|
status: WartungsauftragStatus
|
||||||
|
werkstatt_intern: bool
|
||||||
|
werkstatt_name: str | None
|
||||||
|
faelligkeit_am: date | None
|
||||||
|
erstellt_am: datetime
|
||||||
|
erledigt_am: datetime | None
|
||||||
|
durchgefuehrt_von: int | None
|
||||||
|
kosten: Decimal | None
|
||||||
|
kilometerstand_bei_erledigung: int | None
|
||||||
|
betriebsstunden_bei_erledigung: Decimal | None
|
||||||
|
|
||||||
|
|
||||||
|
class FaelligeWartungRead(BaseModel):
|
||||||
|
wartungsplan_position_id: int
|
||||||
|
wartungsart: str
|
||||||
|
objekt_id: int
|
||||||
|
faellig: bool
|
||||||
|
faellig_am: date | None
|
||||||
|
faellig_bei_km: int | None
|
||||||
|
faellig_bei_betriebsstunden: Decimal | None
|
||||||
@@ -9,9 +9,10 @@ from app.models.historie import Historie
|
|||||||
from app.models.mangel import Mangel
|
from app.models.mangel import Mangel
|
||||||
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 Objekttyp, Standort
|
from app.models.stammdaten import Material, Objekttyp, Standort
|
||||||
|
from app.models.vorlage import Vorlagenposition
|
||||||
from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit
|
from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit
|
||||||
from app.schemas.akte import AkteRead, ZustaendigerBenutzer
|
from app.schemas.akte import AkteRead, BeladungsPosition, ZustaendigerBenutzer
|
||||||
from app.schemas.dokument import DokumentRead
|
from app.schemas.dokument import DokumentRead
|
||||||
from app.schemas.geraet_instanz import GeraetInstanzRead
|
from app.schemas.geraet_instanz import GeraetInstanzRead
|
||||||
from app.schemas.historie import HistorieRead
|
from app.schemas.historie import HistorieRead
|
||||||
@@ -19,7 +20,7 @@ from app.schemas.mangel import MangelRead
|
|||||||
from app.schemas.objekt import FahrzeugdetailsRead, ObjektRead
|
from app.schemas.objekt import FahrzeugdetailsRead, ObjektRead
|
||||||
from app.schemas.zustaendigkeit import KontrollverantwortungRead
|
from app.schemas.zustaendigkeit import KontrollverantwortungRead
|
||||||
from app.services.dashboard import objekt_readiness
|
from app.services.dashboard import objekt_readiness
|
||||||
from app.services.objekte import letzte_kontrollen_map
|
from app.services.objekte import letzte_kontrollen_map, sollmenge_effektiv
|
||||||
|
|
||||||
|
|
||||||
async def _zustaendige_benutzer(db: AsyncSession, objekt: Objekt) -> list[ZustaendigerBenutzer]:
|
async def _zustaendige_benutzer(db: AsyncSession, objekt: Objekt) -> list[ZustaendigerBenutzer]:
|
||||||
@@ -90,6 +91,31 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None:
|
|||||||
|
|
||||||
maengel_result = await db.execute(select(Mangel).where(Mangel.objekt_id == objekt_id))
|
maengel_result = await db.execute(select(Mangel).where(Mangel.objekt_id == objekt_id))
|
||||||
|
|
||||||
|
fach_by_material_id: dict[int, str | None] = {}
|
||||||
|
if objekt.vorlage_id is not None:
|
||||||
|
vorlagenpositionen_result = await db.execute(
|
||||||
|
select(Vorlagenposition).where(Vorlagenposition.vorlage_id == objekt.vorlage_id)
|
||||||
|
)
|
||||||
|
fach_by_material_id = {vp.material_id: vp.fach for vp in vorlagenpositionen_result.scalars().all()}
|
||||||
|
|
||||||
|
beladung_result = await db.execute(
|
||||||
|
select(Objektposition, Material)
|
||||||
|
.join(Material, Objektposition.material_id == Material.id)
|
||||||
|
.where(Objektposition.objekt_id == objekt_id)
|
||||||
|
)
|
||||||
|
beladung = [
|
||||||
|
BeladungsPosition(
|
||||||
|
objektposition_id=position.id,
|
||||||
|
material_id=material.id,
|
||||||
|
material_name=material.name,
|
||||||
|
einheit=material.einheit,
|
||||||
|
fach=fach_by_material_id.get(material.id),
|
||||||
|
sollmenge=sollmenge_effektiv(position),
|
||||||
|
istmenge=position.istmenge,
|
||||||
|
)
|
||||||
|
for position, material in beladung_result.all()
|
||||||
|
]
|
||||||
|
|
||||||
readiness = await objekt_readiness(db, objekt_id)
|
readiness = await objekt_readiness(db, objekt_id)
|
||||||
|
|
||||||
objekt_read = ObjektRead.model_validate(objekt).model_copy(
|
objekt_read = ObjektRead.model_validate(objekt).model_copy(
|
||||||
@@ -115,4 +141,5 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None:
|
|||||||
dokumente=[DokumentRead.model_validate(d) for d in dokumente_result.scalars().all()],
|
dokumente=[DokumentRead.model_validate(d) for d in dokumente_result.scalars().all()],
|
||||||
geraete=[GeraetInstanzRead.model_validate(g) for g in geraete],
|
geraete=[GeraetInstanzRead.model_validate(g) for g in geraete],
|
||||||
maengel=[MangelRead.model_validate(m) for m in maengel_result.scalars().all()],
|
maengel=[MangelRead.model_validate(m) for m in maengel_result.scalars().all()],
|
||||||
|
beladung=beladung,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def plus_monate(stichtag: date, monate: int) -> date:
|
||||||
|
monats_index = stichtag.month - 1 + monate
|
||||||
|
jahr = stichtag.year + monats_index // 12
|
||||||
|
monat = monats_index % 12 + 1
|
||||||
|
letzter_tag = calendar.monthrange(jahr, monat)[1]
|
||||||
|
return date(jahr, monat, min(stichtag.day, letzter_tag))
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import calendar
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -12,17 +11,7 @@ from app.models.objekt import Objekt
|
|||||||
from app.models.objektposition import Objektposition
|
from app.models.objektposition import Objektposition
|
||||||
from app.services import benachrichtigung as benachrichtigung_service
|
from app.services import benachrichtigung as benachrichtigung_service
|
||||||
from app.services import historie as historie_service
|
from app.services import historie as historie_service
|
||||||
|
from app.services.datumshilfen import plus_monate as _plus_monate
|
||||||
|
|
||||||
def _plus_monate(stichtag: date, monate: int) -> date:
|
|
||||||
"""Kein python-dateutil als Dependency für eine einzelne Monatsaddition -
|
|
||||||
Tag wird auf das Monatsende gekappt, falls der Zielmonat kürzer ist
|
|
||||||
(z.B. 31. Januar + 1 Monat -> 28./29. Februar)."""
|
|
||||||
monats_index = stichtag.month - 1 + monate
|
|
||||||
jahr = stichtag.year + monats_index // 12
|
|
||||||
monat = monats_index % 12 + 1
|
|
||||||
letzter_tag = calendar.monthrange(jahr, monat)[1]
|
|
||||||
return date(jahr, monat, min(stichtag.day, letzter_tag))
|
|
||||||
|
|
||||||
|
|
||||||
async def liste_fuer_position(db: AsyncSession, objektposition_id) -> list[GeraetInstanz]:
|
async def liste_fuer_position(db: AsyncSession, objektposition_id) -> list[GeraetInstanz]:
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.fahrzeugdetails import Fahrzeugdetails
|
||||||
|
from app.models.objekt import Objekt
|
||||||
|
from app.models.wartung import (
|
||||||
|
IntervallTyp,
|
||||||
|
Wartungsauftrag,
|
||||||
|
WartungsauftragStatus,
|
||||||
|
Wartungsintervall,
|
||||||
|
Wartungsplan,
|
||||||
|
WartungsplanPosition,
|
||||||
|
)
|
||||||
|
from app.schemas.wartung import WartungsplanPositionCreate
|
||||||
|
from app.services.datumshilfen import plus_monate
|
||||||
|
|
||||||
|
|
||||||
|
async def erstelle_wartungsplan(
|
||||||
|
db: AsyncSession, *, objekttyp_id: int, name: str, positionen: list[WartungsplanPositionCreate]
|
||||||
|
) -> Wartungsplan:
|
||||||
|
plan = Wartungsplan(objekttyp_id=objekttyp_id, name=name)
|
||||||
|
db.add(plan)
|
||||||
|
await db.flush()
|
||||||
|
for pos in positionen:
|
||||||
|
position = WartungsplanPosition(
|
||||||
|
wartungsplan_id=plan.id, wartungsart=pos.wartungsart, beschreibung=pos.beschreibung
|
||||||
|
)
|
||||||
|
db.add(position)
|
||||||
|
await db.flush()
|
||||||
|
for intervall in pos.intervalle:
|
||||||
|
db.add(
|
||||||
|
Wartungsintervall(
|
||||||
|
wartungsplan_position_id=position.id, typ=intervall.typ, wert=intervall.wert
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
async def hole_positionen_mit_intervallen(db: AsyncSession, wartungsplan_id: int) -> list[WartungsplanPosition]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WartungsplanPosition).where(WartungsplanPosition.wartungsplan_id == wartungsplan_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def hole_intervalle(db: AsyncSession, wartungsplan_position_id: int) -> list[Wartungsintervall]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Wartungsintervall).where(Wartungsintervall.wartungsplan_position_id == wartungsplan_position_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def erstelle_wartungsauftrag(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
objekt_id: int,
|
||||||
|
geraet_instanz_id,
|
||||||
|
wartungsplan_position_id: int | None,
|
||||||
|
wartungsart: str,
|
||||||
|
beschreibung: str | None,
|
||||||
|
werkstatt_intern: bool,
|
||||||
|
werkstatt_name: str | None,
|
||||||
|
faelligkeit_am: date | None,
|
||||||
|
) -> Wartungsauftrag:
|
||||||
|
auftrag = Wartungsauftrag(
|
||||||
|
objekt_id=objekt_id,
|
||||||
|
geraet_instanz_id=geraet_instanz_id,
|
||||||
|
wartungsplan_position_id=wartungsplan_position_id,
|
||||||
|
wartungsart=wartungsart,
|
||||||
|
beschreibung=beschreibung,
|
||||||
|
status=WartungsauftragStatus.offen,
|
||||||
|
werkstatt_intern=werkstatt_intern,
|
||||||
|
werkstatt_name=werkstatt_name,
|
||||||
|
faelligkeit_am=faelligkeit_am,
|
||||||
|
erstellt_am=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(auftrag)
|
||||||
|
await db.flush()
|
||||||
|
return auftrag
|
||||||
|
|
||||||
|
|
||||||
|
async def aendere_status(db: AsyncSession, *, auftrag: Wartungsauftrag, status: WartungsauftragStatus) -> Wartungsauftrag:
|
||||||
|
auftrag.status = status
|
||||||
|
await db.flush()
|
||||||
|
return auftrag
|
||||||
|
|
||||||
|
|
||||||
|
async def erledige_wartungsauftrag(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
auftrag: Wartungsauftrag,
|
||||||
|
benutzer_id: int,
|
||||||
|
erledigt_am: date | None,
|
||||||
|
kosten: Decimal | None,
|
||||||
|
kilometerstand_bei_erledigung: int | None,
|
||||||
|
betriebsstunden_bei_erledigung: Decimal | None,
|
||||||
|
) -> Wartungsauftrag:
|
||||||
|
auftrag.status = WartungsauftragStatus.erledigt
|
||||||
|
auftrag.erledigt_am = (
|
||||||
|
datetime.combine(erledigt_am, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
if erledigt_am
|
||||||
|
else datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
auftrag.durchgefuehrt_von = benutzer_id
|
||||||
|
auftrag.kosten = kosten
|
||||||
|
|
||||||
|
if kilometerstand_bei_erledigung is None or betriebsstunden_bei_erledigung is None:
|
||||||
|
fahrzeugdetails = await db.get(Fahrzeugdetails, auftrag.objekt_id)
|
||||||
|
if fahrzeugdetails is not None:
|
||||||
|
if kilometerstand_bei_erledigung is None:
|
||||||
|
kilometerstand_bei_erledigung = fahrzeugdetails.kilometerstand
|
||||||
|
if betriebsstunden_bei_erledigung is None:
|
||||||
|
betriebsstunden_bei_erledigung = fahrzeugdetails.betriebsstunden
|
||||||
|
auftrag.kilometerstand_bei_erledigung = kilometerstand_bei_erledigung
|
||||||
|
auftrag.betriebsstunden_bei_erledigung = betriebsstunden_bei_erledigung
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return auftrag
|
||||||
|
|
||||||
|
|
||||||
|
async def liste_wartungsauftraege(
|
||||||
|
db: AsyncSession, *, objekt_id: int | None = None, status: WartungsauftragStatus | None = None
|
||||||
|
) -> list[Wartungsauftrag]:
|
||||||
|
stmt = select(Wartungsauftrag)
|
||||||
|
if objekt_id is not None:
|
||||||
|
stmt = stmt.where(Wartungsauftrag.objekt_id == objekt_id)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Wartungsauftrag.status == status)
|
||||||
|
stmt = stmt.order_by(Wartungsauftrag.erstellt_am.desc())
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def faellige_wartungen_fuer_objekt(db: AsyncSession, objekt: Objekt) -> list[dict]:
|
||||||
|
"""MAINT-002: kombiniert Zeit-/km-/Betriebsstunden-Intervalle je Position -
|
||||||
|
fällig, sobald das erste Kriterium erreicht ist. Nie durchgeführt gilt
|
||||||
|
immer als fällig (wie "nie kontrolliert" bei der Einsatzbereitschaft)."""
|
||||||
|
plaene = (
|
||||||
|
await db.execute(select(Wartungsplan).where(Wartungsplan.objekttyp_id == objekt.objekttyp_id))
|
||||||
|
).scalars().all()
|
||||||
|
if not plaene:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fahrzeugdetails = await db.get(Fahrzeugdetails, objekt.id)
|
||||||
|
heute = datetime.now(timezone.utc).date()
|
||||||
|
ergebnisse: list[dict] = []
|
||||||
|
|
||||||
|
for plan in plaene:
|
||||||
|
positionen = await hole_positionen_mit_intervallen(db, plan.id)
|
||||||
|
for position in positionen:
|
||||||
|
intervalle = await hole_intervalle(db, position.id)
|
||||||
|
letzter = (
|
||||||
|
await db.execute(
|
||||||
|
select(Wartungsauftrag)
|
||||||
|
.where(
|
||||||
|
Wartungsauftrag.objekt_id == objekt.id,
|
||||||
|
Wartungsauftrag.wartungsplan_position_id == position.id,
|
||||||
|
Wartungsauftrag.status == WartungsauftragStatus.erledigt,
|
||||||
|
)
|
||||||
|
.order_by(Wartungsauftrag.erledigt_am.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if letzter is None:
|
||||||
|
ergebnisse.append(
|
||||||
|
{
|
||||||
|
"wartungsplan_position_id": position.id,
|
||||||
|
"wartungsart": position.wartungsart,
|
||||||
|
"objekt_id": objekt.id,
|
||||||
|
"faellig": True,
|
||||||
|
"faellig_am": None,
|
||||||
|
"faellig_bei_km": None,
|
||||||
|
"faellig_bei_betriebsstunden": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
faellig = False
|
||||||
|
faellig_am = None
|
||||||
|
faellig_bei_km = None
|
||||||
|
faellig_bei_bs = None
|
||||||
|
|
||||||
|
for intervall in intervalle:
|
||||||
|
if intervall.typ == IntervallTyp.zeit:
|
||||||
|
faellig_am = plus_monate(letzter.erledigt_am.date(), intervall.wert)
|
||||||
|
if heute >= faellig_am:
|
||||||
|
faellig = True
|
||||||
|
elif intervall.typ == IntervallTyp.km and fahrzeugdetails is not None:
|
||||||
|
basis_km = letzter.kilometerstand_bei_erledigung or 0
|
||||||
|
faellig_bei_km = basis_km + intervall.wert
|
||||||
|
if (fahrzeugdetails.kilometerstand or 0) >= faellig_bei_km:
|
||||||
|
faellig = True
|
||||||
|
elif intervall.typ == IntervallTyp.betriebsstunden and fahrzeugdetails is not None:
|
||||||
|
basis_bs = letzter.betriebsstunden_bei_erledigung or Decimal(0)
|
||||||
|
faellig_bei_bs = basis_bs + intervall.wert
|
||||||
|
if (fahrzeugdetails.betriebsstunden or Decimal(0)) >= faellig_bei_bs:
|
||||||
|
faellig = True
|
||||||
|
|
||||||
|
ergebnisse.append(
|
||||||
|
{
|
||||||
|
"wartungsplan_position_id": position.id,
|
||||||
|
"wartungsart": position.wartungsart,
|
||||||
|
"objekt_id": objekt.id,
|
||||||
|
"faellig": faellig,
|
||||||
|
"faellig_am": faellig_am,
|
||||||
|
"faellig_bei_km": faellig_bei_km,
|
||||||
|
"faellig_bei_betriebsstunden": faellig_bei_bs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ergebnisse
|
||||||
@@ -50,6 +50,10 @@ async def test_akte_buendelt_teilbereiche(client, db_session, objekt_mit_positio
|
|||||||
assert body["historie"][0]["entitaet_typ"] == "objekt"
|
assert body["historie"][0]["entitaet_typ"] == "objekt"
|
||||||
assert body["einsatzbereitschaft_status"] == "not-ready"
|
assert body["einsatzbereitschaft_status"] == "not-ready"
|
||||||
assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"]
|
assert "mangel_kritisch_offen" in body["einsatzbereitschaft_gruende"]
|
||||||
|
assert len(body["beladung"]) == 1
|
||||||
|
assert body["beladung"][0]["material_id"] == _material.id
|
||||||
|
assert body["beladung"][0]["sollmenge"] == "10"
|
||||||
|
assert body["beladung"][0]["istmenge"] == "0"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.objekt import Objekt
|
||||||
|
from app.services.datumshilfen import plus_monate
|
||||||
|
from tests.conftest import auth_header, login
|
||||||
|
|
||||||
|
|
||||||
|
async def _objekt_mit_typ(db_session, hauptserver_id, *, code, name, objekttyp, standort):
|
||||||
|
objekt = Objekt(
|
||||||
|
code=code,
|
||||||
|
name=name,
|
||||||
|
objekttyp_id=objekttyp.id,
|
||||||
|
standort_id=standort.id,
|
||||||
|
zustaendiger_server_id=hauptserver_id,
|
||||||
|
)
|
||||||
|
db_session.add(objekt)
|
||||||
|
await db_session.flush()
|
||||||
|
return objekt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wartungsplan_mit_intervall_anlegen(client, admin_user, objekttyp_factory):
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
objekttyp = await objekttyp_factory("RTW-Test")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/wartungsplaene",
|
||||||
|
json={
|
||||||
|
"objekttyp_id": objekttyp.id,
|
||||||
|
"name": "RTW Standard",
|
||||||
|
"positionen": [
|
||||||
|
{
|
||||||
|
"wartungsart": "Ölwechsel",
|
||||||
|
"intervalle": [{"typ": "zeit", "wert": 12}, {"typ": "km", "wert": 15000}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["positionen"]) == 1
|
||||||
|
assert len(body["positionen"][0]["intervalle"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nie_durchgefuehrte_wartung_ist_faellig(
|
||||||
|
client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory
|
||||||
|
):
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
objekttyp = await objekttyp_factory("RTW-Test2")
|
||||||
|
standort = await standort_factory("Standort-W2")
|
||||||
|
objekt = await _objekt_mit_typ(
|
||||||
|
db_session, hauptserver_id, code="RTW-W2", name="RTW W2", objekttyp=objekttyp, standort=standort
|
||||||
|
)
|
||||||
|
objekt_id = objekt.id
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/wartungsplaene",
|
||||||
|
json={"objekttyp_id": objekttyp.id, "name": "Plan", "positionen": [{"wartungsart": "Ölwechsel"}]},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get(f"/api/v1/objekte/{objekt_id}/faellige-wartungen", headers=auth_header(token))
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["faellig"] is True
|
||||||
|
assert body[0]["wartungsart"] == "Ölwechsel"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wartungsauftrag_erledigen_macht_zeit_intervall_nicht_faellig(
|
||||||
|
client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory
|
||||||
|
):
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
objekttyp = await objekttyp_factory("RTW-Test3")
|
||||||
|
standort = await standort_factory("Standort-W3")
|
||||||
|
objekt = await _objekt_mit_typ(
|
||||||
|
db_session, hauptserver_id, code="RTW-W3", name="RTW W3", objekttyp=objekttyp, standort=standort
|
||||||
|
)
|
||||||
|
objekt_id = objekt.id
|
||||||
|
|
||||||
|
plan = await client.post(
|
||||||
|
"/api/v1/wartungsplaene",
|
||||||
|
json={
|
||||||
|
"objekttyp_id": objekttyp.id,
|
||||||
|
"name": "Plan",
|
||||||
|
"positionen": [{"wartungsart": "Ölwechsel", "intervalle": [{"typ": "zeit", "wert": 12}]}],
|
||||||
|
},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
position_id = plan.json()["positionen"][0]["id"]
|
||||||
|
|
||||||
|
auftrag = await client.post(
|
||||||
|
"/api/v1/wartungsauftraege",
|
||||||
|
json={"objekt_id": objekt_id, "wartungsplan_position_id": position_id, "wartungsart": "Ölwechsel"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert auftrag.status_code == 201
|
||||||
|
auftrag_id = auftrag.json()["id"]
|
||||||
|
|
||||||
|
erledigt = await client.post(
|
||||||
|
f"/api/v1/wartungsauftraege/{auftrag_id}/erledigen",
|
||||||
|
json={"erledigt_am": date.today().isoformat()},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert erledigt.status_code == 200
|
||||||
|
assert erledigt.json()["status"] == "erledigt"
|
||||||
|
|
||||||
|
faellig = await client.get(f"/api/v1/objekte/{objekt_id}/faellige-wartungen", headers=auth_header(token))
|
||||||
|
assert faellig.json()[0]["faellig"] is False
|
||||||
|
assert faellig.json()[0]["faellig_am"] == plus_monate(date.today(), 12).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_km_intervall_wird_zuerst_faellig_trotz_gueltigem_zeit_intervall(
|
||||||
|
client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory
|
||||||
|
):
|
||||||
|
""""je nachdem was zuerst eintritt" - km-Schwelle erreicht, obwohl das
|
||||||
|
Zeit-Intervall noch nicht abgelaufen ist."""
|
||||||
|
from app.models.fahrzeugdetails import Fahrzeugdetails
|
||||||
|
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
objekttyp = await objekttyp_factory("RTW-Test4")
|
||||||
|
standort = await standort_factory("Standort-W4")
|
||||||
|
objekt = await _objekt_mit_typ(
|
||||||
|
db_session, hauptserver_id, code="RTW-W4", name="RTW W4", objekttyp=objekttyp, standort=standort
|
||||||
|
)
|
||||||
|
objekt_id = objekt.id
|
||||||
|
|
||||||
|
db_session.add(Fahrzeugdetails(objekt_id=objekt_id, kilometerstand=20000))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
plan = await client.post(
|
||||||
|
"/api/v1/wartungsplaene",
|
||||||
|
json={
|
||||||
|
"objekttyp_id": objekttyp.id,
|
||||||
|
"name": "Plan",
|
||||||
|
"positionen": [
|
||||||
|
{
|
||||||
|
"wartungsart": "Ölwechsel",
|
||||||
|
"intervalle": [{"typ": "zeit", "wert": 12}, {"typ": "km", "wert": 15000}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
position_id = plan.json()["positionen"][0]["id"]
|
||||||
|
|
||||||
|
auftrag = await client.post(
|
||||||
|
"/api/v1/wartungsauftraege",
|
||||||
|
json={"objekt_id": objekt_id, "wartungsplan_position_id": position_id, "wartungsart": "Ölwechsel"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
auftrag_id = auftrag.json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/wartungsauftraege/{auftrag_id}/erledigen",
|
||||||
|
json={"erledigt_am": date.today().isoformat(), "kilometerstand_bei_erledigung": 5000},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
faellig = await client.get(f"/api/v1/objekte/{objekt_id}/faellige-wartungen", headers=auth_header(token))
|
||||||
|
body = faellig.json()[0]
|
||||||
|
assert body["faellig"] is True
|
||||||
|
assert body["faellig_bei_km"] == 20000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ad_hoc_reparatur_ohne_wartungsplan_mit_externer_werkstatt(
|
||||||
|
client, admin_user, standort_factory, objekt_factory
|
||||||
|
):
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
standort = await standort_factory("Standort-W5")
|
||||||
|
await objekt_factory(name="RTW W5", code="RTW-W5", standort=standort)
|
||||||
|
objekte = await client.get("/api/v1/objekte", headers=auth_header(token))
|
||||||
|
objekt_id = [o for o in objekte.json() if o["code"] == "RTW-W5"][0]["id"]
|
||||||
|
|
||||||
|
auftrag = await client.post(
|
||||||
|
"/api/v1/wartungsauftraege",
|
||||||
|
json={
|
||||||
|
"objekt_id": objekt_id,
|
||||||
|
"wartungsart": "Bremsleuchte defekt",
|
||||||
|
"werkstatt_intern": False,
|
||||||
|
"werkstatt_name": "KFZ Meyer GmbH",
|
||||||
|
},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert auftrag.status_code == 201
|
||||||
|
body = auftrag.json()
|
||||||
|
assert body["wartungsplan_position_id"] is None
|
||||||
|
assert body["werkstatt_intern"] is False
|
||||||
|
assert body["werkstatt_name"] == "KFZ Meyer GmbH"
|
||||||
|
assert body["status"] == "offen"
|
||||||
|
|
||||||
|
in_arbeit = await client.patch(
|
||||||
|
f"/api/v1/wartungsauftraege/{body['id']}/status",
|
||||||
|
json={"status": "in_arbeit"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert in_arbeit.json()["status"] == "in_arbeit"
|
||||||
|
|
||||||
|
erledigt = await client.post(
|
||||||
|
f"/api/v1/wartungsauftraege/{body['id']}/erledigen",
|
||||||
|
json={"kosten": "89.90"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert erledigt.json()["status"] == "erledigt"
|
||||||
|
assert erledigt.json()["kosten"] == "89.90"
|
||||||
|
|
||||||
|
historie = await client.get(f"/api/v1/wartungsauftraege?objekt_id={objekt_id}", headers=auth_header(token))
|
||||||
|
assert len(historie.json()) == 1
|
||||||
@@ -47,6 +47,57 @@ export interface GeraetInstanz {
|
|||||||
bemerkung: string | null;
|
bemerkung: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Wartungsintervall {
|
||||||
|
id: number;
|
||||||
|
wartungsplan_position_id: number;
|
||||||
|
typ: "zeit" | "km" | "betriebsstunden";
|
||||||
|
wert: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WartungsplanPosition {
|
||||||
|
id: number;
|
||||||
|
wartungsplan_id: number;
|
||||||
|
wartungsart: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
intervalle: Wartungsintervall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wartungsplan {
|
||||||
|
id: number;
|
||||||
|
objekttyp_id: number;
|
||||||
|
name: string;
|
||||||
|
positionen: WartungsplanPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaelligeWartung {
|
||||||
|
wartungsplan_position_id: number;
|
||||||
|
wartungsart: string;
|
||||||
|
objekt_id: number;
|
||||||
|
faellig: boolean;
|
||||||
|
faellig_am: string | null;
|
||||||
|
faellig_bei_km: number | null;
|
||||||
|
faellig_bei_betriebsstunden: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wartungsauftrag {
|
||||||
|
id: string;
|
||||||
|
objekt_id: number;
|
||||||
|
geraet_instanz_id: string | null;
|
||||||
|
wartungsplan_position_id: number | null;
|
||||||
|
wartungsart: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
status: "offen" | "in_arbeit" | "erledigt";
|
||||||
|
werkstatt_intern: boolean;
|
||||||
|
werkstatt_name: string | null;
|
||||||
|
faelligkeit_am: string | null;
|
||||||
|
erstellt_am: string;
|
||||||
|
erledigt_am: string | null;
|
||||||
|
durchgefuehrt_von: number | null;
|
||||||
|
kosten: string | null;
|
||||||
|
kilometerstand_bei_erledigung: number | null;
|
||||||
|
betriebsstunden_bei_erledigung: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Kontrolle {
|
export interface Kontrolle {
|
||||||
id: string;
|
id: string;
|
||||||
objekt_id: number;
|
objekt_id: number;
|
||||||
@@ -260,6 +311,16 @@ export interface ZustaendigerBenutzer {
|
|||||||
quelle: "standort" | "objekt";
|
quelle: "standort" | "objekt";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BeladungsPosition {
|
||||||
|
objektposition_id: string;
|
||||||
|
material_id: number;
|
||||||
|
material_name: string;
|
||||||
|
einheit: string;
|
||||||
|
fach: string | null;
|
||||||
|
sollmenge: string;
|
||||||
|
istmenge: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Akte {
|
export interface Akte {
|
||||||
objekt: Objekt;
|
objekt: Objekt;
|
||||||
einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown";
|
einsatzbereitschaft_status: "ready" | "limited" | "not-ready" | "unknown";
|
||||||
@@ -274,6 +335,7 @@ export interface Akte {
|
|||||||
dokumente: Dokument[];
|
dokumente: Dokument[];
|
||||||
geraete: GeraetInstanz[];
|
geraete: GeraetInstanz[];
|
||||||
maengel: Mangel[];
|
maengel: Mangel[];
|
||||||
|
beladung: BeladungsPosition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistorieEintrag {
|
export interface HistorieEintrag {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function CommandPalette() {
|
|||||||
},
|
},
|
||||||
{ id: "nav-lager", label: "Lager", gehe: () => navigate("/admin?tab=lager") },
|
{ id: "nav-lager", label: "Lager", gehe: () => navigate("/admin?tab=lager") },
|
||||||
{ id: "nav-ausgabe", label: "Ausgabe/Rückgabe", gehe: () => navigate("/admin?tab=ausgabe") },
|
{ id: "nav-ausgabe", label: "Ausgabe/Rückgabe", gehe: () => navigate("/admin?tab=ausgabe") },
|
||||||
|
{ id: "nav-wartungsplaene", label: "Wartungspläne", gehe: () => navigate("/admin?tab=wartungsplaene") },
|
||||||
{ id: "nav-personal", label: "Personal", gehe: () => navigate("/admin?tab=personal") },
|
{ id: "nav-personal", label: "Personal", gehe: () => navigate("/admin?tab=personal") },
|
||||||
{
|
{
|
||||||
id: "nav-material",
|
id: "nav-material",
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { apiRequest } from "../api/client";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import type { FaelligeWartung, Wartungsauftrag } from "../api/types";
|
||||||
|
import { DokumentePanel } from "./DokumentePanel";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objektId: number;
|
||||||
|
onFehler: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<Wartungsauftrag["status"], string> = {
|
||||||
|
offen: "Offen",
|
||||||
|
in_arbeit: "In Arbeit",
|
||||||
|
erledigt: "Erledigt",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<Wartungsauftrag["status"], string> = {
|
||||||
|
offen: "badge-warning",
|
||||||
|
in_arbeit: "badge-warning",
|
||||||
|
erledigt: "badge-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WartungSection({ objektId, onFehler }: Props) {
|
||||||
|
const { istVerantwortlich } = useAuth();
|
||||||
|
const [faellig, setFaellig] = useState<FaelligeWartung[]>([]);
|
||||||
|
const [auftraege, setAuftraege] = useState<Wartungsauftrag[]>([]);
|
||||||
|
const [dokumenteOffenId, setDokumenteOffenId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [neuArt, setNeuArt] = useState("");
|
||||||
|
const [neuExtern, setNeuExtern] = useState(false);
|
||||||
|
const [neuWerkstattName, setNeuWerkstattName] = useState("");
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
const [f, a] = await Promise.all([
|
||||||
|
apiRequest<FaelligeWartung[]>(`/objekte/${objektId}/faellige-wartungen`),
|
||||||
|
apiRequest<Wartungsauftrag[]>(`/wartungsauftraege?objekt_id=${objektId}`),
|
||||||
|
]);
|
||||||
|
setFaellig(f);
|
||||||
|
setAuftraege(a);
|
||||||
|
} catch {
|
||||||
|
onFehler("Wartungsdaten konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
laden();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [objektId]);
|
||||||
|
|
||||||
|
async function auftragAnlegen() {
|
||||||
|
if (!neuArt) {
|
||||||
|
onFehler("Wartungsart angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiRequest("/wartungsauftraege", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
objekt_id: objektId,
|
||||||
|
wartungsart: neuArt,
|
||||||
|
werkstatt_intern: !neuExtern,
|
||||||
|
werkstatt_name: neuExtern ? neuWerkstattName || null : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setNeuArt("");
|
||||||
|
setNeuExtern(false);
|
||||||
|
setNeuWerkstattName("");
|
||||||
|
await laden();
|
||||||
|
} catch {
|
||||||
|
onFehler("Wartungsauftrag konnte nicht angelegt werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statusAendern(auftrag: Wartungsauftrag, status: Wartungsauftrag["status"]) {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/wartungsauftraege/${auftrag.id}/status`, { method: "PATCH", body: { status } });
|
||||||
|
await laden();
|
||||||
|
} catch {
|
||||||
|
onFehler("Status konnte nicht geändert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function erledigen(auftrag: Wartungsauftrag) {
|
||||||
|
const kosten = window.prompt("Kosten (optional, in €):") ?? undefined;
|
||||||
|
try {
|
||||||
|
await apiRequest(`/wartungsauftraege/${auftrag.id}/erledigen`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { kosten: kosten || null },
|
||||||
|
});
|
||||||
|
await laden();
|
||||||
|
} catch {
|
||||||
|
onFehler("Wartungsauftrag konnte nicht als erledigt markiert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const offeneAuftraege = auftraege.filter((a) => a.status !== "erledigt");
|
||||||
|
const erledigteAuftraege = auftraege.filter((a) => a.status === "erledigt");
|
||||||
|
const faelligeOhneOffenenAuftrag = faellig.filter((f) => f.faellig);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Wartung</h3>
|
||||||
|
|
||||||
|
{faelligeOhneOffenenAuftrag.length > 0 && (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
Fällig: {faelligeOhneOffenenAuftrag.map((f) => f.wartungsart).join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{istVerantwortlich && (
|
||||||
|
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.75rem" }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Wartungsart (z.B. Ölwechsel, Reifen, Reparatur)"
|
||||||
|
value={neuArt}
|
||||||
|
onChange={(e) => setNeuArt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="row" style={{ gap: "0.3rem" }}>
|
||||||
|
<input type="checkbox" checked={neuExtern} onChange={(e) => setNeuExtern(e.target.checked)} />
|
||||||
|
externe Werkstatt
|
||||||
|
</label>
|
||||||
|
{neuExtern && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Werkstatt-Name"
|
||||||
|
value={neuWerkstattName}
|
||||||
|
onChange={(e) => setNeuWerkstattName(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={auftragAnlegen}>
|
||||||
|
Auftrag anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offeneAuftraege.length === 0 && erledigteAuftraege.length === 0 && (
|
||||||
|
<p className="text-muted">Keine Wartungsaufträge.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{[...offeneAuftraege, ...erledigteAuftraege].length > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{[...offeneAuftraege, ...erledigteAuftraege].map((a) => (
|
||||||
|
<li key={a.id} style={{ padding: "0.4rem 0" }}>
|
||||||
|
<div className="row-between">
|
||||||
|
<span>
|
||||||
|
{a.wartungsart}{" "}
|
||||||
|
<span className="text-muted">
|
||||||
|
({a.werkstatt_intern ? "intern" : a.werkstatt_name || "extern"})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${STATUS_BADGE[a.status]}`}>{STATUS_LABEL[a.status]}</span>
|
||||||
|
</div>
|
||||||
|
{a.kosten && <div className="text-muted" style={{ fontSize: "0.85rem" }}>Kosten: {a.kosten} €</div>}
|
||||||
|
{a.erledigt_am && (
|
||||||
|
<div className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||||
|
Erledigt am {new Date(a.erledigt_am).toLocaleDateString("de-DE")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="row" style={{ gap: "0.4rem", marginTop: "0.3rem", flexWrap: "wrap" }}>
|
||||||
|
{istVerantwortlich && a.status === "offen" && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => statusAendern(a, "in_arbeit")}>
|
||||||
|
In Arbeit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{istVerantwortlich && a.status !== "erledigt" && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => erledigen(a)}>
|
||||||
|
Erledigt melden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setDokumenteOffenId(dokumenteOffenId === a.id ? null : a.id)}
|
||||||
|
>
|
||||||
|
{dokumenteOffenId === a.id ? "Dokumente schließen" : "Dokumente/Rechnung"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{dokumenteOffenId === a.id && (
|
||||||
|
<div style={{ marginTop: "0.5rem" }}>
|
||||||
|
<DokumentePanel entitaetTyp="wartungsauftrag" entitaetId={a.id} onFehler={onFehler} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { ObjekttypSection } from "./admin/ObjekttypSection";
|
|||||||
import { QualifikationSection } from "./admin/QualifikationSection";
|
import { QualifikationSection } from "./admin/QualifikationSection";
|
||||||
import { StandortSection } from "./admin/StandortSection";
|
import { StandortSection } from "./admin/StandortSection";
|
||||||
import { VorlageSection } from "./admin/VorlageSection";
|
import { VorlageSection } from "./admin/VorlageSection";
|
||||||
|
import { WartungsplanSection } from "./admin/WartungsplanSection";
|
||||||
import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection";
|
import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection";
|
||||||
|
|
||||||
type Tab =
|
type Tab =
|
||||||
@@ -39,6 +40,7 @@ type Tab =
|
|||||||
| "lagerbewegungen"
|
| "lagerbewegungen"
|
||||||
| "lager"
|
| "lager"
|
||||||
| "ausgabe"
|
| "ausgabe"
|
||||||
|
| "wartungsplaene"
|
||||||
| "rollen";
|
| "rollen";
|
||||||
|
|
||||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
||||||
@@ -76,6 +78,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
|
|||||||
label: "Ausgabe/Rückgabe",
|
label: "Ausgabe/Rückgabe",
|
||||||
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "wartungsplaene",
|
||||||
|
label: "Wartungspläne",
|
||||||
|
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||||
|
},
|
||||||
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -247,6 +254,9 @@ export function AdminPage() {
|
|||||||
{aktiverTab === "ausgabe" && (
|
{aktiverTab === "ausgabe" && (
|
||||||
<AusgabeSection materialien={materialien} benutzer={benutzer} onFehler={setFehler} />
|
<AusgabeSection materialien={materialien} benutzer={benutzer} onFehler={setFehler} />
|
||||||
)}
|
)}
|
||||||
|
{aktiverTab === "wartungsplaene" && (
|
||||||
|
<WartungsplanSection objekttypen={objekttypen} onFehler={setFehler} />
|
||||||
|
)}
|
||||||
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
|
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
|
||||||
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
||||||
{aktiverTab === "personal" && (
|
{aktiverTab === "personal" && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiRequest } from "../api/client";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import type { Akte } from "../api/types";
|
import type { Akte } from "../api/types";
|
||||||
import { DokumentePanel } from "../components/DokumentePanel";
|
import { DokumentePanel } from "../components/DokumentePanel";
|
||||||
|
import { WartungSection } from "../components/WartungSection";
|
||||||
import { ReadinessBadge } from "../components/status/ReadinessBadge";
|
import { ReadinessBadge } from "../components/status/ReadinessBadge";
|
||||||
import { GRUND_TEXT } from "../components/status/gruendeText";
|
import { GRUND_TEXT } from "../components/status/gruendeText";
|
||||||
import { EREIGNIS_LABEL } from "../components/status/ereignisText";
|
import { EREIGNIS_LABEL } from "../components/status/ereignisText";
|
||||||
@@ -15,8 +16,6 @@ const MANGEL_PRIORITAET_LABEL: Record<string, string> = {
|
|||||||
hoch: "Hoch",
|
hoch: "Hoch",
|
||||||
};
|
};
|
||||||
|
|
||||||
// FILE-006: EIN Screen für alle Teilbereiche einer Ressource statt mehrerer
|
|
||||||
// Einzelseiten - konsumiert den FILE-001-Aggregations-Endpunkt.
|
|
||||||
export function AktePage() {
|
export function AktePage() {
|
||||||
const { objektId } = useParams<{ objektId: string }>();
|
const { objektId } = useParams<{ objektId: string }>();
|
||||||
const { istVerantwortlich } = useAuth();
|
const { istVerantwortlich } = useAuth();
|
||||||
@@ -52,10 +51,11 @@ export function AktePage() {
|
|||||||
if (!akte) return null;
|
if (!akte) return null;
|
||||||
|
|
||||||
const { objekt } = akte;
|
const { objekt } = akte;
|
||||||
|
const istFahrzeug = akte.fahrzeugdetails !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className={`page-wide akte-grid${istFahrzeug ? " ist-fahrzeug" : ""}`}>
|
||||||
<div className="row-between" style={{ alignItems: "flex-start" }}>
|
<div className="akte-kopf row-between" style={{ alignItems: "flex-start" }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ marginBottom: "0.15rem" }}>{objekt.name}</h1>
|
<h1 style={{ marginBottom: "0.15rem" }}>{objekt.name}</h1>
|
||||||
<div className="text-muted">
|
<div className="text-muted">
|
||||||
@@ -65,7 +65,7 @@ export function AktePage() {
|
|||||||
<ReadinessBadge status={akte.einsatzbereitschaft_status} />
|
<ReadinessBadge status={akte.einsatzbereitschaft_status} />
|
||||||
</div>
|
</div>
|
||||||
{akte.einsatzbereitschaft_gruende.length > 0 && (
|
{akte.einsatzbereitschaft_gruende.length > 0 && (
|
||||||
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
|
<ul className="akte-kopf card-list" style={{ marginTop: "0.5rem" }}>
|
||||||
{akte.einsatzbereitschaft_gruende.map((g) => (
|
{akte.einsatzbereitschaft_gruende.map((g) => (
|
||||||
<li key={g} className="text-muted" style={{ fontSize: "0.9rem" }}>
|
<li key={g} className="text-muted" style={{ fontSize: "0.9rem" }}>
|
||||||
• {GRUND_TEXT[g] ?? g}
|
• {GRUND_TEXT[g] ?? g}
|
||||||
@@ -74,9 +74,7 @@ export function AktePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* nach dem QR-Scan (MOBILE-002) landet man hier - die
|
<div className="akte-aktionen row" style={{ flexWrap: "wrap" }}>
|
||||||
Kernaktionen sollen direkt sichtbar sein, nicht erst am Seitenende. */}
|
|
||||||
<div className="row" style={{ flexWrap: "wrap", marginTop: "1rem" }}>
|
|
||||||
<Link to={`/objekte/${objekt.id}/kontrolle`} className="btn btn-primary">
|
<Link to={`/objekte/${objekt.id}/kontrolle`} className="btn btn-primary">
|
||||||
Kontrolle starten
|
Kontrolle starten
|
||||||
</Link>
|
</Link>
|
||||||
@@ -85,7 +83,7 @@ export function AktePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: "1rem" }}>
|
<div className="akte-stamm card">
|
||||||
<h3 style={{ marginTop: 0 }}>Stammdaten</h3>
|
<h3 style={{ marginTop: 0 }}>Stammdaten</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<div>Standort: {akte.standort_name}</div>
|
<div>Standort: {akte.standort_name}</div>
|
||||||
@@ -108,7 +106,40 @@ export function AktePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: "1rem" }}>
|
<div className="akte-beladung card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Beladung</h3>
|
||||||
|
{akte.beladung.length === 0 && <p className="text-muted">Keine Positionen zugeordnet.</p>}
|
||||||
|
{akte.beladung.length > 0 && (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Material</th>
|
||||||
|
<th>Fach</th>
|
||||||
|
<th>Soll</th>
|
||||||
|
<th>Ist</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{akte.beladung.map((b) => (
|
||||||
|
<tr key={b.objektposition_id}>
|
||||||
|
<td>{b.material_name}</td>
|
||||||
|
<td>{b.fach ?? "–"}</td>
|
||||||
|
<td>
|
||||||
|
{b.sollmenge} {b.einheit}
|
||||||
|
</td>
|
||||||
|
<td className={Number(b.istmenge) < Number(b.sollmenge) ? "text-danger" : undefined}>
|
||||||
|
{b.istmenge} {b.einheit}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="akte-verantwortlich card">
|
||||||
<h3 style={{ marginTop: 0 }}>Verantwortlichkeit</h3>
|
<h3 style={{ marginTop: 0 }}>Verantwortlichkeit</h3>
|
||||||
{akte.zustaendige_benutzer.length === 0 && akte.kontrollverantwortung.length === 0 && (
|
{akte.zustaendige_benutzer.length === 0 && akte.kontrollverantwortung.length === 0 && (
|
||||||
<p className="text-muted">Keine Zuordnung hinterlegt.</p>
|
<p className="text-muted">Keine Zuordnung hinterlegt.</p>
|
||||||
@@ -124,7 +155,7 @@ export function AktePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: "1rem" }}>
|
<div className="akte-pruefungen card">
|
||||||
<h3 style={{ marginTop: 0 }}>Prüfungen (Geräte)</h3>
|
<h3 style={{ marginTop: 0 }}>Prüfungen (Geräte)</h3>
|
||||||
{akte.geraete.length === 0 && <p className="text-muted">Keine Geräte-Instanzen erfasst.</p>}
|
{akte.geraete.length === 0 && <p className="text-muted">Keine Geräte-Instanzen erfasst.</p>}
|
||||||
{akte.geraete.length > 0 && (
|
{akte.geraete.length > 0 && (
|
||||||
@@ -149,7 +180,11 @@ export function AktePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: "1rem" }}>
|
<div className="akte-wartung">
|
||||||
|
<WartungSection objektId={objekt.id} onFehler={setFehler} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="akte-maengel card">
|
||||||
<h3 style={{ marginTop: 0 }}>Mängel</h3>
|
<h3 style={{ marginTop: 0 }}>Mängel</h3>
|
||||||
{akte.maengel.length === 0 && <p className="text-muted">Keine Mängel gemeldet.</p>}
|
{akte.maengel.length === 0 && <p className="text-muted">Keine Mängel gemeldet.</p>}
|
||||||
{akte.maengel.length > 0 && (
|
{akte.maengel.length > 0 && (
|
||||||
@@ -163,12 +198,12 @@ export function AktePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: "1rem" }}>
|
<div className="akte-dokumente card">
|
||||||
<h3 style={{ marginTop: 0 }}>Dokumente</h3>
|
<h3 style={{ marginTop: 0 }}>Dokumente</h3>
|
||||||
<DokumentePanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
|
<DokumentePanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: "1rem" }}>
|
<div className="akte-historie card">
|
||||||
<h3 style={{ marginTop: 0 }}>Historie</h3>
|
<h3 style={{ marginTop: 0 }}>Historie</h3>
|
||||||
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
|
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
|
||||||
{akte.historie.length > 0 && (
|
{akte.historie.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { apiRequest } from "../../api/client";
|
||||||
|
import type { Objekttyp, Wartungsplan } from "../../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objekttypen: Objekttyp[];
|
||||||
|
onFehler: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NeueIntervallZeile {
|
||||||
|
typ: "zeit" | "km" | "betriebsstunden";
|
||||||
|
wert: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WartungsplanSection({ objekttypen, onFehler }: Props) {
|
||||||
|
const [plaene, setPlaene] = useState<Wartungsplan[]>([]);
|
||||||
|
const [objekttypId, setObjekttypId] = useState<number | "">("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [wartungsart, setWartungsart] = useState("");
|
||||||
|
const [intervalle, setIntervalle] = useState<NeueIntervallZeile[]>([{ typ: "zeit", wert: "" }]);
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
setPlaene(await apiRequest<Wartungsplan[]>("/wartungsplaene"));
|
||||||
|
} catch {
|
||||||
|
onFehler("Wartungspläne konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
laden();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function objekttypName(id: number): string {
|
||||||
|
return objekttypen.find((o) => o.id === id)?.name ?? `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervallZeileAendern(index: number, feld: keyof NeueIntervallZeile, wert: string) {
|
||||||
|
setIntervalle((zeilen) => zeilen.map((z, i) => (i === index ? { ...z, [feld]: wert } : z)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function planAnlegen() {
|
||||||
|
if (objekttypId === "" || !name || !wartungsart) {
|
||||||
|
onFehler("Objekttyp, Name und mindestens eine Wartungsart angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiRequest("/wartungsplaene", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
objekttyp_id: objekttypId,
|
||||||
|
name,
|
||||||
|
positionen: [
|
||||||
|
{
|
||||||
|
wartungsart,
|
||||||
|
intervalle: intervalle
|
||||||
|
.filter((z) => z.wert)
|
||||||
|
.map((z) => ({ typ: z.typ, wert: Number(z.wert) })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setName("");
|
||||||
|
setWartungsart("");
|
||||||
|
setIntervalle([{ typ: "zeit", wert: "" }]);
|
||||||
|
await laden();
|
||||||
|
} catch {
|
||||||
|
onFehler("Wartungsplan konnte nicht angelegt werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<h2>Wartungspläne</h2>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Neuer Plan (eine Wartungsart)</h3>
|
||||||
|
<div className="stack">
|
||||||
|
<select className="input" value={objekttypId} onChange={(e) => setObjekttypId(Number(e.target.value) || "")}>
|
||||||
|
<option value="">Objekttyp wählen…</option>
|
||||||
|
{objekttypen.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input className="input" placeholder="Plan-Name (z.B. RTW Standard)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Wartungsart (z.B. Ölwechsel)"
|
||||||
|
value={wartungsart}
|
||||||
|
onChange={(e) => setWartungsart(e.target.value)}
|
||||||
|
/>
|
||||||
|
{intervalle.map((zeile, i) => (
|
||||||
|
<div className="row" key={i}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={zeile.typ}
|
||||||
|
onChange={(e) => intervallZeileAendern(i, "typ", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="zeit">Zeit (Monate)</option>
|
||||||
|
<option value="km">Kilometer</option>
|
||||||
|
<option value="betriebsstunden">Betriebsstunden</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
placeholder="Wert"
|
||||||
|
value={zeile.wert}
|
||||||
|
onChange={(e) => intervallZeileAendern(i, "wert", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setIntervalle((z) => [...z, { typ: "zeit", wert: "" }])}
|
||||||
|
>
|
||||||
|
+ weiteres Intervall
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={planAnlegen}>
|
||||||
|
Plan anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: "1rem" }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Bestehende Pläne</h3>
|
||||||
|
{plaene.length === 0 && <p className="text-muted">Keine Wartungspläne angelegt.</p>}
|
||||||
|
<ul className="card-list">
|
||||||
|
{plaene.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<strong>{p.name}</strong> <span className="text-muted">({objekttypName(p.objekttyp_id)})</span>
|
||||||
|
<ul>
|
||||||
|
{p.positionen.map((pos) => (
|
||||||
|
<li key={pos.id}>
|
||||||
|
{pos.wartungsart}
|
||||||
|
{pos.intervalle.length > 0 && (
|
||||||
|
<span className="text-muted">
|
||||||
|
{" "}
|
||||||
|
– {pos.intervalle.map((i) => `${i.wert} ${i.typ}`).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -677,6 +677,59 @@ select.input:focus {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.akte-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"kopf"
|
||||||
|
"aktionen"
|
||||||
|
"stamm"
|
||||||
|
"beladung"
|
||||||
|
"wartung"
|
||||||
|
"pruefungen"
|
||||||
|
"verantwortlich"
|
||||||
|
"maengel"
|
||||||
|
"dokumente"
|
||||||
|
"historie";
|
||||||
|
}
|
||||||
|
|
||||||
|
.akte-kopf { grid-area: kopf; }
|
||||||
|
.akte-aktionen { grid-area: aktionen; }
|
||||||
|
.akte-stamm { grid-area: stamm; }
|
||||||
|
.akte-beladung { grid-area: beladung; }
|
||||||
|
.akte-wartung { grid-area: wartung; }
|
||||||
|
.akte-pruefungen { grid-area: pruefungen; }
|
||||||
|
.akte-verantwortlich { grid-area: verantwortlich; }
|
||||||
|
.akte-maengel { grid-area: maengel; }
|
||||||
|
.akte-dokumente { grid-area: dokumente; }
|
||||||
|
.akte-historie { grid-area: historie; }
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.akte-grid {
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"kopf kopf"
|
||||||
|
"aktionen aktionen"
|
||||||
|
"stamm wartung"
|
||||||
|
"beladung pruefungen"
|
||||||
|
"verantwortlich maengel"
|
||||||
|
"dokumente dokumente"
|
||||||
|
"historie historie";
|
||||||
|
}
|
||||||
|
|
||||||
|
.akte-grid.ist-fahrzeug {
|
||||||
|
grid-template-areas:
|
||||||
|
"kopf kopf"
|
||||||
|
"aktionen aktionen"
|
||||||
|
"wartung stamm"
|
||||||
|
"pruefungen beladung"
|
||||||
|
"maengel verantwortlich"
|
||||||
|
"dokumente dokumente"
|
||||||
|
"historie historie";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.split-layout {
|
.split-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user