diff --git a/.env.example b/.env.example index 5125c1b..b772b7b 100644 --- a/.env.example +++ b/.env.example @@ -213,6 +213,26 @@ LLM_BASE_URL=http://ollama:11434/v1 LLM_API_KEY= LLM_MODELL=qwen2.5:14b +# ----------------------------------------------------------------------------- +# Einsätze und Übungen (Phase 3) +# ----------------------------------------------------------------------------- +# Adressen zu Koordinaten auflösen. Vorgesehen ist ein SELBST betriebener +# Nominatim — Einsatzadressen gehören nicht an einen öffentlichen Dienst, und +# dessen Nutzungsbedingungen erlauben es ohnehin nicht. +GEOCODING_ENABLED=false +# NOMINATIM_URL=http://nominatim:8080 +GEOCODING_TIMEOUT=5 +# Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt). +GEOCODING_LAENDER=de,at,ch + +# Einsatzportal für Fremdwehren: ein signierter Link ohne Anmeldung, über den +# eine Nachbarwehr den Stand sieht und Lagemeldungen absetzen kann. +PORTAL_ENABLED=true +# Wie lange ein neu erzeugter Link gilt. +PORTAL_GUELTIGKEIT_STUNDEN=24 +# Höchstzahl Zugriffe je Link und Stunde. +PORTAL_MAX_ZUGRIFFE=120 + # ----------------------------------------------------------------------------- # Wartung und Prüfung (Phase 2) # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 212e783..5ac9d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,119 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). ## [Unveröffentlicht] -Nächster Schritt ist Phase 3 (Einsätze und Übungen). +Nächster Schritt ist Phase 4 (Kiosk-PWA mit Offline-Betrieb). + +--- + +## [0.4.0] — 2026-09-03 + +Phase 3: Einsätze und Übungen. Ab hier dokumentiert die Wehr, was sie tut — +für die Gemeinde, für die Versicherung und für die eigene Mannschaft. + +### Hinzugefügt + +**Datenmodell** + +- `Einsatz` mit den sechs Zeitstempeln (alarmiert, ausgerückt, eingetroffen, + Einsatzende, eingerückt, einsatzbereit), Ort samt Koordinaten, Kategorie nach + der bayerischen Feuerwehrstatistik und den Zahlen für die Gemeindemeldung. +- `Einsatzfahrzeug` mit eigenen Zeiten je Fahrzeug — Nachalarmierung bleibt + sichtbar —, Kilometerstand und Wasserabgabe. +- `Einsatzkraft` mit Funktion nach FwDV 3, Fahrzeug, Zeitraum und + Atemschutz-Kennzeichen. +- `Materialverbrauch` und `Lagemeldung` mit Foto und Herkunft. +- `PortalZugang` — ein zeitlich begrenzter Link für eine Fremdwehr, gespeichert + nur als Hash. +- `Uebung` und `Uebungsteilnahme` mit Thema, Lernzielen und Anwesenheit. + +**Schnellerfassung** + +- Stichwort, Ort, ausgerückte Fahrzeuge — mehr fragt die Erfassung im Alarmfall + nicht. Alarmzeit ist der Moment des Anlegens, alles Weitere wird nachgetragen. +- Fortlaufende Einsatznummer `JJJJ-NNNN` je Wehr und Jahr, vergeben unter einer + Transaktionssperre. + +**Zeiten und Stärke** + +- Ein Knopf je Zeitstempel, solange der Einsatz läuft. +- Daraus gerechnet: Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer. +- Stärke nach FwDV 3 in der Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`, + mit Zuordnung zur taktischen Einheit (Trupp, Staffel, Gruppe, Zug). +- Hinweise statt Sperren: eine verdrehte Zeitreihenfolge oder eine fehlende + Führungskraft wird angezeigt, verhindert das Speichern aber nicht — ein + Einsatz, der drei Wochen später nachgetragen wird, ist immer noch besser als + einer, den niemand erfasst. + +**Personalvorschlag** + +- Sortiert nach Einsatzerfahrung der letzten zwölf Monate, mit dem + Qualifikationsstand daneben: eine abgelaufene G26.3 fällt auf, bevor jemand + unter Atemschutz geht. + +**Einsatzbericht und Freigabe** + +- Bericht als PDF über Gotenberg: Kopf, Zeitleiste mit Kennzahlen, Fahrzeuge, + Mannschaft mit Stärke, Material, Verlauf und Bericht. +- Mit der Freigabe wird der Einsatz festgeschrieben — durchgesetzt von einem + Datenbanktrigger, nicht vom Anwendungscode. Ausgenommen bleiben das + nachgetragene PDF und die Lagemeldungen. +- Jahresexport als quer gedrucktes PDF unter `/api/einsaetze/jahresexport`. + +**Statistik** + +- Einsätze nach Kategorie und Monat, Verteilung der Hilfsfristen mit dem + 80-Prozent-Wert, Beteiligung je Person (Einsätze, davon unter Atemschutz, + Übungen) und Auslastung je Fahrzeug. + +**Einsatzportal für Fremdwehren** + +- Signierter Link ohne Anmeldung, gültig 24 Stunden, jederzeit widerrufbar, + jeder Aufruf wird mitgezählt. +- Zeigt Stichwort, Ort, Zeitstempel und Lagemeldungen — keine Namen von + Einsatzkräften, keinen Bericht, keinen Weg in den Rest der Anwendung. +- Nimmt Lagemeldungen mit Foto an, begrenzt auf 30 Meldungen und 10 Fotos je + Zugang und Stunde. +- Eigene Zugriffsbegrenzung über Redis (`src/lib/ratelimit.ts`), die bei + Störung durchlässt statt zu blockieren. + +**Übungen** + +- Thema, Art, Lernziele, Ort und Leitung; Anwesenheit als Liste zum Abhaken. +- Fließt in die Beteiligungsstatistik ein. + +**Adressen zu Koordinaten** + +- Anbindung an einen **selbst betriebenen** Nominatim hinter einer Schnittstelle + in `lib/integrations/`. Ohne Konfiguration bleibt die Suche leer und die + Koordinaten werden von Hand eingetragen. +- Koordinaten liegen doppelt: als Dezimalgrad für die Anwendung und als + PostGIS-Punkt für die Karte ab Phase 6, zusammengehalten von einem Trigger. + +**Sonstiges** + +- Der Seed legt acht Einsätze über das Jahr verteilt an — Brände, technische + Hilfeleistungen, ein Fehlalarm einer Brandmeldeanlage — dazu sechs Übungen + und die Beteiligung der Mannschaft. +- 57 neue Tests: 32 Unit-Tests zu Stärke, Zeiten und Nummernkreis, 14 gegen eine + echte Datenbank (Koordinaten-Trigger, Freigabe, Mandantentrennung) und 11 + Playwright-Durchläufe einschließlich des Portals in einem Browser **ohne** + Sitzung. + +### Geändert + +- Der Nummernkreis `JJJJ-NNNN` liegt jetzt in `src/lib/nummernkreis.ts` und wird + von Prüfprotokollen, Einsätzen und Übungen gemeinsam verwendet. +- Die Seitenleiste zeigt Einsätze und Übungen als verfügbar. +- Das Zeitlimit der E2E-Erwartungen steigt von 15 auf 25 Sekunden: mit fünfzig + Routen dauert die erste Übersetzung einer Seite im Entwicklungsserver unter + Last länger als vorher, und daran scheiterten gelegentlich Tests, die einzeln + grün sind. + +### Behoben + +- Die Anwesenheitsliste einer Übung forderte 300 Mitglieder an und lief damit + in die Höchstzahl von 200 je Seite — die Liste blieb leer. Gefunden über die + Server-Ausgabe während der E2E-Tests. --- diff --git a/README.md b/README.md index 615dbb8..136ab4c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 0 — Fundament | fertig | | Phase 1 — Stammdaten | fertig | | Phase 2 — Wartung und Prüfung | fertig | -| Phase 3 — Einsätze und Übungen | offen | +| Phase 3 — Einsätze und Übungen | fertig | | Phase 4 — Kiosk-PWA | offen | | Phase 5 — Atemschutz (FwDV 7) | offen | | Phase 6 — Kartenmodule | offen | @@ -279,6 +279,129 @@ passt selten eins zu eins auf die Seriennummern im eigenen Bestand. | `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden | +--- + +## Phase 3 — Einsätze und Übungen + +Was die Wehr tut, wird hier dokumentiert — für die Gemeinde, für die +Versicherung und für die eigene Mannschaft. + +### Der Weg durch das Modul + +``` +Schnellerfassung Stichwort, Ort, Fahrzeuge — im Alarmfall in 20 Sekunden + ↓ während des Einsatzes +Zeitstempel alarmiert, ausgerückt, eingetroffen, Ende, eingerückt, + einsatzbereit — je ein Knopf +Lagemeldungen aus der Wehr und über das Portal einer Fremdwehr + ↓ danach, in Ruhe +Kräfte und Material wer war dabei, was wurde verbraucht +Bericht der Text des Einsatzleiters + ↓ Freigabe +Einsatzbericht als PDF, festgeschrieben, an die Gemeinde +``` + +### Die sechs Zeitstempel + +Sie sind der Kern jeder Einsatzauswertung. Aus ihnen ergeben sich drei Zahlen: + +| Kennzahl | Rechnung | sagt etwas aus über | +|---|---|---| +| **Ausrückzeit** | Alarm bis Ausrücken | die Wehr selbst | +| **Anfahrtszeit** | Ausrücken bis Eintreffen | Lage und Wegstrecke | +| **Hilfsfrist** | Alarm bis Eintreffen | das, wonach die Gemeinde fragt | + +In Bayern gilt als Ziel: **in 80 Prozent der Fälle innerhalb von zehn Minuten**. +Genau dieser Wert steht in der Statistik — nicht der Durchschnitt, denn der +verdeckt die Ausreißer, um die es geht. + +### Stärke nach FwDV 3 + +Aus den Funktionen der erfassten Kräfte errechnet FLORIAN die Stärke in der +üblichen Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`: + +| Einheit | Stärke | Besetzung | +|---|---|---| +| Trupp | 0/1/1/2 | Truppführer, Truppmann | +| Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps | +| Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps | +| Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel | + +**Regional unterschiedlich.** Manche Wehren zählen den Maschinisten zu den +Unterführern, andere schreiben dreistellig ohne Unterführer (Gruppe = 1/8/9). +Die Zuordnung steht an einer Stelle — [`src/lib/einsatz/staerke.ts`](src/lib/einsatz/staerke.ts) — +und ist bewusst leicht zu ändern. + +### Der freigegebene Bericht ist unveränderbar + +Dieselbe Logik wie beim Prüfprotokoll aus Phase 2, denn der Bericht geht an +die Gemeinde und im Schadensfall an die Versicherung. Bis zur Freigabe ist +alles änderbar — ein Einsatz wird tagelang ergänzt, und genau dafür ist der +Entwurfsstand da. Mit der Freigabe ist Schluss; der Trigger +`einsatz_freigabe_schuetzen` setzt das durch. + +Zwei Ausnahmen bleiben, beide nachtragend: das PDF, solange keines hinterlegt +ist, und `geaendertAm`. + +**Lagemeldungen sind ausdrücklich ausgenommen.** Sie sind das Protokoll des +Verlaufs, kein Bestandteil des Berichts — und eine Meldung aus dem Portal kann +eintreffen, wenn der Bericht längst geschrieben ist. + +### Einsatzportal für Fremdwehren + +Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor +Ort. Die haben kein Konto in FLORIAN und sollen auch keines bekommen. Der +Einsatzleiter erzeugt stattdessen einen Link, den er als QR-Code zeigt oder +weiterschickt. + +Vier Schranken sichern ihn ab: + +1. **Zufall** — 32 zufällige Bytes im Link. +2. **Nur ein Hash in der Datenbank** — wer sie liest, bekommt keinen gültigen Link. +3. **Zeitlich begrenzt** — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder + Aufruf wird mitgezählt. +4. **Zugriffsbegrenzung** — 120 Aufrufe je Link und Stunde, 30 Meldungen, 10 + Fotos. + +Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort, +Zeitstempel und Lagemeldungen. **Keine Namen von Einsatzkräften, keine +Berichte, kein Weg in den Rest der Anwendung.** + +### Adressen zu Koordinaten + +Über einen **selbst betriebenen** Nominatim (`NOMINATIM_URL`). Einsatzadressen +sagen, wo es gebrannt hat und wer betroffen war; solche Anfragen gehören nicht +an einen fremden Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim +erlauben es ohnehin nicht. + +Ohne Konfiguration bleibt das Feld leer und die Koordinaten werden von Hand +eingetragen — kein Fehler, sondern der Normalfall in einer frischen +Installation. Die Karte dazu kommt in Phase 6. + +Gespeichert wird doppelt: als Dezimalgrad, die Prisma lesen kann, und als +PostGIS-Punkt für die Karte. Ein Trigger hält beides zusammen. + +### Statistik und Jahresexport + +`/einsaetze/statistik` beantwortet beide Fragen: die der Gemeinde (Anzahl nach +Kategorie der Landesstatistik, Hilfsfristen) und die der Wehr (Beteiligung je +Person, Auslastung je Fahrzeug). Der Jahresexport als PDF liegt unter +`/api/einsaetze/jahresexport?jahr=…` und wird bewusst **nicht** abgelegt — er +ist eine Auswertung, keine Urkunde, und ändert sich, solange noch Einsätze +nachgetragen werden. + +### Zusätzliche Umgebungsvariablen + +| Variable | Vorgabe | Bedeutung | +|---|---|---| +| `GEOCODING_ENABLED` | `false` | Adresssuche einschalten | +| `NOMINATIM_URL` | — | Adresse des eigenen Nominatim | +| `GEOCODING_LAENDER` | `de,at,ch` | Eingrenzung der Suche | +| `PORTAL_ENABLED` | `true` | Einsatzportal für Fremdwehren | +| `PORTAL_GUELTIGKEIT_STUNDEN` | `24` | Lebensdauer eines Links | +| `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde | + + --- ## Befehle diff --git a/docker-compose.yml b/docker-compose.yml index dab9a2c..d5dc326 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,13 @@ x-app-env: &app-env LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_API_KEY: ${LLM_API_KEY:-} LLM_MODELL: ${LLM_MODELL:-} + GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false} + NOMINATIM_URL: ${NOMINATIM_URL:-} + GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5} + GEOCODING_LAENDER: ${GEOCODING_LAENDER:-de,at,ch} + PORTAL_ENABLED: ${PORTAL_ENABLED:-true} + PORTAL_GUELTIGKEIT_STUNDEN: ${PORTAL_GUELTIGKEIT_STUNDEN:-24} + PORTAL_MAX_ZUGRIFFE: ${PORTAL_MAX_ZUGRIFFE:-120} WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *} WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *} WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180} diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 14454c4..ad51313 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,9 +1,9 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 2 (Version 0.3.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 3 (Version 0.4.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. -> Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den folgenden Phasen +> Atemschutz, Karten und die Kiosk-Oberfläche kommen in den folgenden Phasen > dazu. Siehe [ROADMAP.md](ROADMAP.md). --- @@ -70,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar. ### Die Seitenleiste Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt -rechts sein Phasenkürzel — `P3` heißt „kommt in Phase 3". +rechts sein Phasenkürzel — `P4` heißt „kommt in Phase 4". Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis: @@ -585,7 +585,200 @@ Ersatz da ist. Der Knopf dazu steht in der Zeile. Voraussetzung ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist (Kapitel 5). -## 18. Rollen und was sie dürfen +--- + +## 18. Einen Einsatz erfassen + +![Einsatzliste](screenshots/19-einsaetze-light.png) + +Im Alarmfall zählt eines: dass der Einsatz überhaupt angelegt wird. Die +**Schnellerfassung** fragt deshalb nur drei Dinge: + +1. **Stichwort** — das, was die Leitstelle durchgegeben hat: „B 3 Wohnhausbrand" +2. **Ort** — Straße, Hausnummer, gegebenenfalls das Objekt +3. **Fahrzeuge** — welche ausgerückt sind + +Die Alarmzeit ist der Moment des Anlegens. Alles andere — Mannschaft, Zeiten, +Bericht — wird später nachgetragen. Der Einsatz bekommt sofort eine +fortlaufende Nummer im Format `JJJJ-NNNN`. + +**Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr +einen eigenen Nominatim betreibt (Kapitel 24). Ohne den bleibt das Feld leer — +das ist in Ordnung, die Karte kommt in Phase 6. + +--- + +## 19. Der Einsatz von innen + +![Einsatzdetail](screenshots/22-einsatz-light.png) + +### Zeitstempel + +Sechs Stempel beschreiben einen Einsatz vollständig: + +| Stempel | wann | +|---|---| +| alarmiert | die Leitstelle hat alarmiert | +| ausgerückt | das erste Fahrzeug verlässt das Gerätehaus | +| eingetroffen | das erste Fahrzeug ist an der Einsatzstelle | +| Einsatzende | die Arbeit an der Einsatzstelle ist beendet | +| eingerückt | zurück im Gerätehaus | +| einsatzbereit | Fahrzeuge und Geräte sind wieder hergerichtet | + +Jeder Stempel ist ein Knopf und setzt die aktuelle Uhrzeit. Ein Klick auf +„zurücknehmen" macht ihn wieder leer, falls er zu früh gedrückt wurde. + +Daraus errechnet FLORIAN vier Zahlen: + +- **Ausrückzeit** — Alarm bis Ausrücken. Sagt etwas über die Wehr aus. +- **Anfahrt** — Ausrücken bis Eintreffen. Sagt etwas über die Lage aus. +- **Hilfsfrist** — Alarm bis Eintreffen. Die Zahl, nach der die Gemeinde fragt. +- **Einsatzdauer** — Alarm bis Einrücken. + +Stimmt die Reihenfolge nicht, erscheint ein Hinweis. **Gespeichert wird +trotzdem**: Zeiten werden oft Tage später aus dem Gedächtnis nachgetragen, und +ein Einsatz mit unsicheren Zeiten ist besser als einer, den niemand erfasst. + +### Mannschaft + +„Mannschaft erfassen" zeigt einen Vorschlag, sortiert danach, wer im letzten +Jahr am häufigsten dabei war. Daneben steht, ob die einsatzrelevanten +Qualifikationen noch gelten — eine abgelaufene G26.3 fällt so auf, **bevor** +jemand unter Atemschutz geht. + +Ein Klick übernimmt die Person, danach lassen sich Funktion und Atemschutz in +der Tabelle einstellen. + +Rechts steht laufend die **Stärke nach FwDV 3**: + +``` +1/3/5/9 entspricht einer Gruppe +│ │ │ └── gesamt +│ │ └──── Mannschaft +│ └────── Unterführer (Truppführer) +└──────── Führer (Gruppen-, Zug-, Staffelführer, Einsatzleiter) +``` + +> **Regional unterschiedlich.** Hier zählt der Maschinist zur Mannschaft. Manche +> Wehren rechnen ihn zu den Unterführern, andere schreiben dreistellig ohne +> Unterführer. Die Zuordnung steht an einer Stelle im Code und lässt sich +> anpassen. + +### Verlauf + +Lagemeldungen halten fest, was währenddessen passiert ist — mit Foto und +wahlweise als **wichtig** gekennzeichnet (Menschenrettung, Nachforderung, +Lageänderung). Meldungen bleiben auch nach der Freigabe möglich. + +--- + +## 20. Bericht und Freigabe + +Der Bericht ist der Text des Einsatzleiters: Lage bei Eintreffen, was getan +wurde, wie es endete. Er lässt sich jederzeit speichern und weiter ergänzen. + +**Freigeben** schließt den Einsatz ab. Danach: + +- ist der Bericht **unveränderbar** — er geht an die Gemeinde und im + Schadensfall an die Versicherung, +- entsteht im Hintergrund das PDF (Knopf „Bericht als PDF"), +- bleiben nur die Lagemeldungen weiter möglich. + +Vor der Freigabe prüft FLORIAN zwei Dinge: dass eine Alarmzeit gesetzt ist und +dass mindestens eine Einsatzkraft erfasst wurde. Ein Bericht ohne Mannschaft +ist kein Nachweis. + +--- + +## 21. Einsatzportal für Fremdwehren + +![Einsatzportal](screenshots/24-portal-light.png) + +Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor +Ort. Die brauchen kein Konto in FLORIAN — sie bekommen einen **Link**. + +So geht es: + +1. Auf der Einsatzseite unter „Portal für Fremdwehren" eintragen, für wen der + Link ist („FF Nachbarhausen"). +2. **Erzeugen** klicken. Der Link erscheint **einmal** — danach steht in der + Datenbank nur noch sein Hash. +3. Link kopieren und weitergeben, oder als QR-Code zeigen. + +Was die Fremdwehr sieht: Stichwort, Ort, die Zeitstempel und den Verlauf. Was +sie **nicht** sieht: Namen von Einsatzkräften, den Bericht, irgendetwas +außerhalb dieses Einsatzes. + +Was sie tun kann: eine Lagemeldung mit Foto absetzen. + +Der Link gilt 24 Stunden, lässt sich jederzeit **widerrufen**, und jeder Aufruf +wird mitgezählt — ein Link, der nach dem Einsatz noch benutzt wird, fällt auf. + +--- + +## 22. Statistik + +![Einsatzstatistik](screenshots/20-statistik-light.png) + +Zwei Fragen, eine Seite. + +**Was die Gemeinde fragt:** wie viele Einsätze welcher Art, und wie schnell wart +ihr da? Dafür stehen oben die Zahlen nach der Landesstatistik und die +**Hilfsfrist als 80-Prozent-Wert**. + +> Warum nicht der Durchschnitt? Weil ein einzelner Einsatz auf der Autobahn ihn +> nach oben zieht, während neunzig Prozent der Fälle im Ort liegen. In Bayern +> gilt als Ziel: in 80 Prozent der Fälle binnen zehn Minuten. Genau diese Zahl +> steht hier. + +**Was die Wehr fragt:** wer trägt die Last? Dafür steht unten die Beteiligung je +Person — Einsätze, davon unter Atemschutz, und Übungen. + +**Jahresexport als PDF** erzeugt das Blatt für die Gemeinde: Überblick, +Kategorien und alle Einsätze des Jahres in einer Tabelle. Es wird nicht +gespeichert, sondern jedes Mal neu gerechnet — es ist eine Auswertung, keine +Urkunde. + +--- + +## 23. Übungen + +![Übungen](screenshots/21-uebungen-light.png) + +Eine Übung besteht aus Thema, Art, Datum und den **Lernzielen** — was danach +sitzen soll. + +![Übung mit Anwesenheit](screenshots/23-uebung-light.png) + +Die Anwesenheit wird als Liste abgehakt: alle aktiven Mitglieder auf einmal, +nicht einzeln gesucht. Bei einer Übung ist die halbe Wehr da. + +Die Teilnahme fließt in die Beteiligungsstatistik ein — das ist die Zahl, nach +der in der Jahreshauptversammlung gefragt wird und die bei Beförderungen zählt. + +--- + +## 24. Adressen zu Koordinaten (Nominatim) + +FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim +betreibt: + +```bash +# .env +GEOCODING_ENABLED=true +NOMINATIM_URL=http://nominatim:8080 +GEOCODING_LAENDER=de,at,ch +``` + +> **Kein öffentlicher Dienst.** Einsatzadressen sagen, wo es gebrannt hat und +> wer betroffen war. Solche Anfragen gehören nicht an einen fremden Server — +> und die Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin +> nicht. + +Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand +eintragen oder weglassen. + +## 25. Rollen und was sie dürfen Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in `src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr @@ -614,7 +807,7 @@ Das Terminal selbst ist nur die Tür. --- -## 19. Anmeldung über authentik einrichten +## 26. Anmeldung über authentik einrichten authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. @@ -667,7 +860,7 @@ ganze Wehr aus. --- -## 20. Terminal-Anmeldung im Gerätehaus +## 27. Terminal-Anmeldung im Gerätehaus Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits. @@ -689,7 +882,7 @@ Huber), PIN `471203`. --- -## 21. Was protokolliert wird +## 28. Was protokolliert wird Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. @@ -714,7 +907,7 @@ jeweiligen Detailseite sichtbar. --- -## 22. Wenn etwas nicht geht +## 29. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index 16cb5c1..27001c8 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -33,7 +33,7 @@ Faktoren, die in die Schätzung eingehen: | 0 | Fundament | 12–16 PT | — | **1:00 h** | | 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** | | 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** | -| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen | +| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** | | 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen | | 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen | | 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen | @@ -149,7 +149,7 @@ Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und ## Phase 3 — Einsätze und Übungen -**Vergleichsaufwand: 12–16 PT** +**Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 2:10 h Sitzungszeit** | Baustein | PT | |---|---:| @@ -166,6 +166,30 @@ Das externe Portal für Fremdwehren ist sicherheitsrelevant: ein Zugang ohne Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand steckt weniger in der Funktion als in der Absicherung. +**Rückblick.** Die Schätzung lag richtig darin, wo der Aufwand steckt — nur +nicht in welcher Reihenfolge. + +Billiger als gedacht war das Portal. Der Grund liegt in Phase 0 und 2: die +Mandantentrennung, das Hashen von Geheimnissen und die Ablage von Dateien gab +es schon, und der Zugang war am Ende ein Datensatz mit Hash, ein Zeitfenster +und ein Zähler in Redis. Was blieb, war die Frage, was das Portal **nicht** +zeigen darf — und die ist fachlicher Natur, nicht technischer. + +Teurer war die Statistik. Nicht das Rechnen, sondern die Entscheidung, welche +Zahl gilt: der Durchschnitt der Eintreffzeiten sagt nichts, weil ein einzelner +Einsatz auf der Autobahn ihn verschiebt. Es zählt der 80-Prozent-Wert, weil die +Gemeinde danach fragt — und das steht in keinem Pflichtenheft, das muss man +wissen. + +Zwei Dinge fielen erst beim Tun auf: + +- Koordinaten müssen doppelt liegen. Prisma kann den PostGIS-Typ nicht lesen; + ohne die Dezimalgrade daneben wäre jede Anzeige eine Rohabfrage geworden. Ein + Trigger hält beides zusammen. +- Der Nummernkreis war schon zweimal da (Prüfprotokolle, jetzt Einsätze und + Übungen). Beim dritten Mal wurde daraus ein eigener Baustein — die passende + Gelegenheit war genau diese, keine früher. + ## Phase 4 — Kiosk-PWA **Vergleichsaufwand: 16–22 PT** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 400872e..3db4b3c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -14,7 +14,109 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 | | Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 | | Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 | -| **Summe** | **5:30 h** | **4** | | +| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 | +| **Summe** | **7:40 h** | **5** | | + +--- + +## 2026-09-03 · Phase 3 — Einsätze und Übungen + +**12:25 – 14:35 · 2:10 h · Version 0.4.0** + +### Was entstanden ist + +Ab hier dokumentiert die Wehr, was sie tut. Acht neue Tabellen, ein öffentlich +erreichbares Portal und die Zahlen, nach denen die Gemeinde fragt. + +**Schnellerfassung.** Im Alarmfall fragt FLORIAN drei Dinge: Stichwort, Ort, +welche Fahrzeuge raus sind. Alarmzeit ist der Moment des Anlegens. Alles +Weitere wird nachgetragen, wenn wieder Ruhe ist. + +**Zeitstempel und Kennzahlen.** Sechs Stempel, je ein Knopf. Daraus rechnet +sich Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer. + +**Stärke nach FwDV 3.** Aus den Funktionen der Mannschaft, in der üblichen +Schreibweise, mit Zuordnung zur taktischen Einheit. + +**Einsatzbericht.** Als PDF über Gotenberg, mit der Freigabe festgeschrieben. +Dazu der Jahresexport quer gedruckt für die Gemeinde. + +**Statistik.** Kategorien nach Landesstatistik, Verteilung der Hilfsfristen mit +dem 80-Prozent-Wert, Beteiligung je Person, Auslastung je Fahrzeug. + +**Portal für Fremdwehren.** Ein signierter Link ohne Anmeldung: Stand sehen, +Lagemeldung mit Foto absetzen. Zeitlich begrenzt, mengenbegrenzt, widerrufbar. + +**Übungen.** Thema, Lernziele, Anwesenheit zum Abhaken — die Grundlage für die +Teilnahmequote in der Jahreshauptversammlung. + +### Entscheidungen + +**Die Hilfsfrist wird als 80-Prozent-Wert ausgewiesen, nicht als Durchschnitt.** +Der Durchschnitt sagt bei Eintreffzeiten nichts: ein einzelner Einsatz auf der +Autobahn zieht ihn nach oben, während neunzig Prozent der Fälle im Ort liegen. +In Bayern gilt als Ziel, in 80 Prozent der Fälle binnen zehn Minuten da zu +sein — also ist genau das die Zahl. + +**Hinweise statt Sperren bei Zeiten und Stärke.** Eine verdrehte Reihenfolge +oder eine fehlende Führungskraft wird angezeigt, aber nicht verhindert. +Einsätze werden Tage später aus dem Gedächtnis nachgetragen; ein Einsatz mit +unsicheren Zeiten ist immer noch besser als einer, den niemand erfasst, weil +das Formular ihn nicht angenommen hat. + +**Der freigegebene Bericht ist unveränderbar — Lagemeldungen nicht.** Der +Bericht geht an die Gemeinde, die Meldungen sind das Protokoll des Verlaufs. +Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst +geschrieben ist; sie nachträglich abzuweisen wäre falsch. + +**Das Portal zeigt bewusst wenig.** Stichwort, Ort, Zeitstempel und Meldungen — +also das, was an der Einsatzstelle ohnehin jeder sieht. Keine Namen von +Einsatzkräften, kein Bericht, keine Navigation. Der Link ist ein Fenster auf +einen Einsatz, keine Tür in die Anwendung. + +**Koordinaten liegen doppelt.** Als Dezimalgrad, die Prisma lesen kann, und als +PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides zusammen — +sonst wäre jede Schreiboperation Roh-SQL geworden, und ein über einen anderen +Weg angelegter Einsatz fehlte später auf der Karte. + +**Geocoding nur gegen einen eigenen Nominatim.** Einsatzadressen sagen, wo es +gebrannt hat. Solche Anfragen gehören nicht an einen fremden Dienst — und die +Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin nicht. + +### Wo es gehakt hat + +| Problem | Ursache | Lösung | +|---|---|---| +| Prisma wollte wieder die Suchindizes löschen | wie in Phase 2: gin_trgm und gist kennt das Schema nicht | DROP INDEX-Zeilen aus der erzeugten Migration entfernt, mit Kommentar | +| Der Prisma-Client ließ sich nicht neu erzeugen | die Engine-DLL war vom laufenden Entwicklungsserver gesperrt | Server und Worker beenden, dann erzeugen — unter Windows unvermeidlich | +| Die Anwesenheitsliste blieb leer | sie forderte 300 Mitglieder an, erlaubt sind 200 je Seite | auf 200 gesetzt; aufgefallen in der Server-Ausgabe während der E2E-Tests | +| Zwei E2E-Tests schlugen im Gesamtlauf fehl, einzeln aber nicht | die erste Übersetzung einer Route dauert unter Last länger als das Erwartungslimit | Limit von 15 auf 25 Sekunden — Wartezeit des Entwicklungsservers, kein Fehler der Anwendung | + +### Abweichung von der Vorgabe + +**Keine.** Alle acht Tabellen tragen `organisationId` und stehen unter Row +Level Security, jede schreibende Operation protokolliert, gelöscht wird weich. + +Eine Ergänzung: die Portalprozeduren sind die **ersten öffentlich erreichbaren +Endpunkte** in FLORIAN. Sie prüfen den Token selbst, laufen für die Suche des +Zugangs bewusst ohne Mandantenfilter (begründet im Code) und arbeiten danach im +Mandantenkontext der gefundenen Wehr. + +### Offen und bewusst so gelassen + +Die Zuordnung der Funktionen zur Stärke ist **regional unterschiedlich**. Hier +zählt der Maschinist zur Mannschaft und nur der Truppführer als Unterführer; +eine Gruppe ergibt damit 1/3/5/9. Andere Wehren rechnen anders. Die Zuordnung +steht an einer Stelle in `lib/einsatz/staerke.ts` und ist leicht zu ändern — +bestätigt ist sie noch nicht. + +### Nachweise + +- `npm run typecheck` — ohne Ausgabe +- `npm run test` — 207 Tests grün, davon 32 gegen eine echte Datenbank +- `npm run test:e2e` — 95 Tests grün, 2 übersprungen +- `npm run build` — 38 Routen +- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 05b9e1f..345c6e3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,7 +12,7 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md). Phase 0 ████████████████████ fertig 0.1.0 Phase 1 ████████████████████ fertig 0.2.0 Phase 2 ████████████████████ fertig 0.3.0 -Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0 +Phase 3 ████████████████████ fertig 0.4.0 Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0 Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0 Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0 @@ -83,7 +83,7 @@ Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner. **Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch die Unfallkasse standhält. -## Phase 3 — Einsätze und Übungen · als Nächstes +## Phase 3 — Einsätze und Übungen · fertig - Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen, Einsatzende, eingerückt, einsatzbereit @@ -97,7 +97,7 @@ die Unfallkasse standhält. **Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen. -## Phase 4 — Kiosk-PWA +## Phase 4 — Kiosk-PWA · als Nächstes Für die Wandtablets in der Fahrzeughalle. diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png index 1195787..48a2476 100644 Binary files a/docs/screenshots/02-uebersicht-dark.png and b/docs/screenshots/02-uebersicht-dark.png differ diff --git a/docs/screenshots/02-uebersicht-light.png b/docs/screenshots/02-uebersicht-light.png index 5db6a1c..dd93ff9 100644 Binary files a/docs/screenshots/02-uebersicht-light.png and b/docs/screenshots/02-uebersicht-light.png differ diff --git a/docs/screenshots/03-geraeteliste-dark.png b/docs/screenshots/03-geraeteliste-dark.png index 287aba5..0da96d8 100644 Binary files a/docs/screenshots/03-geraeteliste-dark.png and b/docs/screenshots/03-geraeteliste-dark.png differ diff --git a/docs/screenshots/03-geraeteliste-light.png b/docs/screenshots/03-geraeteliste-light.png index 3ca275f..02b2a3a 100644 Binary files a/docs/screenshots/03-geraeteliste-light.png and b/docs/screenshots/03-geraeteliste-light.png differ diff --git a/docs/screenshots/04-geraet-dark.png b/docs/screenshots/04-geraet-dark.png index 950c2f5..f144805 100644 Binary files a/docs/screenshots/04-geraet-dark.png and b/docs/screenshots/04-geraet-dark.png differ diff --git a/docs/screenshots/04-geraet-light.png b/docs/screenshots/04-geraet-light.png index 5d9aa3b..44d80ac 100644 Binary files a/docs/screenshots/04-geraet-light.png and b/docs/screenshots/04-geraet-light.png differ diff --git a/docs/screenshots/05-fahrzeuge-dark.png b/docs/screenshots/05-fahrzeuge-dark.png index c8b353e..dbf0b93 100644 Binary files a/docs/screenshots/05-fahrzeuge-dark.png and b/docs/screenshots/05-fahrzeuge-dark.png differ diff --git a/docs/screenshots/05-fahrzeuge-light.png b/docs/screenshots/05-fahrzeuge-light.png index b128e8b..41e503e 100644 Binary files a/docs/screenshots/05-fahrzeuge-light.png and b/docs/screenshots/05-fahrzeuge-light.png differ diff --git a/docs/screenshots/06-fahrzeug-dark.png b/docs/screenshots/06-fahrzeug-dark.png index d56a31d..9645073 100644 Binary files a/docs/screenshots/06-fahrzeug-dark.png and b/docs/screenshots/06-fahrzeug-dark.png differ diff --git a/docs/screenshots/06-fahrzeug-light.png b/docs/screenshots/06-fahrzeug-light.png index 3255f88..99df474 100644 Binary files a/docs/screenshots/06-fahrzeug-light.png and b/docs/screenshots/06-fahrzeug-light.png differ diff --git a/docs/screenshots/07-mitglieder-dark.png b/docs/screenshots/07-mitglieder-dark.png index 38599ae..18af5ac 100644 Binary files a/docs/screenshots/07-mitglieder-dark.png and b/docs/screenshots/07-mitglieder-dark.png differ diff --git a/docs/screenshots/07-mitglieder-light.png b/docs/screenshots/07-mitglieder-light.png index a3c0c47..d52fa3f 100644 Binary files a/docs/screenshots/07-mitglieder-light.png and b/docs/screenshots/07-mitglieder-light.png differ diff --git a/docs/screenshots/08-qualifikationen-dark.png b/docs/screenshots/08-qualifikationen-dark.png index 596b7fa..5b2ddd0 100644 Binary files a/docs/screenshots/08-qualifikationen-dark.png and b/docs/screenshots/08-qualifikationen-dark.png differ diff --git a/docs/screenshots/08-qualifikationen-light.png b/docs/screenshots/08-qualifikationen-light.png index 5ed17d3..8a0996a 100644 Binary files a/docs/screenshots/08-qualifikationen-light.png and b/docs/screenshots/08-qualifikationen-light.png differ diff --git a/docs/screenshots/09-import-dark.png b/docs/screenshots/09-import-dark.png index ce83bea..53a406d 100644 Binary files a/docs/screenshots/09-import-dark.png and b/docs/screenshots/09-import-dark.png differ diff --git a/docs/screenshots/09-import-light.png b/docs/screenshots/09-import-light.png index 3eb9d3f..1dc06d0 100644 Binary files a/docs/screenshots/09-import-light.png and b/docs/screenshots/09-import-light.png differ diff --git a/docs/screenshots/10-geraet-neu-dark.png b/docs/screenshots/10-geraet-neu-dark.png index db5c352..ad02bd4 100644 Binary files a/docs/screenshots/10-geraet-neu-dark.png and b/docs/screenshots/10-geraet-neu-dark.png differ diff --git a/docs/screenshots/10-geraet-neu-light.png b/docs/screenshots/10-geraet-neu-light.png index 273d9b9..4db3b08 100644 Binary files a/docs/screenshots/10-geraet-neu-light.png and b/docs/screenshots/10-geraet-neu-light.png differ diff --git a/docs/screenshots/11-fristen-dark.png b/docs/screenshots/11-fristen-dark.png index 57f51ef..c48abc8 100644 Binary files a/docs/screenshots/11-fristen-dark.png and b/docs/screenshots/11-fristen-dark.png differ diff --git a/docs/screenshots/11-fristen-light.png b/docs/screenshots/11-fristen-light.png index bdc1635..f721046 100644 Binary files a/docs/screenshots/11-fristen-light.png and b/docs/screenshots/11-fristen-light.png differ diff --git a/docs/screenshots/12-vorlagen-dark.png b/docs/screenshots/12-vorlagen-dark.png index dbdcc14..acadb0a 100644 Binary files a/docs/screenshots/12-vorlagen-dark.png and b/docs/screenshots/12-vorlagen-dark.png differ diff --git a/docs/screenshots/12-vorlagen-light.png b/docs/screenshots/12-vorlagen-light.png index 17a59f5..ca5e50a 100644 Binary files a/docs/screenshots/12-vorlagen-light.png and b/docs/screenshots/12-vorlagen-light.png differ diff --git a/docs/screenshots/13-maengel-dark.png b/docs/screenshots/13-maengel-dark.png index 5c51dd5..0ddd64d 100644 Binary files a/docs/screenshots/13-maengel-dark.png and b/docs/screenshots/13-maengel-dark.png differ diff --git a/docs/screenshots/13-maengel-light.png b/docs/screenshots/13-maengel-light.png index 4343a5d..41cf942 100644 Binary files a/docs/screenshots/13-maengel-light.png and b/docs/screenshots/13-maengel-light.png differ diff --git a/docs/screenshots/14-rueckrufe-dark.png b/docs/screenshots/14-rueckrufe-dark.png index a4fa1fb..1468011 100644 Binary files a/docs/screenshots/14-rueckrufe-dark.png and b/docs/screenshots/14-rueckrufe-dark.png differ diff --git a/docs/screenshots/14-rueckrufe-light.png b/docs/screenshots/14-rueckrufe-light.png index fcf87ae..149b209 100644 Binary files a/docs/screenshots/14-rueckrufe-light.png and b/docs/screenshots/14-rueckrufe-light.png differ diff --git a/docs/screenshots/15-lebensdauer-dark.png b/docs/screenshots/15-lebensdauer-dark.png index 1fd938f..8545488 100644 Binary files a/docs/screenshots/15-lebensdauer-dark.png and b/docs/screenshots/15-lebensdauer-dark.png differ diff --git a/docs/screenshots/15-lebensdauer-light.png b/docs/screenshots/15-lebensdauer-light.png index 1838bb6..575bea1 100644 Binary files a/docs/screenshots/15-lebensdauer-light.png and b/docs/screenshots/15-lebensdauer-light.png differ diff --git a/docs/screenshots/16-protokolle-dark.png b/docs/screenshots/16-protokolle-dark.png index 42495fa..054520f 100644 Binary files a/docs/screenshots/16-protokolle-dark.png and b/docs/screenshots/16-protokolle-dark.png differ diff --git a/docs/screenshots/16-protokolle-light.png b/docs/screenshots/16-protokolle-light.png index 59302e3..a8d9361 100644 Binary files a/docs/screenshots/16-protokolle-light.png and b/docs/screenshots/16-protokolle-light.png differ diff --git a/docs/screenshots/17-checkliste-dark.png b/docs/screenshots/17-checkliste-dark.png index fd40c23..1d2a3c4 100644 Binary files a/docs/screenshots/17-checkliste-dark.png and b/docs/screenshots/17-checkliste-dark.png differ diff --git a/docs/screenshots/17-checkliste-light.png b/docs/screenshots/17-checkliste-light.png index 84f2b1d..9bd6147 100644 Binary files a/docs/screenshots/17-checkliste-light.png and b/docs/screenshots/17-checkliste-light.png differ diff --git a/docs/screenshots/18-pruefprotokoll-dark.png b/docs/screenshots/18-pruefprotokoll-dark.png index bcdd323..19b28ff 100644 Binary files a/docs/screenshots/18-pruefprotokoll-dark.png and b/docs/screenshots/18-pruefprotokoll-dark.png differ diff --git a/docs/screenshots/18-pruefprotokoll-light.png b/docs/screenshots/18-pruefprotokoll-light.png index e94e339..1c3d35c 100644 Binary files a/docs/screenshots/18-pruefprotokoll-light.png and b/docs/screenshots/18-pruefprotokoll-light.png differ diff --git a/docs/screenshots/19-einsaetze-dark.png b/docs/screenshots/19-einsaetze-dark.png new file mode 100644 index 0000000..faa6a07 Binary files /dev/null and b/docs/screenshots/19-einsaetze-dark.png differ diff --git a/docs/screenshots/19-einsaetze-light.png b/docs/screenshots/19-einsaetze-light.png new file mode 100644 index 0000000..d93ee53 Binary files /dev/null and b/docs/screenshots/19-einsaetze-light.png differ diff --git a/docs/screenshots/20-statistik-dark.png b/docs/screenshots/20-statistik-dark.png new file mode 100644 index 0000000..772e903 Binary files /dev/null and b/docs/screenshots/20-statistik-dark.png differ diff --git a/docs/screenshots/20-statistik-light.png b/docs/screenshots/20-statistik-light.png new file mode 100644 index 0000000..27070a6 Binary files /dev/null and b/docs/screenshots/20-statistik-light.png differ diff --git a/docs/screenshots/21-uebungen-dark.png b/docs/screenshots/21-uebungen-dark.png new file mode 100644 index 0000000..1f90f58 Binary files /dev/null and b/docs/screenshots/21-uebungen-dark.png differ diff --git a/docs/screenshots/21-uebungen-light.png b/docs/screenshots/21-uebungen-light.png new file mode 100644 index 0000000..b7b9a13 Binary files /dev/null and b/docs/screenshots/21-uebungen-light.png differ diff --git a/docs/screenshots/22-einsatz-dark.png b/docs/screenshots/22-einsatz-dark.png new file mode 100644 index 0000000..50ec7a1 Binary files /dev/null and b/docs/screenshots/22-einsatz-dark.png differ diff --git a/docs/screenshots/22-einsatz-light.png b/docs/screenshots/22-einsatz-light.png new file mode 100644 index 0000000..02cdcfa Binary files /dev/null and b/docs/screenshots/22-einsatz-light.png differ diff --git a/docs/screenshots/23-uebung-dark.png b/docs/screenshots/23-uebung-dark.png new file mode 100644 index 0000000..d305033 Binary files /dev/null and b/docs/screenshots/23-uebung-dark.png differ diff --git a/docs/screenshots/23-uebung-light.png b/docs/screenshots/23-uebung-light.png new file mode 100644 index 0000000..2bc6ad4 Binary files /dev/null and b/docs/screenshots/23-uebung-light.png differ diff --git a/docs/screenshots/24-portal-dark.png b/docs/screenshots/24-portal-dark.png new file mode 100644 index 0000000..a6d5aa9 Binary files /dev/null and b/docs/screenshots/24-portal-dark.png differ diff --git a/docs/screenshots/24-portal-light.png b/docs/screenshots/24-portal-light.png new file mode 100644 index 0000000..4faf465 Binary files /dev/null and b/docs/screenshots/24-portal-light.png differ diff --git a/package.json b/package.json index a5869d2..3784d32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { diff --git a/playwright.config.ts b/playwright.config.ts index dbb945d..3252029 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,7 +24,13 @@ export default defineConfig({ reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]], /** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */ timeout: 90_000, - expect: { timeout: 15_000 }, + /** + * Ab Phase 3 großzügiger: mit fünfzig Routen dauert die erste Übersetzung + * einer bisher unbesuchten Seite unter Last auch mal zwanzig Sekunden. Das + * sind Wartezeiten des Entwicklungsservers, keine der Anwendung — vorher + * schlugen dadurch gelegentlich Tests fehl, die einzeln laufen und grün sind. + */ + expect: { timeout: 25_000 }, use: { baseURL: BASIS_URL, diff --git a/prisma/migrations/20260903000400_einsaetze_und_uebungen/migration.sql b/prisma/migrations/20260903000400_einsaetze_und_uebungen/migration.sql new file mode 100644 index 0000000..d4bab58 --- /dev/null +++ b/prisma/migrations/20260903000400_einsaetze_und_uebungen/migration.sql @@ -0,0 +1,347 @@ +-- ============================================================================= +-- Phase 3: Tabellen für Einsätze und Übungen. +-- +-- Erzeugt mit `prisma migrate dev --create-only` und wie in Phase 2 an einer +-- Stelle bereinigt: die DROP INDEX-Zeilen für die Volltext- und Geoindizes +-- sind entfernt. Prisma kennt gin_trgm_ops und gist nicht und würde sie bei +-- jeder Migration erneut löschen wollen. +-- +-- Mandantentrennung, Koordinaten-Trigger, Unveränderbarkeit freigegebener +-- Berichte und die Suchindizes stehen in der nachfolgenden Migration. +-- ============================================================================= + +-- CreateEnum +CREATE TYPE "EinsatzKategorie" AS ENUM ('BRAND_KLEIN', 'BRAND_MITTEL', 'BRAND_GROSS', 'TECHNISCHE_HILFE_KLEIN', 'TECHNISCHE_HILFE_MITTEL', 'TECHNISCHE_HILFE_GROSS', 'GEFAHRGUT', 'RETTUNGSDIENST', 'BRANDMELDEANLAGE', 'BOESWILLIGER_ALARM', 'SICHERHEITSWACHE', 'UNTERSTUETZUNG', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "EinsatzStatus" AS ENUM ('ENTWURF', 'LAUFEND', 'ABGESCHLOSSEN', 'FREIGEGEBEN'); + +-- CreateEnum +CREATE TYPE "Alarmierungsart" AS ENUM ('SIRENE', 'MELDEEMPFAENGER', 'TELEFON', 'APP', 'PERSOENLICH', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "EinsatzFunktion" AS ENUM ('EINSATZLEITER', 'ZUGFUEHRER', 'GRUPPENFUEHRER', 'STAFFELFUEHRER', 'TRUPPFUEHRER', 'MASCHINIST', 'MELDER', 'TRUPPMANN', 'ATEMSCHUTZTRAEGER', 'SICHERHEITSTRUPP', 'SANITAETER', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "UebungsArt" AS ENUM ('PRAXIS', 'THEORIE', 'ATEMSCHUTZ', 'MASCHINIST', 'FUNK', 'ERSTE_HILFE', 'GEMEINSCHAFTSUEBUNG', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "LagemeldungQuelle" AS ENUM ('INTERN', 'PORTAL'); + +-- CreateTable +CREATE TABLE "einsatz" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatznummer" VARCHAR(40) NOT NULL, + "stichwort" VARCHAR(160) NOT NULL, + "kategorie" "EinsatzKategorie" NOT NULL DEFAULT 'SONSTIGES', + "status" "EinsatzStatus" NOT NULL DEFAULT 'ENTWURF', + "ilsNummer" VARCHAR(60), + "alarmierungsart" "Alarmierungsart", + "strasse" VARCHAR(200), + "hausnummer" VARCHAR(20), + "plz" VARCHAR(10), + "ort" VARCHAR(120), + "ortsteil" VARCHAR(120), + "objekt" VARCHAR(200), + "breitengrad" DECIMAL(9,6), + "laengengrad" DECIMAL(9,6), + "koordinaten" geography(Point, 4326), + "koordinatenQuelle" VARCHAR(40), + "alarmiertUm" TIMESTAMPTZ(3), + "ausgeruecktUm" TIMESTAMPTZ(3), + "eingetroffenUm" TIMESTAMPTZ(3), + "einsatzendeUm" TIMESTAMPTZ(3), + "eingeruecktUm" TIMESTAMPTZ(3), + "einsatzbereitUm" TIMESTAMPTZ(3), + "einsatzleiterId" UUID, + "beschreibung" VARCHAR(4000), + "bericht" TEXT, + "wetter" VARCHAR(200), + "temperatur" INTEGER, + "weitereKraefte" VARCHAR(500), + "geretteteePersonen" INTEGER NOT NULL DEFAULT 0, + "verletztePersonen" INTEGER NOT NULL DEFAULT 0, + "totePersonen" INTEGER NOT NULL DEFAULT 0, + "sachschaden" DECIMAL(12,2), + "kostenpflichtig" BOOLEAN NOT NULL DEFAULT false, + "berichtDateiId" UUID, + "freigegebenAm" TIMESTAMPTZ(3), + "freigegebenVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "einsatz_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "einsatzfahrzeug" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatzId" UUID NOT NULL, + "fahrzeugId" UUID NOT NULL, + "ausgeruecktUm" TIMESTAMPTZ(3), + "eingetroffenUm" TIMESTAMPTZ(3), + "eingeruecktUm" TIMESTAMPTZ(3), + "kilometerAbfahrt" INTEGER, + "kilometerRueckkehr" INTEGER, + "wasserabgabe" INTEGER, + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "einsatzfahrzeug_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "einsatzkraft" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatzId" UUID NOT NULL, + "personId" UUID NOT NULL, + "funktion" "EinsatzFunktion" NOT NULL DEFAULT 'TRUPPMANN', + "fahrzeugId" UUID, + "vonUm" TIMESTAMPTZ(3), + "bisUm" TIMESTAMPTZ(3), + "atemschutz" BOOLEAN NOT NULL DEFAULT false, + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "einsatzkraft_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "materialverbrauch" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatzId" UUID NOT NULL, + "geraetId" UUID, + "bezeichnung" VARCHAR(200) NOT NULL, + "menge" DECIMAL(10,2) NOT NULL, + "einheit" VARCHAR(20) NOT NULL, + "kosten" DECIMAL(10,2), + "beschaedigt" BOOLEAN NOT NULL DEFAULT false, + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "materialverbrauch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lagemeldung" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatzId" UUID NOT NULL, + "zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "text" VARCHAR(2000) NOT NULL, + "quelle" "LagemeldungQuelle" NOT NULL DEFAULT 'INTERN', + "verfasserId" UUID, + "verfasserName" VARCHAR(200), + "fotoDateiId" UUID, + "wichtig" BOOLEAN NOT NULL DEFAULT false, + "portalZugangId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "lagemeldung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "portal_zugang" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatzId" UUID NOT NULL, + "bezeichnung" VARCHAR(160) NOT NULL, + "tokenHash" VARCHAR(120) NOT NULL, + "gueltigBis" TIMESTAMPTZ(3) NOT NULL, + "widerrufenAm" TIMESTAMPTZ(3), + "aufrufe" INTEGER NOT NULL DEFAULT 0, + "letzterAufruf" TIMESTAMPTZ(3), + "letzteIp" INET, + "erstelltVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "portal_zugang_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "uebung" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "nummer" VARCHAR(40) NOT NULL, + "thema" VARCHAR(200) NOT NULL, + "art" "UebungsArt" NOT NULL DEFAULT 'PRAXIS', + "datum" DATE NOT NULL, + "beginnUm" TIMESTAMPTZ(3), + "endeUm" TIMESTAMPTZ(3), + "ort" VARCHAR(200), + "leiterId" UUID, + "lernziele" VARCHAR(2000), + "beschreibung" VARCHAR(4000), + "bericht" TEXT, + "berichtDateiId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "uebung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "uebungsteilnahme" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "uebungId" UUID NOT NULL, + "personId" UUID NOT NULL, + "funktion" "EinsatzFunktion", + "vonUm" TIMESTAMPTZ(3), + "bisUm" TIMESTAMPTZ(3), + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "uebungsteilnahme_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "einsatz_organisationId_alarmiertUm_idx" ON "einsatz"("organisationId", "alarmiertUm" DESC); + +-- CreateIndex +CREATE INDEX "einsatz_organisationId_status_idx" ON "einsatz"("organisationId", "status"); + +-- CreateIndex +CREATE INDEX "einsatz_organisationId_kategorie_idx" ON "einsatz"("organisationId", "kategorie"); + +-- CreateIndex +CREATE UNIQUE INDEX "einsatz_organisationId_einsatznummer_key" ON "einsatz"("organisationId", "einsatznummer"); + +-- CreateIndex +CREATE INDEX "einsatzfahrzeug_organisationId_einsatzId_idx" ON "einsatzfahrzeug"("organisationId", "einsatzId"); + +-- CreateIndex +CREATE INDEX "einsatzfahrzeug_organisationId_fahrzeugId_idx" ON "einsatzfahrzeug"("organisationId", "fahrzeugId"); + +-- CreateIndex +CREATE UNIQUE INDEX "einsatzfahrzeug_einsatzId_fahrzeugId_key" ON "einsatzfahrzeug"("einsatzId", "fahrzeugId"); + +-- CreateIndex +CREATE INDEX "einsatzkraft_organisationId_einsatzId_idx" ON "einsatzkraft"("organisationId", "einsatzId"); + +-- CreateIndex +CREATE INDEX "einsatzkraft_organisationId_personId_idx" ON "einsatzkraft"("organisationId", "personId"); + +-- CreateIndex +CREATE UNIQUE INDEX "einsatzkraft_einsatzId_personId_key" ON "einsatzkraft"("einsatzId", "personId"); + +-- CreateIndex +CREATE INDEX "materialverbrauch_organisationId_einsatzId_idx" ON "materialverbrauch"("organisationId", "einsatzId"); + +-- CreateIndex +CREATE INDEX "lagemeldung_organisationId_einsatzId_zeitpunkt_idx" ON "lagemeldung"("organisationId", "einsatzId", "zeitpunkt"); + +-- CreateIndex +CREATE INDEX "portal_zugang_organisationId_einsatzId_idx" ON "portal_zugang"("organisationId", "einsatzId"); + +-- CreateIndex +CREATE INDEX "portal_zugang_gueltigBis_idx" ON "portal_zugang"("gueltigBis"); + +-- CreateIndex +CREATE INDEX "uebung_organisationId_datum_idx" ON "uebung"("organisationId", "datum" DESC); + +-- CreateIndex +CREATE INDEX "uebung_organisationId_art_idx" ON "uebung"("organisationId", "art"); + +-- CreateIndex +CREATE UNIQUE INDEX "uebung_organisationId_nummer_key" ON "uebung"("organisationId", "nummer"); + +-- CreateIndex +CREATE INDEX "uebungsteilnahme_organisationId_personId_idx" ON "uebungsteilnahme"("organisationId", "personId"); + +-- CreateIndex +CREATE UNIQUE INDEX "uebungsteilnahme_uebungId_personId_key" ON "uebungsteilnahme"("uebungId", "personId"); + +-- AddForeignKey +ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_einsatzleiterId_fkey" FOREIGN KEY ("einsatzleiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_freigegebenVonId_fkey" FOREIGN KEY ("freigegebenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_verfasserId_fkey" FOREIGN KEY ("verfasserId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_portalZugangId_fkey" FOREIGN KEY ("portalZugangId") REFERENCES "portal_zugang"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "uebung" ADD CONSTRAINT "uebung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "uebung" ADD CONSTRAINT "uebung_leiterId_fkey" FOREIGN KEY ("leiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "uebung" ADD CONSTRAINT "uebung_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260903000500_einsaetze_rls/migration.sql b/prisma/migrations/20260903000500_einsaetze_rls/migration.sql new file mode 100644 index 0000000..a5386a7 --- /dev/null +++ b/prisma/migrations/20260903000500_einsaetze_rls/migration.sql @@ -0,0 +1,243 @@ +-- ============================================================================= +-- Phase 3: Mandantentrennung, Koordinaten, Berichtsfreigabe, Indizes. +-- +-- Vier Dinge, die in der Datenbank besser aufgehoben sind als im Code: +-- +-- 1. Row Level Security für die acht neuen Tabellen. +-- +-- 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt. Die Anwendung +-- schreibt Breite und Länge als Zahl; den geography-Punkt setzt ein +-- Trigger. Sonst müsste jede Schreiboperation Roh-SQL sein, und die +-- Karte ab Phase 6 fände Einsätze, die über einen anderen Weg entstanden +-- sind, nicht wieder. +-- +-- 3. Die Unveränderbarkeit freigegebener Einsatzberichte. Der Bericht geht +-- an die Gemeinde und im Schadensfall an die Versicherung. Dieselbe Logik +-- wie beim Prüfprotokoll aus Phase 2. +-- +-- 4. Suchindizes und ein Teilindex auf die laufenden Einsätze. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Row Level Security +-- ----------------------------------------------------------------------------- +DO $$ +DECLARE + tabelle text; +BEGIN + FOREACH tabelle IN ARRAY ARRAY[ + 'einsatz', + 'einsatzfahrzeug', + 'einsatzkraft', + 'materialverbrauch', + 'lagemeldung', + 'portal_zugang', + 'uebung', + 'uebungsteilnahme' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle); + EXECUTE format( + 'CREATE POLICY %I ON %I FOR ALL ' + 'USING ("organisationId" = aktuelle_organisation()) ' + 'WITH CHECK ("organisationId" = aktuelle_organisation())', + tabelle || '_mandant', tabelle + ); + END LOOP; +END; +$$; + +-- ----------------------------------------------------------------------------- +-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION einsatz_koordinaten_setzen() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW."breitengrad" IS NULL OR NEW."laengengrad" IS NULL THEN + NEW."koordinaten" := NULL; + RETURN NEW; + END IF; + + -- Reihenfolge beachten: ST_MakePoint nimmt erst die Länge, dann die Breite. + NEW."koordinaten" := ST_SetSRID( + ST_MakePoint(NEW."laengengrad"::double precision, NEW."breitengrad"::double precision), + 4326 + )::geography; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION einsatz_koordinaten_setzen() IS + 'Haelt den PostGIS-Punkt eines Einsatzes mit den Dezimalgraden im Gleichlauf.'; + +CREATE TRIGGER einsatz_koordinaten + BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "einsatz" + FOR EACH ROW EXECUTE FUNCTION einsatz_koordinaten_setzen(); + +-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler. +ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_koordinaten_gueltig" CHECK ( + ("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90)) + AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180)) +); + +-- ----------------------------------------------------------------------------- +-- Freigegebene Einsatzberichte sind unveränderbar +-- +-- Bis zur Freigabe darf alles nachgetragen werden — ein Einsatz wird tagelang +-- ergänzt, und genau dafür ist der Entwurfsstand da. Mit der Freigabe ist +-- Schluss: der Bericht ist dann ein Dokument, kein Arbeitsstand. +-- +-- Zwei Ausnahmen bleiben, beide nachtragend statt verändernd: das PDF, solange +-- keines hinterlegt ist, und geaendertAm. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION einsatz_freigabe_schuetzen() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + vergleich "einsatz"%ROWTYPE; +BEGIN + IF OLD."status" <> 'FREIGEGEBEN' THEN + RETURN NEW; + END IF; + + vergleich := NEW; + vergleich."geaendertAm" := OLD."geaendertAm"; + + IF OLD."berichtDateiId" IS NULL THEN + vergleich."berichtDateiId" := OLD."berichtDateiId"; + END IF; + + IF vergleich IS DISTINCT FROM OLD THEN + RAISE EXCEPTION + 'Der Einsatzbericht % ist freigegeben und damit unveraenderlich.', + OLD."einsatznummer" + USING ERRCODE = 'insufficient_privilege'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION einsatz_kein_loeschen() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD."status" <> 'FREIGEGEBEN' THEN + RETURN OLD; + END IF; + + IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION + 'Der Einsatzbericht % ist freigegeben und darf nicht geloescht werden ' + '(nur im Aufbewahrungslauf).', + OLD."einsatznummer" + USING ERRCODE = 'insufficient_privilege'; +END; +$$; + +CREATE TRIGGER einsatz_freigabe + BEFORE UPDATE ON "einsatz" + FOR EACH ROW EXECUTE FUNCTION einsatz_freigabe_schuetzen(); + +CREATE TRIGGER einsatz_loeschschutz + BEFORE DELETE ON "einsatz" + FOR EACH ROW EXECUTE FUNCTION einsatz_kein_loeschen(); + +-- Dasselbe für die Bestandteile: sonst ließe sich ein freigegebener Bericht +-- über die Einsatzkräfte oder den Materialverbrauch nachträglich verändern. +CREATE OR REPLACE FUNCTION einsatzinhalt_gesperrt() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + betroffener_einsatz uuid; + zustand "EinsatzStatus"; +BEGIN + IF TG_OP = 'DELETE' THEN + betroffener_einsatz := OLD."einsatzId"; + ELSE + betroffener_einsatz := NEW."einsatzId"; + END IF; + + SELECT "status" INTO zustand FROM "einsatz" WHERE "id" = betroffener_einsatz; + + IF zustand IS NULL OR zustand <> 'FREIGEGEBEN' THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF; + END IF; + + IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN + IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF; + END IF; + + RAISE EXCEPTION + 'Der Einsatz % ist freigegeben. % auf % ist nicht mehr zulaessig.', + betroffener_einsatz, TG_OP, TG_TABLE_NAME + USING ERRCODE = 'insufficient_privilege'; +END; +$$; + +CREATE TRIGGER einsatzfahrzeug_gesperrt + BEFORE INSERT OR UPDATE OR DELETE ON "einsatzfahrzeug" + FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt(); + +CREATE TRIGGER einsatzkraft_gesperrt + BEFORE INSERT OR UPDATE OR DELETE ON "einsatzkraft" + FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt(); + +CREATE TRIGGER materialverbrauch_gesperrt + BEFORE INSERT OR UPDATE OR DELETE ON "materialverbrauch" + FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt(); + +-- Lagemeldungen bleiben ausdrücklich ausgenommen: sie sind ein Protokoll des +-- Verlaufs, kein Bestandteil des Berichts, und eine Meldung aus dem Portal +-- kann eintreffen, während der Bericht schon freigegeben ist. + +-- ----------------------------------------------------------------------------- +-- Portalzugänge +-- ----------------------------------------------------------------------------- +-- Ein Zugang, der nie gültig war, ist ein Fehler in der Erzeugung. +ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_gueltigkeit" CHECK ( + "gueltigBis" > "erstelltAm" +); + +-- ----------------------------------------------------------------------------- +-- Indizes +-- ----------------------------------------------------------------------------- +-- Der Alarmmonitor fragt immer dasselbe: was läuft gerade. +CREATE INDEX "einsatz_laufend" ON "einsatz" ("organisationId", "alarmiertUm" DESC) + WHERE "status" = 'LAUFEND' AND "geloeschtAm" IS NULL; + +-- Räumliche Abfragen ab Phase 6. +CREATE INDEX "einsatz_koordinaten_idx" ON "einsatz" USING gist ("koordinaten"); + +-- Volltextsuche wie in den Phasen davor. +CREATE INDEX "einsatz_suche_stichwort" ON "einsatz" USING gin ("stichwort" gin_trgm_ops); +CREATE INDEX "einsatz_suche_ort" ON "einsatz" USING gin ("ort" gin_trgm_ops); +CREATE INDEX "einsatz_suche_nummer" ON "einsatz" USING gin ("einsatznummer" gin_trgm_ops); +CREATE INDEX "uebung_suche_thema" ON "uebung" USING gin ("thema" gin_trgm_ops); + +-- ----------------------------------------------------------------------------- +-- Rechte für die Anwendungsrolle auf die neuen Tabellen. +-- ----------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app'; + EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app'; + + -- Die Einschraenkungen aus den Phasen davor gelten unveraendert weiter. + EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app'; + ELSE + RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.'; + END IF; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43d0cb9..2fea6d5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -180,6 +180,16 @@ model Organisation { rueckrufBetroffenheiten RueckrufBetroffenheit[] erinnerungen Erinnerung[] + // Phase 3 — Einsätze und Übungen + einsaetze Einsatz[] + einsatzfahrzeuge Einsatzfahrzeug[] + einsatzkraefte Einsatzkraft[] + materialverbrauch Materialverbrauch[] + lagemeldungen Lagemeldung[] + portalzugaenge PortalZugang[] + uebungen Uebung[] + uebungsteilnahmen Uebungsteilnahme[] + @@index([aktiv]) @@map("organisation") } @@ -253,6 +263,15 @@ model Person { zugewieseneMaengel Mangel[] @relation("MangelZustaendig") behobeneMaengel Mangel[] @relation("MangelBehebung") + // Phase 3 — Einsätze und Übungen + geleiteteEinsaetze Einsatz[] @relation("EinsatzLeitung") + freigegebeneBerichte Einsatz[] @relation("EinsatzFreigabe") + einsatzteilnahmen Einsatzkraft[] @relation("EinsatzTeilnahme") + lagemeldungen Lagemeldung[] @relation("LagemeldungVerfasser") + portalzugaenge PortalZugang[] @relation("PortalzugangErsteller") + geleiteteUebungen Uebung[] @relation("UebungLeitung") + uebungsteilnahmen Uebungsteilnahme[] @relation("UebungTeilnahme") + /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@ -482,6 +501,11 @@ model Datei { alsSignaturbild Signatur[] @relation("SignaturBild") alsMangelFoto Mangel[] @relation("MangelFoto") + // Phase 3 + alsEinsatzbericht Einsatz[] @relation("EinsatzBericht") + alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto") + alsUebungsbericht Uebung[] @relation("UebungBericht") + @@unique([bucket, objektSchluessel]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, kategorie]) @@ -648,6 +672,10 @@ model Fahrzeug { geraeteraeume Geraeteraum[] geraete Geraet[] + // Phase 3 + einsaetze Einsatzfahrzeug[] + einsatzkraefte Einsatzkraft[] + @@unique([organisationId, funkrufname]) @@unique([organisationId, kennzeichen]) @@index([organisationId, aktiv]) @@ -783,6 +811,7 @@ model Geraet { pruefungen Pruefung[] maengel Mangel[] rueckrufBetroffenheiten RueckrufBetroffenheit[] + materialverbrauch Materialverbrauch[] @@unique([organisationId, inventarnummer]) @@index([organisationId, status]) @@ -1403,3 +1432,427 @@ model Erinnerung { @@index([organisationId, versendetAm(sort: Desc)]) @@map("erinnerung") } + +// ============================================================================= +// Phase 3 — Einsätze und Übungen +// +// Ordnungsprinzip: +// Einsatz der Vorgang selbst, mit Ort, Zeitstempeln und Bericht +// Einsatzfahrzeug welches Fahrzeug wann ausgerückt und eingerückt ist +// Einsatzkraft wer mitgefahren ist, in welcher Funktion, wie lange +// Materialverbrauch was dabei verbraucht oder beschädigt wurde +// Lagemeldung was währenddessen gemeldet wurde, mit Foto +// PortalZugang ein zeitlich begrenzter Link für eine Fremdwehr +// Uebung dasselbe für Übungen, mit Thema und Lernzielen +// +// Drei Festlegungen, die den Aufbau erklären: +// +// 1. Die Zeitstempel sind der Kern. Alarmierung, Ausrücken, Eintreffen, +// Einsatzende, Einrücken, Einsatzbereitschaft — daraus rechnet sich die +// Ausrückzeit, die Anfahrtszeit und die Einsatzdauer. Sie stehen deshalb +// einzeln und nicht als Sammelfeld. +// +// 2. Koordinaten liegen doppelt: als Dezimalgrad, die Prisma lesen kann, und +// als PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides +// zusammen — sonst müsste jede Schreiboperation Roh-SQL sein. +// +// 3. Ein freigegebener Einsatzbericht ist unveränderbar, dieselbe Logik wie +// beim Prüfprotokoll: er geht an die Gemeinde und an die Versicherung. +// ============================================================================= + +/// Einsatzkategorie nach der bayerischen Feuerwehrstatistik. Danach meldet die +/// Wehr ihre Zahlen an die Gemeinde und den Landkreis. +enum EinsatzKategorie { + BRAND_KLEIN + BRAND_MITTEL + BRAND_GROSS + TECHNISCHE_HILFE_KLEIN + TECHNISCHE_HILFE_MITTEL + TECHNISCHE_HILFE_GROSS + GEFAHRGUT + /// Unterstützung des Rettungsdienstes, z. B. Türöffnung, Tragehilfe. + RETTUNGSDIENST + /// Auslösung einer Brandmeldeanlage ohne Schadenfeuer. + BRANDMELDEANLAGE + BOESWILLIGER_ALARM + SICHERHEITSWACHE + /// Amtshilfe, Nachbarschaftshilfe, Sonstiges. + UNTERSTUETZUNG + SONSTIGES +} + +/// Lebenszyklus eines Einsatzes. +enum EinsatzStatus { + /// Schnellerfassung: Stichwort und Fahrzeuge stehen, der Rest fehlt noch. + ENTWURF + /// Läuft gerade — der Alarmmonitor und das Portal zeigen ihn an. + LAUFEND + /// Nachbereitet und abgeschlossen. + ABGESCHLOSSEN + /// Bericht freigegeben und festgeschrieben. + FREIGEGEBEN +} + +/// Wie alarmiert wurde. Für die Auswertung, ob die Alarmierungswege greifen. +enum Alarmierungsart { + SIRENE + MELDEEMPFAENGER + TELEFON + APP + PERSOENLICH + SONSTIGES +} + +/// Funktion einer Einsatzkraft. Aus ihr ergibt sich die Stärke nach FwDV 3. +enum EinsatzFunktion { + EINSATZLEITER + ZUGFUEHRER + GRUPPENFUEHRER + STAFFELFUEHRER + TRUPPFUEHRER + MASCHINIST + MELDER + TRUPPMANN + ATEMSCHUTZTRAEGER + SICHERHEITSTRUPP + SANITAETER + SONSTIGES +} + +/// Art einer Übung. +enum UebungsArt { + PRAXIS + THEORIE + ATEMSCHUTZ + MASCHINIST + FUNK + ERSTE_HILFE + /// Gemeinsam mit Nachbarwehren, THW oder Rettungsdienst. + GEMEINSCHAFTSUEBUNG + SONSTIGES +} + +/// Woher eine Lagemeldung kommt. +enum LagemeldungQuelle { + /// Aus der Anwendung, von einer angemeldeten Person. + INTERN + /// Über den Portal-Link, von einer Fremdwehr ohne Konto. + PORTAL +} + +// ----------------------------------------------------------------------------- + +/// Ein Einsatz. +model Einsatz { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + /// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN". + einsatznummer String @db.VarChar(40) + /// Einsatzstichwort der Leitstelle, z. B. "B 3 Wohnhausbrand". + stichwort String @db.VarChar(160) + kategorie EinsatzKategorie @default(SONSTIGES) + status EinsatzStatus @default(ENTWURF) + + /// Einsatznummer der Integrierten Leitstelle — die Klammer zu deren Akte. + ilsNummer String? @db.VarChar(60) + alarmierungsart Alarmierungsart? + + // Ort + strasse String? @db.VarChar(200) + hausnummer String? @db.VarChar(20) + plz String? @db.VarChar(10) + ort String? @db.VarChar(120) + ortsteil String? @db.VarChar(120) + /// Objektbezeichnung, z. B. "Grundschule", "Autobahn A9, km 312". + objekt String? @db.VarChar(200) + + /// Koordinaten als Dezimalgrad — von Prisma lesbar. + breitengrad Decimal? @db.Decimal(9, 6) + laengengrad Decimal? @db.Decimal(9, 6) + /// Dieselben Koordinaten als PostGIS-Punkt, von einem Trigger gepflegt. + /// Ab Phase 6 zeichnet die Karte daraus. + koordinaten Unsupported("geography(Point, 4326)")? + /// Woher sie stammen: "nominatim", "manuell", "leitstelle". + koordinatenQuelle String? @db.VarChar(40) + + // Zeitstempel — der Kern jeder Einsatzauswertung + alarmiertUm DateTime? @db.Timestamptz(3) + ausgeruecktUm DateTime? @db.Timestamptz(3) + eingetroffenUm DateTime? @db.Timestamptz(3) + einsatzendeUm DateTime? @db.Timestamptz(3) + eingeruecktUm DateTime? @db.Timestamptz(3) + einsatzbereitUm DateTime? @db.Timestamptz(3) + + einsatzleiterId String? @db.Uuid + + beschreibung String? @db.VarChar(4000) + bericht String? + + wetter String? @db.VarChar(200) + temperatur Int? + /// Wer sonst vor Ort war: Polizei, Rettungsdienst, THW, Nachbarwehren. + weitereKraefte String? @db.VarChar(500) + + /// Zahlen für die Statistik der Gemeinde. + geretteteePersonen Int @default(0) + verletztePersonen Int @default(0) + totePersonen Int @default(0) + sachschaden Decimal? @db.Decimal(12, 2) + /// Kostenersatzpflichtiger Einsatz (Art. 28 BayFwG). + kostenpflichtig Boolean @default(false) + + /// Der erzeugte Einsatzbericht als PDF. + berichtDateiId String? @db.Uuid + freigegebenAm DateTime? @db.Timestamptz(3) + freigegebenVonId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatzleiter Person? @relation("EinsatzLeitung", fields: [einsatzleiterId], references: [id], onDelete: SetNull) + freigegebenVon Person? @relation("EinsatzFreigabe", fields: [freigegebenVonId], references: [id], onDelete: SetNull) + berichtDatei Datei? @relation("EinsatzBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) + fahrzeuge Einsatzfahrzeug[] + kraefte Einsatzkraft[] + material Materialverbrauch[] + lagemeldungen Lagemeldung[] + portalzugaenge PortalZugang[] + + @@unique([organisationId, einsatznummer]) + @@index([organisationId, alarmiertUm(sort: Desc)]) + @@index([organisationId, status]) + @@index([organisationId, kategorie]) + @@map("einsatz") +} + +/// Ein Fahrzeug in einem Einsatz, mit eigenen Zeiten: nicht jedes Fahrzeug +/// rückt gleichzeitig aus, und die Nachalarmierung soll sichtbar bleiben. +model Einsatzfahrzeug { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + einsatzId String @db.Uuid + fahrzeugId String @db.Uuid + + ausgeruecktUm DateTime? @db.Timestamptz(3) + eingetroffenUm DateTime? @db.Timestamptz(3) + eingeruecktUm DateTime? @db.Timestamptz(3) + + kilometerAbfahrt Int? + kilometerRueckkehr Int? + /// Verbrauchtes Löschwasser in Litern. + wasserabgabe Int? + bemerkung String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) + fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict) + + @@unique([einsatzId, fahrzeugId]) + @@index([organisationId, einsatzId]) + @@index([organisationId, fahrzeugId]) + @@map("einsatzfahrzeug") +} + +/// Wer mitgefahren ist. Grundlage für die Stärkemeldung nach FwDV 3 und für +/// den Beteiligungsnachweis je Person. +model Einsatzkraft { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + einsatzId String @db.Uuid + personId String @db.Uuid + + funktion EinsatzFunktion @default(TRUPPMANN) + /// Auf welchem Fahrzeug — leer, wenn jemand selbst angefahren ist. + fahrzeugId String? @db.Uuid + + vonUm DateTime? @db.Timestamptz(3) + bisUm DateTime? @db.Timestamptz(3) + + /// War unter Atemschutz im Einsatz. Zählt auf die Nachweise nach FwDV 7. + atemschutz Boolean @default(false) + bemerkung String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) + person Person @relation("EinsatzTeilnahme", fields: [personId], references: [id], onDelete: Cascade) + fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) + + @@unique([einsatzId, personId]) + @@index([organisationId, einsatzId]) + @@index([organisationId, personId]) + @@map("einsatzkraft") +} + +/// Was verbraucht oder beschädigt wurde — Schaummittel, Bindemittel, ein +/// zerschnittener Schlauch. Grundlage für Nachbeschaffung und Kostenersatz. +model Materialverbrauch { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + einsatzId String @db.Uuid + /// Verweis auf ein erfasstes Gerät, wo es eines gibt. + geraetId String? @db.Uuid + + bezeichnung String @db.VarChar(200) + menge Decimal @db.Decimal(10, 2) + einheit String @db.VarChar(20) + kosten Decimal? @db.Decimal(10, 2) + /// Nicht verbraucht, sondern kaputtgegangen — dann folgt ein Mangel. + beschaedigt Boolean @default(false) + bemerkung String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) + geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull) + + @@index([organisationId, einsatzId]) + @@map("materialverbrauch") +} + +/// Eine Meldung während des Einsatzes. Aus der Anwendung oder über den +/// Portal-Link einer Fremdwehr. +model Lagemeldung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + einsatzId String @db.Uuid + + zeitpunkt DateTime @default(now()) @db.Timestamptz(3) + text String @db.VarChar(2000) + quelle LagemeldungQuelle @default(INTERN) + + verfasserId String? @db.Uuid + /// Klarname oder Wehrname — bei Portalmeldungen die einzige Angabe. + verfasserName String? @db.VarChar(200) + + fotoDateiId String? @db.Uuid + /// Hebt die Meldung im Verlauf hervor: Menschenrettung, Nachforderung, + /// Lageänderung. + wichtig Boolean @default(false) + + /// Über welchen Portalzugang die Meldung kam. + portalZugangId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) + verfasser Person? @relation("LagemeldungVerfasser", fields: [verfasserId], references: [id], onDelete: SetNull) + foto Datei? @relation("LagemeldungFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull) + portalZugang PortalZugang? @relation(fields: [portalZugangId], references: [id], onDelete: SetNull) + + @@index([organisationId, einsatzId, zeitpunkt]) + @@map("lagemeldung") +} + +/// Ein zeitlich begrenzter Zugang zum Einsatzportal für eine Fremdwehr. +/// +/// Der Link enthält die Kennung und ein Geheimnis; gespeichert wird nur dessen +/// Hash. Er gilt bis gueltigBis, lässt sich jederzeit widerrufen und zählt +/// jeden Aufruf mit — ein Link, der nach dem Einsatz noch benutzt wird, fällt +/// so auf. +model PortalZugang { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + einsatzId String @db.Uuid + + /// Für wen der Link ist, z. B. "FF Nachbarhausen". + bezeichnung String @db.VarChar(160) + /// bcrypt-Hash des Geheimnisses aus dem Link. + tokenHash String @db.VarChar(120) + + gueltigBis DateTime @db.Timestamptz(3) + widerrufenAm DateTime? @db.Timestamptz(3) + + aufrufe Int @default(0) + letzterAufruf DateTime? @db.Timestamptz(3) + /// Zuletzt sehende IP — für den Fall, dass ein Link weitergegeben wurde. + letzteIp String? @db.Inet + + erstelltVonId String? @db.Uuid + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) + erstelltVon Person? @relation("PortalzugangErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) + lagemeldungen Lagemeldung[] + + @@index([organisationId, einsatzId]) + @@index([gueltigBis]) + @@map("portal_zugang") +} + +/// Eine Übung. Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und +/// Lernzielen statt Schadenlage — und ohne Portal. +model Uebung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + /// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN". + nummer String @db.VarChar(40) + thema String @db.VarChar(200) + art UebungsArt @default(PRAXIS) + + datum DateTime @db.Date + beginnUm DateTime? @db.Timestamptz(3) + endeUm DateTime? @db.Timestamptz(3) + + ort String? @db.VarChar(200) + leiterId String? @db.Uuid + + /// Was gelernt werden soll — die Lernziele der Ausbildung. + lernziele String? @db.VarChar(2000) + beschreibung String? @db.VarChar(4000) + bericht String? + + berichtDateiId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull) + berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) + teilnahmen Uebungsteilnahme[] + + @@unique([organisationId, nummer]) + @@index([organisationId, datum(sort: Desc)]) + @@index([organisationId, art]) + @@map("uebung") +} + +/// Wer an einer Übung teilgenommen hat. Der Nachweis, den eine Wehr für die +/// Beförderung und für die Statistik braucht. +model Uebungsteilnahme { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + uebungId String @db.Uuid + personId String @db.Uuid + + funktion EinsatzFunktion? + vonUm DateTime? @db.Timestamptz(3) + bisUm DateTime? @db.Timestamptz(3) + bemerkung String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + uebung Uebung @relation(fields: [uebungId], references: [id], onDelete: Cascade) + person Person @relation("UebungTeilnahme", fields: [personId], references: [id], onDelete: Cascade) + + @@unique([uebungId, personId]) + @@index([organisationId, personId]) + @@map("uebungsteilnahme") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 20ca180..e60ec5d 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -7,7 +7,8 @@ * Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der * Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus * fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein - * Beispielrückruf. + * Beispielrückruf. Ab Phase 3 dazu: acht Einsätze über das Jahr verteilt, + * sechs Übungen und die Beteiligung der Mannschaft. * * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang, * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt @@ -20,8 +21,12 @@ */ import "dotenv/config"; import { + Alarmierungsart, AusweisTokenTyp, ChecklistenpunktTyp, + EinsatzFunktion, + EinsatzKategorie, + EinsatzStatus, Dienstgrad, FahrzeugArt, GeraeteKategorie, @@ -38,6 +43,7 @@ import { RolleSchluessel, SignaturRolle, StandortArt, + UebungsArt, } from "@prisma/client"; import bcrypt from "bcryptjs"; @@ -1512,6 +1518,437 @@ async function rueckrufAnlegen(organisationId: string) { log("Rückruf: 1 Beispiel"); } +// ----------------------------------------------------------------------------- +// Phase 3 — Einsätze und Übungen +// +// Ein Jahr im Leben einer Wehr: Brände, technische Hilfeleistungen, ein +// Fehlalarm einer Brandmeldeanlage und die Übungen dazwischen. Die Zahlen sind +// so gewählt, dass die Statistik etwas zeigt — auch eine Hilfsfrist, die +// gerade noch passt. +// ----------------------------------------------------------------------------- + +interface EinsatzVorlage { + /** Tage vor heute. */ + vorTagen: number; + uhrzeit: [number, number]; + stichwort: string; + kategorie: EinsatzKategorie; + strasse: string; + hausnummer?: string; + objekt?: string; + /** Minuten nach der Alarmierung: ausgerückt, eingetroffen, Ende, eingerückt. */ + ablauf: [number, number, number, number]; + fahrzeuge: number[]; + /** Wie viele Kräfte, und wer davon führt. */ + mannschaft: number; + gerettet?: number; + verletzt?: number; + sachschaden?: number; + beschreibung: string; + bericht?: string; + meldungen?: Array<{ nachMinuten: number; text: string; wichtig?: boolean }>; + material?: Array<{ bezeichnung: string; menge: number; einheit: string }>; + status?: EinsatzStatus; +} + +const EINSAETZE: EinsatzVorlage[] = [ + { + vorTagen: 12, + uhrzeit: [3, 42], + stichwort: "B 3 Wohnhausbrand", + kategorie: EinsatzKategorie.BRAND_GROSS, + strasse: "Bahnhofstraße", + hausnummer: "14", + objekt: "Reihenhaus, zwei Vollgeschosse", + ablauf: [5, 9, 143, 168], + fahrzeuge: [0, 1], + mannschaft: 14, + gerettet: 2, + verletzt: 1, + sachschaden: 85000, + beschreibung: + "Bei Eintreffen Rauch aus dem Dachgeschoss, zwei Personen im Obergeschoss gemeldet.", + bericht: + "Menschenrettung über die Steckleiter durch den Angriffstrupp. Zwei Personen unverletzt gerettet, eine Person mit Rauchgasintoxikation an den Rettungsdienst übergeben.\n\nBrandbekämpfung mit einem C-Rohr unter Atemschutz vom Treppenraum aus. Nachlöscharbeiten und Kontrolle mit der Wärmebildkamera bis 06:05 Uhr. Einsatzstelle an die Polizei übergeben.", + meldungen: [ + { nachMinuten: 10, text: "Rauch aus Dachgeschoss, zwei Personen vermisst.", wichtig: true }, + { nachMinuten: 22, text: "Beide Personen gerettet, Menschenrettung abgeschlossen.", wichtig: true }, + { nachMinuten: 48, text: "Feuer unter Kontrolle." }, + { nachMinuten: 140, text: "Nachlöscharbeiten beendet, Kontrolle mit Wärmebildkamera." }, + ], + material: [ + { bezeichnung: "Atemluftflaschen 300 bar", menge: 6, einheit: "Stück" }, + { bezeichnung: "Löschwasser", menge: 4500, einheit: "Liter" }, + ], + }, + { + vorTagen: 26, + uhrzeit: [14, 8], + stichwort: "THL 1 Ölspur", + kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN, + strasse: "Hauptstraße", + ablauf: [6, 11, 55, 68], + fahrzeuge: [1], + mannschaft: 6, + beschreibung: "Ölspur über etwa 300 Meter, ausgehend von einem Traktor.", + bericht: "Ölspur mit Bindemittel abgestreut und aufgenommen. Straßenmeisterei verständigt.", + material: [{ bezeichnung: "Ölbindemittel", menge: 40, einheit: "kg" }], + }, + { + vorTagen: 41, + uhrzeit: [19, 55], + stichwort: "THL 2 Verkehrsunfall", + kategorie: EinsatzKategorie.TECHNISCHE_HILFE_MITTEL, + strasse: "Kreisstraße MÜ 12", + objekt: "zwischen Musterhausen und Nachbarhausen", + ablauf: [4, 12, 78, 96], + fahrzeuge: [0], + mannschaft: 9, + verletzt: 2, + sachschaden: 24000, + beschreibung: "Zwei Fahrzeuge, eine Person eingeklemmt.", + bericht: + "Person mit dem hydraulischen Rettungssatz befreit und an den Rettungsdienst übergeben. Brandschutz sichergestellt, Fahrzeuge gegen Wegrollen gesichert, Betriebsstoffe aufgenommen.", + meldungen: [ + { nachMinuten: 14, text: "Eine Person eingeklemmt, Rettungsdienst vor Ort.", wichtig: true }, + { nachMinuten: 39, text: "Person befreit und übergeben.", wichtig: true }, + ], + material: [{ bezeichnung: "Ölbindemittel", menge: 15, einheit: "kg" }], + }, + { + vorTagen: 58, + uhrzeit: [9, 12], + stichwort: "BMA Auslösung", + kategorie: EinsatzKategorie.BRANDMELDEANLAGE, + strasse: "Schulstraße", + hausnummer: "3", + objekt: "Grundschule", + ablauf: [7, 13, 32, 44], + fahrzeuge: [0], + mannschaft: 8, + beschreibung: "Auslösung eines Rauchmelders im Werkraum.", + bericht: "Kein Schadenfeuer. Auslösung durch Staub bei Reinigungsarbeiten. Anlage zurückgestellt.", + }, + { + vorTagen: 73, + uhrzeit: [16, 30], + stichwort: "B 1 Kleinbrand", + kategorie: EinsatzKategorie.BRAND_KLEIN, + strasse: "Am Anger", + hausnummer: "7", + ablauf: [5, 8, 41, 52], + fahrzeuge: [1], + mannschaft: 6, + sachschaden: 1200, + beschreibung: "Brennende Mülltonne an der Hauswand.", + bericht: "Ablöschen mit Schnellangriff, Kontrolle der Fassade mit der Wärmebildkamera.", + }, + { + vorTagen: 95, + uhrzeit: [21, 47], + stichwort: "THL 1 Sturmschaden", + kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN, + strasse: "Lindenweg", + ablauf: [8, 16, 62, 74], + fahrzeuge: [0], + mannschaft: 7, + beschreibung: "Baum auf Fahrbahn nach Gewitterfront.", + bericht: "Baum mit der Motorkettensäge zerlegt und beiseitegeräumt. Fahrbahn gereinigt.", + material: [{ bezeichnung: "Kettensägenbenzin", menge: 2, einheit: "Liter" }], + }, + { + vorTagen: 118, + uhrzeit: [11, 20], + stichwort: "Sicherheitswache Pfarrfest", + kategorie: EinsatzKategorie.SICHERHEITSWACHE, + strasse: "Kirchplatz", + ablauf: [10, 20, 300, 310], + fahrzeuge: [2], + mannschaft: 4, + beschreibung: "Brandsicherheitswache beim Pfarrfest.", + bericht: "Ohne besondere Vorkommnisse.", + }, + { + vorTagen: 140, + uhrzeit: [6, 5], + stichwort: "THL 1 Türöffnung", + kategorie: EinsatzKategorie.RETTUNGSDIENST, + strasse: "Rosenstraße", + hausnummer: "22", + ablauf: [6, 10, 28, 38], + fahrzeuge: [1], + mannschaft: 5, + gerettet: 1, + beschreibung: "Hilflose Person hinter verschlossener Tür, Rettungsdienst vor Ort.", + bericht: "Tür schonend geöffnet, Person an den Rettungsdienst übergeben.", + }, +]; + +/** Funktionen einer Gruppe in der Reihenfolge, in der sie besetzt werden. */ +const FUNKTIONSFOLGE: EinsatzFunktion[] = [ + EinsatzFunktion.GRUPPENFUEHRER, + EinsatzFunktion.MASCHINIST, + EinsatzFunktion.MELDER, + EinsatzFunktion.TRUPPFUEHRER, + EinsatzFunktion.TRUPPMANN, + EinsatzFunktion.TRUPPFUEHRER, + EinsatzFunktion.TRUPPMANN, + EinsatzFunktion.TRUPPFUEHRER, + EinsatzFunktion.TRUPPMANN, +]; + +function minutenSpaeter(basis: Date, minuten: number): Date { + return new Date(basis.getTime() + minuten * 60_000); +} + +async function einsaetzeAnlegen( + organisationId: string, + fahrzeuge: Array<{ id: string; funkrufname: string }>, +) { + const vorhanden = await prisma.einsatz.count({ where: { organisationId } }); + if (vorhanden > 0) { + log(`Einsätze: bereits ${vorhanden} vorhanden`); + return; + } + + const personen = await prisma.person.findMany({ + where: { organisationId, aktiv: true, geloeschtAm: null }, + select: { id: true, vorname: true, nachname: true }, + orderBy: { mitgliedsnummer: "asc" }, + }); + + if (personen.length === 0 || fahrzeuge.length === 0) return; + + let laufend = 0; + + for (const vorlage of EINSAETZE) { + laufend += 1; + + const alarmiert = new Date(); + alarmiert.setUTCDate(alarmiert.getUTCDate() - vorlage.vorTagen); + alarmiert.setUTCHours(vorlage.uhrzeit[0], vorlage.uhrzeit[1], 0, 0); + + const jahr = alarmiert.getUTCFullYear(); + const [ausgerueckt, eingetroffen, ende, eingerueckt] = vorlage.ablauf; + + const einsatz = await prisma.einsatz.create({ + data: { + organisationId, + einsatznummer: `${jahr}-${String(laufend).padStart(4, "0")}`, + stichwort: vorlage.stichwort, + kategorie: vorlage.kategorie, + status: vorlage.status ?? EinsatzStatus.ABGESCHLOSSEN, + alarmierungsart: Alarmierungsart.MELDEEMPFAENGER, + strasse: vorlage.strasse, + hausnummer: vorlage.hausnummer ?? null, + plz: "84543", + ort: "Musterhausen", + objekt: vorlage.objekt ?? null, + alarmiertUm: alarmiert, + ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt), + eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen), + einsatzendeUm: minutenSpaeter(alarmiert, ende), + eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt), + einsatzbereitUm: minutenSpaeter(alarmiert, eingerueckt + 30), + einsatzleiterId: personen[0]?.id ?? null, + beschreibung: vorlage.beschreibung, + bericht: vorlage.bericht ?? null, + geretteteePersonen: vorlage.gerettet ?? 0, + verletztePersonen: vorlage.verletzt ?? 0, + sachschaden: vorlage.sachschaden ?? null, + fahrzeuge: { + create: vorlage.fahrzeuge + .map((index) => fahrzeuge[index]) + .filter((fahrzeug): fahrzeug is { id: string; funkrufname: string } => Boolean(fahrzeug)) + .map((fahrzeug, position) => ({ + organisationId, + fahrzeugId: fahrzeug.id, + ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt + position), + eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen + position), + eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt), + kilometerAbfahrt: 12000 + laufend * 40, + kilometerRueckkehr: 12000 + laufend * 40 + 8 + position * 3, + wasserabgabe: vorlage.kategorie.startsWith("BRAND") ? 1500 : null, + })), + }, + kraefte: { + create: Array.from({ length: Math.min(vorlage.mannschaft, personen.length) }).map( + (_, index) => ({ + organisationId, + // Die Mannschaft rotiert, damit die Beteiligung je Person + // unterschiedlich ausfällt. + personId: personen[(index + laufend * 3) % personen.length]!.id, + funktion: FUNKTIONSFOLGE[index % FUNKTIONSFOLGE.length]!, + atemschutz: vorlage.kategorie.startsWith("BRAND") && index >= 3 && index <= 6, + vonUm: minutenSpaeter(alarmiert, ausgerueckt), + bisUm: minutenSpaeter(alarmiert, eingerueckt), + }), + ), + }, + lagemeldungen: { + create: (vorlage.meldungen ?? []).map((meldung) => ({ + organisationId, + zeitpunkt: minutenSpaeter(alarmiert, meldung.nachMinuten), + text: meldung.text, + wichtig: meldung.wichtig ?? false, + verfasserId: personen[0]?.id ?? null, + verfasserName: personen[0] + ? `${personen[0].vorname} ${personen[0].nachname}` + : "Einsatzleitung", + })), + }, + material: { + create: (vorlage.material ?? []).map((posten) => ({ + organisationId, + bezeichnung: posten.bezeichnung, + menge: posten.menge, + einheit: posten.einheit, + })), + }, + }, + }); + + // Der älteste Einsatz ist bereits freigegeben — damit die Ansicht eines + // festgeschriebenen Berichts nicht leer bleibt. + if (vorlage.vorTagen >= 140) { + await prisma.einsatz.update({ + where: { id: einsatz.id }, + data: { + status: EinsatzStatus.FREIGEGEBEN, + freigegebenAm: minutenSpaeter(alarmiert, 2880), + freigegebenVonId: personen[0]?.id ?? null, + }, + }); + } + } + + log(`Einsätze: ${EINSAETZE.length} mit Fahrzeugen, Mannschaft und Lagemeldungen`); +} + +interface UebungVorlage { + vorTagen: number; + thema: string; + art: UebungsArt; + ort: string; + lernziele: string; + teilnehmer: number; + dauer: number; +} + +const UEBUNGEN: UebungVorlage[] = [ + { + vorTagen: 7, + thema: "Innenangriff mit Atemschutz", + art: UebungsArt.ATEMSCHUTZ, + ort: "Übungscontainer Nachbarhausen", + lernziele: + "Vorgehen im Trupp unter Nullsicht, Türöffnungsprozedur, Rückweg sichern, Kommunikation mit dem Gruppenführer.", + teilnehmer: 9, + dauer: 150, + }, + { + vorTagen: 21, + thema: "Wasserförderung über lange Wegstrecke", + art: UebungsArt.PRAXIS, + ort: "Löschwasserentnahmestelle Mühlbach", + lernziele: "Aufbau einer B-Leitung über 600 m, Verstärkerpumpe setzen, Druckverluste erkennen.", + teilnehmer: 12, + dauer: 120, + }, + { + vorTagen: 35, + thema: "Technische Hilfeleistung nach Verkehrsunfall", + art: UebungsArt.PRAXIS, + ort: "Übungsgelände am Bauhof", + lernziele: + "Fahrzeug sichern, Glasmanagement, patientengerechte Rettung, Zusammenarbeit mit dem Rettungsdienst.", + teilnehmer: 11, + dauer: 180, + }, + { + vorTagen: 49, + thema: "Funkausbildung nach BOS-Richtlinie", + art: UebungsArt.FUNK, + ort: "Gerätehaus, Schulungsraum", + lernziele: "Funkdisziplin, Meldeschema, Umgang mit dem Handsprechfunkgerät.", + teilnehmer: 8, + dauer: 90, + }, + { + vorTagen: 63, + thema: "Maschinistenausbildung Tragkraftspritze", + art: UebungsArt.MASCHINIST, + ort: "Gerätehaus", + lernziele: "Inbetriebnahme, Entlüften, Störungssuche, Wartung nach dem Einsatz.", + teilnehmer: 5, + dauer: 120, + }, + { + vorTagen: 84, + thema: "Gemeinschaftsübung mit der FF Nachbarhausen", + art: UebungsArt.GEMEINSCHAFTSUEBUNG, + ort: "Sägewerk Nachbarhausen", + lernziele: + "Zusammenarbeit zweier Wehren, gemeinsame Einsatzleitung, Wasserversorgung aus offenem Gewässer.", + teilnehmer: 14, + dauer: 210, + }, +]; + +async function uebungenAnlegen(organisationId: string) { + const vorhanden = await prisma.uebung.count({ where: { organisationId } }); + if (vorhanden > 0) { + log(`Übungen: bereits ${vorhanden} vorhanden`); + return; + } + + const personen = await prisma.person.findMany({ + where: { organisationId, aktiv: true, geloeschtAm: null }, + select: { id: true }, + orderBy: { mitgliedsnummer: "asc" }, + }); + + if (personen.length === 0) return; + + let laufend = 0; + + for (const vorlage of UEBUNGEN) { + laufend += 1; + + const datum = new Date(); + datum.setUTCDate(datum.getUTCDate() - vorlage.vorTagen); + datum.setUTCHours(0, 0, 0, 0); + + const beginn = new Date(datum); + beginn.setUTCHours(19, 0, 0, 0); + + await prisma.uebung.create({ + data: { + organisationId, + nummer: `${datum.getUTCFullYear()}-${String(laufend).padStart(4, "0")}`, + thema: vorlage.thema, + art: vorlage.art, + datum, + beginnUm: beginn, + endeUm: minutenSpaeter(beginn, vorlage.dauer), + ort: vorlage.ort, + leiterId: personen[0]?.id ?? null, + lernziele: vorlage.lernziele, + teilnahmen: { + create: Array.from({ length: Math.min(vorlage.teilnehmer, personen.length) }).map( + (_, index) => ({ + organisationId, + personId: personen[(index + laufend * 2) % personen.length]!.id, + }), + ), + }, + }, + }); + } + + log(`Übungen: ${UEBUNGEN.length} mit Teilnahmen`); +} + // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- @@ -1539,6 +1976,10 @@ async function main(): Promise { await beispielPruefungAnlegen(organisation.id, wartungsvorlagen); await rueckrufAnlegen(organisation.id); + // Phase 3 — Einsätze und Übungen + await einsaetzeAnlegen(organisation.id, fahrzeuge); + await uebungenAnlegen(organisation.id); + log("Fertig."); log(""); log("Anmeldung für den Erststart:"); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index eec1371..26eb2eb 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -118,6 +118,26 @@ const AUFNAHMEN: Aufnahme[] = [ beschreibung: "Abgeschlossene Prüfprotokolle", angemeldet: true, }, + + // Phase 3 — Einsätze und Übungen + { + name: "19-einsaetze", + pfad: "/einsaetze", + beschreibung: "Einsatzliste mit Schnellerfassung", + angemeldet: true, + }, + { + name: "20-statistik", + pfad: "/einsaetze/statistik", + beschreibung: "Einsatzstatistik mit Hilfsfristen und Beteiligung", + angemeldet: true, + }, + { + name: "21-uebungen", + pfad: "/uebungen", + beschreibung: "Übungen mit Thema und Teilnehmerzahl", + angemeldet: true, + }, ]; /** @@ -163,6 +183,18 @@ const DYNAMISCHE_AUFNAHMEN: Array<{ // Nur fertige Protokolle: die tragen eine Nummer, Entwürfe nicht. textMuster: /^\d{4}-\d{4}$/, }, + { + name: "22-einsatz", + beschreibung: "Einsatzdetail mit Zeitleiste, Stärke und Verlauf", + von: "/einsaetze", + linkMuster: /^\/einsaetze\/[0-9a-f-]{36}$/, + }, + { + name: "23-uebung", + beschreibung: "Übung mit Anwesenheitsliste", + von: "/uebungen", + linkMuster: /^\/uebungen\/[0-9a-f-]{36}$/, + }, ]; /** @@ -201,6 +233,49 @@ async function anmelden(seite: Page): Promise { await seite.waitForLoadState("networkidle"); } +/** + * Das Einsatzportal lässt sich nicht über eine feste Adresse aufrufen — der + * Link entsteht erst, wenn ihn jemand erzeugt. Also erzeugt ihn dieses Skript + * selbst, im ersten Einsatz der Liste, und fotografiert das Ergebnis. + */ +async function portalAufnehmen(seite: Page, darstellung: string): Promise { + try { + await seite.goto(`${BASIS}/einsaetze`, { waitUntil: "networkidle" }); + await fertigGeladen(seite); + + const ziele = await seite + .locator("a[href]") + .evaluateAll((elemente) => + elemente.map((element) => element.getAttribute("href") ?? "").filter(Boolean), + ); + + const einsatz = ziele.find((href) => /^\/einsaetze\/[0-9a-f-]{36}$/.test(href)); + if (!einsatz) { + console.warn("[screenshots] Kein Einsatz für das Portal gefunden — übersprungen."); + return; + } + + await seite.goto(`${BASIS}${einsatz}`, { waitUntil: "networkidle" }); + await fertigGeladen(seite); + + await seite.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen"); + await seite.getByRole("button", { name: "Erzeugen" }).click(); + + const linkfeld = seite.locator("input[readonly]").first(); + await linkfeld.waitFor({ timeout: 15_000 }); + const adresse = await linkfeld.inputValue(); + + await seite.goto(adresse, { waitUntil: "networkidle" }); + await fertigGeladen(seite); + + const datei = path.join(ZIEL, `24-portal-${darstellung}.png`); + await seite.screenshot({ path: datei, fullPage: true }); + console.info(`[screenshots] Einsatzportal für Fremdwehren (${darstellung}) -> ${datei}`); + } catch (fehler) { + console.warn("[screenshots] Portalaufnahme fehlgeschlagen:", fehler); + } +} + async function main(): Promise { await mkdir(ZIEL, { recursive: true }); @@ -266,6 +341,8 @@ async function main(): Promise { console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`); } + await portalAufnehmen(seite, darstellung); + await kontext.close(); } diff --git a/src/app/(app)/einsaetze/[id]/einsatz-detail.tsx b/src/app/(app)/einsaetze/[id]/einsatz-detail.tsx new file mode 100644 index 0000000..6c72df8 --- /dev/null +++ b/src/app/(app)/einsaetze/[id]/einsatz-detail.tsx @@ -0,0 +1,691 @@ +"use client"; + +import * as React from "react"; +import { Check, Copy, FileDown, Link2, Plus, Send, Trash2, Users } from "lucide-react"; +import { EinsatzFunktion } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige"; +import { + EinsatzStatusAbzeichen, + Hinweisliste, + KategorieAbzeichen, + StaerkeAnzeige, + Zeitkennzahlen, + Zeitleiste, +} from "@/components/einsatz/anzeige"; +import { DateiFeld } from "@/components/wartung/datei-feld"; +import { FUNKTION_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte"; +import { ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten"; +import { alsDatumZeit, alsUhrzeit } from "@/lib/utils"; +import { taktischeEinheit } from "@/lib/einsatz/staerke"; + +/** + * Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz. + * + * Aufbau in der Reihenfolge, in der gearbeitet wird: + * + * 1. Zeitstempel — ein Knopf je Stempel, solange es läuft + * 2. Fahrzeuge und Kräfte — wer war dabei, in welcher Funktion + * 3. Lagemeldungen — der Verlauf, auch aus dem Portal + * 4. Material — was verbraucht wurde + * 5. Bericht und Freigabe — zum Schluss, in Ruhe + * + * Nach der Freigabe ist alles gesperrt. Die Oberfläche zeigt das, indem sie + * die Bedienelemente weglässt statt sie auszugrauen: was nicht mehr geht, + * braucht auch keinen Knopf. + */ +export function EinsatzDetail({ + einsatzId, + darfBearbeiten, + darfFreigeben, +}: { + einsatzId: string; + darfBearbeiten: boolean; + darfFreigeben: boolean; +}) { + const [meldung, setMeldung] = React.useState(null); + const [neueMeldung, setNeueMeldung] = React.useState(""); + const [meldungWichtig, setMeldungWichtig] = React.useState(false); + const [meldungFoto, setMeldungFoto] = React.useState(null); + const [bericht, setBericht] = React.useState(null); + const [portalName, setPortalName] = React.useState(""); + const [portalLink, setPortalLink] = React.useState(null); + const [personalOffen, setPersonalOffen] = React.useState(false); + + const werkzeuge = trpc.useUtils(); + const abfrage = trpc.einsatz.einzeln.useQuery({ id: einsatzId }); + + const aktualisieren = () => void werkzeuge.einsatz.einzeln.invalidate({ id: einsatzId }); + const fehlerZeigen = (fehler: { message: string }) => setMeldung(fehler.message); + + const zeitstempelSetzen = trpc.einsatz.zeitstempelSetzen.useMutation({ + onSuccess: aktualisieren, + onError: fehlerZeigen, + }); + + const kraefteSetzen = trpc.einsatz.kraefteSetzen.useMutation({ + onSuccess: (ergebnis) => { + setMeldung(`${ergebnis.erfasst} Kräfte erfasst — Stärke ${ergebnis.staerkeText}.`); + aktualisieren(); + }, + onError: fehlerZeigen, + }); + + const kraftEntfernen = trpc.einsatz.kraftEntfernen.useMutation({ + onSuccess: aktualisieren, + onError: fehlerZeigen, + }); + + const lagemeldung = trpc.einsatz.lagemeldungErfassen.useMutation({ + onSuccess: () => { + setNeueMeldung(""); + setMeldungWichtig(false); + setMeldungFoto(null); + aktualisieren(); + }, + onError: fehlerZeigen, + }); + + const aendern = trpc.einsatz.aendern.useMutation({ + onSuccess: () => { + setMeldung("Gespeichert."); + aktualisieren(); + }, + onError: fehlerZeigen, + }); + + const freigeben = trpc.einsatz.freigeben.useMutation({ + onSuccess: () => { + setMeldung("Bericht freigegeben. Das PDF entsteht im Hintergrund."); + aktualisieren(); + }, + onError: fehlerZeigen, + }); + + const berichtLink = trpc.einsatz.berichtLink.useMutation({ + onSuccess: (ergebnis) => { + if (ergebnis.bereit && ergebnis.adresse) { + window.open(ergebnis.adresse, "_blank", "noopener"); + } else { + setMeldung("Der Bericht wird gerade erzeugt. Gleich noch einmal versuchen."); + } + }, + onError: fehlerZeigen, + }); + + const portalErstellen = trpc.einsatz.portalzugangErstellen.useMutation({ + onSuccess: (zugang) => { + setPortalLink(zugang.adresse); + setPortalName(""); + aktualisieren(); + }, + onError: fehlerZeigen, + }); + + const portalWiderrufen = trpc.einsatz.portalzugangWiderrufen.useMutation({ + onSuccess: aktualisieren, + onError: fehlerZeigen, + }); + + const vorschlag = trpc.einsatz.personalvorschlag.useQuery( + { einsatzId, anzahl: 60 }, + { enabled: personalOffen && darfBearbeiten }, + ); + + if (abfrage.isLoading) { + return

