diff --git a/arbeitskacheln/16_operations.md b/arbeitskacheln/16_operations.md index 8d71c29..d43a58c 100644 --- a/arbeitskacheln/16_operations.md +++ b/arbeitskacheln/16_operations.md @@ -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`. diff --git a/backend/alembic/versions/0043_einsatz.py b/backend/alembic/versions/0043_einsatz.py new file mode 100644 index 0000000..25ad518 --- /dev/null +++ b/backend/alembic/versions/0043_einsatz.py @@ -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") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 512aca9..f536662 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/einsatz.py b/backend/app/api/v1/endpoints/einsatz.py new file mode 100644 index 0000000..5c2bc0b --- /dev/null +++ b/backend/app/api/v1/endpoints/einsatz.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b85ca56..da725e8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/einsatz.py b/backend/app/models/einsatz.py new file mode 100644 index 0000000..9b353be --- /dev/null +++ b/backend/app/models/einsatz.py @@ -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) diff --git a/backend/app/schemas/einsatz.py b/backend/app/schemas/einsatz.py new file mode 100644 index 0000000..dee8f88 --- /dev/null +++ b/backend/app/schemas/einsatz.py @@ -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] = [] diff --git a/backend/app/services/einsatz.py b/backend/app/services/einsatz.py new file mode 100644 index 0000000..9baa333 --- /dev/null +++ b/backend/app/services/einsatz.py @@ -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() diff --git a/backend/tests/test_einsatz.py b/backend/tests/test_einsatz.py new file mode 100644 index 0000000..5e11aa7 --- /dev/null +++ b/backend/tests/test_einsatz.py @@ -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 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f57198e..6272815 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index e097585..0b363ea 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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" && } {aktiverTab === "readinessRegeln" && } + {aktiverTab === "einsaetze" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( <> diff --git a/frontend/src/pages/admin/EinsatzSection.tsx b/frontend/src/pages/admin/EinsatzSection.tsx new file mode 100644 index 0000000..80bccca --- /dev/null +++ b/frontend/src/pages/admin/EinsatzSection.tsx @@ -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([]); + 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(null); + const [neueRessourceId, setNeueRessourceId] = useState(""); + const [notizEntwurf, setNotizEntwurf] = useState(""); + + async function laden() { + setLaedt(true); + try { + setEinsaetze(await apiRequest("/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 ( +
+

Einsätze

+
+
+ setBezeichnung(e.target.value)} + /> + setDatum(e.target.value)} /> + setOrt(e.target.value)} + /> + +
+ + {laedt &&

Lade…

} + {!laedt && einsaetze.length === 0 &&

Noch keine Einsätze erfasst.

} + {!laedt && einsaetze.length > 0 && ( + + + + + + + + + + + + + + {einsaetze.map((e) => ( + + + + + + + + + + {ausgeklapptId === e.id && ( + +
DatumBezeichnungOrtStatusRessourcen
{new Date(e.datum).toLocaleDateString("de-DE")}{e.bezeichnung}{e.ort ?? "–"} + + {e.ressourcen_objekt_ids.length} + +
+
+
+ Ressourcen +
    + {e.ressourcen_objekt_ids.map((objektId) => { + const objekt = objekte.find((o) => o.id === objektId); + return ( +
  • + {objekt ? `${objekt.name} (${objekt.code})` : `#${objektId}`} + +
  • + ); + })} +
+
+ + +
+
+
+ Debriefing-Notiz +