feat(maintenance): MAINT-Epic (Wartungspläne/-aufträge, Werkstatt) + Akte-Redesign
CI / backend-tests (push) Failing after 2m4s
CI / frontend-build (push) Successful in 18s

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:
2026-09-06 23:17:39 +02:00
co-authored by Claude Sonnet 5
parent d607a1101d
commit 2a4d391711
23 changed files with 1505 additions and 44 deletions
+26 -14
View File
@@ -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).
+39
View File
@@ -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.
+82
View File
@@ -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")
+2
View File
@@ -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"])
+153
View File
@@ -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,
)
+14
View File
@@ -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",
] ]
+80
View File
@@ -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)
+13
View File
@@ -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]
+1 -1
View File
@@ -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"
] ]
+101
View File
@@ -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
+30 -3
View File
@@ -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,
) )
+10
View File
@@ -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 -12
View File
@@ -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]:
+216
View File
@@ -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
+4
View File
@@ -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
+214
View File
@@ -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
+62
View File
@@ -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",
+191
View File
@@ -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>
);
}
+10
View File
@@ -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" && (
+49 -14
View File
@@ -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>
);
}
+53
View File
@@ -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;