feat(einsatz): Epic 16 Einsatz-Nachdokumentation (OPS-001/002/003 kombiniert)
CI / backend-tests (push) Successful in 2m48s
CI / frontend-build (push) Successful in 19s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 13:07:47 +02:00
1 parent 46a65ba6ff
commit ddd1b446a2
12 files changed
+621 -67

No files matched your search

+38 -66
View File
@@ -1,81 +1,53 @@
# Epic 16 — Operations
Details zu OPS-001 … OPS-003. **Bewusst nur als Platzhalter ausgearbeitet** — die
Ursprungs-Anforderung sagt explizit: „Einsatzverwaltung/Einsatzdisposition noch nicht
implementieren, aber bei der Architektur berücksichtigen." Diese Datei erfüllt genau das:
genug Grundgerüst, damit FLEET-006 und andere Epics wissen, worauf sie sich später beziehen
— keine vollständige Fachspezifikation, da kein konkreter Bedarf vorliegt.
Details zu OPS-001 … OPS-003. **Bedarf konkretisiert 2026-09-10** (bis dahin bewusster
Platzhalter — Ursprungs-Anforderung sagte „Einsatzverwaltung noch nicht implementieren,
aber Architektur berücksichtigen"): Nutzer wollte reine **Einsatz-Nachdokumentation**
(kein Live-Dispositionsbetrieb während eines laufenden Einsatzes), angelegt/ausgewertet
von Leitungsverantwortlichen/Administration. Alle drei Kacheln als EIN kombiniertes
Feature umgesetzt statt getrennt, da der geklärte Bedarf klein genug ist, um sie nicht
künstlich zu trennen.
---
## OPS-001 — Einsatzverwaltung (Platzhalter)
## OPS-001 — Einsatzverwaltung (Nachdokumentation)
- **Ziel:** Grundgerüst für eine künftige Einsatzverwaltung, das keine Änderung an
bereits gebauten Epics erzwingt.
- **Beschreibung:** minimales Platzhalter-Datenmodell (Einsatz-Entität mit ID,
Zeitstempel, Status) — Rest folgt erst, wenn das Epic tatsächlich angegangen wird.
- **Benutzerwert:** TBD — noch keine konkrete Anforderung geäußert.
- **Ziel:** Einsatz nachträglich erfassen (Bezeichnung, Datum, Ort, Status).
- **Abhängigkeiten:** FILE-001
- **Datenmodell:** `einsatz` (id, bezeichnung, alarmiert_am, status) — Platzhalter, nicht
endgültig
- **Backend/Frontend/Mobile:** TBD
- **QR-Code/Seriennummer/Inventarnummer:** nein
- **Akte:** künftig eigener „Einsatz"-Akte-Typ denkbar (Ressourcen-Akte und Einsatz-Akte
sind unterschiedliche Konzepte)
- **Rechte/Audit:** TBD
- **Akzeptanzkriterien/Tests:** TBD
- **DoD:** **DECISION REQUIRED:** dieses Epic explizit NICHT umsetzen, bevor ein echter
Bedarf konkret geäußert wird. Der Platzhalter dient nur dazu, dass FLEET-006/OPS-002
wissen, worauf sie sich später beziehen. Aufwand/Risiko/Priorität sind ohne echte
Anforderung nicht sinnvoll schätzbar.
- **Datenmodell:** `einsatz` (id, bezeichnung, datum, ort nullable, status
`offen`/`abgeschlossen`, debriefing_notiz nullable, erstellt_von_benutzer_id,
erstellt_am)
- **Backend:** `GET/POST /einsaetze`, `PATCH /einsaetze/{id}`
- **Frontend:** Admin-Tab „Einsätze" (Liste, Anlegen, Status umschalten)
- **Rechte:** Leitungsverantwortliche/Administration (Klärung 2026-09-10)
- **DoD:** ✅ umgesetzt (2026-09-10). Migration 0043, `app/services/einsatz.py`,
`backend/tests/test_einsatz.py`.
## OPS-002 — Einsatzmittel-Verknüpfung
- **Ziel:** Ressourcen (Assets) einem Einsatz zuordnen (welches Fahrzeug/Material war bei
Einsatz X im Einsatz).
- **Beschreibung:** M:N Einsatz↔Asset, Zeitraum der Zuordnung.
- **Benutzerwert:** Nutzungsnachweis, Grundlage für FLEET-006 (Fahrzeug-Einsatzhistorie).
- **Abhängigkeiten:** OPS-001, ASSET-003
- **Datenmodell:** `einsatz_ressource` (einsatz_id, asset_id, von, bis) — Platzhalter
- **Backend/Frontend/Mobile:** TBD
- **QR-Code/Seriennummer/Inventarnummer:** nein
- **Akte:** Ressourcen-Akte könnte „Einsatzhistorie" als zusätzlichen Bereich bekommen
(siehe FLEET-006)
- **Rechte/Audit:** TBD
- **Akzeptanzkriterien/Tests:** TBD
- **DoD:** abhängig von OPS-001, gleiche Einschränkung — nicht umsetzen ohne konkreten
Bedarf.
- **Ziel:** Ressourcen (Objekte: Fahrzeuge/Geräte/Rucksäcke) einem Einsatz zuordnen.
- **Beschreibung (vereinfacht gegenüber ursprünglicher Skizze):** reine M:N-Zuordnung
OHNE Zeitraum-Tracking (`von`/`bis` entfallen) — Bedarf ist Nachdokumentation
"welche Ressourcen waren dabei", nicht minutengenaues Live-Tracking.
- **Datenmodell:** `einsatz_ressource` (einsatz_id, objekt_id)
- **Backend:** `PUT/DELETE /einsaetze/{id}/ressourcen/{objekt_id}`
- **Frontend:** Ressourcen-Zuordnung in den aufgeklappten Einsatz-Details
- **DoD:** ✅ umgesetzt (2026-09-10), zusammen mit OPS-001.
## OPS-003 — Einsatznachbereitung (Platzhalter)
## OPS-003 — Einsatznachbereitung
- **Ziel:** Nach einem Einsatz Rückschau/Auswertung (Verbrauchsmaterial-Nachbestellung,
Debriefing-Notizen).
- **Beschreibung:** bewusst nicht spezifiziert.
- **Benutzerwert:** TBD
- **Abhängigkeiten:** OPS-001
- **Datenmodell/Backend/Frontend/Mobile/QR/SN/Inv/Akte/Rechte/Audit:** TBD
- **Akzeptanzkriterien/Tests:** TBD
- **DoD:** **DECISION REQUIRED**, wie OPS-001 — reiner Platzhalter, absichtlich nicht
weiter ausgearbeitet gemäß Ursprungs-Anforderung.
- **Ziel:** Debriefing-Notiz je Einsatz.
- **Beschreibung (vereinfacht):** ein Freitextfeld (`einsatz.debriefing_notiz`)
statt eines eigenen Notiz-Modells — deckt den geäußerten Bedarf ab, ohne
FILE-009 (AkteNotiz) zu duplizieren. Verbrauchsmaterial-Nachbestellung nicht
umgesetzt (kein konkreter Bedarf dafür geäußert, kann bei Bedarf als eigene
Ausbaustufe nachgezogen werden — z.B. Verknüpfung zu FOUND-007-Import oder
einer neuen Nachbestell-Liste).
- **DoD:** ✅ Debriefing-Notiz umgesetzt (2026-09-10), zusammen mit OPS-001.
Nachbestellungs-Funktion bewusst nicht umgesetzt (kein Bedarf geäußert).
---
**MABEA-Ist-Stand-Abgleich:** entfällt — dieses Epic ist im gesamten Backlog das einzige,
das bewusst nicht gegen einen Ist-Stand abgeglichen wird, weil weder MABEA noch die
Ursprungs-Anforderung dafür eine konkrete Zielvorstellung liefern. Sollte dieses Epic
später tatsächlich angegangen werden, sollte der erste Schritt eine eigene, echte
Anforderungsklärung mit dem Nutzer sein (analog zum Ablauf, der zu diesem gesamten Backlog
geführt hat) — nicht ein direkter Sprung in die Umsetzung.
---
## Abschluss der Backlog-Detaillierungsphase
Mit diesem Epic sind **alle 16 Epics des Backlogs** (siehe `00_index.md`) mindestens einmal
durchgegangen — 13 davon vollständig fachlich spezifiziert (Foundation bis Mobile), dieses
letzte bewusst nur als Platzhalter. Insgesamt wurden im Zuge dieser Detaillierung
**zwei echte Bugs in MABEA gefunden und noch am selben Tag live gefixt** (Betriebsstunden
fälschlich fahrzeug-only geplant → generischer Zähler ASSET-009 + Funkkenner-Feld ergänzt;
UNKNOWN-Readiness-Zustand fehlte → nie kontrollierte Objekte galten fälschlich als
einsatzbereit). Die verbleibenden offenen Punkte sind in `00_index.md` unter „Decisions
Required" gesammelt.
**MABEA-Ist-Stand-Abgleich (2026-09-10):** alle drei Kacheln umgesetzt als
kombiniertes Feature. `app/models/einsatz.py`, `app/services/einsatz.py`,
`app/api/v1/endpoints/einsatz.py`, `frontend/src/pages/admin/EinsatzSection.tsx`.
+49
View File
@@ -0,0 +1,49 @@
"""OPS-001/002/003: Einsatz-Nachdokumentation (Epic 16, Bedarf 2026-09-10 konkretisiert).
Revision ID: 0043_einsatz
Revises: 0042_readiness_regel
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0043_einsatz"
down_revision: Union[str, None] = "0042_readiness_regel"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE TYPE einsatz_status AS ENUM ('offen', 'abgeschlossen')")
op.execute(
"""
CREATE TABLE einsatz (
id UUID PRIMARY KEY,
bezeichnung TEXT NOT NULL,
datum DATE NOT NULL,
ort TEXT,
status einsatz_status NOT NULL DEFAULT 'offen',
debriefing_notiz TEXT,
erstellt_von_benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
erstellt_am TIMESTAMPTZ NOT NULL
)
"""
)
op.execute(
"""
CREATE TABLE einsatz_ressource (
einsatz_id UUID NOT NULL REFERENCES einsatz(id) ON DELETE CASCADE,
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
PRIMARY KEY (einsatz_id, objekt_id)
)
"""
)
op.execute("CREATE INDEX ix_einsatz_ressource_objekt ON einsatz_ressource (objekt_id)")
def downgrade() -> None:
op.execute("DROP TABLE einsatz_ressource")
op.execute("DROP TABLE einsatz")
op.execute("DROP TYPE IF EXISTS einsatz_status")
+2
View File
@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
custody,
dashboard,
dokument,
einsatz,
eskalation,
fehlbestaende,
geraet_instanz,
@@ -68,3 +69,4 @@ api_router.include_router(suche.router, tags=["suche"])
api_router.include_router(tankbuch.router, tags=["tankbuch"])
api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"])
api_router.include_router(readiness_regel.router, tags=["readiness_regel"])
api_router.include_router(einsatz.router, tags=["einsatz"])
+87
View File
@@ -0,0 +1,87 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.auth import Benutzer, RolleTyp
from app.models.einsatz import Einsatz
from app.models.objekt import Objekt
from app.schemas.einsatz import EinsatzCreate, EinsatzRead, EinsatzUpdate
from app.services.einsatz import (
aendere_einsatz,
entferne_ressource,
erstelle_einsatz,
liste_einsaetze,
ressourcen_fuer_einsatz,
setze_ressource,
)
router = APIRouter()
_leitung_plus = require_roles(RolleTyp.administration, RolleTyp.leitungsverantwortlicher)
async def _einsatz_read(db: AsyncSession, einsatz: Einsatz) -> EinsatzRead:
ressourcen = await ressourcen_fuer_einsatz(db, einsatz.id)
return EinsatzRead(
id=einsatz.id,
bezeichnung=einsatz.bezeichnung,
datum=einsatz.datum,
ort=einsatz.ort,
status=einsatz.status,
debriefing_notiz=einsatz.debriefing_notiz,
erstellt_von_benutzer_id=einsatz.erstellt_von_benutzer_id,
erstellt_am=einsatz.erstellt_am,
ressourcen_objekt_ids=ressourcen,
)
@router.get("/einsaetze", response_model=list[EinsatzRead])
async def hole_einsaetze(db: AsyncSession = Depends(get_db), _=Depends(_leitung_plus)) -> list[EinsatzRead]:
return [await _einsatz_read(db, e) for e in await liste_einsaetze(db)]
@router.post("/einsaetze", response_model=EinsatzRead, status_code=status.HTTP_201_CREATED)
async def erstelle_einsatz_endpoint(
payload: EinsatzCreate,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_leitung_plus),
) -> EinsatzRead:
einsatz = await erstelle_einsatz(
db, bezeichnung=payload.bezeichnung, datum=payload.datum, ort=payload.ort, benutzer_id=current_user.id
)
return await _einsatz_read(db, einsatz)
@router.patch("/einsaetze/{einsatz_id}", response_model=EinsatzRead)
async def aendere_einsatz_endpoint(
einsatz_id: uuid.UUID,
payload: EinsatzUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(_leitung_plus),
) -> EinsatzRead:
einsatz = await db.get(Einsatz, einsatz_id)
if einsatz is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Einsatz nicht gefunden")
einsatz = await aendere_einsatz(db, einsatz, **payload.model_dump(exclude_unset=True))
return await _einsatz_read(db, einsatz)
@router.put("/einsaetze/{einsatz_id}/ressourcen/{objekt_id}", status_code=status.HTTP_204_NO_CONTENT)
async def einsatz_ressource_hinzufuegen(
einsatz_id: uuid.UUID, objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(_leitung_plus)
) -> None:
if await db.get(Einsatz, einsatz_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Einsatz nicht gefunden")
if await db.get(Objekt, objekt_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
await setze_ressource(db, einsatz_id, objekt_id)
@router.delete("/einsaetze/{einsatz_id}/ressourcen/{objekt_id}", status_code=status.HTTP_204_NO_CONTENT)
async def einsatz_ressource_entfernen(
einsatz_id: uuid.UUID, objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(_leitung_plus)
) -> None:
await entferne_ressource(db, einsatz_id, objekt_id)
+4
View File
@@ -2,6 +2,7 @@ from app.models.ausgabe import Ausgabe, AusgabeStatus
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
from app.models.custody import GeraetInstanzCustody, SignierToken, SignierTokenStatus
from app.models.dokument import Dokument
from app.models.einsatz import Einsatz, EinsatzRessource, EinsatzStatus
from app.models.eskalation import EskalationKonfiguration
from app.models.fahrzeugdetails import Fahrzeugdetails
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
@@ -52,6 +53,9 @@ __all__ = [
"SignierToken",
"SignierTokenStatus",
"Dokument",
"Einsatz",
"EinsatzRessource",
"EinsatzStatus",
"EskalationKonfiguration",
"Fahrzeugdetails",
"Fehlbestand",
+48
View File
@@ -0,0 +1,48 @@
import enum
import uuid
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, 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 EinsatzStatus(str, enum.Enum):
offen = "offen"
abgeschlossen = "abgeschlossen"
einsatz_status_pg = PgEnum(EinsatzStatus, name="einsatz_status", create_type=False)
class Einsatz(Base):
"""OPS-001/003 (Epic 16, Bedarf konkretisiert 2026-09-10): reine
Nachdokumentation, kein Live-Dispositionsbetrieb während des Einsatzes -
Einsatz wird ANGELEGT, nachdem er stattgefunden hat. `debriefing_notiz`
deckt den ursprünglich separat geplanten OPS-003-Bedarf ab (kein eigenes
Notiz-Modell nötig, ein Freitextfeld reicht für den geäußerten Bedarf)."""
__tablename__ = "einsatz"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
bezeichnung: Mapped[str] = mapped_column(String, nullable=False)
datum: Mapped[date] = mapped_column(Date, nullable=False)
ort: Mapped[str | None] = mapped_column(String)
status: Mapped[EinsatzStatus] = mapped_column(einsatz_status_pg, nullable=False, default=EinsatzStatus.offen)
debriefing_notiz: Mapped[str | None] = mapped_column(Text)
erstellt_von_benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
class EinsatzRessource(Base):
"""OPS-002: welches Objekt (Fahrzeug/Gerät/Rucksack) war bei diesem
Einsatz im Einsatz - reine Zuordnung, kein Zeitraum-Tracking (Bedarf laut
Klärung 2026-09-10 ist Nachdokumentation, kein minutengenaues
Live-Tracking während des Einsatzes)."""
__tablename__ = "einsatz_ressource"
einsatz_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("einsatz.id", ondelete="CASCADE"), primary_key=True)
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), primary_key=True)
+33
View File
@@ -0,0 +1,33 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
from app.models.einsatz import EinsatzStatus
class EinsatzCreate(BaseModel):
bezeichnung: str
datum: date
ort: str | None = None
class EinsatzUpdate(BaseModel):
bezeichnung: str | None = None
datum: date | None = None
ort: str | None = None
status: EinsatzStatus | None = None
debriefing_notiz: str | None = None
class EinsatzRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
bezeichnung: str
datum: date
ort: str | None
status: EinsatzStatus
debriefing_notiz: str | None
erstellt_von_benutzer_id: int
erstellt_am: datetime
ressourcen_objekt_ids: list[int] = []
+58
View File
@@ -0,0 +1,58 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.einsatz import Einsatz, EinsatzRessource
async def erstelle_einsatz(
db: AsyncSession, *, bezeichnung: str, datum, ort: str | None, benutzer_id: int
) -> Einsatz:
einsatz = Einsatz(
bezeichnung=bezeichnung,
datum=datum,
ort=ort,
erstellt_von_benutzer_id=benutzer_id,
erstellt_am=datetime.now(timezone.utc),
)
db.add(einsatz)
await db.commit()
await db.refresh(einsatz)
return einsatz
async def aendere_einsatz(db: AsyncSession, einsatz: Einsatz, **felder) -> Einsatz:
for feld, wert in felder.items():
if wert is not None:
setattr(einsatz, feld, wert)
await db.commit()
await db.refresh(einsatz)
return einsatz
async def liste_einsaetze(db: AsyncSession) -> list[Einsatz]:
result = await db.execute(select(Einsatz).order_by(Einsatz.datum.desc()))
return list(result.scalars().all())
async def ressourcen_fuer_einsatz(db: AsyncSession, einsatz_id: uuid.UUID) -> list[int]:
result = await db.execute(
select(EinsatzRessource.objekt_id).where(EinsatzRessource.einsatz_id == einsatz_id)
)
return list(result.scalars().all())
async def setze_ressource(db: AsyncSession, einsatz_id: uuid.UUID, objekt_id: int) -> None:
bestehend = await db.get(EinsatzRessource, {"einsatz_id": einsatz_id, "objekt_id": objekt_id})
if bestehend is None:
db.add(EinsatzRessource(einsatz_id=einsatz_id, objekt_id=objekt_id))
await db.commit()
async def entferne_ressource(db: AsyncSession, einsatz_id: uuid.UUID, objekt_id: int) -> None:
bestehend = await db.get(EinsatzRessource, {"einsatz_id": einsatz_id, "objekt_id": objekt_id})
if bestehend is not None:
await db.delete(bestehend)
await db.commit()
+54
View File
@@ -0,0 +1,54 @@
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_einsatz_crud_und_ressourcen(
client, db_session, admin_user, standort_factory, objekt_factory
):
standort = await standort_factory("Wache-Einsatz")
fahrzeug = await objekt_factory(name="RTW Einsatz", code="RTW-EINSATZ-1", standort=standort)
admin_token = await login(client, "admin1")
erstellt = await client.post(
"/api/v1/einsaetze",
json={"bezeichnung": "Verkehrsunfall B1", "datum": "2026-09-10", "ort": "B1 km 12"},
headers=auth_header(admin_token),
)
assert erstellt.status_code == 201
einsatz_id = erstellt.json()["id"]
assert erstellt.json()["status"] == "offen"
ressource = await client.put(
f"/api/v1/einsaetze/{einsatz_id}/ressourcen/{fahrzeug.id}", headers=auth_header(admin_token)
)
assert ressource.status_code == 204
liste = await client.get("/api/v1/einsaetze", headers=auth_header(admin_token))
eintrag = next(e for e in liste.json() if e["id"] == einsatz_id)
assert eintrag["ressourcen_objekt_ids"] == [fahrzeug.id]
abgeschlossen = await client.patch(
f"/api/v1/einsaetze/{einsatz_id}",
json={"status": "abgeschlossen", "debriefing_notiz": "Gut verlaufen."},
headers=auth_header(admin_token),
)
assert abgeschlossen.status_code == 200
assert abgeschlossen.json()["status"] == "abgeschlossen"
assert abgeschlossen.json()["debriefing_notiz"] == "Gut verlaufen."
entfernt = await client.delete(
f"/api/v1/einsaetze/{einsatz_id}/ressourcen/{fahrzeug.id}", headers=auth_header(admin_token)
)
assert entfernt.status_code == 204
liste_danach = await client.get("/api/v1/einsaetze", headers=auth_header(admin_token))
eintrag_danach = next(e for e in liste_danach.json() if e["id"] == einsatz_id)
assert eintrag_danach["ressourcen_objekt_ids"] == []
@pytest.mark.asyncio
async def test_einsatz_nur_leitung_plus(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
response = await client.get("/api/v1/einsaetze", headers=auth_header(token))
assert response.status_code == 403
+12
View File
@@ -477,6 +477,18 @@ export interface CustomRolle {
berechtigung_ids: number[];
}
export interface Einsatz {
id: string;
bezeichnung: string;
datum: string;
ort: string | null;
status: "offen" | "abgeschlossen";
debriefing_notiz: string | null;
erstellt_von_benutzer_id: number;
erstellt_am: string;
ressourcen_objekt_ids: number[];
}
export interface ReadinessRegel {
schluessel: string;
beschreibung: string;
+5 -1
View File
@@ -28,6 +28,7 @@ import { VorlageSection } from "./admin/VorlageSection";
import { WartungsplanSection } from "./admin/WartungsplanSection";
import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection";
import { ReadinessRegelSection } from "./admin/ReadinessRegelSection";
import { EinsatzSection } from "./admin/EinsatzSection";
type Tab =
| "objekte"
@@ -46,7 +47,8 @@ type Tab =
| "ausgabe"
| "wartungsplaene"
| "rollen"
| "readinessRegeln";
| "readinessRegeln"
| "einsaetze";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
@@ -90,6 +92,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
},
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
{ key: "readinessRegeln", label: "Bereitschaftsregeln", rollen: ["administration"] },
{ key: "einsaetze", label: "Einsätze", rollen: ["administration", "leitungsverantwortlicher"] },
];
/**
@@ -308,6 +311,7 @@ export function AdminPage() {
)}
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} standorte={standorte} onFehler={setFehler} />}
{aktiverTab === "readinessRegeln" && <ReadinessRegelSection onFehler={setFehler} />}
{aktiverTab === "einsaetze" && <EinsatzSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
<>
+231
View File
@@ -0,0 +1,231 @@
import { Fragment, useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Einsatz, Objekt } from "../../api/types";
import { TableWrapper } from "../../components/ui/table-wrapper";
interface Props {
objekte: Objekt[];
onFehler: (text: string) => void;
}
// OPS-001/002/003 (Epic 16, Bedarf konkretisiert 2026-09-10): reine
// Nachdokumentation - Einsatz wird NACH dem Einsatz angelegt, kein
// Live-Dispositionsbetrieb. Leitungsverantwortliche/Administration.
export function EinsatzSection({ objekte, onFehler }: Props) {
const [einsaetze, setEinsaetze] = useState<Einsatz[]>([]);
const [laedt, setLaedt] = useState(true);
const [bezeichnung, setBezeichnung] = useState("");
const [datum, setDatum] = useState("");
const [ort, setOrt] = useState("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [ausgeklapptId, setAusgeklapptId] = useState<string | null>(null);
const [neueRessourceId, setNeueRessourceId] = useState<number | "">("");
const [notizEntwurf, setNotizEntwurf] = useState("");
async function laden() {
setLaedt(true);
try {
setEinsaetze(await apiRequest<Einsatz[]>("/einsaetze"));
} catch {
onFehler("Einsätze konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
}, []);
async function anlegen() {
if (!bezeichnung || !datum) return;
setWirdAngelegt(true);
try {
await apiRequest("/einsaetze", { method: "POST", body: { bezeichnung, datum, ort: ort || null } });
setBezeichnung("");
setDatum("");
setOrt("");
await laden();
} catch {
onFehler("Einsatz konnte nicht angelegt werden.");
} finally {
setWirdAngelegt(false);
}
}
function ausklappen(e: Einsatz) {
if (ausgeklapptId === e.id) {
setAusgeklapptId(null);
return;
}
setAusgeklapptId(e.id);
setNotizEntwurf(e.debriefing_notiz ?? "");
}
async function statusUmschalten(e: Einsatz) {
try {
await apiRequest(`/einsaetze/${e.id}`, {
method: "PATCH",
body: { status: e.status === "offen" ? "abgeschlossen" : "offen" },
});
await laden();
} catch {
onFehler("Status konnte nicht geändert werden.");
}
}
async function notizSpeichern(e: Einsatz) {
try {
await apiRequest(`/einsaetze/${e.id}`, { method: "PATCH", body: { debriefing_notiz: notizEntwurf } });
await laden();
} catch {
onFehler("Notiz konnte nicht gespeichert werden.");
}
}
async function ressourceHinzufuegen(e: Einsatz) {
if (neueRessourceId === "") return;
try {
await apiRequest(`/einsaetze/${e.id}/ressourcen/${neueRessourceId}`, { method: "PUT" });
setNeueRessourceId("");
await laden();
} catch {
onFehler("Ressource konnte nicht zugeordnet werden.");
}
}
async function ressourceEntfernen(e: Einsatz, objektId: number) {
try {
await apiRequest(`/einsaetze/${e.id}/ressourcen/${objektId}`, { method: "DELETE" });
await laden();
} catch {
onFehler("Ressource konnte nicht entfernt werden.");
}
}
return (
<section className="section">
<h2>Einsätze</h2>
<div className="card">
<div className="row" style={{ flexWrap: "wrap", marginBottom: "1rem" }}>
<input
className="input"
placeholder="Bezeichnung"
value={bezeichnung}
onChange={(e) => setBezeichnung(e.target.value)}
/>
<input type="date" className="input" value={datum} onChange={(e) => setDatum(e.target.value)} />
<input
className="input"
placeholder="Ort (optional)"
value={ort}
onChange={(e) => setOrt(e.target.value)}
/>
<button className="btn btn-primary" onClick={anlegen} disabled={!bezeichnung || !datum || wirdAngelegt}>
Anlegen
</button>
</div>
{laedt && <p className="text-muted">Lade…</p>}
{!laedt && einsaetze.length === 0 && <p className="text-muted">Noch keine Einsätze erfasst.</p>}
{!laedt && einsaetze.length > 0 && (
<TableWrapper>
<table className="table">
<thead>
<tr>
<th>Datum</th>
<th>Bezeichnung</th>
<th>Ort</th>
<th>Status</th>
<th>Ressourcen</th>
<th></th>
</tr>
</thead>
<tbody>
{einsaetze.map((e) => (
<Fragment key={e.id}>
<tr>
<td>{new Date(e.datum).toLocaleDateString("de-DE")}</td>
<td>{e.bezeichnung}</td>
<td>{e.ort ?? "–"}</td>
<td>
<button className="btn btn-secondary" onClick={() => statusUmschalten(e)}>
{e.status === "offen" ? "Offen" : "Abgeschlossen"}
</button>
</td>
<td>{e.ressourcen_objekt_ids.length}</td>
<td>
<button className="btn btn-secondary" onClick={() => ausklappen(e)}>
{ausgeklapptId === e.id ? "Schließen" : "Details"}
</button>
</td>
</tr>
{ausgeklapptId === e.id && (
<tr>
<td colSpan={6}>
<div className="stack" style={{ padding: "0.5rem 0" }}>
<div>
<strong className="text-muted">Ressourcen</strong>
<ul className="card-list">
{e.ressourcen_objekt_ids.map((objektId) => {
const objekt = objekte.find((o) => o.id === objektId);
return (
<li key={objektId} className="row-between" style={{ padding: "0.2rem 0" }}>
<span>{objekt ? `${objekt.name} (${objekt.code})` : `#${objektId}`}</span>
<button
className="btn btn-secondary"
onClick={() => ressourceEntfernen(e, objektId)}
>
Entfernen
</button>
</li>
);
})}
</ul>
<div className="row" style={{ marginTop: "0.3rem" }}>
<select
className="input"
value={neueRessourceId}
onChange={(ev) => setNeueRessourceId(Number(ev.target.value) || "")}
>
<option value="">Objekt wählen…</option>
{objekte
.filter((o) => !e.ressourcen_objekt_ids.includes(o.id))
.map((o) => (
<option key={o.id} value={o.id}>
{o.name} ({o.code})
</option>
))}
</select>
<button className="btn btn-primary" onClick={() => ressourceHinzufuegen(e)}>
Hinzufügen
</button>
</div>
</div>
<div>
<strong className="text-muted">Debriefing-Notiz</strong>
<textarea
className="input"
style={{ width: "100%", minHeight: "4rem" }}
value={notizEntwurf}
onChange={(ev) => setNotizEntwurf(ev.target.value)}
/>
<button className="btn btn-secondary" onClick={() => notizSpeichern(e)}>
Notiz speichern
</button>
</div>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</TableWrapper>
)}
</div>
</section>
);
}