Wird geladen …

; + } + if (abfrage.error) return ; + if (!abfrage.data) return null; + + const einsatz = abfrage.data; + const freigegeben = einsatz.status === "FREIGEGEBEN"; + const bearbeitbar = darfBearbeiten && !freigegeben; + const einheit = taktischeEinheit(einsatz.staerke); + + const adresse = [ + [einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" "), + [einsatz.plz, einsatz.ort].filter(Boolean).join(" "), + ] + .filter(Boolean) + .join(", "); + + return ( +
+ {/* Kopf */} +
+
+

{einsatz.stichwort}

+

+ {einsatz.einsatznummer} + + + {adresse && · {adresse}} + {einsatz.objekt && · {einsatz.objekt}} +

+
+ +
+ {freigegeben && ( + + )} + + {darfFreigeben && !freigegeben && ( + + )} +
+
+ + {meldung && } + + + {freigegeben && ( +
+ Dieser Bericht ist seit {alsDatumZeit(einsatz.freigegebenAm)} freigegeben + {einsatz.freigegebenVon && + ` — freigegeben von ${einsatz.freigegebenVon.vorname} ${einsatz.freigegebenVon.nachname}`} + . Änderungen sind nicht mehr möglich. +
+ )} + + {/* Zeiten */} + + + Zeiten + + Die sechs Stempel des Einsatzes. Ein Klick setzt die aktuelle Uhrzeit. + + + + ({ + bezeichnung: ZEITSTEMPEL_TEXT[stempel], + uhrzeit: einsatz[stempel] ? alsUhrzeit(einsatz[stempel]) : null, + gesetzt: Boolean(einsatz[stempel]), + }))} + /> + + {bearbeitbar && ( +
+ {ZEITSTEMPEL.map((stempel) => ( + + ))} +
+ )} + + +
+
+ + {/* Fahrzeuge und Kräfte */} +
+ + +
+ Eingesetzte Kräfte + + {einsatz.kraefte.length} Personen · {einsatz.fahrzeuge.length} Fahrzeuge + +
+ + {bearbeitbar && ( + + )} +
+ + + {einsatz.fahrzeuge.length > 0 && ( +
    + {einsatz.fahrzeuge.map((eintrag) => ( +
  • + {eintrag.fahrzeug.funkrufname} + {eintrag.ausgeruecktUm && ( + + ab {alsUhrzeit(eintrag.ausgeruecktUm)} + + )} +
  • + ))} +
+ )} + + {einsatz.kraefte.length === 0 ? ( + + ) : ( + + + + Name + Funktion + Fahrzeug + Atemschutz + {bearbeitbar && } + + + + {einsatz.kraefte.map((kraft) => ( + + + {kraft.person.nachname}, {kraft.person.vorname} + + + {bearbeitbar ? ( + + ) : ( + FUNKTION_TEXT[kraft.funktion] + )} + + + {kraft.fahrzeug?.funkrufname ?? "—"} + + + + kraefteSetzen.mutate({ + einsatzId, + kraefte: [ + { + personId: kraft.personId, + funktion: kraft.funktion, + atemschutz: an === true, + fahrzeugId: kraft.fahrzeugId, + }, + ], + }) + } + aria-label={`Atemschutz für ${kraft.person.nachname}`} + /> + + {bearbeitbar && ( + + + + )} + + ))} + +
+ )} + + {/* Personalvorschlag */} + {personalOffen && bearbeitbar && ( +
+

+ Vorschlag — sortiert nach Einsätzen im letzten Jahr +

+

+ Eine echte Verfügbarkeitsmeldung gibt es erst mit den Terminen in Phase 7. Bis + dahin steht hier, wer erfahrungsgemäß kommt — und ob die Qualifikation noch gilt. +

+ +
+ {vorschlag.data?.map((person) => ( + + ))} +
+
+ )} +
+
+ +
+ + + {/* Portalzugänge */} + + + + + Portal für Fremdwehren + + + Ein Link ohne Anmeldung: Stand sehen, Lagemeldung mit Foto absetzen. Zeitlich + begrenzt und jederzeit widerrufbar. + + + + {bearbeitbar && ( +
+ setPortalName(ereignis.target.value)} + placeholder="FF Nachbarhausen" + aria-label="Für wen ist der Link?" + /> + +
+ )} + + {portalLink && ( +
+

+ Dieser Link wird nur jetzt angezeigt — danach steht in der Datenbank nur noch + sein Hash. +

+
+ + +
+
+ )} + +
    + {einsatz.portalzugaenge.map((zugang) => ( +
  • + + {zugang.bezeichnung} + + {zugang.widerrufenAm + ? "widerrufen" + : `gültig bis ${alsDatumZeit(zugang.gueltigBis)}`} + {" · "} + {zugang.aufrufe} Aufrufe + + + {bearbeitbar && !zugang.widerrufenAm && ( + + )} +
  • + ))} +
+
+
+
+
+ + {/* Lagemeldungen */} + + + Verlauf + + Lagemeldungen aus der Wehr und über das Portal. Sie bleiben auch nach der Freigabe + möglich — der Verlauf gehört nicht zum festgeschriebenen Bericht. + + + + {darfBearbeiten && ( +
+