Phase 3: Einsatz- und Übungserfassung (#4)

This commit was merged in pull request #4.
This commit is contained in:
2026-09-03 12:06:22 +00:00
100 changed files with 9667 additions and 38 deletions
+20
View File
@@ -213,6 +213,26 @@ LLM_BASE_URL=http://ollama:11434/v1
LLM_API_KEY= LLM_API_KEY=
LLM_MODELL=qwen2.5:14b 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) # Wartung und Prüfung (Phase 2)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+113 -1
View File
@@ -8,7 +8,119 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht] ## [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.
--- ---
+124 -1
View File
@@ -26,7 +26,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 0 — Fundament | fertig | | Phase 0 — Fundament | fertig |
| Phase 1 — Stammdaten | fertig | | Phase 1 — Stammdaten | fertig |
| Phase 2 — Wartung und Prüfung | 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 4 — Kiosk-PWA | offen |
| Phase 5 — Atemschutz (FwDV 7) | offen | | Phase 5 — Atemschutz (FwDV 7) | offen |
| Phase 6 — Kartenmodule | 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 | | `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 ## Befehle
+7
View File
@@ -61,6 +61,13 @@ x-app-env: &app-env
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-} LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODELL: ${LLM_MODELL:-} 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_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *} WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180} WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
+201 -8
View File
@@ -1,9 +1,9 @@
# Anleitung # 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. 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). > dazu. Siehe [ROADMAP.md](ROADMAP.md).
--- ---
@@ -70,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
### Die Seitenleiste ### Die Seitenleiste
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt 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 Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis: 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 Voraussetzung ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist
(Kapitel 5). (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 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 `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. 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. 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, Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. 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 | | Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---| |---|---|---|
+26 -2
View File
@@ -33,7 +33,7 @@ Faktoren, die in die Schätzung eingehen:
| 0 | Fundament | 12–16 PT | — | **1:00 h** | | 0 | Fundament | 12–16 PT | — | **1:00 h** |
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 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** | | 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 | | 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 | | 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
| 6 | Kartenmodule | 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 ## Phase 3 — Einsätze und Übungen
**Vergleichsaufwand: 12–16 PT** **Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 2:10 h Sitzungszeit**
| Baustein | PT | | 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 Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
steckt weniger in der Funktion als in der Absicherung. 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 ## Phase 4 — Kiosk-PWA
**Vergleichsaufwand: 16–22 PT** **Vergleichsaufwand: 16–22 PT**
+103 -1
View File
@@ -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 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
| Phase 1 — Nachtrag Geräteformular | 0:50 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 | | 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
--- ---
+3 -3
View File
@@ -12,7 +12,7 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
Phase 0 ████████████████████ fertig 0.1.0 Phase 0 ████████████████████ fertig 0.1.0
Phase 1 ████████████████████ fertig 0.2.0 Phase 1 ████████████████████ fertig 0.2.0
Phase 2 ████████████████████ fertig 0.3.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 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0 Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.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 **Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
die Unfallkasse standhält. 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, - Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
Einsatzende, eingerückt, einsatzbereit Einsatzende, eingerückt, einsatzbereit
@@ -97,7 +97,7 @@ die Unfallkasse standhält.
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen. **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. Für die Wandtablets in der Fahrzeughalle.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 KiB

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 962 KiB

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1008 KiB

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "florian", "name": "florian",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": { "engines": {
+7 -1
View File
@@ -24,7 +24,13 @@ export default defineConfig({
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]], reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */ /** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
timeout: 90_000, 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: { use: {
baseURL: BASIS_URL, baseURL: BASIS_URL,
@@ -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;
@@ -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;
$$;
+453
View File
@@ -180,6 +180,16 @@ model Organisation {
rueckrufBetroffenheiten RueckrufBetroffenheit[] rueckrufBetroffenheiten RueckrufBetroffenheit[]
erinnerungen Erinnerung[] 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]) @@index([aktiv])
@@map("organisation") @@map("organisation")
} }
@@ -253,6 +263,15 @@ model Person {
zugewieseneMaengel Mangel[] @relation("MangelZustaendig") zugewieseneMaengel Mangel[] @relation("MangelZustaendig")
behobeneMaengel Mangel[] @relation("MangelBehebung") 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 /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein. /// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email]) @@unique([organisationId, email])
@@ -482,6 +501,11 @@ model Datei {
alsSignaturbild Signatur[] @relation("SignaturBild") alsSignaturbild Signatur[] @relation("SignaturBild")
alsMangelFoto Mangel[] @relation("MangelFoto") alsMangelFoto Mangel[] @relation("MangelFoto")
// Phase 3
alsEinsatzbericht Einsatz[] @relation("EinsatzBericht")
alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto")
alsUebungsbericht Uebung[] @relation("UebungBericht")
@@unique([bucket, objektSchluessel]) @@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId]) @@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie]) @@index([organisationId, kategorie])
@@ -648,6 +672,10 @@ model Fahrzeug {
geraeteraeume Geraeteraum[] geraeteraeume Geraeteraum[]
geraete Geraet[] geraete Geraet[]
// Phase 3
einsaetze Einsatzfahrzeug[]
einsatzkraefte Einsatzkraft[]
@@unique([organisationId, funkrufname]) @@unique([organisationId, funkrufname])
@@unique([organisationId, kennzeichen]) @@unique([organisationId, kennzeichen])
@@index([organisationId, aktiv]) @@index([organisationId, aktiv])
@@ -783,6 +811,7 @@ model Geraet {
pruefungen Pruefung[] pruefungen Pruefung[]
maengel Mangel[] maengel Mangel[]
rueckrufBetroffenheiten RueckrufBetroffenheit[] rueckrufBetroffenheiten RueckrufBetroffenheit[]
materialverbrauch Materialverbrauch[]
@@unique([organisationId, inventarnummer]) @@unique([organisationId, inventarnummer])
@@index([organisationId, status]) @@index([organisationId, status])
@@ -1403,3 +1432,427 @@ model Erinnerung {
@@index([organisationId, versendetAm(sort: Desc)]) @@index([organisationId, versendetAm(sort: Desc)])
@@map("erinnerung") @@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")
}
+442 -1
View File
@@ -7,7 +7,8 @@
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der * Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus * Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein * 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, * 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 * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -20,8 +21,12 @@
*/ */
import "dotenv/config"; import "dotenv/config";
import { import {
Alarmierungsart,
AusweisTokenTyp, AusweisTokenTyp,
ChecklistenpunktTyp, ChecklistenpunktTyp,
EinsatzFunktion,
EinsatzKategorie,
EinsatzStatus,
Dienstgrad, Dienstgrad,
FahrzeugArt, FahrzeugArt,
GeraeteKategorie, GeraeteKategorie,
@@ -38,6 +43,7 @@ import {
RolleSchluessel, RolleSchluessel,
SignaturRolle, SignaturRolle,
StandortArt, StandortArt,
UebungsArt,
} from "@prisma/client"; } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -1512,6 +1518,437 @@ async function rueckrufAnlegen(organisationId: string) {
log("Rückruf: 1 Beispiel"); 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 // Ablauf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -1539,6 +1976,10 @@ async function main(): Promise<void> {
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen); await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
await rueckrufAnlegen(organisation.id); await rueckrufAnlegen(organisation.id);
// Phase 3 — Einsätze und Übungen
await einsaetzeAnlegen(organisation.id, fahrzeuge);
await uebungenAnlegen(organisation.id);
log("Fertig."); log("Fertig.");
log(""); log("");
log("Anmeldung für den Erststart:"); log("Anmeldung für den Erststart:");
+77
View File
@@ -118,6 +118,26 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Abgeschlossene Prüfprotokolle", beschreibung: "Abgeschlossene Prüfprotokolle",
angemeldet: true, 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. // Nur fertige Protokolle: die tragen eine Nummer, Entwürfe nicht.
textMuster: /^\d{4}-\d{4}$/, 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<void> {
await seite.waitForLoadState("networkidle"); 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<void> {
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<void> { async function main(): Promise<void> {
await mkdir(ZIEL, { recursive: true }); await mkdir(ZIEL, { recursive: true });
@@ -266,6 +341,8 @@ async function main(): Promise<void> {
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`); console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
} }
await portalAufnehmen(seite, darstellung);
await kontext.close(); await kontext.close();
} }
@@ -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<string | null>(null);
const [neueMeldung, setNeueMeldung] = React.useState("");
const [meldungWichtig, setMeldungWichtig] = React.useState(false);
const [meldungFoto, setMeldungFoto] = React.useState<string | null>(null);
const [bericht, setBericht] = React.useState<string | null>(null);
const [portalName, setPortalName] = React.useState("");
const [portalLink, setPortalLink] = React.useState<string | null>(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 <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
}
if (abfrage.error) return <Fehlermeldung meldung={abfrage.error.message} />;
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 (
<div className="space-y-6">
{/* Kopf */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{einsatz.stichwort}</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
<span className="tabellenzahlen">{einsatz.einsatznummer}</span>
<KategorieAbzeichen kategorie={einsatz.kategorie} />
<EinsatzStatusAbzeichen status={einsatz.status} />
{adresse && <span>· {adresse}</span>}
{einsatz.objekt && <span>· {einsatz.objekt}</span>}
</p>
</div>
<div className="flex flex-wrap gap-2">
{freigegeben && (
<Button
variant="outline"
onClick={() => berichtLink.mutate({ id: einsatzId })}
disabled={berichtLink.isPending}
>
<FileDown aria-hidden />
Bericht als PDF
</Button>
)}
{darfFreigeben && !freigegeben && (
<Button
onClick={() => {
if (
window.confirm(
"Bericht freigeben? Danach lässt sich am Einsatz nichts mehr ändern.",
)
) {
freigeben.mutate({ id: einsatzId, bericht: bericht ?? undefined });
}
}}
disabled={freigeben.isPending}
>
<Check aria-hidden />
Bericht freigeben
</Button>
)}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="einsatzdetailmeldung" />}
<Hinweisliste hinweise={einsatz.hinweise} />
{freigegeben && (
<div className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 p-3 text-sm">
Dieser Bericht ist seit {alsDatumZeit(einsatz.freigegebenAm)} freigegeben
{einsatz.freigegebenVon &&
` — freigegeben von ${einsatz.freigegebenVon.vorname} ${einsatz.freigegebenVon.nachname}`}
. Änderungen sind nicht mehr möglich.
</div>
)}
{/* Zeiten */}
<Card>
<CardHeader>
<CardTitle>Zeiten</CardTitle>
<CardDescription>
Die sechs Stempel des Einsatzes. Ein Klick setzt die aktuelle Uhrzeit.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Zeitleiste
stempel={ZEITSTEMPEL.map((stempel) => ({
bezeichnung: ZEITSTEMPEL_TEXT[stempel],
uhrzeit: einsatz[stempel] ? alsUhrzeit(einsatz[stempel]) : null,
gesetzt: Boolean(einsatz[stempel]),
}))}
/>
{bearbeitbar && (
<div className="flex flex-wrap gap-2">
{ZEITSTEMPEL.map((stempel) => (
<Button
key={stempel}
variant={einsatz[stempel] ? "ghost" : "outline"}
size="sm"
onClick={() =>
zeitstempelSetzen.mutate({
id: einsatzId,
stempel: stempel as Zeitstempel,
zeitpunkt: einsatz[stempel] ? null : undefined,
})
}
>
{einsatz[stempel]
? `${ZEITSTEMPEL_TEXT[stempel]} zurücknehmen`
: `${ZEITSTEMPEL_TEXT[stempel]} jetzt`}
</Button>
))}
</div>
)}
<Zeitkennzahlen
ausrueckzeit={einsatz.ausrueckzeit}
anfahrtszeit={einsatz.anfahrtszeit}
hilfsfrist={einsatz.hilfsfrist}
dauer={einsatz.dauer}
/>
</CardContent>
</Card>
{/* Fahrzeuge und Kräfte */}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>Eingesetzte Kräfte</CardTitle>
<CardDescription>
{einsatz.kraefte.length} Personen · {einsatz.fahrzeuge.length} Fahrzeuge
</CardDescription>
</div>
{bearbeitbar && (
<Button
variant="outline"
size="sm"
onClick={() => setPersonalOffen((offen) => !offen)}
>
<Users aria-hidden />
{personalOffen ? "Vorschlag schließen" : "Mannschaft erfassen"}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{einsatz.fahrzeuge.length > 0 && (
<ul className="flex flex-wrap gap-2">
{einsatz.fahrzeuge.map((eintrag) => (
<li
key={eintrag.id}
className="rounded-md border border-[var(--border)] px-3 py-1.5 text-sm"
>
{eintrag.fahrzeug.funkrufname}
{eintrag.ausgeruecktUm && (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
ab {alsUhrzeit(eintrag.ausgeruecktUm)}
</span>
)}
</li>
))}
</ul>
)}
{einsatz.kraefte.length === 0 ? (
<NichtsGefunden
titel="Noch keine Mannschaft erfasst"
hinweis="Ohne Kräfte lässt sich kein Bericht freigeben — und die Beteiligung je Person bliebe leer."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Funktion</TableHead>
<TableHead>Fahrzeug</TableHead>
<TableHead>Atemschutz</TableHead>
{bearbeitbar && <TableHead className="w-12" aria-label="Aktion" />}
</TableRow>
</TableHeader>
<TableBody>
{einsatz.kraefte.map((kraft) => (
<TableRow key={kraft.id}>
<TableCell>
{kraft.person.nachname}, {kraft.person.vorname}
</TableCell>
<TableCell>
{bearbeitbar ? (
<Select
value={kraft.funktion}
onValueChange={(wert) =>
kraefteSetzen.mutate({
einsatzId,
kraefte: [
{
personId: kraft.personId,
funktion: wert as EinsatzFunktion,
atemschutz: kraft.atemschutz,
fahrzeugId: kraft.fahrzeugId,
},
],
})
}
>
<SelectTrigger
className="h-8 w-52"
aria-label={`Funktion von ${kraft.person.nachname}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(FUNKTION_TEXT) as EinsatzFunktion[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{FUNKTION_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
FUNKTION_TEXT[kraft.funktion]
)}
</TableCell>
<TableCell className="text-sm">
{kraft.fahrzeug?.funkrufname ?? "—"}
</TableCell>
<TableCell>
<Checkbox
checked={kraft.atemschutz}
disabled={!bearbeitbar}
onCheckedChange={(an) =>
kraefteSetzen.mutate({
einsatzId,
kraefte: [
{
personId: kraft.personId,
funktion: kraft.funktion,
atemschutz: an === true,
fahrzeugId: kraft.fahrzeugId,
},
],
})
}
aria-label={`Atemschutz für ${kraft.person.nachname}`}
/>
</TableCell>
{bearbeitbar && (
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() =>
kraftEntfernen.mutate({ einsatzId, personId: kraft.personId })
}
aria-label={`${kraft.person.nachname} entfernen`}
>
<Trash2 aria-hidden />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
{/* Personalvorschlag */}
{personalOffen && bearbeitbar && (
<div className="rounded-md border border-[var(--border)] p-3">
<p className="mb-2 text-sm font-medium">
Vorschlag — sortiert nach Einsätzen im letzten Jahr
</p>
<p className="mb-3 text-xs text-[var(--muted-foreground)]">
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.
</p>
<div className="grid gap-1.5 sm:grid-cols-2">
{vorschlag.data?.map((person) => (
<button
key={person.id}
type="button"
onClick={() =>
kraefteSetzen.mutate({
einsatzId,
kraefte: [
{
personId: person.id,
funktion: EinsatzFunktion.TRUPPMANN,
atemschutz: false,
},
],
})
}
className="flex items-center justify-between gap-2 rounded border border-[var(--border)] px-3 py-2 text-left text-sm hover:border-[var(--primary)]"
>
<span>
{person.nachname}, {person.vorname}
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
{person.einsaetzeImJahr} Einsätze
</span>
{person.hatAbgelaufeneQualifikation && (
<span className="ml-2 text-xs font-medium text-[var(--gesperrt)]">
Qualifikation abgelaufen
</span>
)}
</span>
<Plus className="size-4 shrink-0" aria-hidden />
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
<div className="space-y-6">
<StaerkeAnzeige staerke={einsatz.staerke} einheit={einheit.einheit} />
{/* Portalzugänge */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link2 className="size-4" aria-hidden />
Portal für Fremdwehren
</CardTitle>
<CardDescription>
Ein Link ohne Anmeldung: Stand sehen, Lagemeldung mit Foto absetzen. Zeitlich
begrenzt und jederzeit widerrufbar.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bearbeitbar && (
<div className="flex gap-2">
<Input
value={portalName}
onChange={(ereignis) => setPortalName(ereignis.target.value)}
placeholder="FF Nachbarhausen"
aria-label="Für wen ist der Link?"
/>
<Button
size="sm"
onClick={() =>
portalErstellen.mutate({ einsatzId, bezeichnung: portalName })
}
disabled={!portalName.trim() || portalErstellen.isPending}
>
Erzeugen
</Button>
</div>
)}
{portalLink && (
<div className="space-y-1 rounded-md border border-[var(--primary)] p-2">
<p className="text-xs font-medium">
Dieser Link wird nur jetzt angezeigt — danach steht in der Datenbank nur noch
sein Hash.
</p>
<div className="flex gap-2">
<Input readOnly value={portalLink} className="text-xs" />
<Button
variant="outline"
size="sm"
onClick={() => void navigator.clipboard.writeText(portalLink)}
aria-label="Link kopieren"
>
<Copy aria-hidden />
</Button>
</div>
</div>
)}
<ul className="space-y-2 text-sm">
{einsatz.portalzugaenge.map((zugang) => (
<li key={zugang.id} className="flex items-center justify-between gap-2">
<span>
{zugang.bezeichnung}
<span className="block text-xs text-[var(--muted-foreground)]">
{zugang.widerrufenAm
? "widerrufen"
: `gültig bis ${alsDatumZeit(zugang.gueltigBis)}`}
{" · "}
{zugang.aufrufe} Aufrufe
</span>
</span>
{bearbeitbar && !zugang.widerrufenAm && (
<Button
variant="ghost"
size="sm"
onClick={() => portalWiderrufen.mutate({ id: zugang.id })}
>
Widerrufen
</Button>
)}
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</div>
{/* Lagemeldungen */}
<Card>
<CardHeader>
<CardTitle>Verlauf</CardTitle>
<CardDescription>
Lagemeldungen aus der Wehr und über das Portal. Sie bleiben auch nach der Freigabe
möglich — der Verlauf gehört nicht zum festgeschriebenen Bericht.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{darfBearbeiten && (
<div className="space-y-2 rounded-md border border-[var(--border)] p-3">
<Textarea
rows={2}
value={neueMeldung}
onChange={(ereignis) => setNeueMeldung(ereignis.target.value)}
placeholder="Was ist der Stand?"
aria-label="Neue Lagemeldung"
/>
<DateiFeld
entitaet="Lagemeldung"
entitaetId={einsatzId}
kategorie="lagemeldung"
dateiId={meldungFoto}
onHochgeladen={setMeldungFoto}
/>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={meldungWichtig}
onCheckedChange={(an) => setMeldungWichtig(an === true)}
/>
wichtig
</label>
<Button
size="sm"
className="ml-auto"
disabled={!neueMeldung.trim() || lagemeldung.isPending}
onClick={() =>
lagemeldung.mutate({
einsatzId,
text: neueMeldung,
wichtig: meldungWichtig,
fotoDateiId: meldungFoto,
})
}
>
<Send aria-hidden />
Melden
</Button>
</div>
</div>
)}
{einsatz.lagemeldungen.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Noch keine Meldung.</p>
) : (
<ol className="space-y-3">
{einsatz.lagemeldungen.map((meldung) => (
<li
key={meldung.id}
className={`border-l-2 pl-3 text-sm ${
meldung.wichtig ? "border-[var(--gesperrt)]" : "border-[var(--border)]"
}`}
>
<div className="flex flex-wrap items-baseline gap-2">
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{alsDatumZeit(meldung.zeitpunkt)}
</span>
{meldung.wichtig && (
<span className="text-xs font-medium text-[var(--gesperrt)]">wichtig</span>
)}
<span className="text-xs text-[var(--muted-foreground)]">
{meldung.verfasserName ??
(meldung.verfasser
? `${meldung.verfasser.vorname} ${meldung.verfasser.nachname}`
: "unbekannt")}{" "}
· {QUELLE_TEXT[meldung.quelle]}
</span>
</div>
<p className="mt-0.5">{meldung.text}</p>
</li>
))}
</ol>
)}
</CardContent>
</Card>
{/* Bericht */}
<Card>
<CardHeader>
<CardTitle>Bericht</CardTitle>
<CardDescription>
Der Text, der später an die Gemeinde geht. Mit der Freigabe wird er festgeschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bearbeitbar ? (
<>
<div className="space-y-2">
<Label htmlFor="bericht">Einsatzbericht</Label>
<Textarea
id="bericht"
rows={8}
value={bericht ?? einsatz.bericht ?? ""}
onChange={(ereignis) => setBericht(ereignis.target.value)}
placeholder="Was war die Lage bei Eintreffen, was wurde getan, wie endete der Einsatz?"
/>
</div>
<Button
variant="outline"
onClick={() => aendern.mutate({ id: einsatzId, bericht: bericht ?? "" })}
disabled={aendern.isPending || bericht === null}
>
Bericht speichern
</Button>
</>
) : (
<p className="whitespace-pre-line text-sm">
{einsatz.bericht ?? "Noch kein Bericht geschrieben."}
</p>
)}
</CardContent>
</Card>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
export const metadata = { title: "Einsatz" };
/**
* Ein Einsatz mit allem, was dazugehört.
*
* Client-Komponente, weil hier während eines laufenden Einsatzes gearbeitet
* wird: Zeitstempel setzen, Kräfte nachtragen, Lagemeldungen schreiben. Jede
* dieser Aktionen soll die Seite nicht neu laden.
*/
export default async function EinsatzSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/einsaetze">
<ArrowLeft aria-hidden />
Zurück zu den Einsätzen
</Link>
</Button>
</div>
<EinsatzDetail
einsatzId={id}
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
/>
</div>
);
}
+492
View File
@@ -0,0 +1,492 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MapPin, Search, Siren } from "lucide-react";
import { EinsatzKategorie, EinsatzStatus } 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 { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { EinsatzStatusAbzeichen, KategorieAbzeichen } from "@/components/einsatz/anzeige";
import { KATEGORIE_TEXT } from "@/lib/einsatz/texte";
import { alsDauer } from "@/lib/einsatz/zeiten";
import { alsDatumZeit } from "@/lib/utils";
/**
* Einsatzliste mit Schnellerfassung.
*
* Die Schnellerfassung ist der wichtigste Knopf der ganzen Phase. Sie fragt
* drei Dinge: was, wo, welche Fahrzeuge. Die Alarmzeit ist jetzt. Alles
* Weitere — Kräfte, Zeiten, Bericht — wird auf der Detailseite nachgetragen,
* wenn wieder Ruhe ist.
*/
const LEER = {
stichwort: "",
kategorie: EinsatzKategorie.SONSTIGES as EinsatzKategorie,
strasse: "",
hausnummer: "",
plz: "",
ort: "",
objekt: "",
breitengrad: null as number | null,
laengengrad: null as number | null,
koordinatenQuelle: null as string | null,
};
export function EinsatzListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const router = useRouter();
const [suche, setSuche] = React.useState("");
const [status, setStatus] = React.useState<EinsatzStatus | "alle">("alle");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [offen, setOffen] = React.useState(false);
const [formular, setFormular] = React.useState(LEER);
const [fahrzeugIds, setFahrzeugIds] = React.useState<Set<string>>(new Set());
const [ausgerueckt, setAusgerueckt] = React.useState(true);
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const einsaetze = trpc.einsatz.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "desc",
suche: suche || undefined,
status: status === "alle" ? undefined : [status],
jahr: jahr === "alle" ? undefined : jahr,
});
const jahre = trpc.statistik.jahre.useQuery();
const fahrzeuge = trpc.fahrzeug.liste.useQuery(
{ seite: 1, seitengroesse: 100, richtung: "asc" },
{ enabled: offen },
);
// Adresssuche über den eigenen Nominatim — nur auf Knopfdruck, nicht bei
// jedem Tastendruck: der Dienst gehört nicht mit Anfragen überzogen.
const [adressuche, setAdressuche] = React.useState<string | null>(null);
const adressen = trpc.einsatz.adresseSuchen.useQuery(
{ suche: adressuche ?? "" },
{ enabled: Boolean(adressuche) },
);
const anlegen = trpc.einsatz.anlegen.useMutation({
onSuccess: (einsatz) => {
setOffen(false);
setFormular(LEER);
setFahrzeugIds(new Set());
void werkzeuge.einsatz.invalidate();
router.push(`/einsaetze/${einsatz.id}`);
},
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = einsaetze.data?.eintraege ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Stichwort, Einsatznummer, Ort …"
className="pl-8"
aria-label="Einsätze durchsuchen"
/>
</div>
<Select
value={status}
onValueChange={(wert) => setStatus(wert as EinsatzStatus | "alle")}
>
<SelectTrigger className="w-44" aria-label="Nach Stand filtern">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Zustände</SelectItem>
<SelectItem value={EinsatzStatus.LAUFEND}>läuft</SelectItem>
<SelectItem value={EinsatzStatus.ENTWURF}>Entwurf</SelectItem>
<SelectItem value={EinsatzStatus.ABGESCHLOSSEN}>abgeschlossen</SelectItem>
<SelectItem value={EinsatzStatus.FREIGEGEBEN}>freigegeben</SelectItem>
</SelectContent>
</Select>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr wählen">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Jahre</SelectItem>
{(jahre.data ?? [new Date().getFullYear()]).map((eintrag) => (
<SelectItem key={eintrag} value={String(eintrag)}>
{eintrag}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button onClick={() => setOffen(true)}>
<Siren aria-hidden />
Einsatz erfassen
</Button>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="einsatzmeldung" />}
{einsaetze.error && <Fehlermeldung meldung={einsaetze.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nummer</TableHead>
<TableHead>Stichwort</TableHead>
<TableHead>Kategorie</TableHead>
<TableHead>Ort</TableHead>
<TableHead>Fahrzeuge</TableHead>
<TableHead>Stand</TableHead>
<TableHead className="text-right">alarmiert</TableHead>
<TableHead className="text-right">Hilfsfrist</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{einsaetze.isLoading && <LadeZeilen spalten={8} />}
{eintraege.map((einsatz) => (
<TableRow key={einsatz.id}>
<TableCell className="tabellenzahlen font-medium">
<Link
href={`/einsaetze/${einsatz.id}`}
className="underline-offset-4 hover:underline"
>
{einsatz.einsatznummer}
</Link>
</TableCell>
<TableCell>
{einsatz.stichwort}
{einsatz.objekt && (
<div className="text-xs text-[var(--muted-foreground)]">{einsatz.objekt}</div>
)}
</TableCell>
<TableCell>
<KategorieAbzeichen kategorie={einsatz.kategorie} />
</TableCell>
<TableCell className="text-sm">
{[einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" ") || "—"}
{einsatz.ort && (
<div className="text-xs text-[var(--muted-foreground)]">{einsatz.ort}</div>
)}
</TableCell>
<TableCell className="text-sm">
{einsatz.fahrzeuge.length === 0
? "—"
: einsatz.fahrzeuge
.map((eintrag) => eintrag.fahrzeug.funkrufname.split(" ").pop())
.join(", ")}
</TableCell>
<TableCell>
<EinsatzStatusAbzeichen status={einsatz.status} />
</TableCell>
<TableCell className="text-right tabellenzahlen whitespace-nowrap">
{alsDatumZeit(einsatz.alarmiertUm)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDauer(einsatz.hilfsfrist)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!einsaetze.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel={suche ? "Kein Einsatz gefunden" : "Noch kein Einsatz erfasst"}
hinweis={
suche
? "Vielleicht in einem anderen Jahr?"
: "Im Alarmfall genügen Stichwort, Ort und die ausgerückten Fahrzeuge — der Rest wird nachgetragen."
}
aktion={
darfBearbeiten && !suche ? (
<Button onClick={() => setOffen(true)}>
<Siren aria-hidden />
Einsatz erfassen
</Button>
) : undefined
}
/>
</div>
)}
</div>
{/* Schnellerfassung */}
<Dialog open={offen} onOpenChange={setOffen}>
<DialogContent className="max-w-2xl">
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
setMeldung(null);
anlegen.mutate({
stichwort: formular.stichwort,
kategorie: formular.kategorie,
strasse: formular.strasse || null,
hausnummer: formular.hausnummer || null,
plz: formular.plz || null,
ort: formular.ort || null,
objekt: formular.objekt || null,
breitengrad: formular.breitengrad,
laengengrad: formular.laengengrad,
koordinatenQuelle: formular.koordinatenQuelle,
fahrzeugIds: [...fahrzeugIds],
ausgerueckt,
});
}}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>Einsatz erfassen</DialogTitle>
<DialogDescription>
Stichwort, Ort, Fahrzeuge — mehr braucht es jetzt nicht. Alarmzeit ist dieser
Moment.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="stichwort">Einsatzstichwort</Label>
<Input
id="stichwort"
required
autoFocus
value={formular.stichwort}
onChange={(ereignis) =>
setFormular({ ...formular, stichwort: ereignis.target.value })
}
placeholder="B 3 Wohnhausbrand"
/>
</div>
<div className="space-y-2">
<Label htmlFor="kategorie">Kategorie</Label>
<Select
value={formular.kategorie}
onValueChange={(wert) =>
setFormular({ ...formular, kategorie: wert as EinsatzKategorie })
}
>
<SelectTrigger id="kategorie">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(KATEGORIE_TEXT) as EinsatzKategorie[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="strasse">Straße</Label>
<Input
id="strasse"
value={formular.strasse}
onChange={(ereignis) =>
setFormular({ ...formular, strasse: ereignis.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="hausnummer">Nummer</Label>
<Input
id="hausnummer"
value={formular.hausnummer}
onChange={(ereignis) =>
setFormular({ ...formular, hausnummer: ereignis.target.value })
}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="plz">PLZ</Label>
<Input
id="plz"
value={formular.plz}
onChange={(ereignis) => setFormular({ ...formular, plz: ereignis.target.value })}
/>
</div>
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="ort">Ort</Label>
<Input
id="ort"
value={formular.ort}
onChange={(ereignis) => setFormular({ ...formular, ort: ereignis.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="objekt">Objekt</Label>
<Input
id="objekt"
value={formular.objekt}
onChange={(ereignis) => setFormular({ ...formular, objekt: ereignis.target.value })}
placeholder="Grundschule, Autobahn A9 km 312 …"
/>
</div>
{/* Koordinaten über den eigenen Nominatim */}
<div className="space-y-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setAdressuche(
[formular.strasse, formular.hausnummer, formular.plz, formular.ort]
.filter(Boolean)
.join(" "),
)
}
>
<MapPin aria-hidden />
Koordinaten suchen
</Button>
{adressen.data && !adressen.data.verfuegbar && (
<p className="text-xs text-[var(--muted-foreground)]">
Es ist kein eigener Nominatim eingerichtet — Koordinaten bleiben leer. Das ist
in Ordnung; die Karte kommt in Phase 6.
</p>
)}
{adressen.data?.treffer.map((treffer) => (
<button
key={`${treffer.breitengrad},${treffer.laengengrad}`}
type="button"
onClick={() =>
setFormular({
...formular,
breitengrad: treffer.breitengrad,
laengengrad: treffer.laengengrad,
koordinatenQuelle: "nominatim",
})
}
className="block w-full rounded border border-[var(--border)] p-2 text-left text-sm hover:border-[var(--primary)]"
>
{treffer.anzeige}
</button>
))}
{formular.breitengrad !== null && (
<p className="text-xs text-[var(--muted-foreground)]">
Koordinaten übernommen: {formular.breitengrad.toFixed(5)},{" "}
{formular.laengengrad?.toFixed(5)}
</p>
)}
</div>
<div className="space-y-2">
<Label>Ausgerückte Fahrzeuge</Label>
<div className="grid gap-1.5 sm:grid-cols-2">
{fahrzeuge.data?.eintraege.map((fahrzeug) => (
<label
key={fahrzeug.id}
className="flex cursor-pointer items-center gap-2 rounded border border-[var(--border)] px-3 py-2 text-sm"
>
<Checkbox
checked={fahrzeugIds.has(fahrzeug.id)}
onCheckedChange={(an) =>
setFahrzeugIds((alt) => {
const neu = new Set(alt);
if (an) neu.add(fahrzeug.id);
else neu.delete(fahrzeug.id);
return neu;
})
}
/>
<span>
{fahrzeug.funkrufname}
{fahrzeug.dinTyp && (
<span className="text-[var(--muted-foreground)]"> · {fahrzeug.dinTyp}</span>
)}
</span>
</label>
))}
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={ausgerueckt}
onCheckedChange={(an) => setAusgerueckt(an === true)}
/>
Fahrzeuge sind jetzt ausgerückt
</label>
</div>
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={anlegen.isPending}>
{anlegen.isPending ? "wird angelegt …" : "Einsatz anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { BarChart3, GraduationCap } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { EinsatzListe } from "@/app/(app)/einsaetze/einsatz-liste";
export const metadata = { title: "Einsätze" };
/**
* Einsatzübersicht.
*
* Oben die Schnellerfassung: im Alarmfall zählt, dass der Einsatz überhaupt
* angelegt wird. Alles andere wird nachgetragen.
*/
export default async function EinsaetzeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Einsätze</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Vom Alarm bis zum freigegebenen Bericht.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href="/uebungen">
<GraduationCap aria-hidden />
Übungen
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/einsaetze/statistik">
<BarChart3 aria-hidden />
Statistik
</Link>
</Button>
</div>
</div>
<EinsatzListe darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Einsatzstatistik } from "@/app/(app)/einsaetze/statistik/statistik";
export const metadata = { title: "Einsatzstatistik" };
/**
* Die Zahlen des Jahres.
*
* Zwei Adressaten: die Gemeinde fragt nach Anzahl und Hilfsfrist, die Wehr
* nach der Beteiligung. Beides steht auf dieser Seite, und der Export darunter
* bringt es aufs Papier.
*/
export default async function StatistikSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/einsaetze">
<ArrowLeft aria-hidden />
Zurück zu den Einsätzen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Einsatzstatistik</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Einsätze nach Art und Monat, Hilfsfristen, Beteiligung und Fahrzeugauslastung.
</p>
</div>
<Einsatzstatistik />
</div>
);
}
@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import { FileDown } from "lucide-react";
import type { EinsatzKategorie, UebungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
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 { Balkendiagramm } from "@/components/einsatz/anzeige";
import { Kennzahl } from "@/components/wartung/anzeige";
import { KATEGORIE_TEXT, MONAT_KURZ, UEBUNGSART_TEXT } from "@/lib/einsatz/texte";
import { alsDauer } from "@/lib/einsatz/zeiten";
/**
* Einsatzstatistik eines Jahres.
*
* Die wichtigste Zahl steht oben links und heißt Hilfsfrist: in Bayern soll die
* Wehr in 80 Prozent der Fälle innerhalb von zehn Minuten nach Alarmierung an
* der Einsatzstelle sein. Genau dieser Wert wird hier ausgewiesen — nicht der
* Durchschnitt, denn der verdeckt die Ausreißer, um die es geht.
*/
export function Einsatzstatistik() {
const [jahr, setJahr] = React.useState<number>(new Date().getFullYear());
const jahre = trpc.statistik.jahre.useQuery();
const daten = trpc.statistik.jahr.useQuery({ jahr });
if (daten.error) return <Fehlermeldung meldung={daten.error.message} />;
const zahlen = daten.data;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1">
<Label htmlFor="jahr">Jahr</Label>
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger id="jahr" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(jahre.data ?? [new Date().getFullYear()]).map((eintrag) => (
<SelectItem key={eintrag} value={String(eintrag)}>
{eintrag}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" asChild>
<a href={`/api/einsaetze/jahresexport?jahr=${jahr}`} target="_blank" rel="noopener">
<FileDown aria-hidden />
Jahresexport als PDF
</a>
</Button>
</div>
{daten.isLoading && (
<p className="text-sm text-[var(--muted-foreground)]">Wird gerechnet …</p>
)}
{zahlen && zahlen.gesamt === 0 && (
<NichtsGefunden
titel={`Für ${jahr} ist kein Einsatz erfasst`}
hinweis="Ein anderes Jahr zeigt vielleicht mehr."
/>
)}
{zahlen && zahlen.gesamt > 0 && (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl
wert={zahlen.gesamt}
bezeichnung="Einsätze"
hinweis={`${zahlen.freigegeben} Berichte freigegeben`}
/>
<div className="flex flex-col gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
<span className="tabellenzahlen text-3xl font-semibold">
{alsDauer(zahlen.zeiten.hilfsfristP80)}
</span>
<span className="text-sm font-medium">Hilfsfrist (80 %)</span>
<span className="text-xs text-[var(--muted-foreground)]">
in 80 % der Fälle war die Wehr schneller da
</span>
</div>
<div className="flex flex-col gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
<span className="tabellenzahlen text-3xl font-semibold">
{alsDauer(zahlen.zeiten.ausrueckzeitMedian)}
</span>
<span className="text-sm font-medium">Ausrückzeit (Median)</span>
<span className="text-xs text-[var(--muted-foreground)]">Alarm bis Ausrücken</span>
</div>
<Kennzahl
wert={zahlen.personen.gerettet}
bezeichnung="gerettete Personen"
hinweis={`${zahlen.personen.verletzt} verletzt, ${zahlen.personen.getoetet} getötet`}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Nach Kategorie</CardTitle>
<CardDescription>Einteilung nach der Landesstatistik.</CardDescription>
</CardHeader>
<CardContent>
<Balkendiagramm
werte={zahlen.nachKategorie.map((eintrag) => ({
beschriftung: KATEGORIE_TEXT[eintrag.kategorie as EinsatzKategorie] ?? eintrag.kategorie,
wert: eintrag.anzahl,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Nach Monat</CardTitle>
<CardDescription>Wann im Jahr die Arbeit anfällt.</CardDescription>
</CardHeader>
<CardContent>
<Balkendiagramm
werte={zahlen.nachMonat.map((eintrag) => ({
beschriftung: MONAT_KURZ[eintrag.monat - 1] ?? String(eintrag.monat),
wert: eintrag.anzahl,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Hilfsfristen</CardTitle>
<CardDescription>
Verteilung über {zahlen.zeiten.gemessen} Einsätze mit vollständigen Zeiten.
Median {alsDauer(zahlen.zeiten.hilfsfristMedian)}.
</CardDescription>
</CardHeader>
<CardContent>
<Balkendiagramm
werte={zahlen.zeiten.verteilung.map((eintrag) => ({
beschriftung: eintrag.text,
wert: eintrag.anzahl,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Fahrzeuge</CardTitle>
<CardDescription>Einsätze, gefahrene Kilometer, Wasserabgabe.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Fahrzeug</TableHead>
<TableHead className="text-right">Einsätze</TableHead>
<TableHead className="text-right">km</TableHead>
<TableHead className="text-right">Wasser</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zahlen.fahrzeuge.map((fahrzeug) => (
<TableRow key={fahrzeug.fahrzeugId}>
<TableCell>{fahrzeug.name}</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrzeug.einsaetze}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrzeug.kilometer || "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrzeug.wasser ? `${fahrzeug.wasser} l` : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Beteiligung</CardTitle>
<CardDescription>
Wer trägt die Last — Einsätze und Übungen je Person. {zahlen.uebungen.gesamt}{" "}
Übungen mit {zahlen.uebungen.teilnahmen} Teilnahmen
{zahlen.uebungen.nachArt.length > 0 &&
` (${zahlen.uebungen.nachArt
.map(
(eintrag) =>
`${UEBUNGSART_TEXT[eintrag.art as UebungsArt] ?? eintrag.art}: ${eintrag.anzahl}`,
)
.join(", ")})`}
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="text-right">Einsätze</TableHead>
<TableHead className="text-right">davon Atemschutz</TableHead>
<TableHead className="text-right">Übungen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zahlen.beteiligung.map((person) => (
<TableRow key={person.personId}>
<TableCell>{person.name}</TableCell>
<TableCell className="text-right tabellenzahlen">
{person.einsaetze}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{person.atemschutzeinsaetze || "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{person.uebungen || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { UebungDetail } from "@/app/(app)/uebungen/[id]/uebung-detail";
export const metadata = { title: "Übung" };
/** Eine Übung mit Anwesenheitsliste. */
export default async function UebungSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/uebungen">
<ArrowLeft aria-hidden />
Zurück zu den Übungen
</Link>
</Button>
</div>
<UebungDetail uebungId={id} darfBearbeiten={hatRecht(sitzung, "ausbildung.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
import * as React from "react";
import { Save } from "lucide-react";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { UebungsartAbzeichen } from "@/components/einsatz/anzeige";
import { alsDatum } from "@/lib/utils";
/**
* Übungsdetail mit Anwesenheitsliste.
*
* Die Liste ist der eigentliche Zweck der Seite: Namen abhaken, fertig. Sie
* zeigt deshalb alle aktiven Mitglieder auf einmal und nicht eine Suche, in
* der man jeden einzeln nachschlagen müsste — bei einer Übung ist die halbe
* Wehr da.
*/
export function UebungDetail({
uebungId,
darfBearbeiten,
}: {
uebungId: string;
darfBearbeiten: boolean;
}) {
const [meldung, setMeldung] = React.useState<string | null>(null);
const [anwesend, setAnwesend] = React.useState<Set<string> | null>(null);
const [bericht, setBericht] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const abfrage = trpc.uebung.einzeln.useQuery({ id: uebungId });
// 200 ist die Höchstzahl je Seite (siehe gemeinsam.ts). Eine Wehr mit mehr
// aktiven Mitgliedern bräuchte hier eine Blätterung — bei einer Freiwilligen
// Feuerwehr ist das der theoretische Fall.
const mitglieder = trpc.person.liste.useQuery({ seite: 1, seitengroesse: 200, richtung: "asc" });
// Die vorhandene Teilnahme einmal in den Bearbeitungsstand übernehmen.
React.useEffect(() => {
if (abfrage.data && anwesend === null) {
setAnwesend(new Set(abfrage.data.teilnahmen.map((teilnahme) => teilnahme.personId)));
}
}, [abfrage.data, anwesend]);
const teilnahmeSetzen = trpc.uebung.teilnahmeSetzen.useMutation({
onSuccess: (ergebnis) => {
setMeldung(`${ergebnis.erfasst} Teilnehmer gespeichert.`);
void werkzeuge.uebung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const aendern = trpc.uebung.aendern.useMutation({
onSuccess: () => {
setMeldung("Gespeichert.");
void werkzeuge.uebung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
if (abfrage.isLoading) {
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
}
if (abfrage.error) return <Fehlermeldung meldung={abfrage.error.message} />;
if (!abfrage.data) return null;
const uebung = abfrage.data;
const personen = mitglieder.data?.eintraege ?? [];
const anwesendeMenge = anwesend ?? new Set<string>();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{uebung.thema}</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
<span className="tabellenzahlen">{uebung.nummer}</span>
<UebungsartAbzeichen art={uebung.art} />
<span>· {alsDatum(uebung.datum)}</span>
{uebung.ort && <span>· {uebung.ort}</span>}
{uebung.leiter && (
<span>
· Leitung {uebung.leiter.nachname}, {uebung.leiter.vorname}
</span>
)}
<span>· {uebung.teilnahmen.length} Teilnehmer</span>
</p>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="uebungmeldung" />}
{uebung.lernziele && (
<Card>
<CardHeader>
<CardTitle>Lernziele</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line text-sm">{uebung.lernziele}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>Anwesenheit</CardTitle>
<CardDescription>
{anwesendeMenge.size} von {personen.length} aktiven Mitgliedern
</CardDescription>
</div>
{darfBearbeiten && (
<Button
size="sm"
onClick={() =>
teilnahmeSetzen.mutate({
uebungId,
teilnehmer: [...anwesendeMenge].map((personId) => ({ personId })),
ersetzen: true,
})
}
disabled={teilnahmeSetzen.isPending}
>
<Save aria-hidden />
Anwesenheit speichern
</Button>
)}
</CardHeader>
<CardContent>
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{personen.map((person) => (
<label
key={person.id}
className="flex cursor-pointer items-center gap-2 rounded border border-[var(--border)] px-3 py-2 text-sm"
>
<Checkbox
checked={anwesendeMenge.has(person.id)}
disabled={!darfBearbeiten}
onCheckedChange={(an) =>
setAnwesend((alt) => {
const neu = new Set(alt ?? []);
if (an) neu.add(person.id);
else neu.delete(person.id);
return neu;
})
}
/>
<span>
{person.nachname}, {person.vorname}
</span>
</label>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bericht</CardTitle>
<CardDescription>
Was gemacht wurde und was aufgefallen ist — die Grundlage für die nächste Übung.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{darfBearbeiten ? (
<>
<div className="space-y-2">
<Label htmlFor="uebungsbericht">Übungsbericht</Label>
<Textarea
id="uebungsbericht"
rows={6}
value={bericht ?? uebung.bericht ?? ""}
onChange={(ereignis) => setBericht(ereignis.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="beginn">Beginn</Label>
<Input
id="beginn"
type="time"
defaultValue={
uebung.beginnUm ? uebung.beginnUm.toISOString().slice(11, 16) : ""
}
onBlur={(ereignis) => {
if (!ereignis.target.value) return;
const [stunde, minute] = ereignis.target.value.split(":").map(Number);
const zeit = new Date(uebung.datum);
zeit.setUTCHours(stunde ?? 0, minute ?? 0, 0, 0);
aendern.mutate({ id: uebungId, beginnUm: zeit });
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ende">Ende</Label>
<Input
id="ende"
type="time"
defaultValue={uebung.endeUm ? uebung.endeUm.toISOString().slice(11, 16) : ""}
onBlur={(ereignis) => {
if (!ereignis.target.value) return;
const [stunde, minute] = ereignis.target.value.split(":").map(Number);
const zeit = new Date(uebung.datum);
zeit.setUTCHours(stunde ?? 0, minute ?? 0, 0, 0);
aendern.mutate({ id: uebungId, endeUm: zeit });
}}
/>
</div>
</div>
<Button
variant="outline"
onClick={() => aendern.mutate({ id: uebungId, bericht: bericht ?? "" })}
disabled={aendern.isPending || bericht === null}
>
Bericht speichern
</Button>
</>
) : (
<p className="whitespace-pre-line text-sm">
{uebung.bericht ?? "Noch kein Bericht geschrieben."}
</p>
)}
{uebung.dauerMinuten !== null && (
<p className="text-xs text-[var(--muted-foreground)]">
Dauer: {uebung.dauerMinuten} Minuten
</p>
)}
</CardContent>
</Card>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { UebungListe } from "@/app/(app)/uebungen/uebung-liste";
export const metadata = { title: "Übungen" };
/**
* Übungen und Ausbildung.
*
* Der Nachweis nach innen: wer war dabei, wie oft, und woran wurde gearbeitet.
* Daraus wird in der Jahreshauptversammlung die Teilnahmequote — und bei
* Beförderungen die Grundlage.
*/
export default async function UebungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Übungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Thema, Lernziele und Teilnahme — je Übung dokumentiert.
</p>
</div>
<UebungListe darfBearbeiten={hatRecht(sitzung, "ausbildung.bearbeiten")} />
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { UebungsArt } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { UebungsartAbzeichen } from "@/components/einsatz/anzeige";
import { UEBUNGSART_TEXT } from "@/lib/einsatz/texte";
import { alsDatum } from "@/lib/utils";
/** Datum von heute im Format, das ein date-Feld erwartet. */
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
export function UebungListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const router = useRouter();
const [suche, setSuche] = React.useState("");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [offen, setOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [thema, setThema] = React.useState("");
const [art, setArt] = React.useState<UebungsArt>(UebungsArt.PRAXIS);
const [datum, setDatum] = React.useState(heute());
const [ort, setOrt] = React.useState("");
const [lernziele, setLernziele] = React.useState("");
const werkzeuge = trpc.useUtils();
const uebungen = trpc.uebung.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "desc",
suche: suche || undefined,
jahr: jahr === "alle" ? undefined : jahr,
});
const anlegen = trpc.uebung.anlegen.useMutation({
onSuccess: (uebung) => {
setOffen(false);
setThema("");
setLernziele("");
void werkzeuge.uebung.invalidate();
router.push(`/uebungen/${uebung.id}`);
},
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = uebungen.data?.eintraege ?? [];
const jahreAuswahl = Array.from({ length: 6 }, (_, index) => new Date().getFullYear() - index);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Thema, Nummer, Ort …"
className="pl-8"
aria-label="Übungen durchsuchen"
/>
</div>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr wählen">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Jahre</SelectItem>
{jahreAuswahl.map((eintrag) => (
<SelectItem key={eintrag} value={String(eintrag)}>
{eintrag}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Übung anlegen
</Button>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} />}
{uebungen.error && <Fehlermeldung meldung={uebungen.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nummer</TableHead>
<TableHead>Thema</TableHead>
<TableHead>Art</TableHead>
<TableHead>Ort</TableHead>
<TableHead>Leitung</TableHead>
<TableHead className="text-right">Datum</TableHead>
<TableHead className="text-right">Teilnehmer</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uebungen.isLoading && <LadeZeilen spalten={7} />}
{eintraege.map((uebung) => (
<TableRow key={uebung.id}>
<TableCell className="tabellenzahlen font-medium">
<Link
href={`/uebungen/${uebung.id}`}
className="underline-offset-4 hover:underline"
>
{uebung.nummer}
</Link>
</TableCell>
<TableCell>{uebung.thema}</TableCell>
<TableCell>
<UebungsartAbzeichen art={uebung.art} />
</TableCell>
<TableCell className="text-sm">{uebung.ort ?? "—"}</TableCell>
<TableCell className="text-sm">
{uebung.leiter ? `${uebung.leiter.nachname}, ${uebung.leiter.vorname}` : "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(uebung.datum)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{uebung._count.teilnahmen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!uebungen.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel="Keine Übung erfasst"
hinweis="Thema, Datum und wer dabei war — mehr braucht ein Übungsnachweis nicht."
aktion={
darfBearbeiten ? (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Übung anlegen
</Button>
) : undefined
}
/>
</div>
)}
</div>
<Dialog open={offen} onOpenChange={setOffen}>
<DialogContent>
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
setMeldung(null);
anlegen.mutate({
thema,
art,
datum: new Date(datum),
ort: ort || null,
lernziele: lernziele || null,
});
}}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>Übung anlegen</DialogTitle>
<DialogDescription>
Die Teilnehmer werden im nächsten Schritt abgehakt.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="thema">Thema</Label>
<Input
id="thema"
required
autoFocus
value={thema}
onChange={(ereignis) => setThema(ereignis.target.value)}
placeholder="Innenangriff mit Atemschutz"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as UebungsArt)}>
<SelectTrigger id="art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(UEBUNGSART_TEXT) as UebungsArt[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{UEBUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="datum">Datum</Label>
<Input
id="datum"
type="date"
required
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ort">Ort</Label>
<Input
id="ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
placeholder="Gerätehaus, Übungsgelände …"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lernziele">Lernziele</Label>
<Textarea
id="lernziele"
rows={3}
value={lernziele}
onChange={(ereignis) => setLernziele(ereignis.target.value)}
placeholder="Was soll danach sitzen?"
/>
</div>
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={anlegen.isPending}>
{anlegen.isPending ? "wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { AuditAktion, protokolliere } from "@/lib/audit";
import { jahresexportPdf, type JahresexportDaten } from "@/lib/einsatz/bericht";
import { KATEGORIE_TEXT, einsatzAdresse } from "@/lib/einsatz/bericht-erzeugen";
import { einsatzdauer, hilfsfrist } from "@/lib/einsatz/zeiten";
import { staerkeAlsText, staerkeBerechnen } from "@/lib/einsatz/staerke";
/**
* Sammelexport eines Jahres als PDF.
*
* Das Blatt, das die Wehr der Gemeinde vorlegt. Eigener Endpunkt statt tRPC,
* weil ein PDF eine Datei ist und keine JSON-Antwort.
*
* Anders als der einzelne Einsatzbericht wird es nicht abgelegt: es ist eine
* Auswertung, keine Urkunde, und sie ändert sich, solange noch Einsätze des
* Jahres nachgetragen werden.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const eingabe = z.object({
jahr: z.coerce.number().int().min(2000).max(2100),
});
export async function GET(anfrage: Request) {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
}
if (!hatRecht(sitzung, "einsatz.lesen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
}
const adresse = new URL(anfrage.url);
const geprueft = eingabe.safeParse({
jahr: adresse.searchParams.get("jahr") ?? new Date().getUTCFullYear(),
});
if (!geprueft.success) {
return NextResponse.json({ fehler: "Ungültige Jahresangabe." }, { status: 400 });
}
const jahr = geprueft.data.jahr;
const von = new Date(Date.UTC(jahr, 0, 1));
const bis = new Date(Date.UTC(jahr + 1, 0, 1));
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const [organisation, einsaetze, uebungen] = await Promise.all([
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { name: true },
}),
db.einsatz.findMany({
where: { geloeschtAm: null, alarmiertUm: { gte: von, lt: bis } },
orderBy: { alarmiertUm: "asc" },
include: {
kraefte: { select: { funktion: true } },
},
}),
db.uebung.findMany({
where: { geloeschtAm: null, datum: { gte: von, lt: bis } },
select: { _count: { select: { teilnahmen: true } } },
}),
]);
return { organisation, einsaetze, uebungen };
});
const nachKategorie = new Map<string, number>();
const hilfsfristen: number[] = [];
let gerettet = 0;
let verletzt = 0;
let getoetet = 0;
const zeilen = daten.einsaetze.map((einsatz) => {
const text = KATEGORIE_TEXT[einsatz.kategorie] ?? einsatz.kategorie;
nachKategorie.set(text, (nachKategorie.get(text) ?? 0) + 1);
const frist = hilfsfrist(einsatz);
if (frist !== null) hilfsfristen.push(frist);
gerettet += einsatz.geretteteePersonen;
verletzt += einsatz.verletztePersonen;
getoetet += einsatz.totePersonen;
return {
einsatznummer: einsatz.einsatznummer,
datum: einsatz.alarmiertUm,
stichwort: einsatz.stichwort,
kategorie: text,
ort: einsatzAdresse(einsatz),
staerke: staerkeAlsText(staerkeBerechnen(einsatz.kraefte.map((kraft) => kraft.funktion))),
hilfsfrist: frist,
dauer: einsatzdauer(einsatz),
};
});
const sortiert = [...hilfsfristen].sort((a, b) => a - b);
const perzentil = (anteil: number) =>
sortiert.length > 0
? (sortiert[Math.min(sortiert.length - 1, Math.floor(anteil * sortiert.length))] ?? null)
: null;
const exportDaten: JahresexportDaten = {
organisation: { name: daten.organisation?.name ?? "Feuerwehr" },
jahr,
einsaetze: zeilen,
nachKategorie: [...nachKategorie.entries()]
.map(([kategorie, anzahl]) => ({ kategorie, anzahl }))
.sort((a, b) => b.anzahl - a.anzahl),
zeiten: {
hilfsfristP80: perzentil(0.8),
hilfsfristMedian: perzentil(0.5),
gemessen: hilfsfristen.length,
},
personen: { gerettet, verletzt, getoetet },
uebungen: {
gesamt: daten.uebungen.length,
teilnahmen: daten.uebungen.reduce((summe, uebung) => summe + uebung._count.teilnahmen, 0),
},
erzeugtAm: new Date(),
};
try {
const pdf = await jahresexportPdf(exportDaten);
await protokolliere({
organisationId: sitzung.organisationId,
aktion: AuditAktion.EXPORTIERT,
entitaet: "Einsatz",
beschreibung: `Jahresexport ${jahr} mit ${zeilen.length} Einsätzen erzeugt`,
sitzung,
kontext: { jahr, anzahl: zeilen.length },
});
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="einsatzstatistik-${jahr}.pdf"`,
"Cache-Control": "no-store",
},
});
} catch (fehler) {
console.error("[jahresexport] PDF fehlgeschlagen:", fehler);
return NextResponse.json(
{
fehler:
"Der Jahresexport ließ sich nicht erzeugen. Läuft der Gotenberg-Container? (docker compose ps)",
},
{ status: 502 },
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import { NextResponse } from "next/server";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { hochladen, objektSchluessel } from "@/lib/storage";
import { PORTAL_FEHLER_TEXT, portalPruefen } from "@/lib/einsatz/portal";
import { zugriffZaehlen } from "@/lib/ratelimit";
/**
* Foto-Upload aus dem Einsatzportal.
*
* Der einzige Endpunkt in FLORIAN, der Dateien ohne Anmeldung annimmt.
* Entsprechend eng gefasst:
*
* * nur mit gültigem Portal-Token
* * nur Bilder, höchstens 10 MB
* * höchstens zehn Uploads je Zugang und Stunde
*
* Alles andere — PDF, mehrere Dateien, beliebige Größe — bleibt dem
* angemeldeten Weg über /api/dateien vorbehalten.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const ERLAUBTE_TYPEN = new Set(["image/jpeg", "image/png", "image/webp", "image/heic"]);
const MAX_BYTES = 10 * 1024 * 1024;
function ipAus(anfrage: Request): string | null {
const weitergeleitet = anfrage.headers.get("x-forwarded-for");
if (weitergeleitet) return weitergeleitet.split(",")[0]?.trim() ?? null;
return anfrage.headers.get("x-real-ip");
}
export async function POST(anfrage: Request) {
const formular = await anfrage.formData().catch(() => null);
if (!formular) {
return NextResponse.json({ fehler: "Die Anfrage enthält kein Formular." }, { status: 400 });
}
const token = formular.get("token");
if (typeof token !== "string") {
return NextResponse.json({ fehler: "Es fehlt der Zugangslink." }, { status: 400 });
}
const ipAdresse = ipAus(anfrage);
const geprueft = await portalPruefen(token, ipAdresse);
if ("fehler" in geprueft) {
return NextResponse.json(
{ fehler: PORTAL_FEHLER_TEXT[geprueft.fehler] },
{ status: geprueft.fehler === "zu_viele_zugriffe" ? 429 : 401 },
);
}
const sitzung = geprueft.sitzung;
const grenze = await zugriffZaehlen(`portal-foto:${sitzung.zugangId}`, 10, 3600);
if (!grenze.erlaubt) {
return NextResponse.json(
{ fehler: "Zu viele Fotos in kurzer Zeit. Bitte später noch einmal." },
{ status: 429 },
);
}
const datei = formular.get("datei");
if (!(datei instanceof File)) {
return NextResponse.json({ fehler: "Es wurde kein Foto übergeben." }, { status: 400 });
}
const mimeTyp = datei.type || "application/octet-stream";
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
return NextResponse.json(
{ fehler: "Über das Portal lassen sich nur Fotos hochladen." },
{ status: 415 },
);
}
if (datei.size > MAX_BYTES) {
return NextResponse.json(
{ fehler: `Das Foto ist zu groß (erlaubt sind 10 MB).` },
{ status: 413 },
);
}
const inhalt = Buffer.from(await datei.arrayBuffer());
try {
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { slug: true },
}),
);
const schluessel = objektSchluessel({
organisationSlug: organisation?.slug ?? sitzung.organisationId,
entitaet: "Lagemeldung",
entitaetId: sitzung.einsatzId,
dateiname: datei.name || "portalfoto.jpg",
});
const abgelegt = await hochladen(schluessel, inhalt, mimeTyp, {
portalzugang: sitzung.zugangId,
});
const datensatz = await fuerOrganisation(sitzung.organisationId, async (db) => {
const angelegt = await db.datei.create({
data: {
organisationId: sitzung.organisationId,
bucket: abgelegt.bucket,
objektSchluessel: abgelegt.objektSchluessel,
dateiname: datei.name || "portalfoto.jpg",
mimeTyp,
groesseBytes: BigInt(abgelegt.groesseBytes),
pruefsumme: abgelegt.pruefsumme,
entitaet: "Lagemeldung",
entitaetId: sitzung.einsatzId,
kategorie: "portal-foto",
},
});
await protokolliereIn(db, {
organisationId: sitzung.organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: "Lagemeldung",
entitaetId: sitzung.einsatzId,
beschreibung: `Foto über den Portal-Zugang "${sitzung.bezeichnung}" hochgeladen`,
akteurTyp: AkteurTyp.API,
akteurName: sitzung.bezeichnung,
nachher: { dateiId: angelegt.id, groesseBytes: abgelegt.groesseBytes },
ipAdresse,
userAgent: anfrage.headers.get("user-agent"),
});
return angelegt;
});
return NextResponse.json({ id: datensatz.id, dateiname: datensatz.dateiname });
} catch (fehler) {
console.error("[portal] Foto-Upload fehlgeschlagen:", fehler);
return NextResponse.json({ fehler: "Das Foto ließ sich nicht ablegen." }, { status: 502 });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { PortalAnsicht } from "@/app/portal/[token]/portal-ansicht";
export const metadata = {
title: "Einsatzportal",
// Ein Link, der geteilt wird, soll nicht in Suchmaschinen landen.
robots: { index: false, follow: false },
};
/**
* Das Einsatzportal — die einzige Seite in FLORIAN ohne Anmeldung.
*
* Sie liegt bewusst außerhalb der Gruppe (app): kein Menü, keine Seitenleiste,
* kein Zugang zu irgendetwas anderem. Wer den Link hat, sieht genau einen
* Einsatz und kann genau eine Sache tun — eine Lagemeldung absetzen.
*
* Der Token steht in der Adresse. Geprüft wird er bei jedem Aufruf serverseitig
* (siehe lib/einsatz/portal.ts) — Gültigkeit, Widerruf und Zugriffsbegrenzung
* inbegriffen.
*/
export default async function PortalSeite({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
return <PortalAnsicht token={token} />;
}
+283
View File
@@ -0,0 +1,283 @@
"use client";
import * as React from "react";
import { Camera, Flame, RefreshCw, Send } from "lucide-react";
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 { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { KATEGORIE_TEXT, EINSATZ_STATUS_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte";
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
/**
* Portalansicht für Fremdwehren.
*
* Aufgebaut für ein Handy im Regen: große Schrift, wenig Text, ein Formular.
* Was hier fehlt, fehlt mit Absicht — keine Namen von Einsatzkräften, kein
* Bericht, keine Navigation in den Rest der Anwendung.
*
* Die Ansicht lädt sich alle 30 Sekunden neu. Wer an der Einsatzstelle steht,
* soll nicht überlegen müssen, ob der Stand noch aktuell ist.
*/
export function PortalAnsicht({ token }: { token: string }) {
const [text, setText] = React.useState("");
const [name, setName] = React.useState("");
const [wichtig, setWichtig] = React.useState(false);
const [fotoId, setFotoId] = React.useState<string | null>(null);
const [fotoName, setFotoName] = React.useState<string | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [laedtFoto, setLaedtFoto] = React.useState(false);
const dateiFeld = React.useRef<HTMLInputElement | null>(null);
const abfrage = trpc.portal.laden.useQuery({ token }, { refetchInterval: 30_000, retry: false });
const werkzeuge = trpc.useUtils();
const melden = trpc.portal.melden.useMutation({
onSuccess: () => {
setText("");
setWichtig(false);
setFotoId(null);
setFotoName(null);
setMeldung("Meldung übermittelt.");
void werkzeuge.portal.laden.invalidate({ token });
},
onError: (fehler) => setMeldung(fehler.message),
});
/** Foto über den eigenen Portal-Endpunkt hochladen. */
async function fotoHochladen(datei: File) {
setLaedtFoto(true);
setMeldung(null);
try {
const formular = new FormData();
formular.append("token", token);
formular.append("datei", datei);
const antwort = await fetch("/api/portal/foto", { method: "POST", body: formular });
const inhalt = (await antwort.json()) as { id?: string; dateiname?: string; fehler?: string };
if (!antwort.ok || !inhalt.id) {
setMeldung(inhalt.fehler ?? "Das Foto ließ sich nicht übertragen.");
return;
}
setFotoId(inhalt.id);
setFotoName(inhalt.dateiname ?? datei.name);
} catch {
setMeldung("Das Foto ließ sich nicht übertragen. Netzempfang?");
} finally {
setLaedtFoto(false);
if (dateiFeld.current) dateiFeld.current.value = "";
}
}
if (abfrage.isLoading) {
return (
<main className="mx-auto max-w-2xl p-6">
<p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>
</main>
);
}
if (abfrage.error) {
return (
<main className="mx-auto max-w-2xl space-y-4 p-6">
<h1 className="flex items-center gap-2 text-xl font-semibold">
<Flame className="size-5 text-[var(--primary)]" aria-hidden />
Einsatzportal
</h1>
<Fehlermeldung meldung={abfrage.error.message} kennung="portalfehler" />
<p className="text-sm text-[var(--muted-foreground)]">
Wenden Sie sich an die Wehr, die Ihnen den Link geschickt hat — sie kann einen neuen
erzeugen.
</p>
</main>
);
}
if (!abfrage.data) return null;
const { einsatz, lagemeldungen, zugang } = abfrage.data;
const adresse = [
[einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" "),
[einsatz.plz, einsatz.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(", ");
return (
<main className="mx-auto max-w-2xl space-y-6 p-4 sm:p-6">
<header className="space-y-1 border-b border-[var(--border)] pb-4">
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Flame className="size-4 text-[var(--primary)]" aria-hidden />
Einsatzportal der {einsatz.organisation.name}
</p>
<h1 className="text-2xl font-semibold tracking-tight">{einsatz.stichwort}</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{KATEGORIE_TEXT[einsatz.kategorie]} · {EINSATZ_STATUS_TEXT[einsatz.status]}
{adresse && ` · ${adresse}`}
{einsatz.objekt && ` · ${einsatz.objekt}`}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
Zugang für {zugang.bezeichnung} · gültig bis {alsDatumZeit(zugang.gueltigBis)}
</p>
</header>
<section className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ text: "alarmiert", wert: einsatz.alarmiertUm },
{ text: "ausgerückt", wert: einsatz.ausgeruecktUm },
{ text: "eingetroffen", wert: einsatz.eingetroffenUm },
{ text: "Einsatzende", wert: einsatz.einsatzendeUm },
].map((eintrag) => (
<div
key={eintrag.text}
className="rounded-md border border-[var(--border)] p-3 text-center"
>
<div className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{eintrag.text}
</div>
<div className="tabellenzahlen text-lg font-semibold">
{eintrag.wert ? alsUhrzeit(eintrag.wert) : "—"}
</div>
</div>
))}
</section>
{/* Meldung absetzen */}
<section className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<h2 className="text-lg font-medium">Lagemeldung absetzen</h2>
<div className="space-y-2">
<Label htmlFor="name">Wer meldet?</Label>
<Input
id="name"
value={name}
onChange={(ereignis) => setName(ereignis.target.value)}
placeholder="Name oder Funktion"
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="meldung">Meldung</Label>
<Textarea
id="meldung"
rows={3}
value={text}
onChange={(ereignis) => setText(ereignis.target.value)}
placeholder="Was ist der Stand?"
/>
</div>
<input
ref={dateiFeld}
type="file"
accept="image/*"
capture="environment"
className="hidden"
aria-label="Foto aufnehmen"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void fotoHochladen(datei);
}}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => dateiFeld.current?.click()}
disabled={laedtFoto}
>
<Camera aria-hidden />
{laedtFoto ? "wird übertragen …" : fotoId ? "Foto ersetzen" : "Foto aufnehmen"}
</Button>
{fotoName && (
<span className="text-sm text-[var(--muted-foreground)]">{fotoName}</span>
)}
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={wichtig} onCheckedChange={(an) => setWichtig(an === true)} />
wichtig
</label>
<Button
type="button"
className="ml-auto"
disabled={!text.trim() || !name.trim() || melden.isPending}
onClick={() =>
melden.mutate({
token,
text,
verfasserName: name,
wichtig,
fotoDateiId: fotoId,
})
}
>
<Send aria-hidden />
{melden.isPending ? "wird gesendet …" : "Melden"}
</Button>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="portalmeldung" />}
</section>
{/* Verlauf */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Verlauf</h2>
<Button
variant="ghost"
size="sm"
onClick={() => void werkzeuge.portal.laden.invalidate({ token })}
>
<RefreshCw aria-hidden />
Aktualisieren
</Button>
</div>
{lagemeldungen.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Noch keine Meldung.</p>
) : (
<ol className="space-y-3">
{lagemeldungen.map((eintrag) => (
<li
key={eintrag.id}
className={`border-l-2 pl-3 text-sm ${
eintrag.wichtig ? "border-[var(--gesperrt)]" : "border-[var(--border)]"
}`}
>
<div className="flex flex-wrap items-baseline gap-2">
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{alsDatumZeit(eintrag.zeitpunkt)}
</span>
{eintrag.wichtig && (
<span className="text-xs font-medium text-[var(--gesperrt)]">wichtig</span>
)}
<span className="text-xs text-[var(--muted-foreground)]">
{eintrag.verfasserName ?? "Wehr"} · {QUELLE_TEXT[eintrag.quelle]}
</span>
</div>
<p className="mt-0.5">{eintrag.text}</p>
</li>
))}
</ol>
)}
</section>
<footer className="border-t border-[var(--border)] pt-4 text-xs text-[var(--muted-foreground)]">
Dieser Zugang ist zeitlich begrenzt und protokolliert. Er zeigt ausschließlich diesen
Einsatz — keine Personendaten und keine weiteren Daten der Wehr.
</footer>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { TrpcProvider } from "@/lib/trpc/provider";
/**
* Layout des Einsatzportals.
*
* Bewusst eigenständig: keine Anmeldung, keine Seitenleiste, kein Weg in den
* Rest der Anwendung. Nur der tRPC-Zugang, damit die Seite ihre Daten holen
* kann — die Prozeduren darunter sind die einzigen öffentlichen in FLORIAN
* und prüfen selbst, ob der Token gilt.
*/
export default function PortalLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<TrpcProvider>
<div className="min-h-screen bg-[var(--background)]">{children}</div>
</TrpcProvider>
);
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
import type { EinsatzKategorie, EinsatzStatus, UebungsArt } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import {
EINSATZ_STATUS_TEXT,
KATEGORIE_GRUPPE,
KATEGORIE_TEXT,
UEBUNGSART_TEXT,
} from "@/lib/einsatz/texte";
import { alsDauer } from "@/lib/einsatz/zeiten";
/**
* Anzeigebausteine für Einsätze und Übungen.
*
* Die Farblehre bleibt dieselbe wie im Rest der Anwendung. Neu ist nur, was
* Rot hier bedeutet: nicht „kaputt", sondern „läuft gerade" — ein laufender
* Einsatz ist das Dringendste, was die Anwendung zeigen kann.
*/
export function EinsatzStatusAbzeichen({ status }: { status: EinsatzStatus }) {
const variante =
status === "LAUFEND"
? "gesperrt"
: status === "ENTWURF"
? "pruefungFaellig"
: status === "FREIGEGEBEN"
? "einsatzbereit"
: "outline";
return <Badge variant={variante}>{EINSATZ_STATUS_TEXT[status]}</Badge>;
}
export function KategorieAbzeichen({ kategorie }: { kategorie: EinsatzKategorie }) {
const gruppe = KATEGORIE_GRUPPE[kategorie];
const variante =
gruppe === "brand" ? "gesperrt" : gruppe === "hilfe" ? "pruefungFaellig" : "secondary";
return <Badge variant={variante}>{KATEGORIE_TEXT[kategorie]}</Badge>;
}
export function UebungsartAbzeichen({ art }: { art: UebungsArt }) {
return <Badge variant="outline">{UEBUNGSART_TEXT[art]}</Badge>;
}
/**
* Die Zeitleiste eines Einsatzes.
*
* Sechs Stempel nebeneinander. Was fehlt, bleibt sichtbar leer — das ist die
* Aufforderung, es nachzutragen, und gleichzeitig die ehrliche Auskunft
* darüber, was dokumentiert ist und was nicht.
*/
export function Zeitleiste({
stempel,
}: {
stempel: Array<{ bezeichnung: string; uhrzeit: string | null; gesetzt: boolean }>;
}) {
return (
<ol className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{stempel.map((eintrag) => (
<li
key={eintrag.bezeichnung}
className={`rounded-md border p-3 text-center ${
eintrag.gesetzt
? "border-[var(--border)] bg-[var(--card)]"
: "border-dashed border-[var(--border)] text-[var(--muted-foreground)]"
}`}
>
<div className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{eintrag.bezeichnung}
</div>
<div className="tabellenzahlen text-lg font-semibold">{eintrag.uhrzeit ?? "—"}</div>
</li>
))}
</ol>
);
}
/** Die drei Kennzahlen, die aus den Stempeln folgen. */
export function Zeitkennzahlen({
ausrueckzeit,
anfahrtszeit,
hilfsfrist,
dauer,
}: {
ausrueckzeit: number | null;
anfahrtszeit: number | null;
hilfsfrist: number | null;
dauer: number | null;
}) {
const werte = [
{ text: "Ausrückzeit", wert: ausrueckzeit, hinweis: "Alarm bis Ausrücken" },
{ text: "Anfahrt", wert: anfahrtszeit, hinweis: "Ausrücken bis Eintreffen" },
{ text: "Hilfsfrist", wert: hilfsfrist, hinweis: "Alarm bis Eintreffen" },
{ text: "Einsatzdauer", wert: dauer, hinweis: "Alarm bis Einrücken" },
];
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{werte.map((eintrag) => (
<div
key={eintrag.text}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-3"
>
<div className="tabellenzahlen text-xl font-semibold">{alsDauer(eintrag.wert)}</div>
<div className="text-sm font-medium">{eintrag.text}</div>
<div className="text-xs text-[var(--muted-foreground)]">{eintrag.hinweis}</div>
</div>
))}
</div>
);
}
/**
* Die Stärke nach FwDV 3, groß dargestellt.
*
* Vier Zahlen, die jeder in der Wehr lesen kann — und darunter der Klartext
* für alle anderen.
*/
export function StaerkeAnzeige({
staerke,
einheit,
}: {
staerke: { fuehrer: number; unterfuehrer: number; mannschaft: number; gesamt: number };
einheit?: string | null;
}) {
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
<div className="tabellenzahlen text-2xl font-semibold">
{staerke.fuehrer}/{staerke.unterfuehrer}/{staerke.mannschaft}/{staerke.gesamt}
</div>
<p className="text-sm text-[var(--muted-foreground)]">
{staerke.fuehrer} Führer, {staerke.unterfuehrer} Unterführer, {staerke.mannschaft}{" "}
Mannschaft
{einheit ? ` · entspricht einer ${einheit}` : ""}
</p>
</div>
);
}
/** Hinweise zu Zeiten und Stärke — sichtbar, aber nicht blockierend. */
export function Hinweisliste({ hinweise }: { hinweise: string[] }) {
if (hinweise.length === 0) return null;
return (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
<ul className="space-y-1 text-sm">
{hinweise.map((hinweis) => (
<li key={hinweis}>{hinweis}</li>
))}
</ul>
</div>
);
}
/**
* Ein einfaches Balkendiagramm.
*
* Bewusst ohne Diagrammbibliothek: zwölf Balken mit Beschriftung sind ein
* Stück CSS, und jede Bibliothek dafür wäre ein zusätzliches Paket, das
* gepflegt und mitgeliefert werden müsste — bei einer Anwendung ohne CDN
* fällt das ins Gewicht.
*/
export function Balkendiagramm({
werte,
einheit = "",
}: {
werte: Array<{ beschriftung: string; wert: number }>;
einheit?: string;
}) {
const hoechster = Math.max(1, ...werte.map((eintrag) => eintrag.wert));
return (
<div className="space-y-1.5">
{werte.map((eintrag) => (
<div key={eintrag.beschriftung} className="flex items-center gap-2 text-sm">
{/* Breit genug für "Technische Hilfeleistung klein" — sonst sehen zwei
Kategorien in der Statistik gleich aus. Der volle Text steht
zusätzlich im title-Attribut. */}
<span
className="w-44 shrink-0 truncate text-[var(--muted-foreground)]"
title={eintrag.beschriftung}
>
{eintrag.beschriftung}
</span>
<div className="h-4 flex-1 overflow-hidden rounded bg-[var(--muted)]">
<div
className="h-full bg-[var(--primary)]"
style={{ width: `${(eintrag.wert / hoechster) * 100}%` }}
role="presentation"
/>
</div>
<span className="tabellenzahlen w-14 shrink-0 text-right">
{eintrag.wert}
{einheit}
</span>
</div>
))}
</div>
);
}
+9 -1
View File
@@ -10,6 +10,7 @@ import {
ClipboardCheck, ClipboardCheck,
Flame, Flame,
Gauge, Gauge,
GraduationCap,
Map, Map,
Menu, Menu,
MonitorSmartphone, MonitorSmartphone,
@@ -81,6 +82,13 @@ const EINTRAEGE: Eintrag[] = [
abPhase: 2, abPhase: 2,
}, },
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 }, { titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
{
titel: "Übungen",
pfad: "/uebungen",
symbol: GraduationCap,
rechte: ["ausbildung.lesen"],
abPhase: 3,
},
{ {
titel: "Terminal", titel: "Terminal",
pfad: "/kiosk", pfad: "/kiosk",
@@ -115,7 +123,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist /** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */ * sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 2; const AKTUELLE_PHASE = 3;
export interface SeitenleisteProps { export interface SeitenleisteProps {
rechte: Recht[]; rechte: Recht[];
+248
View File
@@ -0,0 +1,248 @@
import "server-only";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { hochladen, objektSchluessel } from "@/lib/storage";
import { berichtPdf, type BerichtDaten } from "@/lib/einsatz/bericht";
import { kennzahlen } from "@/lib/einsatz/zeiten";
import { staerkeAlsText, staerkeBerechnen } from "@/lib/einsatz/staerke";
/**
* Vom Einsatz zum abgelegten Bericht.
*
* Wie beim Prüfprotokoll dreigeteilt — lesen, rendern, schreiben. Zwischen
* Lesen und Schreiben liegt der Aufruf an Gotenberg, und der darf dauern.
*
* Wiederholbar: gibt es den Bericht schon, passiert nichts.
*/
const KATEGORIE_TEXT: Record<string, string> = {
BRAND_KLEIN: "Brand klein",
BRAND_MITTEL: "Brand mittel",
BRAND_GROSS: "Brand groß",
TECHNISCHE_HILFE_KLEIN: "Technische Hilfeleistung klein",
TECHNISCHE_HILFE_MITTEL: "Technische Hilfeleistung mittel",
TECHNISCHE_HILFE_GROSS: "Technische Hilfeleistung groß",
GEFAHRGUT: "Gefahrguteinsatz",
RETTUNGSDIENST: "Unterstützung Rettungsdienst",
BRANDMELDEANLAGE: "Brandmeldeanlage",
BOESWILLIGER_ALARM: "böswilliger Alarm",
SICHERHEITSWACHE: "Sicherheitswache",
UNTERSTUETZUNG: "Unterstützung / Amtshilfe",
SONSTIGES: "Sonstiges",
};
const ALARMIERUNG_TEXT: Record<string, string> = {
SIRENE: "Sirene",
MELDEEMPFAENGER: "Meldeempfänger",
TELEFON: "Telefon",
APP: "App",
PERSOENLICH: "persönlich",
SONSTIGES: "sonstige",
};
/** Adresse aus den Einzelfeldern, wie sie im Bericht stehen soll. */
function adresse(einsatz: {
strasse: string | null;
hausnummer: string | null;
plz: string | null;
ort: string | null;
ortsteil: string | null;
}): string {
const strasse = [einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" ");
const ort = [einsatz.plz, einsatz.ort ?? einsatz.ortsteil].filter(Boolean).join(" ");
return [strasse, ort].filter(Boolean).join(", ") || "ohne Ortsangabe";
}
export async function einsatzberichtErzeugen(
organisationId: string,
einsatzId: string,
): Promise<{ dateiId: string; neu: boolean } | null> {
// ---------------------------------------------------------------- lesen
const geladen = await fuerOrganisation(organisationId, async (db) => {
const einsatz = await db.einsatz.findUnique({
where: { id: einsatzId },
include: {
organisation: { select: { name: true, slug: true, strasse: true, plz: true, ort: true } },
einsatzleiter: { select: { vorname: true, nachname: true } },
freigegebenVon: { select: { vorname: true, nachname: true } },
fahrzeuge: {
include: { fahrzeug: { select: { funkrufname: true, bezeichnung: true } } },
orderBy: { erstelltAm: "asc" },
},
kraefte: {
include: {
person: { select: { vorname: true, nachname: true, dienstgrad: true } },
fahrzeug: { select: { funkrufname: true } },
},
orderBy: { erstelltAm: "asc" },
},
material: { orderBy: { erstelltAm: "asc" } },
lagemeldungen: {
where: { geloeschtAm: null },
orderBy: { zeitpunkt: "asc" },
},
},
});
if (!einsatz) return null;
if (einsatz.status !== "FREIGEGEBEN") return null;
return einsatz;
});
if (!geladen) return null;
if (geladen.berichtDateiId) return { dateiId: geladen.berichtDateiId, neu: false };
// --------------------------------------------------------------- rendern
const staerke = staerkeBerechnen(geladen.kraefte.map((kraft) => kraft.funktion));
const daten: BerichtDaten = {
organisation: {
name: geladen.organisation.name,
strasse: geladen.organisation.strasse,
plz: geladen.organisation.plz,
ort: geladen.organisation.ort,
},
einsatz: {
einsatznummer: geladen.einsatznummer,
stichwort: geladen.stichwort,
kategorie: KATEGORIE_TEXT[geladen.kategorie] ?? geladen.kategorie,
ilsNummer: geladen.ilsNummer,
alarmierungsart: geladen.alarmierungsart
? (ALARMIERUNG_TEXT[geladen.alarmierungsart] ?? geladen.alarmierungsart)
: null,
adresse: adresse(geladen),
objekt: geladen.objekt,
koordinaten:
geladen.breitengrad && geladen.laengengrad
? `${geladen.breitengrad.toString()}, ${geladen.laengengrad.toString()}`
: null,
beschreibung: geladen.beschreibung,
bericht: geladen.bericht,
wetter: geladen.wetter,
temperatur: geladen.temperatur,
weitereKraefte: geladen.weitereKraefte,
gerettet: geladen.geretteteePersonen,
verletzt: geladen.verletztePersonen,
getoetet: geladen.totePersonen,
sachschaden: geladen.sachschaden ? geladen.sachschaden.toNumber() : null,
kostenpflichtig: geladen.kostenpflichtig,
einsatzleiter: geladen.einsatzleiter
? `${geladen.einsatzleiter.vorname} ${geladen.einsatzleiter.nachname}`
: null,
freigegebenVon: geladen.freigegebenVon
? `${geladen.freigegebenVon.vorname} ${geladen.freigegebenVon.nachname}`
: null,
freigegebenAm: geladen.freigegebenAm,
},
zeiten: {
alarmiertUm: geladen.alarmiertUm,
ausgeruecktUm: geladen.ausgeruecktUm,
eingetroffenUm: geladen.eingetroffenUm,
einsatzendeUm: geladen.einsatzendeUm,
eingeruecktUm: geladen.eingeruecktUm,
einsatzbereitUm: geladen.einsatzbereitUm,
},
kennzahlen: kennzahlen(geladen),
staerkeText: staerkeAlsText(staerke),
fahrzeuge: geladen.fahrzeuge.map((eintrag) => ({
funkrufname: eintrag.fahrzeug.funkrufname,
bezeichnung: eintrag.fahrzeug.bezeichnung,
ausgeruecktUm: eintrag.ausgeruecktUm,
eingetroffenUm: eintrag.eingetroffenUm,
eingeruecktUm: eintrag.eingeruecktUm,
kilometer:
eintrag.kilometerAbfahrt !== null && eintrag.kilometerRueckkehr !== null
? eintrag.kilometerRueckkehr - eintrag.kilometerAbfahrt
: null,
wasserabgabe: eintrag.wasserabgabe,
})),
kraefte: geladen.kraefte.map((kraft) => ({
name: `${kraft.person.nachname}, ${kraft.person.vorname}`,
dienstgrad: kraft.person.dienstgrad,
funktion: kraft.funktion,
atemschutz: kraft.atemschutz,
fahrzeug: kraft.fahrzeug?.funkrufname ?? null,
von: kraft.vonUm,
bis: kraft.bisUm,
})),
material: geladen.material.map((posten) => ({
bezeichnung: posten.bezeichnung,
menge: `${posten.menge.toString()} ${posten.einheit}`,
beschaedigt: posten.beschaedigt,
})),
meldungen: geladen.lagemeldungen.map((meldung) => ({
zeitpunkt: meldung.zeitpunkt,
text: meldung.text,
wichtig: meldung.wichtig,
verfasser: meldung.verfasserName,
quelle: meldung.quelle,
})),
erzeugtAm: new Date(),
};
const pdf = await berichtPdf(daten);
const dateiname = `Einsatzbericht-${geladen.einsatznummer}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: geladen.organisation.slug,
entitaet: "Einsatz",
entitaetId: geladen.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
einsatz: geladen.id,
nummer: geladen.einsatznummer,
});
// -------------------------------------------------------------- schreiben
return fuerOrganisation(organisationId, async (db) => {
const stand = await db.einsatz.findUnique({
where: { id: geladen.id },
select: { berichtDateiId: true },
});
if (stand?.berichtDateiId) return { dateiId: stand.berichtDateiId, neu: false };
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: abgelegt.objektSchluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: BigInt(abgelegt.groesseBytes),
pruefsumme: abgelegt.pruefsumme,
entitaet: "Einsatz",
entitaetId: geladen.id,
kategorie: "einsatzbericht",
hochgeladenVonId: geladen.freigegebenVonId,
},
});
// Erlaubt trotz Freigabe: das Berichtsfeld war leer (siehe Trigger
// einsatz_freigabe_schuetzen in Migration 20260903000500).
await db.einsatz.update({
where: { id: geladen.id },
data: { berichtDateiId: datei.id },
});
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: "Einsatz",
entitaetId: geladen.id,
beschreibung: `Einsatzbericht ${geladen.einsatznummer} erzeugt (${Math.round(
abgelegt.groesseBytes / 1024,
)} kB)`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
});
return { dateiId: datei.id, neu: true };
});
}
export { KATEGORIE_TEXT, ALARMIERUNG_TEXT, adresse as einsatzAdresse };
+642
View File
@@ -0,0 +1,642 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { alsDatum, alsDatumZeit, alsEuro, alsUhrzeit } from "@/lib/utils";
import { alsDauer, ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
import { FUNKTION_TEXT, type Funktion } from "@/lib/einsatz/staerke";
/**
* Der Einsatzbericht als PDF.
*
* Er geht an die Gemeinde, bei Kostenersatz an den Verursacher und im
* Schadensfall an die Versicherung. Danach richtet sich der Aufbau:
*
* Kopf Wehr, Einsatznummer, Stichwort, Ergebnis auf einen Blick
* Einsatzdaten Ort, Kategorie, Leitstellennummer
* Zeiten die sechs Stempel und die drei Kennzahlen daraus
* Kräfte Fahrzeuge und Mannschaft mit Stärke nach FwDV 3
* Verlauf Lagemeldungen in zeitlicher Reihenfolge
* Bericht der Text des Einsatzleiters
* Fuß wer freigegeben hat und wann
*
* Wie beim Prüfprotokoll: alles eingebettet, keine externen Schriften, keine
* nachgeladenen Bilder.
*/
export interface BerichtKraft {
name: string;
dienstgrad?: string | null;
funktion: Funktion;
atemschutz: boolean;
fahrzeug?: string | null;
von?: Date | null;
bis?: Date | null;
}
export interface BerichtFahrzeug {
funkrufname: string;
bezeichnung?: string | null;
ausgeruecktUm?: Date | null;
eingetroffenUm?: Date | null;
eingeruecktUm?: Date | null;
kilometer?: number | null;
wasserabgabe?: number | null;
}
export interface BerichtMeldung {
zeitpunkt: Date;
text: string;
wichtig: boolean;
verfasser?: string | null;
quelle: string;
}
export interface BerichtDaten {
organisation: {
name: string;
strasse?: string | null;
plz?: string | null;
ort?: string | null;
};
einsatz: {
einsatznummer: string;
stichwort: string;
kategorie: string;
ilsNummer?: string | null;
alarmierungsart?: string | null;
adresse: string;
objekt?: string | null;
koordinaten?: string | null;
beschreibung?: string | null;
bericht?: string | null;
wetter?: string | null;
temperatur?: number | null;
weitereKraefte?: string | null;
gerettet: number;
verletzt: number;
getoetet: number;
sachschaden?: number | null;
kostenpflichtig: boolean;
einsatzleiter?: string | null;
freigegebenVon?: string | null;
freigegebenAm?: Date | null;
};
zeiten: Partial<Record<Zeitstempel, Date | null>>;
kennzahlen: {
ausrueckzeit: number | null;
anfahrtszeit: number | null;
hilfsfrist: number | null;
dauer: number | null;
};
staerkeText: string;
fahrzeuge: BerichtFahrzeug[];
kraefte: BerichtKraft[];
material: Array<{ bezeichnung: string; menge: string; beschaedigt: boolean }>;
meldungen: BerichtMeldung[];
erzeugtAm: Date;
}
function feld(bezeichnung: string, wert: string | null | undefined): string {
return `
<div class="feld">
<div class="beschriftung">${html(bezeichnung)}</div>
<div class="wert">${html(wert && wert !== "" ? wert : "—")}</div>
</div>`;
}
const STIL = `
/* Ränder kommen von Gotenberg — hier keine setzen. */
@page { size: A4; }
* { box-sizing: border-box; }
html, body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.5pt;
color: #111;
-webkit-print-color-adjust: exact;
}
h1 { font-size: 15pt; margin: 0 0 1mm; }
h2 {
font-size: 10.5pt;
margin: 6mm 0 2mm;
padding-bottom: 1mm;
border-bottom: 0.4mm solid #111;
}
.kopf {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8mm;
padding-bottom: 3mm;
border-bottom: 0.6mm solid #111;
}
.kopf .wehr { font-size: 8.5pt; color: #333; }
.nummer {
text-align: right;
white-space: nowrap;
}
.nummer .marke {
display: inline-block;
padding: 1.5mm 3mm;
border: 0.5mm solid #111;
border-radius: 1.5mm;
font-weight: 700;
font-size: 11pt;
}
.felder {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2mm 6mm;
margin-top: 3mm;
}
.feld .beschriftung {
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
}
.feld .wert { font-size: 9.5pt; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
border-bottom: 0.3mm solid #999;
padding: 1.5mm 2mm;
}
td {
padding: 1.6mm 2mm;
border-bottom: 0.2mm solid #ddd;
vertical-align: top;
}
tr { break-inside: avoid; }
td.zahl, th.zahl { text-align: right; white-space: nowrap; }
.zeitleiste {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 2mm;
margin-top: 2mm;
}
.zeitleiste .stempel {
border: 0.2mm solid #bbb;
border-radius: 1.5mm;
padding: 2mm;
text-align: center;
}
.zeitleiste .bezeichnung {
font-size: 7pt;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #555;
}
.zeitleiste .uhrzeit { font-size: 11pt; font-weight: 700; }
.kennzahlen {
display: flex;
gap: 6mm;
margin-top: 3mm;
font-size: 9pt;
}
.kennzahlen strong { font-size: 11pt; }
.verlauf li { margin-bottom: 1.5mm; break-inside: avoid; }
.verlauf .zeit { font-weight: 700; }
.verlauf .wichtig { color: #b91c1c; font-weight: 700; }
.bericht {
white-space: pre-line;
margin-top: 2mm;
}
.hinweis {
margin-top: 6mm;
padding: 2.5mm 3mm;
background: #f4f4f5;
border-left: 1mm solid #999;
font-size: 8pt;
color: #333;
}
`;
/** Baut das eigenständige HTML des Einsatzberichts. */
export function berichtHtml(daten: BerichtDaten): string {
const { organisation, einsatz, zeiten, kennzahlen, fahrzeuge, kraefte, material, meldungen } =
daten;
const anschrift = [organisation.strasse, [organisation.plz, organisation.ort].filter(Boolean).join(" ")]
.filter(Boolean)
.join(", ");
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Einsatzbericht ${html(einsatz.einsatznummer)}</title>
<style>${STIL}</style>
</head>
<body>
<div class="kopf">
<div>
<h1>Einsatzbericht</h1>
<div class="wehr">${html(organisation.name)}${anschrift ? ` · ${html(anschrift)}` : ""}</div>
</div>
<div class="nummer">
<div class="marke">${html(einsatz.einsatznummer)}</div>
<div class="wehr" style="margin-top:1.5mm">${html(einsatz.kategorie)}</div>
</div>
</div>
<section>
<h2>Einsatz</h2>
<div class="felder">
${feld("Stichwort", einsatz.stichwort)}
${feld("Kategorie", einsatz.kategorie)}
${feld("Leitstellennummer", einsatz.ilsNummer)}
${feld("Einsatzort", einsatz.adresse)}
${feld("Objekt", einsatz.objekt)}
${feld("Koordinaten", einsatz.koordinaten)}
${feld("Alarmierung", einsatz.alarmierungsart)}
${feld("Einsatzleiter", einsatz.einsatzleiter)}
${feld("Stärke (FwDV 3)", daten.staerkeText)}
${feld("Wetter", [einsatz.wetter, einsatz.temperatur !== null && einsatz.temperatur !== undefined ? `${einsatz.temperatur} °C` : null].filter(Boolean).join(", "))}
${feld("Weitere Kräfte", einsatz.weitereKraefte)}
${feld("Kostenersatzpflichtig", einsatz.kostenpflichtig ? "ja" : "nein")}
</div>
${einsatz.beschreibung ? `<p style="margin-top:3mm">${html(einsatz.beschreibung)}</p>` : ""}
</section>
<section>
<h2>Zeiten</h2>
<div class="zeitleiste">
${ZEITSTEMPEL.map(
(stempel) => `
<div class="stempel">
<div class="bezeichnung">${html(ZEITSTEMPEL_TEXT[stempel])}</div>
<div class="uhrzeit">${zeiten[stempel] ? html(alsUhrzeit(zeiten[stempel])) : "—"}</div>
</div>`,
).join("")}
</div>
<div class="kennzahlen">
<div>Ausrückzeit <strong>${html(alsDauer(kennzahlen.ausrueckzeit))}</strong></div>
<div>Anfahrt <strong>${html(alsDauer(kennzahlen.anfahrtszeit))}</strong></div>
<div>Hilfsfrist <strong>${html(alsDauer(kennzahlen.hilfsfrist))}</strong></div>
<div>Einsatzdauer <strong>${html(alsDauer(kennzahlen.dauer))}</strong></div>
</div>
</section>
<section>
<h2>Fahrzeuge</h2>
${
fahrzeuge.length === 0
? "<p>Kein Fahrzeug erfasst.</p>"
: `<table>
<thead>
<tr>
<th>Fahrzeug</th>
<th>ausgerückt</th>
<th>eingetroffen</th>
<th>eingerückt</th>
<th class="zahl">km</th>
<th class="zahl">Wasser</th>
</tr>
</thead>
<tbody>
${fahrzeuge
.map(
(fahrzeug) => `
<tr>
<td>
${html(fahrzeug.funkrufname)}
${fahrzeug.bezeichnung ? `<div style="font-size:8pt;color:#555">${html(fahrzeug.bezeichnung)}</div>` : ""}
</td>
<td>${fahrzeug.ausgeruecktUm ? html(alsUhrzeit(fahrzeug.ausgeruecktUm)) : "—"}</td>
<td>${fahrzeug.eingetroffenUm ? html(alsUhrzeit(fahrzeug.eingetroffenUm)) : "—"}</td>
<td>${fahrzeug.eingeruecktUm ? html(alsUhrzeit(fahrzeug.eingeruecktUm)) : "—"}</td>
<td class="zahl">${fahrzeug.kilometer ?? "—"}</td>
<td class="zahl">${fahrzeug.wasserabgabe ? `${fahrzeug.wasserabgabe} l` : "—"}</td>
</tr>`,
)
.join("")}
</tbody>
</table>`
}
</section>
<section>
<h2>Eingesetzte Kräfte — Stärke ${html(daten.staerkeText)}</h2>
${
kraefte.length === 0
? "<p>Keine Einsatzkraft erfasst.</p>"
: `<table>
<thead>
<tr>
<th>Name</th>
<th>Funktion</th>
<th>Fahrzeug</th>
<th>Atemschutz</th>
<th>von</th>
<th>bis</th>
</tr>
</thead>
<tbody>
${kraefte
.map(
(kraft) => `
<tr>
<td>${html(kraft.name)}</td>
<td>${html(FUNKTION_TEXT[kraft.funktion] ?? kraft.funktion)}</td>
<td>${html(kraft.fahrzeug ?? "—")}</td>
<td>${kraft.atemschutz ? "ja" : "—"}</td>
<td>${kraft.von ? html(alsUhrzeit(kraft.von)) : "—"}</td>
<td>${kraft.bis ? html(alsUhrzeit(kraft.bis)) : "—"}</td>
</tr>`,
)
.join("")}
</tbody>
</table>`
}
</section>
${
material.length > 0
? `<section>
<h2>Material</h2>
<table>
<thead><tr><th>Bezeichnung</th><th>Menge</th><th>Anmerkung</th></tr></thead>
<tbody>
${material
.map(
(posten) => `
<tr>
<td>${html(posten.bezeichnung)}</td>
<td>${html(posten.menge)}</td>
<td>${posten.beschaedigt ? "beschädigt" : "verbraucht"}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</section>`
: ""
}
${
meldungen.length > 0
? `<section>
<h2>Verlauf</h2>
<ul class="verlauf">
${meldungen
.map(
(meldung) => `
<li>
<span class="zeit">${html(alsUhrzeit(meldung.zeitpunkt))}</span>
${meldung.wichtig ? '<span class="wichtig">[wichtig]</span>' : ""}
${html(meldung.text)}
${meldung.verfasser ? `<span style="color:#555"> — ${html(meldung.verfasser)}</span>` : ""}
</li>`,
)
.join("")}
</ul>
</section>`
: ""
}
${
einsatz.bericht
? `<section>
<h2>Bericht</h2>
<div class="bericht">${html(einsatz.bericht)}</div>
</section>`
: ""
}
<section>
<h2>Ergebnis</h2>
<div class="felder">
${feld("Gerettete Personen", String(einsatz.gerettet))}
${feld("Verletzte", String(einsatz.verletzt))}
${feld("Getötete", String(einsatz.getoetet))}
${feld(
"Geschätzter Sachschaden",
einsatz.sachschaden !== null && einsatz.sachschaden !== undefined
? alsEuro(einsatz.sachschaden)
: null,
)}
${feld("Freigegeben von", einsatz.freigegebenVon)}
${feld("Freigegeben am", einsatz.freigegebenAm ? alsDatumZeit(einsatz.freigegebenAm) : null)}
</div>
</section>
<div class="hinweis">
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Dieser Bericht
ist nach der Freigabe unveränderbar gespeichert.
</div>
</body>
</html>`;
}
function kopfzeile(daten: BerichtDaten): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>${html(daten.organisation.name)}</span>
<span>Einsatzbericht ${html(daten.einsatz.einsatznummer)} · ${html(daten.einsatz.stichwort)}</span>
</div>
</body></html>`;
}
function fusszeile(daten: BerichtDaten): string {
const datum = daten.zeiten.alarmiertUm ? alsDatum(daten.zeiten.alarmiertUm) : "";
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>Einsatz vom ${html(datum)}</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>
</body></html>`;
}
/** Erzeugt den Einsatzbericht als PDF. */
export async function berichtPdf(daten: BerichtDaten): Promise<Buffer> {
return htmlZuPdf(berichtHtml(daten), {
randOben: 0.55,
randUnten: 0.55,
randLinks: 0.5,
randRechts: 0.5,
hintergrundDrucken: true,
kopfzeileHtml: kopfzeile(daten),
fusszeileHtml: fusszeile(daten),
});
}
// -----------------------------------------------------------------------------
// Jahresexport für die Gemeinde
// -----------------------------------------------------------------------------
export interface JahresexportDaten {
organisation: { name: string };
jahr: number;
einsaetze: Array<{
einsatznummer: string;
datum: Date | null;
stichwort: string;
kategorie: string;
ort: string;
staerke: string;
hilfsfrist: number | null;
dauer: number | null;
}>;
nachKategorie: Array<{ kategorie: string; anzahl: number }>;
zeiten: { hilfsfristP80: number | null; hilfsfristMedian: number | null; gemessen: number };
personen: { gerettet: number; verletzt: number; getoetet: number };
uebungen: { gesamt: number; teilnahmen: number };
erzeugtAm: Date;
}
/**
* Der Sammelexport eines Jahres.
*
* Das ist das Blatt, das die Wehr der Gemeinde vorlegt: wie viele Einsätze
* welcher Art, wie schnell, und was dabei herauskam. Querformat, weil die
* Tabelle sonst nicht lesbar ist.
*/
export function jahresexportHtml(daten: JahresexportDaten): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Einsatzstatistik ${daten.jahr} — ${html(daten.organisation.name)}</title>
<style>${STIL}
.felder { grid-template-columns: repeat(4, 1fr); }
td, th { font-size: 8.5pt; }
</style>
</head>
<body>
<div class="kopf">
<div>
<h1>Einsatzstatistik ${daten.jahr}</h1>
<div class="wehr">${html(daten.organisation.name)}</div>
</div>
<div class="nummer">
<div class="marke">${daten.einsaetze.length} Einsätze</div>
</div>
</div>
<section>
<h2>Überblick</h2>
<div class="felder">
${feld("Einsätze gesamt", String(daten.einsaetze.length))}
${feld(
"Hilfsfrist (80 %)",
daten.zeiten.hilfsfristP80 !== null ? alsDauer(daten.zeiten.hilfsfristP80) : null,
)}
${feld(
"Hilfsfrist (Median)",
daten.zeiten.hilfsfristMedian !== null ? alsDauer(daten.zeiten.hilfsfristMedian) : null,
)}
${feld("gemessen an", `${daten.zeiten.gemessen} Einsätzen`)}
${feld("Gerettete Personen", String(daten.personen.gerettet))}
${feld("Verletzte", String(daten.personen.verletzt))}
${feld("Getötete", String(daten.personen.getoetet))}
${feld("Übungen", `${daten.uebungen.gesamt} mit ${daten.uebungen.teilnahmen} Teilnahmen`)}
</div>
</section>
<section>
<h2>Nach Kategorie</h2>
<table>
<thead><tr><th>Kategorie</th><th class="zahl">Anzahl</th></tr></thead>
<tbody>
${daten.nachKategorie
.map(
(eintrag) =>
`<tr><td>${html(eintrag.kategorie)}</td><td class="zahl">${eintrag.anzahl}</td></tr>`,
)
.join("")}
</tbody>
</table>
</section>
<section>
<h2>Einsätze im Einzelnen</h2>
<table>
<thead>
<tr>
<th>Nummer</th>
<th>Datum</th>
<th>Stichwort</th>
<th>Kategorie</th>
<th>Ort</th>
<th>Stärke</th>
<th class="zahl">Hilfsfrist</th>
<th class="zahl">Dauer</th>
</tr>
</thead>
<tbody>
${daten.einsaetze
.map(
(einsatz) => `
<tr>
<td>${html(einsatz.einsatznummer)}</td>
<td>${einsatz.datum ? html(alsDatum(einsatz.datum)) : "—"}</td>
<td>${html(einsatz.stichwort)}</td>
<td>${html(einsatz.kategorie)}</td>
<td>${html(einsatz.ort)}</td>
<td>${html(einsatz.staerke)}</td>
<td class="zahl">${html(alsDauer(einsatz.hilfsfrist))}</td>
<td class="zahl">${html(alsDauer(einsatz.dauer))}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</section>
<div class="hinweis">
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Enthalten sind
alle nicht gelöschten Einsätze des Jahres ${daten.jahr}.
</div>
</body>
</html>`;
}
/** Erzeugt den Jahresexport als PDF — quer, damit die Tabelle passt. */
export async function jahresexportPdf(daten: JahresexportDaten): Promise<Buffer> {
return htmlZuPdf(jahresexportHtml(daten), {
quer: true,
randOben: 0.5,
randUnten: 0.5,
randLinks: 0.4,
randRechts: 0.4,
hintergrundDrucken: true,
});
}
+179
View File
@@ -0,0 +1,179 @@
import "server-only";
import { randomBytes } from "node:crypto";
import { env } from "@/lib/env";
import { hashen, pruefen } from "@/lib/passwort";
import { ohneMandantenfilter } from "@/lib/db";
import { zugriffZaehlen } from "@/lib/ratelimit";
/**
* Einsatzportal für Fremdwehren.
*
* Die Lage: bei einer größeren Sache sind Nachbarwehren, THW oder
* Rettungsdienst mit vor Ort. Die haben kein Konto in FLORIAN und sollen auch
* keines bekommen — sie sollen den Stand sehen und eine Meldung mit Foto
* absetzen können, mehr nicht.
*
* Der Weg dahin ist ein Link, den der Einsatzleiter erzeugt und als QR-Code
* zeigt oder weiterschickt. Vier Schranken sichern ihn ab:
*
* 1. **Zufall.** Das Geheimnis im Link sind 32 zufällige Bytes. Raten ist
* aussichtslos.
* 2. **Nur ein Hash liegt in der Datenbank.** Wer sie liest, bekommt keinen
* gültigen Link.
* 3. **Zeitlich begrenzt.** Nach `PORTAL_GUELTIGKEIT_STUNDEN` ist Schluss;
* widerrufen lässt er sich jederzeit sofort.
* 4. **Zugriffsbegrenzung.** Höchstens `PORTAL_MAX_ZUGRIFFE` Aufrufe je Link
* und Stunde.
*
* Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort
* und Lagemeldungen — keine Namen von Einsatzkräften, keine Patientendaten,
* keine Berichte.
*/
/** Aufbau des Links: `<zugangId>.<geheimnis>`. */
const TRENNER = ".";
export interface NeuerZugang {
/** Der vollständige Token für den Link. Nur jetzt einmal im Klartext. */
token: string;
tokenHash: string;
gueltigBis: Date;
}
/**
* Erzeugt Geheimnis und Hash für einen neuen Zugang.
*
* Die Kennung des Datensatzes wird erst danach bekannt, deshalb setzt der
* Aufrufer sie mit `tokenZusammensetzen` davor.
*/
export async function zugangErzeugen(gueltigkeitStunden?: number): Promise<NeuerZugang> {
const geheimnis = randomBytes(32).toString("base64url");
const stunden = gueltigkeitStunden ?? env.PORTAL_GUELTIGKEIT_STUNDEN;
return {
token: geheimnis,
tokenHash: await hashen(geheimnis),
gueltigBis: new Date(Date.now() + stunden * 3600_000),
};
}
/** Setzt Kennung und Geheimnis zum Token des Links zusammen. */
export function tokenZusammensetzen(zugangId: string, geheimnis: string): string {
return `${zugangId}${TRENNER}${geheimnis}`;
}
/** Zerlegt den Token wieder. Gibt null zurück, wenn er nicht passt. */
export function tokenZerlegen(token: string): { zugangId: string; geheimnis: string } | null {
const stelle = token.indexOf(TRENNER);
if (stelle <= 0 || stelle === token.length - 1) return null;
const zugangId = token.slice(0, stelle);
const geheimnis = token.slice(stelle + 1);
// Grobe Vorprüfung, bevor irgendetwas in eine Abfrage wandert.
if (!/^[0-9a-f-]{36}$/i.test(zugangId)) return null;
if (!/^[A-Za-z0-9_-]{20,200}$/.test(geheimnis)) return null;
return { zugangId, geheimnis };
}
/** Die vollständige Adresse des Portals für einen Token. */
export function portalAdresse(token: string): string {
return `${env.APP_URL}/portal/${token}`;
}
export type PortalFehler =
| "abgeschaltet"
| "ungueltig"
| "abgelaufen"
| "widerrufen"
| "zu_viele_zugriffe";
export interface PortalSitzung {
zugangId: string;
einsatzId: string;
organisationId: string;
bezeichnung: string;
gueltigBis: Date;
}
/**
* Prüft einen Token und liefert den Zugang.
*
* Läuft bewusst ohne Mandantenfilter: der Aufrufer hat keine Sitzung, und
* welche Wehr gemeint ist, steht erst im gefundenen Datensatz. Alles Weitere
* passiert danach im Mandantenkontext dieser Wehr.
*/
export async function portalPruefen(
token: string,
ipAdresse?: string | null,
): Promise<{ sitzung: PortalSitzung } | { fehler: PortalFehler; wartenSekunden?: number }> {
if (!env.PORTAL_ENABLED) return { fehler: "abgeschaltet" };
const zerlegt = tokenZerlegen(token);
if (!zerlegt) return { fehler: "ungueltig" };
// Erst die Zugriffsbegrenzung, dann der teure bcrypt-Vergleich. Sonst wäre
// die Prüfung selbst der Hebel für eine Überlastung.
const begrenzung = await zugriffZaehlen(
`portal:${zerlegt.zugangId}`,
env.PORTAL_MAX_ZUGRIFFE,
);
if (!begrenzung.erlaubt) {
return { fehler: "zu_viele_zugriffe", wartenSekunden: begrenzung.zuruecksetztIn };
}
const zugang = await ohneMandantenfilter(
"Portal-Zugang prüfen — der Aufrufer hat keine Sitzung",
(db) =>
db.portalZugang.findUnique({
where: { id: zerlegt.zugangId },
select: {
id: true,
organisationId: true,
einsatzId: true,
bezeichnung: true,
tokenHash: true,
gueltigBis: true,
widerrufenAm: true,
},
}),
);
if (!zugang) return { fehler: "ungueltig" };
if (!(await pruefen(zerlegt.geheimnis, zugang.tokenHash))) return { fehler: "ungueltig" };
if (zugang.widerrufenAm) return { fehler: "widerrufen" };
if (zugang.gueltigBis.getTime() < Date.now()) return { fehler: "abgelaufen" };
// Jeder Aufruf wird mitgezählt. Ein Link, der nach dem Einsatz noch benutzt
// wird, fällt so auf.
await ohneMandantenfilter("Portal-Aufruf mitzählen", (db) =>
db.portalZugang.update({
where: { id: zugang.id },
data: {
aufrufe: { increment: 1 },
letzterAufruf: new Date(),
letzteIp: ipAdresse ?? null,
},
}),
);
return {
sitzung: {
zugangId: zugang.id,
einsatzId: zugang.einsatzId,
organisationId: zugang.organisationId,
bezeichnung: zugang.bezeichnung,
gueltigBis: zugang.gueltigBis,
},
};
}
export const PORTAL_FEHLER_TEXT: Record<PortalFehler, string> = {
abgeschaltet: "Das Einsatzportal ist in dieser Installation abgeschaltet.",
ungueltig: "Dieser Link ist nicht gültig.",
abgelaufen: "Dieser Link ist abgelaufen. Die Wehr kann einen neuen erzeugen.",
widerrufen: "Dieser Link wurde zurückgezogen.",
zu_viele_zugriffe: "Zu viele Zugriffe in kurzer Zeit. Bitte später noch einmal versuchen.",
};
+156
View File
@@ -0,0 +1,156 @@
/**
* Einsatzstärke nach FwDV 3.
*
* Die Stärke ist die Kurzform dafür, wer ausgerückt ist. Geschrieben wird sie
* als `Führer / Unterführer / Mannschaft / Gesamt`, gesprochen "eins, drei,
* fünf, neun".
*
* Die taktischen Einheiten der FwDV 3:
*
* | Einheit | Stärke | wer |
* |---|---|---|
* | Trupp | 0/1/1/2 | Truppführer und 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 |
*
* **Achtung, regional unterschiedlich.** Manche Wehren zählen den Maschinisten
* zu den Unterführern, andere zur Mannschaft; verbreitet ist auch die
* dreistellige Schreibweise ohne Unterführer (Gruppe = 1/8/9). Die Zuordnung
* steht deshalb an einer Stelle und ist bewusst leicht zu ändern.
*/
export type Funktion =
| "EINSATZLEITER"
| "ZUGFUEHRER"
| "GRUPPENFUEHRER"
| "STAFFELFUEHRER"
| "TRUPPFUEHRER"
| "MASCHINIST"
| "MELDER"
| "TRUPPMANN"
| "ATEMSCHUTZTRAEGER"
| "SICHERHEITSTRUPP"
| "SANITAETER"
| "SONSTIGES";
export const FUNKTION_TEXT: Record<Funktion, string> = {
EINSATZLEITER: "Einsatzleiter",
ZUGFUEHRER: "Zugführer",
GRUPPENFUEHRER: "Gruppenführer",
STAFFELFUEHRER: "Staffelführer",
TRUPPFUEHRER: "Truppführer",
MASCHINIST: "Maschinist",
MELDER: "Melder",
TRUPPMANN: "Truppmann",
ATEMSCHUTZTRAEGER: "Atemschutzgeräteträger",
SICHERHEITSTRUPP: "Sicherheitstrupp",
SANITAETER: "Sanitäter",
SONSTIGES: "sonstige Funktion",
};
/** Wer als Führer zählt: wer eine taktische Einheit führt. */
const FUEHRER: Funktion[] = [
"EINSATZLEITER",
"ZUGFUEHRER",
"GRUPPENFUEHRER",
"STAFFELFUEHRER",
];
/** Wer als Unterführer zählt: wer innerhalb der Einheit einen Trupp führt. */
const UNTERFUEHRER: Funktion[] = ["TRUPPFUEHRER"];
export interface Staerke {
fuehrer: number;
unterfuehrer: number;
mannschaft: number;
gesamt: number;
}
export const LEERE_STAERKE: Staerke = {
fuehrer: 0,
unterfuehrer: 0,
mannschaft: 0,
gesamt: 0,
};
/** Zählt die Funktionen zur Stärke zusammen. */
export function staerkeBerechnen(funktionen: Funktion[]): Staerke {
const staerke = { ...LEERE_STAERKE };
for (const funktion of funktionen) {
if (FUEHRER.includes(funktion)) staerke.fuehrer += 1;
else if (UNTERFUEHRER.includes(funktion)) staerke.unterfuehrer += 1;
else staerke.mannschaft += 1;
staerke.gesamt += 1;
}
return staerke;
}
/** Die übliche Schreibweise: "1/3/5/9". */
export function staerkeAlsText(staerke: Staerke): string {
return `${staerke.fuehrer}/${staerke.unterfuehrer}/${staerke.mannschaft}/${staerke.gesamt}`;
}
/** Die taktischen Einheiten der FwDV 3, von klein nach groß. */
export const EINHEITEN = [
{ name: "Trupp", staerke: 2 },
{ name: "Staffel", staerke: 6 },
{ name: "Gruppe", staerke: 9 },
{ name: "Zug", staerke: 18 },
] as const;
/**
* Welcher taktischen Einheit die Mannschaft entspricht.
*
* Gibt die größte Einheit zurück, die vollständig besetzt ist — und dazu, wie
* viele darüber hinaus mitgefahren sind. Eine Wehr rückt selten in exakter
* Sollstärke aus; die Angabe soll trotzdem etwas sagen.
*/
export function taktischeEinheit(staerke: Staerke): {
einheit: string | null;
ueberzaehlig: number;
} {
if (staerke.gesamt === 0) return { einheit: null, ueberzaehlig: 0 };
let gefunden: (typeof EINHEITEN)[number] | null = null;
for (const einheit of EINHEITEN) {
if (staerke.gesamt >= einheit.staerke) gefunden = einheit;
}
if (!gefunden) {
return { einheit: null, ueberzaehlig: staerke.gesamt };
}
return { einheit: gefunden.name, ueberzaehlig: staerke.gesamt - gefunden.staerke };
}
/**
* Fehlt eine Führungskraft?
*
* Ohne Führer keine taktische Einheit — das ist keine Formsache, sondern der
* Unterschied zwischen einer Einheit und einer Ansammlung von Leuten. Die
* Oberfläche weist darauf hin; verhindert wird das Speichern nicht, denn beim
* Nachtragen eines Einsatzes von vor drei Wochen weiß man es manchmal nicht
* mehr genau.
*/
export function staerkeHinweise(staerke: Staerke): string[] {
const hinweise: string[] = [];
if (staerke.gesamt === 0) {
hinweise.push("Zu diesem Einsatz ist noch niemand erfasst.");
return hinweise;
}
if (staerke.fuehrer === 0) {
hinweise.push("Es ist keine Führungskraft erfasst — wer hat den Einsatz geführt?");
}
if (staerke.gesamt < 2) {
hinweise.push("Ein Trupp besteht aus zwei Kräften. Alleine geht niemand vor.");
}
return hinweise;
}
+110
View File
@@ -0,0 +1,110 @@
import type {
Alarmierungsart,
EinsatzFunktion,
EinsatzKategorie,
EinsatzStatus,
LagemeldungQuelle,
UebungsArt,
} from "@prisma/client";
/**
* Klartext zu den Aufzählungen aus Phase 3.
*
* Wie bei der Wartung: eine Datei ohne "use client", damit Server- und
* Client-Komponenten dieselben Beschriftungen verwenden können.
*/
export const KATEGORIE_TEXT: Record<EinsatzKategorie, string> = {
BRAND_KLEIN: "Brand klein",
BRAND_MITTEL: "Brand mittel",
BRAND_GROSS: "Brand groß",
TECHNISCHE_HILFE_KLEIN: "Technische Hilfeleistung klein",
TECHNISCHE_HILFE_MITTEL: "Technische Hilfeleistung mittel",
TECHNISCHE_HILFE_GROSS: "Technische Hilfeleistung groß",
GEFAHRGUT: "Gefahrguteinsatz",
RETTUNGSDIENST: "Unterstützung Rettungsdienst",
BRANDMELDEANLAGE: "Brandmeldeanlage",
BOESWILLIGER_ALARM: "böswilliger Alarm",
SICHERHEITSWACHE: "Sicherheitswache",
UNTERSTUETZUNG: "Unterstützung / Amtshilfe",
SONSTIGES: "Sonstiges",
};
/** Grobe Einordnung für Farben und Auswertungen. */
export const KATEGORIE_GRUPPE: Record<EinsatzKategorie, "brand" | "hilfe" | "sonstige"> = {
BRAND_KLEIN: "brand",
BRAND_MITTEL: "brand",
BRAND_GROSS: "brand",
TECHNISCHE_HILFE_KLEIN: "hilfe",
TECHNISCHE_HILFE_MITTEL: "hilfe",
TECHNISCHE_HILFE_GROSS: "hilfe",
GEFAHRGUT: "hilfe",
RETTUNGSDIENST: "hilfe",
BRANDMELDEANLAGE: "sonstige",
BOESWILLIGER_ALARM: "sonstige",
SICHERHEITSWACHE: "sonstige",
UNTERSTUETZUNG: "sonstige",
SONSTIGES: "sonstige",
};
export const EINSATZ_STATUS_TEXT: Record<EinsatzStatus, string> = {
ENTWURF: "Entwurf",
LAUFEND: "läuft",
ABGESCHLOSSEN: "abgeschlossen",
FREIGEGEBEN: "freigegeben",
};
export const ALARMIERUNG_TEXT: Record<Alarmierungsart, string> = {
SIRENE: "Sirene",
MELDEEMPFAENGER: "Meldeempfänger",
TELEFON: "Telefon",
APP: "App",
PERSOENLICH: "persönlich",
SONSTIGES: "sonstige",
};
export const FUNKTION_TEXT: Record<EinsatzFunktion, string> = {
EINSATZLEITER: "Einsatzleiter",
ZUGFUEHRER: "Zugführer",
GRUPPENFUEHRER: "Gruppenführer",
STAFFELFUEHRER: "Staffelführer",
TRUPPFUEHRER: "Truppführer",
MASCHINIST: "Maschinist",
MELDER: "Melder",
TRUPPMANN: "Truppmann",
ATEMSCHUTZTRAEGER: "Atemschutzgeräteträger",
SICHERHEITSTRUPP: "Sicherheitstrupp",
SANITAETER: "Sanitäter",
SONSTIGES: "sonstige Funktion",
};
export const UEBUNGSART_TEXT: Record<UebungsArt, string> = {
PRAXIS: "Praxisübung",
THEORIE: "Theorie",
ATEMSCHUTZ: "Atemschutz",
MASCHINIST: "Maschinist",
FUNK: "Funk",
ERSTE_HILFE: "Erste Hilfe",
GEMEINSCHAFTSUEBUNG: "Gemeinschaftsübung",
SONSTIGES: "Sonstiges",
};
export const QUELLE_TEXT: Record<LagemeldungQuelle, string> = {
INTERN: "aus der Wehr",
PORTAL: "über das Portal",
};
export const MONAT_KURZ = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
] as const;
+156
View File
@@ -0,0 +1,156 @@
/**
* Einsatzzeiten.
*
* Sechs Zeitstempel beschreiben einen Einsatz vollständig:
*
* | Stempel | bedeutet |
* |---|---|
* | 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 |
*
* Daraus ergeben sich die drei Zahlen, nach denen eine Wehr gefragt wird:
*
* - **Ausrückzeit** — Alarm bis Ausrücken. Sagt etwas über die Wehr aus.
* - **Anfahrtszeit** — Ausrücken bis Eintreffen. Sagt etwas über die Lage aus.
* - **Hilfsfrist** — Alarm bis Eintreffen. Die Zahl, die in der Diskussion mit
* der Gemeinde zählt (in Bayern: zehn Minuten in 80 Prozent der Fälle).
*
* Reine Funktionen ohne Datenbankzugriff — sie gehen später in die Statistik
* ein und müssen ohne laufenden Stack durchtestbar sein.
*/
export interface Einsatzzeiten {
alarmiertUm?: Date | null;
ausgeruecktUm?: Date | null;
eingetroffenUm?: Date | null;
einsatzendeUm?: Date | null;
eingeruecktUm?: Date | null;
einsatzbereitUm?: Date | null;
}
/** Die Stempel in ihrer natürlichen Reihenfolge. */
export const ZEITSTEMPEL = [
"alarmiertUm",
"ausgeruecktUm",
"eingetroffenUm",
"einsatzendeUm",
"eingeruecktUm",
"einsatzbereitUm",
] as const;
export type Zeitstempel = (typeof ZEITSTEMPEL)[number];
export const ZEITSTEMPEL_TEXT: Record<Zeitstempel, string> = {
alarmiertUm: "alarmiert",
ausgeruecktUm: "ausgerückt",
eingetroffenUm: "eingetroffen",
einsatzendeUm: "Einsatzende",
eingeruecktUm: "eingerückt",
einsatzbereitUm: "einsatzbereit",
};
/** Minuten zwischen zwei Zeitpunkten, auf ganze Minuten gerundet. */
function minutenZwischen(von: Date | null | undefined, bis: Date | null | undefined): number | null {
if (!von || !bis) return null;
const minuten = Math.round((bis.getTime() - von.getTime()) / 60_000);
return minuten >= 0 ? minuten : null;
}
/** Alarm bis Ausrücken. */
export function ausrueckzeit(zeiten: Einsatzzeiten): number | null {
return minutenZwischen(zeiten.alarmiertUm, zeiten.ausgeruecktUm);
}
/** Ausrücken bis Eintreffen. */
export function anfahrtszeit(zeiten: Einsatzzeiten): number | null {
return minutenZwischen(zeiten.ausgeruecktUm, zeiten.eingetroffenUm);
}
/** Alarm bis Eintreffen — die Hilfsfrist. */
export function hilfsfrist(zeiten: Einsatzzeiten): number | null {
return minutenZwischen(zeiten.alarmiertUm, zeiten.eingetroffenUm);
}
/**
* Wie lange der Einsatz gedauert hat.
*
* Gerechnet wird von der Alarmierung bis zum Einrücken, ersatzweise bis zum
* Einsatzende — das ist die Zeit, die den Leuten tatsächlich gefehlt hat.
* Die Wiederherstellung der Einsatzbereitschaft zählt bewusst nicht mit: sie
* macht der Gerätewart oft am Tag darauf.
*/
export function einsatzdauer(zeiten: Einsatzzeiten): number | null {
return (
minutenZwischen(zeiten.alarmiertUm, zeiten.eingeruecktUm) ??
minutenZwischen(zeiten.alarmiertUm, zeiten.einsatzendeUm)
);
}
/** Alle drei Kennzahlen auf einmal. */
export function kennzahlen(zeiten: Einsatzzeiten) {
return {
ausrueckzeit: ausrueckzeit(zeiten),
anfahrtszeit: anfahrtszeit(zeiten),
hilfsfrist: hilfsfrist(zeiten),
dauer: einsatzdauer(zeiten),
};
}
/**
* Widersprüche in der Reihenfolge.
*
* Absichtlich Hinweise und keine Fehler: Zeiten werden oft Tage später aus dem
* Gedächtnis nachgetragen, und ein Einsatz, dessen Stempel nicht ganz stimmen,
* ist immer noch besser als einer, den niemand erfasst hat. Die Oberfläche
* zeigt die Hinweise an, das Speichern verhindern sie nicht.
*/
export function zeitHinweise(zeiten: Einsatzzeiten): string[] {
const hinweise: string[] = [];
const paare: Array<[Zeitstempel, Zeitstempel]> = [
["alarmiertUm", "ausgeruecktUm"],
["ausgeruecktUm", "eingetroffenUm"],
["eingetroffenUm", "einsatzendeUm"],
["einsatzendeUm", "eingeruecktUm"],
["eingeruecktUm", "einsatzbereitUm"],
];
for (const [frueher, spaeter] of paare) {
const a = zeiten[frueher];
const b = zeiten[spaeter];
if (a && b && b.getTime() < a.getTime()) {
hinweise.push(
`„${ZEITSTEMPEL_TEXT[spaeter]}" liegt vor „${ZEITSTEMPEL_TEXT[frueher]}" — bitte prüfen.`,
);
}
}
const frist = hilfsfrist(zeiten);
if (frist !== null && frist > 60) {
hinweise.push(
`Zwischen Alarm und Eintreffen liegen ${frist} Minuten. Stimmen die Zeiten?`,
);
}
return hinweise;
}
/** Der nächste Stempel, der gesetzt werden müsste. */
export function naechsterStempel(zeiten: Einsatzzeiten): Zeitstempel | null {
for (const stempel of ZEITSTEMPEL) {
if (!zeiten[stempel]) return stempel;
}
return null;
}
/** Minuten als "1:05 h" oder "23 min". */
export function alsDauer(minuten: number | null | undefined): string {
if (minuten === null || minuten === undefined) return "—";
if (minuten < 60) return `${minuten} min`;
const stunden = Math.floor(minuten / 60);
return `${stunden}:${String(minuten % 60).padStart(2, "0")} h`;
}
+17
View File
@@ -106,6 +106,23 @@ const schema = z.object({
LLM_API_KEY: z.string().optional(), LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(), LLM_MODELL: z.string().optional(),
// Einsätze und Übungen (Phase 3)
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
GEOCODING_ENABLED: boolAusText(false),
/// Adresse des SELBST betriebenen Nominatim. Einsatzadressen gehören nicht
/// an einen öffentlichen Dienst.
NOMINATIM_URL: z.string().url().optional(),
GEOCODING_TIMEOUT: zahl(5),
/// Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt).
GEOCODING_LAENDER: z.string().default("de,at,ch"),
/// Einsatzportal für Fremdwehren: Link ohne Anmeldung, zeitlich begrenzt.
PORTAL_ENABLED: boolAusText(true),
/// Wie lange ein neu erzeugter Portal-Link gilt.
PORTAL_GUELTIGKEIT_STUNDEN: zahl(24),
/// Höchstzahl Zugriffe je Portal-Link und Stunde.
PORTAL_MAX_ZUGRIFFE: zahl(120),
// Wartung und Prüfung (Phase 2) // Wartung und Prüfung (Phase 2)
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */ /** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"), WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
+161
View File
@@ -0,0 +1,161 @@
import "server-only";
import { env } from "@/lib/env";
/**
* Adressen zu Koordinaten — hinter einer Schnittstelle, wie alle externen
* Dienste (Projektregel).
*
* Vorgesehen ist ein **selbst betriebener** Nominatim, kein öffentlicher.
* Einsatzadressen sind besonders schutzwürdig: sie 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.
*
* Ist nichts konfiguriert, liefert die Suche schlicht kein Ergebnis. Die
* Anwendung fällt dann auf die Eingabe von Hand zurück — das ist kein Fehler,
* sondern der Normalfall in einer frischen Installation.
*/
export interface Ort {
breitengrad: number;
laengengrad: number;
/** Die vom Dienst zurückgegebene Schreibweise der Adresse. */
anzeige: string;
/** Wie sicher der Treffer ist, 0 bis 1. */
guete?: number;
}
export interface Adresssuche {
suchen(adresse: string): Promise<Ort[]>;
/** Umgekehrt: von Koordinaten zur Adresse — für den Kartenklick ab Phase 6. */
rueckwaerts(breitengrad: number, laengengrad: number): Promise<Ort | null>;
verfuegbar(): boolean;
}
/** Ohne Konfiguration: keine Treffer, kein Fehler. */
class KeineSuche implements Adresssuche {
verfuegbar(): boolean {
return false;
}
async suchen(adresse: string): Promise<Ort[]> {
console.info(
`[geocoding] (abgeschaltet) Adresse "${adresse}" wird nicht aufgelöst — Koordinaten von Hand eintragen.`,
);
return [];
}
async rueckwaerts(): Promise<Ort | null> {
return null;
}
}
/** Ein selbst betriebener Nominatim. */
class Nominatim implements Adresssuche {
verfuegbar(): boolean {
return Boolean(env.NOMINATIM_URL);
}
private async abfragen(pfad: string, parameter: Record<string, string>): Promise<unknown> {
const adresse = new URL(pfad, env.NOMINATIM_URL);
for (const [schluessel, wert] of Object.entries(parameter)) {
adresse.searchParams.set(schluessel, wert);
}
adresse.searchParams.set("format", "jsonv2");
// Deutsche Schreibweise der Orte, unabhängig davon, wie der Server steht.
adresse.searchParams.set("accept-language", "de");
const antwort = await fetch(adresse, {
signal: AbortSignal.timeout(env.GEOCODING_TIMEOUT * 1000),
headers: {
// Nominatim verlangt eine erkennbare Kennung, auch beim eigenen Server.
"User-Agent": "FLORIAN Feuerwehrverwaltung",
},
});
if (!antwort.ok) {
throw new Error(`Nominatim antwortete mit ${antwort.status}.`);
}
return antwort.json();
}
async suchen(adresse: string): Promise<Ort[]> {
if (!this.verfuegbar() || !adresse.trim()) return [];
try {
const ergebnis = (await this.abfragen("/search", {
q: adresse,
limit: "5",
countrycodes: env.GEOCODING_LAENDER,
})) as Array<{
lat: string;
lon: string;
display_name: string;
importance?: number;
}>;
return ergebnis.map((treffer) => ({
breitengrad: Number(treffer.lat),
laengengrad: Number(treffer.lon),
anzeige: treffer.display_name,
guete: treffer.importance,
}));
} catch (fehler) {
// Ein ausgefallener Geocoder darf die Einsatzerfassung nicht aufhalten.
// Im Alarmfall zählt, dass der Einsatz erfasst wird, nicht dass die
// Karte stimmt.
console.error("[geocoding] Adresssuche fehlgeschlagen:", fehler);
return [];
}
}
async rueckwaerts(breitengrad: number, laengengrad: number): Promise<Ort | null> {
if (!this.verfuegbar()) return null;
try {
const treffer = (await this.abfragen("/reverse", {
lat: String(breitengrad),
lon: String(laengengrad),
})) as { lat: string; lon: string; display_name: string } | null;
if (!treffer) return null;
return {
breitengrad: Number(treffer.lat),
laengengrad: Number(treffer.lon),
anzeige: treffer.display_name,
};
} catch (fehler) {
console.error("[geocoding] Rückwärtssuche fehlgeschlagen:", fehler);
return null;
}
}
}
let sucheCache: Adresssuche | null = null;
export function adresssuche(): Adresssuche {
if (!sucheCache) {
sucheCache = env.GEOCODING_ENABLED && env.NOMINATIM_URL ? new Nominatim() : new KeineSuche();
}
return sucheCache;
}
/** Nur für Tests: eigene Umsetzung unterschieben. */
export function _adresssucheErsetzen(ersatz: Adresssuche | null): void {
sucheCache = ersatz;
}
/** Baut aus den Einzelfeldern eines Einsatzes eine Suchzeile. */
export function adresszeile(teile: {
strasse?: string | null;
hausnummer?: string | null;
plz?: string | null;
ort?: string | null;
ortsteil?: string | null;
}): string {
const strasse = [teile.strasse, teile.hausnummer].filter(Boolean).join(" ");
const ort = [teile.plz, teile.ort ?? teile.ortsteil].filter(Boolean).join(" ");
return [strasse, ort].filter(Boolean).join(", ");
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Laufende Nummern je Wehr und Jahr.
*
* Prüfprotokolle, Einsätze und Übungen tragen dieselbe Art von Nummer:
* `JJJJ-NNNN`, fortlaufend innerhalb eines Jahres, je Wehr getrennt. Sie steht
* auf Papier und wird am Telefon genannt — deshalb kurz, sprechbar und ohne
* Bindestrichwüste.
*
* Die Nummern werden nie wiederverwendet. Vergeben wird die höchste vorhandene
* plus eins, nicht die Anzahl der Datensätze; sonst bekäme ein im
* Aufbewahrungslauf entfernter Vorgang seine Nummer an einen neuen vererbt.
*
* Reine Zeichenkettenarbeit, kein Datenbankzugriff: wer die Nummer vergibt,
* muss dabei selbst für eine Sperre sorgen (siehe `abschliessen` im
* Prüfungs-Router und `anlegen` im Einsatz-Router).
*/
/** Vier Stellen reichen für 9999 Vorgänge im Jahr — mehr hat keine Wehr. */
const STELLEN = 4;
/** Bildet "2026-0042". */
export function nummerBilden(jahr: number, laufend: number): string {
return `${jahr}-${String(laufend).padStart(STELLEN, "0")}`;
}
/** Zerlegt eine Nummer wieder. Gibt null zurück, wenn sie nicht passt. */
export function nummerLesen(
nummer: string | null | undefined,
): { jahr: number; laufend: number } | null {
if (!nummer) return null;
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
if (!treffer) return null;
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
}
/**
* Die nächste Nummer nach der bisher höchsten.
*
* @param hoechste die bisher höchste Nummer des Jahres, oder null
*/
export function naechsteNummer(jahr: number, hoechste: string | null | undefined): string {
return nummerBilden(jahr, (nummerLesen(hoechste)?.laufend ?? 0) + 1);
}
+73
View File
@@ -0,0 +1,73 @@
import "server-only";
import { redis } from "@/lib/warteschlange";
/**
* Zugriffsbegrenzung über Redis.
*
* Gebraucht für alles, was ohne Anmeldung erreichbar ist — bisher das
* Einsatzportal für Fremdwehren. Ein Link, der aus Versehen in einer
* WhatsApp-Gruppe landet, soll die Anwendung nicht in die Knie zwingen.
*
* Verfahren: ein Zähler je Schlüssel und Zeitfenster. INCR legt den Schlüssel
* an, wenn es ihn nicht gibt; beim ersten Zugriff bekommt er eine Lebensdauer.
* Das ist ein festes Fenster und keine gleitende Rate — ungenau an der
* Fenstergrenze, dafür in zwei Redis-Befehlen erledigt und ohne Zustand in der
* Anwendung.
*
* Fällt Redis aus, wird durchgelassen. Eine Zugriffsbegrenzung, die bei
* Störung den Betrieb anhält, richtet mehr Schaden an als sie verhindert —
* die eigentliche Zugangsprüfung sitzt ohnehin woanders.
*/
export interface Begrenzung {
/** Wurde der Zugriff durchgelassen? */
erlaubt: boolean;
/** Wie viele Zugriffe im laufenden Fenster noch übrig sind. */
uebrig: number;
/** Sekunden bis zum Zurücksetzen des Fensters. */
zuruecksetztIn: number;
}
/**
* Zählt einen Zugriff und sagt, ob er noch erlaubt ist.
*
* @param schluessel fachliche Kennung, z. B. `portal:<zugangId>`
* @param hoechstzahl erlaubte Zugriffe je Fenster
* @param fensterSekunden Länge des Fensters
*/
export async function zugriffZaehlen(
schluessel: string,
hoechstzahl: number,
fensterSekunden = 3600,
): Promise<Begrenzung> {
const redisSchluessel = `ratelimit:${schluessel}`;
try {
const verbindung = redis();
const anzahl = await verbindung.incr(redisSchluessel);
if (anzahl === 1) {
await verbindung.expire(redisSchluessel, fensterSekunden);
}
const rest = await verbindung.ttl(redisSchluessel);
return {
erlaubt: anzahl <= hoechstzahl,
uebrig: Math.max(0, hoechstzahl - anzahl),
zuruecksetztIn: rest > 0 ? rest : fensterSekunden,
};
} catch (fehler) {
console.error(`[ratelimit] Redis nicht erreichbar (${schluessel}):`, fehler);
return { erlaubt: true, uebrig: hoechstzahl, zuruecksetztIn: fensterSekunden };
}
}
/** Setzt den Zähler zurück — für Tests und für den Widerruf eines Zugangs. */
export async function zaehlerZuruecksetzen(schluessel: string): Promise<void> {
try {
await redis().del(`ratelimit:${schluessel}`);
} catch {
// Ein nicht gelöschter Zähler läuft von selbst ab.
}
}
+5 -1
View File
@@ -16,7 +16,7 @@ import { env } from "@/lib/env";
export const WARTESCHLANGE = "florian"; export const WARTESCHLANGE = "florian";
/** Bekannte Auftragsarten. Ab Phase 2 kommen die fachlichen Jobs dazu. */ /** Bekannte Auftragsarten. */
export const AUFTRAG = { export const AUFTRAG = {
/** Prüft alle Geräte auf fällige Fristen und setzt den Status. */ /** Prüft alle Geräte auf fällige Fristen und setzt den Status. */
FRISTEN_PRUEFEN: "fristen.pruefen", FRISTEN_PRUEFEN: "fristen.pruefen",
@@ -24,6 +24,10 @@ export const AUFTRAG = {
ERINNERUNG_SENDEN: "erinnerung.senden", ERINNERUNG_SENDEN: "erinnerung.senden",
/** Erzeugt ein PDF über Gotenberg. */ /** Erzeugt ein PDF über Gotenberg. */
PDF_ERZEUGEN: "pdf.erzeugen", PDF_ERZEUGEN: "pdf.erzeugen",
/** Erzeugt einen Einsatzbericht als PDF (Phase 3). */
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */ /** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen", AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
} as const; } as const;
+8 -15
View File
@@ -4,6 +4,7 @@ import {
tagesabstand, tagesabstand,
utcTag, utcTag,
} from "@/lib/stammdaten/fristen"; } from "@/lib/stammdaten/fristen";
import { nummerBilden, nummerLesen } from "@/lib/nummernkreis";
/** /**
* Fristenlogik der Wartung. * Fristenlogik der Wartung.
@@ -148,19 +149,11 @@ export function innerhalbPlanungshorizont(
* Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042". * Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042".
* *
* Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist * Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz, * das, was in einer Rückfrage der Unfallkasse genannt wird.
* sprechbar und ohne Bindestrichwüste. *
* Ab Phase 3 teilen sich Prüfprotokolle, Einsätze und Übungen denselben
* Nummernkreis-Baustein — die Regel ist dieselbe, und zwei Umsetzungen davon
* würden über kurz oder lang verschieden aussehen.
*/ */
export function protokollNummer(jahr: number, laufend: number): string { export const protokollNummer = nummerBilden;
return `${jahr}-${String(laufend).padStart(4, "0")}`; export const protokollNummerLesen = nummerLesen;
}
/** Zerlegt eine Protokollnummer wieder. Gibt null zurück, wenn sie nicht passt. */
export function protokollNummerLesen(
nummer: string | null | undefined,
): { jahr: number; laufend: number } | null {
if (!nummer) return null;
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
if (!treffer) return null;
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
}
+11
View File
@@ -14,6 +14,10 @@ import { wartungsterminRouter } from "@/server/routers/wartung/termin";
import { pruefungRouter } from "@/server/routers/wartung/pruefung"; import { pruefungRouter } from "@/server/routers/wartung/pruefung";
import { mangelRouter } from "@/server/routers/wartung/mangel"; import { mangelRouter } from "@/server/routers/wartung/mangel";
import { rueckrufRouter } from "@/server/routers/wartung/rueckruf"; import { rueckrufRouter } from "@/server/routers/wartung/rueckruf";
import { einsatzRouter } from "@/server/routers/einsatz/einsatz";
import { uebungRouter } from "@/server/routers/einsatz/uebung";
import { statistikRouter } from "@/server/routers/einsatz/statistik";
import { portalRouter } from "@/server/routers/einsatz/portal";
/** /**
* Wurzel-Router. * Wurzel-Router.
@@ -151,6 +155,13 @@ export const appRouter = router({
pruefung: pruefungRouter, pruefung: pruefungRouter,
mangel: mangelRouter, mangel: mangelRouter,
rueckruf: rueckrufRouter, rueckruf: rueckrufRouter,
// Phase 3 — Einsätze und Übungen
einsatz: einsatzRouter,
uebung: uebungRouter,
statistik: statistikRouter,
/** Öffentlich erreichbar, über einen signierten Link. Siehe lib/einsatz/portal.ts. */
portal: portalRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { LagemeldungQuelle } from "@prisma/client";
import { router, oeffentlich } from "@/server/trpc";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { zugriffZaehlen } from "@/lib/ratelimit";
import {
PORTAL_FEHLER_TEXT,
portalPruefen,
type PortalSitzung,
} from "@/lib/einsatz/portal";
/**
* Das Einsatzportal für Fremdwehren — ohne Anmeldung, über einen signierten
* Link.
*
* Diese Prozeduren sind als einzige in FLORIAN öffentlich erreichbar. Sie
* gehen deshalb bewusst sparsam mit dem um, was sie zeigen und annehmen:
*
* gezeigt wird Stichwort, Kategorie, Ort, Zeitstempel und die
* Lagemeldungen — also das, was an der Einsatzstelle
* ohnehin jeder sieht
* nicht gezeigt Namen von Einsatzkräften, Bericht, Material, Kosten,
* irgendetwas über die Wehr außerhalb dieses Einsatzes
* angenommen wird eine Lagemeldung mit Text und Foto, mehr nicht
*
* Jeder Aufruf läuft durch die Zugriffsbegrenzung und wird am Zugang
* mitgezählt.
*/
const tokenEingabe = z.object({ token: z.string().min(20).max(400) });
/** Prüft den Token oder wirft einen sprechenden Fehler. */
async function sitzungOderFehler(
token: string,
ipAdresse: string | null,
): Promise<PortalSitzung> {
const ergebnis = await portalPruefen(token, ipAdresse);
if ("fehler" in ergebnis) {
throw new TRPCError({
code: ergebnis.fehler === "zu_viele_zugriffe" ? "TOO_MANY_REQUESTS" : "UNAUTHORIZED",
message: PORTAL_FEHLER_TEXT[ergebnis.fehler],
});
}
return ergebnis.sitzung;
}
export const portalRouter = router({
/** Was das Portal zeigt. */
laden: oeffentlich.input(tokenEingabe).query(async ({ ctx, input }) => {
const sitzung = await sitzungOderFehler(input.token, ctx.ipAdresse);
return fuerOrganisation(sitzung.organisationId, async (db) => {
const einsatz = await db.einsatz.findUnique({
where: { id: sitzung.einsatzId },
select: {
id: true,
einsatznummer: true,
stichwort: true,
kategorie: true,
status: true,
strasse: true,
hausnummer: true,
plz: true,
ort: true,
ortsteil: true,
objekt: true,
breitengrad: true,
laengengrad: true,
alarmiertUm: true,
ausgeruecktUm: true,
eingetroffenUm: true,
einsatzendeUm: true,
organisation: { select: { name: true, kurzname: true } },
},
});
if (!einsatz) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Einsatz gibt es nicht mehr." });
}
const lagemeldungen = await db.lagemeldung.findMany({
where: { einsatzId: sitzung.einsatzId, geloeschtAm: null },
orderBy: { zeitpunkt: "desc" },
take: 100,
select: {
id: true,
zeitpunkt: true,
text: true,
wichtig: true,
quelle: true,
verfasserName: true,
fotoDateiId: true,
},
});
return {
zugang: {
bezeichnung: sitzung.bezeichnung,
gueltigBis: sitzung.gueltigBis,
},
einsatz,
lagemeldungen,
};
});
}),
/**
* Eine Lagemeldung aus dem Portal.
*
* Zusätzlich zur allgemeinen Zugriffsbegrenzung ein eigenes, engeres Limit:
* lesen darf man oft, schreiben selten. Sonst wäre ein weitergegebener Link
* ein Werkzeug, um die Lagemeldungen zuzuschütten.
*/
melden: oeffentlich
.input(
tokenEingabe.extend({
text: z.string().trim().min(1, "Ohne Text ist es keine Meldung.").max(2000),
verfasserName: z.string().trim().min(1, "Bitte angeben, wer meldet.").max(200),
fotoDateiId: z.string().uuid().nullish(),
wichtig: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const sitzung = await sitzungOderFehler(input.token, ctx.ipAdresse);
const schreibgrenze = await zugriffZaehlen(`portal-melden:${sitzung.zugangId}`, 30, 3600);
if (!schreibgrenze.erlaubt) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Zu viele Meldungen in kurzer Zeit. Bitte kurz warten.",
});
}
return fuerOrganisation(sitzung.organisationId, async (db) => {
const meldung = await db.lagemeldung.create({
data: {
organisationId: sitzung.organisationId,
einsatzId: sitzung.einsatzId,
text: input.text,
wichtig: input.wichtig,
quelle: LagemeldungQuelle.PORTAL,
verfasserName: `${input.verfasserName} (${sitzung.bezeichnung})`,
fotoDateiId: input.fotoDateiId ?? null,
portalZugangId: sitzung.zugangId,
},
});
await protokolliereIn(db, {
organisationId: sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Lagemeldung",
entitaetId: meldung.id,
beschreibung: `Lagemeldung über das Portal von "${sitzung.bezeichnung}"`,
akteurTyp: AkteurTyp.API,
akteurName: input.verfasserName,
nachher: { text: input.text, wichtig: input.wichtig },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { id: meldung.id, zeitpunkt: meldung.zeitpunkt };
});
}),
});
+279
View File
@@ -0,0 +1,279 @@
import { z } from "zod";
import { EinsatzStatus } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { hilfsfrist, ausrueckzeit, einsatzdauer } from "@/lib/einsatz/zeiten";
/**
* Statistik über Einsätze und Übungen.
*
* Zwei Adressaten, zwei Fragen:
*
* Die Gemeinde fragt: wie viele Einsätze welcher Art, und wie schnell wart
* ihr da? Dafür stehen die Kategorien nach Landesstatistik und die
* Hilfsfrist.
*
* Die Wehr fragt: wer trägt die Last? Dafür steht die Beteiligung je Person
* und die Auslastung je Fahrzeug.
*
* Gerechnet wird in der Anwendung, nicht in SQL. Ein Jahr hat auch bei einer
* gut beschäftigten Wehr selten mehr als dreihundert Einsätze; dafür lohnt
* keine Aggregatabfrage, die niemand mehr lesen kann.
*/
/** Klassen für die Verteilung der Hilfsfrist, in Minuten. */
const FRISTKLASSEN = [
{ bis: 5, text: "bis 5 min" },
{ bis: 8, text: "5 bis 8 min" },
{ bis: 10, text: "8 bis 10 min" },
{ bis: 15, text: "10 bis 15 min" },
{ bis: Number.POSITIVE_INFINITY, text: "über 15 min" },
] as const;
/** Perzentil einer sortierten Liste. */
function perzentil(werte: number[], anteil: number): number | null {
if (werte.length === 0) return null;
const sortiert = [...werte].sort((a, b) => a - b);
const stelle = Math.min(sortiert.length - 1, Math.floor(anteil * sortiert.length));
return sortiert[stelle] ?? null;
}
export const statistikRouter = router({
/**
* Die Jahresübersicht.
*
* Enthält alles, was in den Jahresbericht der Wehr und in die Meldung an die
* Gemeinde geht.
*/
jahr: mitRecht("einsatz.lesen")
.input(
z
.object({ jahr: z.number().int().min(2000).max(2100).optional() })
.default({}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const jahr = input.jahr ?? new Date().getUTCFullYear();
const von = new Date(Date.UTC(jahr, 0, 1));
const bis = new Date(Date.UTC(jahr + 1, 0, 1));
const einsaetze = await db.einsatz.findMany({
where: { geloeschtAm: null, alarmiertUm: { gte: von, lt: bis } },
select: {
id: true,
einsatznummer: true,
kategorie: true,
status: true,
alarmiertUm: true,
ausgeruecktUm: true,
eingetroffenUm: true,
einsatzendeUm: true,
eingeruecktUm: true,
geretteteePersonen: true,
verletztePersonen: true,
totePersonen: true,
kraefte: {
select: {
personId: true,
atemschutz: true,
person: { select: { vorname: true, nachname: true } },
},
},
fahrzeuge: {
select: {
fahrzeugId: true,
kilometerAbfahrt: true,
kilometerRueckkehr: true,
wasserabgabe: true,
fahrzeug: { select: { funkrufname: true } },
},
},
},
});
// --- nach Kategorie und Monat -----------------------------------------
const nachKategorie = new Map<string, number>();
const nachMonat = Array.from({ length: 12 }, () => 0);
// --- Zeiten ------------------------------------------------------------
const hilfsfristen: number[] = [];
const ausrueckzeiten: number[] = [];
const dauern: number[] = [];
const fristklassen = FRISTKLASSEN.map((klasse) => ({ text: klasse.text, anzahl: 0 }));
// --- Beteiligung und Fahrzeuge ----------------------------------------
const jePerson = new Map<
string,
{ name: string; einsaetze: number; atemschutz: number }
>();
const jeFahrzeug = new Map<
string,
{ name: string; einsaetze: number; kilometer: number; wasser: number }
>();
let gerettet = 0;
let verletzt = 0;
let getoetet = 0;
for (const einsatz of einsaetze) {
nachKategorie.set(einsatz.kategorie, (nachKategorie.get(einsatz.kategorie) ?? 0) + 1);
if (einsatz.alarmiertUm) {
const monat = einsatz.alarmiertUm.getUTCMonth();
nachMonat[monat] = (nachMonat[monat] ?? 0) + 1;
}
const frist = hilfsfrist(einsatz);
if (frist !== null) {
hilfsfristen.push(frist);
const klasse = FRISTKLASSEN.findIndex((eintrag) => frist <= eintrag.bis);
if (klasse >= 0) fristklassen[klasse]!.anzahl += 1;
}
const ausruecken = ausrueckzeit(einsatz);
if (ausruecken !== null) ausrueckzeiten.push(ausruecken);
const dauer = einsatzdauer(einsatz);
if (dauer !== null) dauern.push(dauer);
gerettet += einsatz.geretteteePersonen;
verletzt += einsatz.verletztePersonen;
getoetet += einsatz.totePersonen;
for (const kraft of einsatz.kraefte) {
const eintrag = jePerson.get(kraft.personId) ?? {
name: `${kraft.person.nachname}, ${kraft.person.vorname}`,
einsaetze: 0,
atemschutz: 0,
};
eintrag.einsaetze += 1;
if (kraft.atemschutz) eintrag.atemschutz += 1;
jePerson.set(kraft.personId, eintrag);
}
for (const fahrzeug of einsatz.fahrzeuge) {
const eintrag = jeFahrzeug.get(fahrzeug.fahrzeugId) ?? {
name: fahrzeug.fahrzeug.funkrufname,
einsaetze: 0,
kilometer: 0,
wasser: 0,
};
eintrag.einsaetze += 1;
if (fahrzeug.kilometerAbfahrt !== null && fahrzeug.kilometerRueckkehr !== null) {
const gefahren = fahrzeug.kilometerRueckkehr - fahrzeug.kilometerAbfahrt;
if (gefahren > 0 && gefahren < 5000) eintrag.kilometer += gefahren;
}
eintrag.wasser += fahrzeug.wasserabgabe ?? 0;
jeFahrzeug.set(fahrzeug.fahrzeugId, eintrag);
}
}
// --- Übungen -----------------------------------------------------------
const uebungen = await db.uebung.findMany({
where: { geloeschtAm: null, datum: { gte: von, lt: bis } },
select: {
id: true,
art: true,
teilnahmen: {
select: {
personId: true,
person: { select: { vorname: true, nachname: true } },
},
},
},
});
const uebungenJePerson = new Map<string, { name: string; anzahl: number }>();
const uebungenNachArt = new Map<string, number>();
for (const uebung of uebungen) {
uebungenNachArt.set(uebung.art, (uebungenNachArt.get(uebung.art) ?? 0) + 1);
for (const teilnahme of uebung.teilnahmen) {
const eintrag = uebungenJePerson.get(teilnahme.personId) ?? {
name: `${teilnahme.person.nachname}, ${teilnahme.person.vorname}`,
anzahl: 0,
};
eintrag.anzahl += 1;
uebungenJePerson.set(teilnahme.personId, eintrag);
}
}
// --- Beteiligung zusammenführen ---------------------------------------
const beteiligung = [...jePerson.entries()]
.map(([personId, eintrag]) => ({
personId,
name: eintrag.name,
einsaetze: eintrag.einsaetze,
atemschutzeinsaetze: eintrag.atemschutz,
uebungen: uebungenJePerson.get(personId)?.anzahl ?? 0,
}))
.sort((a, b) => b.einsaetze - a.einsaetze);
// Wer nur bei Übungen war, fehlt sonst ganz.
for (const [personId, eintrag] of uebungenJePerson) {
if (!jePerson.has(personId)) {
beteiligung.push({
personId,
name: eintrag.name,
einsaetze: 0,
atemschutzeinsaetze: 0,
uebungen: eintrag.anzahl,
});
}
}
return {
jahr,
gesamt: einsaetze.length,
freigegeben: einsaetze.filter((e) => e.status === EinsatzStatus.FREIGEGEBEN).length,
offen: einsaetze.filter((e) => e.status !== EinsatzStatus.FREIGEGEBEN).length,
nachKategorie: [...nachKategorie.entries()]
.map(([kategorie, anzahl]) => ({ kategorie, anzahl }))
.sort((a, b) => b.anzahl - a.anzahl),
nachMonat: nachMonat.map((anzahl, monat) => ({ monat: monat + 1, anzahl })),
zeiten: {
// Die Zahl, nach der die Gemeinde fragt: in 80 Prozent der Fälle
// war die Wehr innerhalb dieser Zeit da.
hilfsfristP80: perzentil(hilfsfristen, 0.8),
hilfsfristMedian: perzentil(hilfsfristen, 0.5),
ausrueckzeitMedian: perzentil(ausrueckzeiten, 0.5),
dauerMedian: perzentil(dauern, 0.5),
gemessen: hilfsfristen.length,
verteilung: fristklassen,
},
personen: { gerettet, verletzt, getoetet },
beteiligung,
fahrzeuge: [...jeFahrzeug.entries()]
.map(([fahrzeugId, eintrag]) => ({ fahrzeugId, ...eintrag }))
.sort((a, b) => b.einsaetze - a.einsaetze),
uebungen: {
gesamt: uebungen.length,
teilnahmen: uebungen.reduce((summe, uebung) => summe + uebung.teilnahmen.length, 0),
nachArt: [...uebungenNachArt.entries()]
.map(([art, anzahl]) => ({ art, anzahl }))
.sort((a, b) => b.anzahl - a.anzahl),
},
};
}),
),
/** In welchen Jahren es überhaupt Daten gibt — für die Auswahl. */
jahre: mitRecht("einsatz.lesen").query(async ({ ctx }) =>
ctx.db(async (db) => {
const aeltester = await db.einsatz.findFirst({
where: { geloeschtAm: null, alarmiertUm: { not: null } },
orderBy: { alarmiertUm: "asc" },
select: { alarmiertUm: true },
});
const jetzt = new Date().getUTCFullYear();
const start = aeltester?.alarmiertUm?.getUTCFullYear() ?? jetzt;
return Array.from({ length: jetzt - start + 1 }, (_, index) => jetzt - index);
}),
),
});
+314
View File
@@ -0,0 +1,314 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { EinsatzFunktion, Prisma, UebungsArt } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
} from "@/server/routers/stammdaten/gemeinsam";
import { naechsteNummer } from "@/lib/nummernkreis";
/**
* Übungen.
*
* Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und Lernzielen statt
* Schadenlage. Der Zweck ist ein anderer: eine Übung ist kein Nachweis
* gegenüber der Gemeinde, sondern gegenüber der eigenen Mannschaft — wer war
* dabei, wie oft, und woran wurde gearbeitet.
*
* Daraus ergibt sich die Teilnahmequote, nach der in der Jahreshauptversammlung
* gefragt wird, und die Grundlage für Beförderungen.
*
* Ein Übungsbericht wird nicht festgeschrieben. Er geht an niemanden außerhalb
* der Wehr, und ein Tippfehler soll ohne Korrekturvorgang zu beheben sein.
*/
const LISTEN_AUSWAHL = {
id: true,
nummer: true,
thema: true,
art: true,
datum: true,
beginnUm: true,
endeUm: true,
ort: true,
geloeschtAm: true,
leiter: { select: { id: true, vorname: true, nachname: true } },
_count: { select: { teilnahmen: true } },
} satisfies Prisma.UebungSelect;
const uebungFelder = z.object({
thema: z.string().trim().min(1, "Ohne Thema ist es keine Übung, sondern ein Treffen.").max(200),
art: z.nativeEnum(UebungsArt).default(UebungsArt.PRAXIS),
datum: z.coerce.date(),
beginnUm: z.coerce.date().nullish(),
endeUm: z.coerce.date().nullish(),
ort: z.string().trim().max(200).nullish(),
leiterId: z.string().uuid().nullish(),
lernziele: z.string().trim().max(2000).nullish(),
beschreibung: z.string().trim().max(4000).nullish(),
bericht: z.string().trim().max(50_000).nullish(),
});
export const uebungRouter = router({
liste: mitRecht("ausbildung.lesen")
.input(
listenEingabe(["datum", "thema", "nummer"] as const).extend({
art: z.array(z.nativeEnum(UebungsArt)).optional(),
jahr: z.number().int().min(2000).max(2100).optional(),
personId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.UebungWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.art?.length ? { art: { in: input.art } } : {}),
...(input.jahr
? {
datum: {
gte: new Date(Date.UTC(input.jahr, 0, 1)),
lt: new Date(Date.UTC(input.jahr + 1, 0, 1)),
},
}
: {}),
...(input.personId ? { teilnahmen: { some: { personId: input.personId } } } : {}),
...(input.suche
? {
OR: [
{ thema: { contains: input.suche, mode: "insensitive" as const } },
{ nummer: { contains: input.suche, mode: "insensitive" as const } },
{ ort: { contains: input.suche, mode: "insensitive" as const } },
],
}
: {}),
};
const [eintraege, gesamt] = await Promise.all([
db.uebung.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: input.sortierung ? { [input.sortierung]: input.richtung } : { datum: "desc" },
...blaettern(input),
}),
db.uebung.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
einzeln: mitRecht("ausbildung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const uebung = await db.uebung.findUnique({
where: { id: input.id },
include: {
leiter: { select: { id: true, vorname: true, nachname: true } },
teilnahmen: {
include: {
person: {
select: { id: true, vorname: true, nachname: true, dienstgrad: true },
},
},
orderBy: { erstelltAm: "asc" },
},
},
});
if (!uebung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
const dauer =
uebung.beginnUm && uebung.endeUm
? Math.round((uebung.endeUm.getTime() - uebung.beginnUm.getTime()) / 60_000)
: null;
return { ...uebung, dauerMinuten: dauer };
}),
),
anlegen: mitRecht("ausbildung.bearbeiten")
.input(uebungFelder.extend({ teilnehmerIds: z.array(z.string().uuid()).max(200).default([]) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { teilnehmerIds, ...felder } = input;
const jahr = felder.datum.getUTCFullYear();
await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`uebung:${ctx.sitzung.organisationId}:${jahr}`}))`;
const letzte = await db.uebung.findFirst({
where: { nummer: { startsWith: `${jahr}-` } },
orderBy: { nummer: "desc" },
select: { nummer: true },
});
const nummer = naechsteNummer(jahr, letzte?.nummer);
const uebung = await db.uebung.create({
data: {
organisationId: ctx.sitzung.organisationId,
nummer,
...felder,
teilnahmen: {
create: teilnehmerIds.map((personId) => ({
organisationId: ctx.sitzung.organisationId,
personId,
})),
},
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Uebung",
entitaetId: uebung.id,
beschreibung: `Übung ${nummer} — ${uebung.thema} mit ${teilnehmerIds.length} Teilnehmern`,
sitzung: ctx.sitzung,
nachher: uebung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return uebung;
}),
),
aendern: mitRecht("ausbildung.bearbeiten")
.input(uebungFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.uebung.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
const nachher = await db.uebung.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Uebung",
entitaetId: id,
beschreibung: `Übung ${nachher.nummer} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/** Teilnehmerliste setzen — Anwesenheit wird am Stück abgehakt. */
teilnahmeSetzen: mitRecht("ausbildung.bearbeiten")
.input(
z.object({
uebungId: z.string().uuid(),
teilnehmer: z
.array(
z.object({
personId: z.string().uuid(),
funktion: z.nativeEnum(EinsatzFunktion).nullish(),
vonUm: z.coerce.date().nullish(),
bisUm: z.coerce.date().nullish(),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.max(200),
/** Nicht genannte Teilnehmer entfernen — die Liste ist dann vollständig. */
ersetzen: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const uebung = await db.uebung.findUnique({
where: { id: input.uebungId },
select: { id: true, nummer: true },
});
if (!uebung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
if (input.ersetzen) {
await db.uebungsteilnahme.deleteMany({
where: {
uebungId: input.uebungId,
personId: { notIn: input.teilnehmer.map((eintrag) => eintrag.personId) },
},
});
}
for (const teilnehmer of input.teilnehmer) {
await db.uebungsteilnahme.upsert({
where: {
uebungId_personId: { uebungId: input.uebungId, personId: teilnehmer.personId },
},
create: {
organisationId: ctx.sitzung.organisationId,
uebungId: input.uebungId,
...teilnehmer,
},
update: {
funktion: teilnehmer.funktion ?? null,
vonUm: teilnehmer.vonUm ?? null,
bisUm: teilnehmer.bisUm ?? null,
bemerkung: teilnehmer.bemerkung ?? null,
},
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Uebung",
entitaetId: input.uebungId,
beschreibung: `Übung ${uebung.nummer}: ${input.teilnehmer.length} Teilnehmer erfasst`,
sitzung: ctx.sitzung,
nachher: { anzahl: input.teilnehmer.length },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfasst: input.teilnehmer.length };
}),
),
loeschen: mitRecht("ausbildung.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const uebung = await db.uebung.findUnique({ where: { id: input.id } });
if (!uebung || uebung.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
await db.uebung.update({ where: { id: input.id }, data: { geloeschtAm: new Date() } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Uebung",
entitaetId: input.id,
beschreibung: `Übung ${uebung.nummer} gelöscht`,
sitzung: ctx.sitzung,
vorher: uebung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
+27 -2
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/warteschlange"; } from "@/lib/warteschlange";
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db"; import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen"; import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
import { einsatzberichtErzeugen } from "@/lib/einsatz/bericht-erzeugen";
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung"; import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
/** /**
@@ -20,8 +21,8 @@ import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/wor
* *
* Läuft als eigener Container neben der Anwendung und arbeitet die * Läuft als eigener Container neben der Anwendung und arbeitet die
* Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin: * Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die * Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle, ab Phase 3
* Aufbewahrung folgt in Phase 8. * dazu die Einsatzberichte. Die Aufbewahrung folgt in Phase 8.
* *
* Beim Start trägt sich der Prozess die wiederkehrenden Läufe je Wehr ein. * Beim Start trägt sich der Prozess die wiederkehrenden Läufe je Wehr ein.
* Das ist bewusst hier und nicht in der Anwendung: der Worker ist der Einzige, * Das ist bewusst hier und nicht in der Anwendung: der Worker ist der Einzige,
@@ -81,6 +82,29 @@ async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId }; return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
} }
async function einsatzberichtErzeugenAuftrag(auftrag: Job<AuftragsDaten>) {
const { organisationId, einsatzId } = auftrag.data as AuftragsDaten & { einsatzId?: string };
if (!einsatzId) {
throw new Error("Berichtsauftrag ohne Angabe, wozu — einsatzId fehlt.");
}
const ergebnis = await einsatzberichtErzeugen(organisationId, einsatzId);
if (!ergebnis) {
protokoll(`Kein Bericht für Einsatz ${einsatzId} — unbekannt oder nicht freigegeben.`);
return { erzeugt: false };
}
protokoll(
ergebnis.neu
? `Einsatzbericht für ${einsatzId} erzeugt.`
: `Einsatzbericht für ${einsatzId} lag bereits vor.`,
);
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> { async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`); protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 8: Audit-Log exportieren und danach abräumen, // Ab Phase 8: Audit-Log exportieren und danach abräumen,
@@ -92,6 +116,7 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
[AUFTRAG.FRISTEN_PRUEFEN]: fristenPruefen, [AUFTRAG.FRISTEN_PRUEFEN]: fristenPruefen,
[AUFTRAG.ERINNERUNG_SENDEN]: erinnerungSenden, [AUFTRAG.ERINNERUNG_SENDEN]: erinnerungSenden,
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen, [AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
[AUFTRAG.EINSATZBERICHT_ERZEUGEN]: einsatzberichtErzeugenAuftrag,
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen, [AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
}; };
+195
View File
@@ -0,0 +1,195 @@
import { expect, test, type Page } from "@playwright/test";
/**
* E2E-Tests für Phase 3.
*
* Geprüft wird der Weg vom Alarm bis zur Auswertung: schnell erfassen,
* Zeitstempel setzen, Mannschaft eintragen, melden — und am Ende die Zahlen,
* die die Gemeinde sehen will.
*
* Dazu der Portal-Link: er ist der einzige Weg in FLORIAN hinein, der ohne
* Anmeldung funktioniert, und wird deshalb ausdrücklich in einem Browser
* **ohne** Sitzung geprüft.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
}
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Einsatzliste", () => {
test("zeigt die Einsätze der Wehr", async ({ page }) => {
await page.goto("/einsaetze");
await expect(page.getByRole("heading", { name: "Einsätze" })).toBeVisible();
// Aus dem Seed: acht Einsätze des laufenden Jahres.
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
await expect(page.getByText("B 3 Wohnhausbrand")).toBeVisible();
});
test("filtert über die Suche", async ({ page }) => {
await page.goto("/einsaetze");
await page.getByLabel("Einsätze durchsuchen").fill("Ölspur");
await expect(page.getByText("THL 1 Ölspur")).toBeVisible();
await expect(page.getByText("B 3 Wohnhausbrand")).toHaveCount(0);
});
});
/**
* Nacheinander: die Tests bauen aufeinander auf — erst wird ein Einsatz
* erfasst, dann daran weitergearbeitet.
*/
test.describe.configure({ mode: "serial" });
test.describe("Einsatz erfassen und bearbeiten", () => {
/** Merkt sich die Adresse des in diesem Durchlauf angelegten Einsatzes. */
let einsatzAdresse = "";
test("legt einen Einsatz über die Schnellerfassung an", async ({ page }) => {
await page.goto("/einsaetze");
await page.getByRole("button", { name: "Einsatz erfassen" }).click();
await page
.getByLabel("Einsatzstichwort")
.fill(`THL 1 Testeinsatz ${test.info().project.name}`);
await page.getByLabel("Straße").fill("Teststraße");
await page.getByLabel("Ort", { exact: true }).fill("Musterhausen");
await page.getByRole("button", { name: "Einsatz anlegen" }).click();
// Danach steht die Detailseite offen.
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
einsatzAdresse = page.url();
await expect(page.getByRole("heading", { name: /THL 1 Testeinsatz/ })).toBeVisible();
await expect(page.getByText("läuft")).toBeVisible();
});
test("setzt Zeitstempel und rechnet die Kennzahlen", async ({ page }) => {
await page.goto(einsatzAdresse);
await expect(page.getByRole("heading", { name: "Zeiten" })).toBeVisible();
await page.getByRole("button", { name: "ausgerückt jetzt" }).click();
await expect(page.getByRole("button", { name: "ausgerückt zurücknehmen" })).toBeVisible();
await page.getByRole("button", { name: "eingetroffen jetzt" }).click();
await expect(page.getByRole("button", { name: "eingetroffen zurücknehmen" })).toBeVisible();
// Die Hilfsfrist steht danach da — auch wenn sie null Minuten beträgt.
await expect(page.getByText("Hilfsfrist")).toBeVisible();
});
test("erfasst Mannschaft über den Vorschlag und zeigt die Stärke", async ({ page }) => {
await page.goto(einsatzAdresse);
await page.getByRole("button", { name: "Mannschaft erfassen" }).click();
await expect(page.getByText(/sortiert nach Einsätzen im letzten Jahr/)).toBeVisible();
// Die ersten drei Vorschläge übernehmen.
const vorschlaege = page.locator("button", { hasText: /\d+ Einsätze/ });
await vorschlaege.nth(0).click();
await expect(page.getByText(/Kräfte erfasst/)).toBeVisible();
// Die Stärke wird sofort angezeigt.
await expect(page.getByText(/^\d+\/\d+\/\d+\/\d+$/)).toBeVisible();
});
test("nimmt eine Lagemeldung auf", async ({ page }) => {
await page.goto(einsatzAdresse);
await page.getByLabel("Neue Lagemeldung").fill("Lage erkundet, keine Gefahr für Personen.");
await page.getByRole("button", { name: "Melden" }).click();
await expect(page.getByText("Lage erkundet, keine Gefahr für Personen.")).toBeVisible();
});
test("gibt einen Portal-Link aus, der ohne Anmeldung funktioniert", async ({ page, browser }) => {
await page.goto(einsatzAdresse);
await page.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen");
await page.getByRole("button", { name: "Erzeugen" }).click();
const linkfeld = page.locator('input[readonly]');
await expect(linkfeld).toBeVisible();
const adresse = await linkfeld.inputValue();
expect(adresse).toContain("/portal/");
// Jetzt ein Browser ohne Sitzung — genau so, wie ihn eine Fremdwehr hätte.
const fremd = await browser.newContext();
const fremdeSeite = await fremd.newPage();
await fremdeSeite.goto(adresse);
await expect(fremdeSeite.getByRole("heading", { name: /Testeinsatz/ })).toBeVisible();
await expect(fremdeSeite.getByText("Zugang für FF Nachbarhausen")).toBeVisible();
// Und eine Meldung absetzen.
await fremdeSeite.getByLabel("Wer meldet?").fill("Gruppenführer Huber");
await fremdeSeite.getByLabel("Meldung").fill("Wir sind mit einer Staffel eingetroffen.");
await fremdeSeite.getByRole("button", { name: "Melden" }).click();
await expect(fremdeSeite.getByText("Wir sind mit einer Staffel eingetroffen.")).toBeVisible();
// Kein Weg in den Rest der Anwendung.
await expect(fremdeSeite.getByRole("link", { name: "Geräte" })).toHaveCount(0);
await fremd.close();
});
test("weist einen erfundenen Portal-Link ab", async ({ browser }) => {
const fremd = await browser.newContext();
const seite = await fremd.newPage();
await seite.goto("/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01");
await expect(seite.getByTestId("portalfehler")).toBeVisible();
await expect(seite.getByText(/nicht gültig/)).toBeVisible();
await fremd.close();
});
});
test.describe("Statistik", () => {
test("zeigt Hilfsfrist, Kategorien und Beteiligung", async ({ page }) => {
await page.goto("/einsaetze/statistik");
await expect(page.getByRole("heading", { name: "Einsatzstatistik" })).toBeVisible();
await expect(page.getByText("Hilfsfrist (80 %)")).toBeVisible();
await expect(page.getByRole("heading", { name: "Nach Kategorie" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Beteiligung" })).toBeVisible();
// Aus dem Seed: die Kategorien der acht Einsätze.
await expect(page.getByText("Brand groß").first()).toBeVisible();
});
});
test.describe("Übungen", () => {
test("zeigt die Übungen und ihre Teilnehmerzahl", async ({ page }) => {
await page.goto("/uebungen");
await expect(page.getByRole("heading", { name: "Übungen" })).toBeVisible();
await expect(page.getByText("Innenangriff mit Atemschutz")).toBeVisible();
});
test("öffnet eine Übung und zeigt die Anwesenheitsliste", async ({ page }) => {
await page.goto("/uebungen");
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click();
await expect(page.getByRole("heading", { name: "Anwesenheit" })).toBeVisible();
await expect(page.getByText(/von \d+ aktiven Mitgliedern/)).toBeVisible();
});
});
+304
View File
@@ -0,0 +1,304 @@
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tokenZerlegen, tokenZusammensetzen } from "@/lib/einsatz/portal";
/**
* Was die Datenbank bei Einsätzen durchsetzt.
*
* Drei Dinge, die nicht im Anwendungscode stehen dürfen, weil ein vergessener
* Zweig dort ausreichen würde:
*
* 1. Mandantentrennung der acht neuen Tabellen
* 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt
* 3. Die Unveränderbarkeit freigegebener Einsatzberichte
*
* Dazu die Zerlegung des Portal-Tokens, die ohne Datenbank auskommt.
*
* Ohne erreichbare Datenbank überspringen sich die ersten drei Gruppen selbst.
*/
const APP_URL = process.env.DATABASE_URL ?? "";
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
const SLUG = "test-einsatz-wehr";
let erreichbar = false;
let organisationId = "";
let personId = "";
beforeAll(async () => {
try {
await system.$queryRaw`SELECT 1`;
erreichbar = true;
} catch {
return;
}
const organisation = await system.organisation.upsert({
where: { slug: SLUG },
update: {},
create: { slug: SLUG, name: "Einsatz-Testwehr", kurzname: "ETW" },
});
organisationId = organisation.id;
const person = await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "E001" } },
update: {},
create: { organisationId, mitgliedsnummer: "E001", vorname: "Erika", nachname: "Einsatz" },
});
personId = person.id;
});
afterAll(async () => {
if (erreichbar) {
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: SLUG } });
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
let laufend = 0;
/** Legt einen Einsatz an — mit fortlaufender Nummer, damit Tests sich nicht stören. */
async function einsatzAnlegen(felder: Record<string, unknown> = {}) {
laufend += 1;
return system.einsatz.create({
data: {
organisationId,
einsatznummer: `2026-${String(9000 + laufend)}`,
stichwort: "Testeinsatz",
alarmiertUm: new Date("2026-06-15T01:42:00.000Z"),
...felder,
},
});
}
describe.runIf(process.env.DATABASE_URL)("Koordinaten", () => {
it("setzt den PostGIS-Punkt aus den Dezimalgraden", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ breitengrad: 48.137154, laengengrad: 11.576124 });
const [zeile] = await system.$queryRaw<Array<{ laenge: number; breite: number }>>`
SELECT ST_X("koordinaten"::geometry) AS laenge, ST_Y("koordinaten"::geometry) AS breite
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
`;
expect(zeile?.breite).toBeCloseTo(48.137154, 5);
expect(zeile?.laenge).toBeCloseTo(11.576124, 5);
});
it("räumt den Punkt wieder ab, wenn die Koordinaten entfallen", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ breitengrad: 48.1, laengengrad: 11.5 });
await system.einsatz.update({
where: { id: einsatz.id },
data: { breitengrad: null, laengengrad: null },
});
const [zeile] = await system.$queryRaw<Array<{ vorhanden: boolean }>>`
SELECT ("koordinaten" IS NOT NULL) AS vorhanden
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
`;
expect(zeile?.vorhanden).toBe(false);
});
it("weist Koordinaten ab, die es auf der Erde nicht gibt", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(einsatzAnlegen({ breitengrad: 95, laengengrad: 11.5 })).rejects.toThrow();
});
});
describe.runIf(process.env.DATABASE_URL)("Freigegebene Einsatzberichte", () => {
it("lässt sich bis zur Freigabe frei ändern", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
const geaendert = await system.einsatz.update({
where: { id: einsatz.id },
data: { bericht: "wird noch geschrieben" },
});
expect(geaendert.bericht).toBe("wird noch geschrieben");
});
it("weist jede Änderung nach der Freigabe ab", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({
status: "FREIGEGEBEN",
freigegebenAm: new Date(),
bericht: "So war es.",
});
await expect(
system.einsatz.update({ where: { id: einsatz.id }, data: { bericht: "Doch anders." } }),
).rejects.toThrow(/unveraenderlich/i);
await expect(
system.einsatz.update({ where: { id: einsatz.id }, data: { stichwort: "Anderes" } }),
).rejects.toThrow(/unveraenderlich/i);
});
it("lässt das Berichts-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
const datei = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${einsatz.id}.pdf`,
dateiname: "bericht.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(2048),
},
});
const mitPdf = await system.einsatz.update({
where: { id: einsatz.id },
data: { berichtDateiId: datei.id },
});
expect(mitPdf.berichtDateiId).toBe(datei.id);
});
it("sperrt den Umweg über die Einsatzkräfte", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
const kraft = await system.einsatzkraft.create({
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "TRUPPMANN" },
});
await system.einsatz.update({
where: { id: einsatz.id },
data: { status: "FREIGEGEBEN", freigegebenAm: new Date() },
});
await expect(
system.einsatzkraft.update({
where: { id: kraft.id },
data: { funktion: "GRUPPENFUEHRER" },
}),
).rejects.toThrow(/freigegeben/i);
await expect(
system.einsatzkraft.create({
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "MELDER" },
}),
).rejects.toThrow(/freigegeben/i);
});
it("lässt Lagemeldungen ausdrücklich weiter zu", async ({ skip }) => {
if (!erreichbar) return skip();
// Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
// geschrieben ist. Der Verlauf ist kein Bestandteil des Berichts.
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
const meldung = await system.lagemeldung.create({
data: {
organisationId,
einsatzId: einsatz.id,
text: "Nachtrag von der Nachbarwehr",
quelle: "PORTAL",
},
});
expect(meldung.id).toBeTruthy();
});
it("verhindert das Löschen freigegebener Berichte", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
await expect(system.einsatz.delete({ where: { id: einsatz.id } })).rejects.toThrow(
/nicht geloescht/i,
);
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.einsatz.delete({ where: { id: einsatz.id } });
});
expect(await system.einsatz.findUnique({ where: { id: einsatz.id } })).toBeNull();
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Einsatztabellen", () => {
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
if (!erreichbar) return skip();
await einsatzAnlegen();
const gefunden = await app.$transaction(async (tx) => ({
einsaetze: await tx.einsatz.findMany(),
kraefte: await tx.einsatzkraft.findMany(),
meldungen: await tx.lagemeldung.findMany(),
zugaenge: await tx.portalZugang.findMany(),
uebungen: await tx.uebung.findMany(),
}));
expect(gefunden.einsaetze).toHaveLength(0);
expect(gefunden.kraefte).toHaveLength(0);
expect(gefunden.meldungen).toHaveLength(0);
expect(gefunden.zugaenge).toHaveLength(0);
expect(gefunden.uebungen).toHaveLength(0);
});
it("zeigt im Kontext der Wehr nur ihre eigenen Einsätze", async ({ skip }) => {
if (!erreichbar) return skip();
const einsaetze = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.einsatz.findMany();
});
expect(einsaetze.length).toBeGreaterThan(0);
expect(einsaetze.every((einsatz) => einsatz.organisationId === organisationId)).toBe(true);
});
});
describe("Portal-Token", () => {
it("setzt Kennung und Geheimnis zusammen und zerlegt sie wieder", () => {
const kennung = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
const geheimnis = "abcdefghijklmnopqrstuvwxyz012345";
const token = tokenZusammensetzen(kennung, geheimnis);
expect(tokenZerlegen(token)).toEqual({ zugangId: kennung, geheimnis });
});
it("weist alles ab, was nicht wie ein Token aussieht", () => {
expect(tokenZerlegen("")).toBeNull();
expect(tokenZerlegen("ohnetrenner")).toBeNull();
expect(tokenZerlegen(".nurgeheimnis")).toBeNull();
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.")).toBeNull();
// Keine gültige Kennung
expect(tokenZerlegen("nichtsGutes.abcdefghijklmnopqrstuvwxyz012345")).toBeNull();
// Geheimnis zu kurz
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.kurz")).toBeNull();
});
it("verträgt Trennzeichen im Geheimnis nicht als Verwechslung", () => {
// base64url enthält kein "." — der erste Punkt trennt also eindeutig.
const token = tokenZusammensetzen(
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"abc_def-ghi012345678901234567890",
);
expect(tokenZerlegen(token)?.geheimnis).toBe("abc_def-ghi012345678901234567890");
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
EINHEITEN,
staerkeAlsText,
staerkeBerechnen,
staerkeHinweise,
taktischeEinheit,
type Funktion,
} from "@/lib/einsatz/staerke";
/**
* Stärke nach FwDV 3.
*
* Die Zahlen stehen später im Einsatzbericht und in der Meldung an die
* Gemeinde. Wer die Zuordnung ändert, ändert damit die Statistik der ganzen
* Wehr — deshalb steht sie hier ausführlich unter Test.
*/
/** Die Besetzung einer Gruppe nach FwDV 3. */
const GRUPPE: Funktion[] = [
"GRUPPENFUEHRER",
"MASCHINIST",
"MELDER",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
];
/** Die Besetzung einer Staffel. */
const STAFFEL: Funktion[] = [
"GRUPPENFUEHRER",
"MASCHINIST",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
];
describe("staerkeBerechnen", () => {
it("zählt eine Gruppe als 1/3/5/9", () => {
const staerke = staerkeBerechnen(GRUPPE);
expect(staerke).toEqual({ fuehrer: 1, unterfuehrer: 3, mannschaft: 5, gesamt: 9 });
expect(staerkeAlsText(staerke)).toBe("1/3/5/9");
});
it("zählt eine Staffel als 1/2/3/6", () => {
expect(staerkeAlsText(staerkeBerechnen(STAFFEL))).toBe("1/2/3/6");
});
it("zählt einen Trupp als 0/1/1/2", () => {
expect(staerkeAlsText(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"]))).toBe("0/1/1/2");
});
it("zählt jede Führungsfunktion als Führer", () => {
const staerke = staerkeBerechnen([
"EINSATZLEITER",
"ZUGFUEHRER",
"GRUPPENFUEHRER",
"STAFFELFUEHRER",
]);
expect(staerke.fuehrer).toBe(4);
expect(staerke.mannschaft).toBe(0);
});
it("zählt den Maschinisten zur Mannschaft", () => {
// Regional unterschiedlich — dieser Test hält die getroffene Festlegung
// fest, damit eine Änderung auffällt statt durchzurutschen.
const staerke = staerkeBerechnen(["MASCHINIST"]);
expect(staerke.mannschaft).toBe(1);
expect(staerke.unterfuehrer).toBe(0);
});
it("liefert für niemanden lauter Nullen", () => {
expect(staerkeAlsText(staerkeBerechnen([]))).toBe("0/0/0/0");
});
});
describe("taktischeEinheit", () => {
it("erkennt die Einheiten der FwDV 3", () => {
expect(taktischeEinheit(staerkeBerechnen(GRUPPE)).einheit).toBe("Gruppe");
expect(taktischeEinheit(staerkeBerechnen(STAFFEL)).einheit).toBe("Staffel");
expect(taktischeEinheit(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"])).einheit).toBe("Trupp");
});
it("nennt die überzähligen Kräfte", () => {
const zehn = [...GRUPPE, "TRUPPMANN" as Funktion];
const ergebnis = taktischeEinheit(staerkeBerechnen(zehn));
expect(ergebnis.einheit).toBe("Gruppe");
expect(ergebnis.ueberzaehlig).toBe(1);
});
it("gibt bei einer einzelnen Kraft keine Einheit zurück", () => {
const ergebnis = taktischeEinheit(staerkeBerechnen(["GRUPPENFUEHRER"]));
expect(ergebnis.einheit).toBeNull();
expect(ergebnis.ueberzaehlig).toBe(1);
});
it("kennt die Sollstärken", () => {
expect(EINHEITEN.map((einheit) => einheit.staerke)).toEqual([2, 6, 9, 18]);
});
});
describe("staerkeHinweise", () => {
it("meldet, wenn niemand erfasst ist", () => {
expect(staerkeHinweise(staerkeBerechnen([]))[0]).toContain("noch niemand erfasst");
});
it("meldet eine fehlende Führungskraft", () => {
const hinweise = staerkeHinweise(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"]));
expect(hinweise.some((hinweis) => hinweis.includes("Führungskraft"))).toBe(true);
});
it("meldet den Alleingang", () => {
const hinweise = staerkeHinweise(staerkeBerechnen(["GRUPPENFUEHRER"]));
expect(hinweise.some((hinweis) => hinweis.includes("Alleine"))).toBe(true);
});
it("schweigt bei einer vollständigen Gruppe", () => {
expect(staerkeHinweise(staerkeBerechnen(GRUPPE))).toHaveLength(0);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
alsDauer,
anfahrtszeit,
ausrueckzeit,
einsatzdauer,
hilfsfrist,
kennzahlen,
naechsterStempel,
zeitHinweise,
} from "@/lib/einsatz/zeiten";
import { nummerBilden, nummerLesen, naechsteNummer } from "@/lib/nummernkreis";
/**
* Einsatzzeiten und Nummernkreis.
*
* Die Hilfsfrist ist die Zahl, über die eine Wehr mit ihrer Gemeinde
* diskutiert. Wenn sie falsch gerechnet wird, merkt es niemand — bis es
* darauf ankommt.
*/
function uhr(stunde: number, minute: number): Date {
return new Date(Date.UTC(2026, 5, 15, stunde, minute, 0));
}
const EINSATZ = {
alarmiertUm: uhr(3, 42),
ausgeruecktUm: uhr(3, 47),
eingetroffenUm: uhr(3, 51),
einsatzendeUm: uhr(6, 5),
eingeruecktUm: uhr(6, 30),
einsatzbereitUm: uhr(7, 0),
};
describe("Kennzahlen", () => {
it("rechnet Ausrückzeit, Anfahrt und Hilfsfrist", () => {
expect(ausrueckzeit(EINSATZ)).toBe(5);
expect(anfahrtszeit(EINSATZ)).toBe(4);
expect(hilfsfrist(EINSATZ)).toBe(9);
});
it("rechnet die Einsatzdauer bis zum Einrücken", () => {
expect(einsatzdauer(EINSATZ)).toBe(168);
});
it("nimmt für die Dauer ersatzweise das Einsatzende", () => {
expect(einsatzdauer({ ...EINSATZ, eingeruecktUm: null })).toBe(143);
});
it("liefert null, wo ein Stempel fehlt", () => {
expect(hilfsfrist({ alarmiertUm: uhr(3, 42) })).toBeNull();
expect(ausrueckzeit({})).toBeNull();
});
it("liefert alle vier Zahlen auf einmal", () => {
expect(kennzahlen(EINSATZ)).toEqual({
ausrueckzeit: 5,
anfahrtszeit: 4,
hilfsfrist: 9,
dauer: 168,
});
});
it("gibt keine negative Zeit zurück, wenn Stempel verdreht sind", () => {
// Die Reihenfolge stimmt nicht — dann lieber keine Zahl als eine falsche.
expect(ausrueckzeit({ alarmiertUm: uhr(4, 0), ausgeruecktUm: uhr(3, 50) })).toBeNull();
});
});
describe("zeitHinweise", () => {
it("schweigt bei einem stimmigen Ablauf", () => {
expect(zeitHinweise(EINSATZ)).toHaveLength(0);
});
it("meldet eine verdrehte Reihenfolge", () => {
const hinweise = zeitHinweise({ ...EINSATZ, eingetroffenUm: uhr(3, 40) });
expect(hinweise.length).toBeGreaterThan(0);
expect(hinweise[0]).toContain("eingetroffen");
});
it("meldet eine unplausibel lange Anfahrt", () => {
const hinweise = zeitHinweise({
alarmiertUm: uhr(3, 0),
eingetroffenUm: uhr(5, 0),
});
expect(hinweise.some((hinweis) => hinweis.includes("120 Minuten"))).toBe(true);
});
});
describe("naechsterStempel", () => {
it("nennt den ersten offenen Stempel", () => {
expect(naechsterStempel({})).toBe("alarmiertUm");
expect(naechsterStempel({ alarmiertUm: uhr(3, 42) })).toBe("ausgeruecktUm");
});
it("gibt null zurück, wenn alles gesetzt ist", () => {
expect(naechsterStempel(EINSATZ)).toBeNull();
});
});
describe("alsDauer", () => {
it("schreibt Minuten unter einer Stunde als Minuten", () => {
expect(alsDauer(9)).toBe("9 min");
expect(alsDauer(59)).toBe("59 min");
});
it("schreibt längere Zeiten als Stunden", () => {
expect(alsDauer(60)).toBe("1:00 h");
expect(alsDauer(168)).toBe("2:48 h");
});
it("kommt mit fehlenden Werten zurecht", () => {
expect(alsDauer(null)).toBe("—");
expect(alsDauer(undefined)).toBe("—");
});
});
describe("Nummernkreis", () => {
it("bildet und liest Nummern", () => {
expect(nummerBilden(2026, 7)).toBe("2026-0007");
expect(nummerLesen("2026-0007")).toEqual({ jahr: 2026, laufend: 7 });
expect(nummerLesen("Unsinn")).toBeNull();
});
it("zählt von der bisher höchsten Nummer weiter", () => {
expect(naechsteNummer(2026, "2026-0041")).toBe("2026-0042");
});
it("beginnt im leeren Jahr bei eins", () => {
expect(naechsteNummer(2027, null)).toBe("2027-0001");
});
it("übernimmt keine Nummer aus einem anderen Jahr", () => {
// Der Aufrufer sucht die höchste Nummer des Jahres; bekommt er keine,
// beginnt das neue Jahr wieder bei eins.
expect(naechsteNummer(2027, undefined)).toBe("2027-0001");
});
});