Phase 3: Einsatz- und Übungserfassung #4
@@ -213,6 +213,26 @@ LLM_BASE_URL=http://ollama:11434/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODELL=qwen2.5:14b
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Einsätze und Übungen (Phase 3)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Adressen zu Koordinaten auflösen. Vorgesehen ist ein SELBST betriebener
|
||||
# Nominatim — Einsatzadressen gehören nicht an einen öffentlichen Dienst, und
|
||||
# dessen Nutzungsbedingungen erlauben es ohnehin nicht.
|
||||
GEOCODING_ENABLED=false
|
||||
# NOMINATIM_URL=http://nominatim:8080
|
||||
GEOCODING_TIMEOUT=5
|
||||
# Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt).
|
||||
GEOCODING_LAENDER=de,at,ch
|
||||
|
||||
# Einsatzportal für Fremdwehren: ein signierter Link ohne Anmeldung, über den
|
||||
# eine Nachbarwehr den Stand sieht und Lagemeldungen absetzen kann.
|
||||
PORTAL_ENABLED=true
|
||||
# Wie lange ein neu erzeugter Link gilt.
|
||||
PORTAL_GUELTIGKEIT_STUNDEN=24
|
||||
# Höchstzahl Zugriffe je Link und Stunde.
|
||||
PORTAL_MAX_ZUGRIFFE=120
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wartung und Prüfung (Phase 2)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,119 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
Nächster Schritt ist Phase 3 (Einsätze und Übungen).
|
||||
Nächster Schritt ist Phase 4 (Kiosk-PWA mit Offline-Betrieb).
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] — 2026-09-03
|
||||
|
||||
Phase 3: Einsätze und Übungen. Ab hier dokumentiert die Wehr, was sie tut —
|
||||
für die Gemeinde, für die Versicherung und für die eigene Mannschaft.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Einsatz` mit den sechs Zeitstempeln (alarmiert, ausgerückt, eingetroffen,
|
||||
Einsatzende, eingerückt, einsatzbereit), Ort samt Koordinaten, Kategorie nach
|
||||
der bayerischen Feuerwehrstatistik und den Zahlen für die Gemeindemeldung.
|
||||
- `Einsatzfahrzeug` mit eigenen Zeiten je Fahrzeug — Nachalarmierung bleibt
|
||||
sichtbar —, Kilometerstand und Wasserabgabe.
|
||||
- `Einsatzkraft` mit Funktion nach FwDV 3, Fahrzeug, Zeitraum und
|
||||
Atemschutz-Kennzeichen.
|
||||
- `Materialverbrauch` und `Lagemeldung` mit Foto und Herkunft.
|
||||
- `PortalZugang` — ein zeitlich begrenzter Link für eine Fremdwehr, gespeichert
|
||||
nur als Hash.
|
||||
- `Uebung` und `Uebungsteilnahme` mit Thema, Lernzielen und Anwesenheit.
|
||||
|
||||
**Schnellerfassung**
|
||||
|
||||
- Stichwort, Ort, ausgerückte Fahrzeuge — mehr fragt die Erfassung im Alarmfall
|
||||
nicht. Alarmzeit ist der Moment des Anlegens, alles Weitere wird nachgetragen.
|
||||
- Fortlaufende Einsatznummer `JJJJ-NNNN` je Wehr und Jahr, vergeben unter einer
|
||||
Transaktionssperre.
|
||||
|
||||
**Zeiten und Stärke**
|
||||
|
||||
- Ein Knopf je Zeitstempel, solange der Einsatz läuft.
|
||||
- Daraus gerechnet: Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
|
||||
- Stärke nach FwDV 3 in der Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`,
|
||||
mit Zuordnung zur taktischen Einheit (Trupp, Staffel, Gruppe, Zug).
|
||||
- Hinweise statt Sperren: eine verdrehte Zeitreihenfolge oder eine fehlende
|
||||
Führungskraft wird angezeigt, verhindert das Speichern aber nicht — ein
|
||||
Einsatz, der drei Wochen später nachgetragen wird, ist immer noch besser als
|
||||
einer, den niemand erfasst.
|
||||
|
||||
**Personalvorschlag**
|
||||
|
||||
- Sortiert nach Einsatzerfahrung der letzten zwölf Monate, mit dem
|
||||
Qualifikationsstand daneben: eine abgelaufene G26.3 fällt auf, bevor jemand
|
||||
unter Atemschutz geht.
|
||||
|
||||
**Einsatzbericht und Freigabe**
|
||||
|
||||
- Bericht als PDF über Gotenberg: Kopf, Zeitleiste mit Kennzahlen, Fahrzeuge,
|
||||
Mannschaft mit Stärke, Material, Verlauf und Bericht.
|
||||
- Mit der Freigabe wird der Einsatz festgeschrieben — durchgesetzt von einem
|
||||
Datenbanktrigger, nicht vom Anwendungscode. Ausgenommen bleiben das
|
||||
nachgetragene PDF und die Lagemeldungen.
|
||||
- Jahresexport als quer gedrucktes PDF unter `/api/einsaetze/jahresexport`.
|
||||
|
||||
**Statistik**
|
||||
|
||||
- Einsätze nach Kategorie und Monat, Verteilung der Hilfsfristen mit dem
|
||||
80-Prozent-Wert, Beteiligung je Person (Einsätze, davon unter Atemschutz,
|
||||
Übungen) und Auslastung je Fahrzeug.
|
||||
|
||||
**Einsatzportal für Fremdwehren**
|
||||
|
||||
- Signierter Link ohne Anmeldung, gültig 24 Stunden, jederzeit widerrufbar,
|
||||
jeder Aufruf wird mitgezählt.
|
||||
- Zeigt Stichwort, Ort, Zeitstempel und Lagemeldungen — keine Namen von
|
||||
Einsatzkräften, keinen Bericht, keinen Weg in den Rest der Anwendung.
|
||||
- Nimmt Lagemeldungen mit Foto an, begrenzt auf 30 Meldungen und 10 Fotos je
|
||||
Zugang und Stunde.
|
||||
- Eigene Zugriffsbegrenzung über Redis (`src/lib/ratelimit.ts`), die bei
|
||||
Störung durchlässt statt zu blockieren.
|
||||
|
||||
**Übungen**
|
||||
|
||||
- Thema, Art, Lernziele, Ort und Leitung; Anwesenheit als Liste zum Abhaken.
|
||||
- Fließt in die Beteiligungsstatistik ein.
|
||||
|
||||
**Adressen zu Koordinaten**
|
||||
|
||||
- Anbindung an einen **selbst betriebenen** Nominatim hinter einer Schnittstelle
|
||||
in `lib/integrations/`. Ohne Konfiguration bleibt die Suche leer und die
|
||||
Koordinaten werden von Hand eingetragen.
|
||||
- Koordinaten liegen doppelt: als Dezimalgrad für die Anwendung und als
|
||||
PostGIS-Punkt für die Karte ab Phase 6, zusammengehalten von einem Trigger.
|
||||
|
||||
**Sonstiges**
|
||||
|
||||
- Der Seed legt acht Einsätze über das Jahr verteilt an — Brände, technische
|
||||
Hilfeleistungen, ein Fehlalarm einer Brandmeldeanlage — dazu sechs Übungen
|
||||
und die Beteiligung der Mannschaft.
|
||||
- 57 neue Tests: 32 Unit-Tests zu Stärke, Zeiten und Nummernkreis, 14 gegen eine
|
||||
echte Datenbank (Koordinaten-Trigger, Freigabe, Mandantentrennung) und 11
|
||||
Playwright-Durchläufe einschließlich des Portals in einem Browser **ohne**
|
||||
Sitzung.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Der Nummernkreis `JJJJ-NNNN` liegt jetzt in `src/lib/nummernkreis.ts` und wird
|
||||
von Prüfprotokollen, Einsätzen und Übungen gemeinsam verwendet.
|
||||
- Die Seitenleiste zeigt Einsätze und Übungen als verfügbar.
|
||||
- Das Zeitlimit der E2E-Erwartungen steigt von 15 auf 25 Sekunden: mit fünfzig
|
||||
Routen dauert die erste Übersetzung einer Seite im Entwicklungsserver unter
|
||||
Last länger als vorher, und daran scheiterten gelegentlich Tests, die einzeln
|
||||
grün sind.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Anwesenheitsliste einer Übung forderte 300 Mitglieder an und lief damit
|
||||
in die Höchstzahl von 200 je Seite — die Liste blieb leer. Gefunden über die
|
||||
Server-Ausgabe während der E2E-Tests.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
| Phase 0 — Fundament | fertig |
|
||||
| Phase 1 — Stammdaten | fertig |
|
||||
| Phase 2 — Wartung und Prüfung | fertig |
|
||||
| Phase 3 — Einsätze und Übungen | offen |
|
||||
| Phase 3 — Einsätze und Übungen | fertig |
|
||||
| Phase 4 — Kiosk-PWA | offen |
|
||||
| Phase 5 — Atemschutz (FwDV 7) | offen |
|
||||
| Phase 6 — Kartenmodule | offen |
|
||||
@@ -279,6 +279,129 @@ passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
|
||||
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
|
||||
Was die Wehr tut, wird hier dokumentiert — für die Gemeinde, für die
|
||||
Versicherung und für die eigene Mannschaft.
|
||||
|
||||
### Der Weg durch das Modul
|
||||
|
||||
```
|
||||
Schnellerfassung Stichwort, Ort, Fahrzeuge — im Alarmfall in 20 Sekunden
|
||||
↓ während des Einsatzes
|
||||
Zeitstempel alarmiert, ausgerückt, eingetroffen, Ende, eingerückt,
|
||||
einsatzbereit — je ein Knopf
|
||||
Lagemeldungen aus der Wehr und über das Portal einer Fremdwehr
|
||||
↓ danach, in Ruhe
|
||||
Kräfte und Material wer war dabei, was wurde verbraucht
|
||||
Bericht der Text des Einsatzleiters
|
||||
↓ Freigabe
|
||||
Einsatzbericht als PDF, festgeschrieben, an die Gemeinde
|
||||
```
|
||||
|
||||
### Die sechs Zeitstempel
|
||||
|
||||
Sie sind der Kern jeder Einsatzauswertung. Aus ihnen ergeben sich drei Zahlen:
|
||||
|
||||
| Kennzahl | Rechnung | sagt etwas aus über |
|
||||
|---|---|---|
|
||||
| **Ausrückzeit** | Alarm bis Ausrücken | die Wehr selbst |
|
||||
| **Anfahrtszeit** | Ausrücken bis Eintreffen | Lage und Wegstrecke |
|
||||
| **Hilfsfrist** | Alarm bis Eintreffen | das, wonach die Gemeinde fragt |
|
||||
|
||||
In Bayern gilt als Ziel: **in 80 Prozent der Fälle innerhalb von zehn Minuten**.
|
||||
Genau dieser Wert steht in der Statistik — nicht der Durchschnitt, denn der
|
||||
verdeckt die Ausreißer, um die es geht.
|
||||
|
||||
### Stärke nach FwDV 3
|
||||
|
||||
Aus den Funktionen der erfassten Kräfte errechnet FLORIAN die Stärke in der
|
||||
üblichen Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`:
|
||||
|
||||
| Einheit | Stärke | Besetzung |
|
||||
|---|---|---|
|
||||
| Trupp | 0/1/1/2 | Truppführer, Truppmann |
|
||||
| Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps |
|
||||
| Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps |
|
||||
| Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel |
|
||||
|
||||
**Regional unterschiedlich.** Manche Wehren zählen den Maschinisten zu den
|
||||
Unterführern, andere schreiben dreistellig ohne Unterführer (Gruppe = 1/8/9).
|
||||
Die Zuordnung steht an einer Stelle — [`src/lib/einsatz/staerke.ts`](src/lib/einsatz/staerke.ts) —
|
||||
und ist bewusst leicht zu ändern.
|
||||
|
||||
### Der freigegebene Bericht ist unveränderbar
|
||||
|
||||
Dieselbe Logik wie beim Prüfprotokoll aus Phase 2, denn der Bericht geht an
|
||||
die Gemeinde und im Schadensfall an die Versicherung. Bis zur Freigabe ist
|
||||
alles änderbar — ein Einsatz wird tagelang ergänzt, und genau dafür ist der
|
||||
Entwurfsstand da. Mit der Freigabe ist Schluss; der Trigger
|
||||
`einsatz_freigabe_schuetzen` setzt das durch.
|
||||
|
||||
Zwei Ausnahmen bleiben, beide nachtragend: das PDF, solange keines hinterlegt
|
||||
ist, und `geaendertAm`.
|
||||
|
||||
**Lagemeldungen sind ausdrücklich ausgenommen.** Sie sind das Protokoll des
|
||||
Verlaufs, kein Bestandteil des Berichts — und eine Meldung aus dem Portal kann
|
||||
eintreffen, wenn der Bericht längst geschrieben ist.
|
||||
|
||||
### Einsatzportal für Fremdwehren
|
||||
|
||||
Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor
|
||||
Ort. Die haben kein Konto in FLORIAN und sollen auch keines bekommen. Der
|
||||
Einsatzleiter erzeugt stattdessen einen Link, den er als QR-Code zeigt oder
|
||||
weiterschickt.
|
||||
|
||||
Vier Schranken sichern ihn ab:
|
||||
|
||||
1. **Zufall** — 32 zufällige Bytes im Link.
|
||||
2. **Nur ein Hash in der Datenbank** — wer sie liest, bekommt keinen gültigen Link.
|
||||
3. **Zeitlich begrenzt** — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder
|
||||
Aufruf wird mitgezählt.
|
||||
4. **Zugriffsbegrenzung** — 120 Aufrufe je Link und Stunde, 30 Meldungen, 10
|
||||
Fotos.
|
||||
|
||||
Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort,
|
||||
Zeitstempel und Lagemeldungen. **Keine Namen von Einsatzkräften, keine
|
||||
Berichte, kein Weg in den Rest der Anwendung.**
|
||||
|
||||
### Adressen zu Koordinaten
|
||||
|
||||
Über einen **selbst betriebenen** Nominatim (`NOMINATIM_URL`). Einsatzadressen
|
||||
sagen, wo es gebrannt hat und wer betroffen war; solche Anfragen gehören nicht
|
||||
an einen fremden Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim
|
||||
erlauben es ohnehin nicht.
|
||||
|
||||
Ohne Konfiguration bleibt das Feld leer und die Koordinaten werden von Hand
|
||||
eingetragen — kein Fehler, sondern der Normalfall in einer frischen
|
||||
Installation. Die Karte dazu kommt in Phase 6.
|
||||
|
||||
Gespeichert wird doppelt: als Dezimalgrad, die Prisma lesen kann, und als
|
||||
PostGIS-Punkt für die Karte. Ein Trigger hält beides zusammen.
|
||||
|
||||
### Statistik und Jahresexport
|
||||
|
||||
`/einsaetze/statistik` beantwortet beide Fragen: die der Gemeinde (Anzahl nach
|
||||
Kategorie der Landesstatistik, Hilfsfristen) und die der Wehr (Beteiligung je
|
||||
Person, Auslastung je Fahrzeug). Der Jahresexport als PDF liegt unter
|
||||
`/api/einsaetze/jahresexport?jahr=…` und wird bewusst **nicht** abgelegt — er
|
||||
ist eine Auswertung, keine Urkunde, und ändert sich, solange noch Einsätze
|
||||
nachgetragen werden.
|
||||
|
||||
### Zusätzliche Umgebungsvariablen
|
||||
|
||||
| Variable | Vorgabe | Bedeutung |
|
||||
|---|---|---|
|
||||
| `GEOCODING_ENABLED` | `false` | Adresssuche einschalten |
|
||||
| `NOMINATIM_URL` | — | Adresse des eigenen Nominatim |
|
||||
| `GEOCODING_LAENDER` | `de,at,ch` | Eingrenzung der Suche |
|
||||
| `PORTAL_ENABLED` | `true` | Einsatzportal für Fremdwehren |
|
||||
| `PORTAL_GUELTIGKEIT_STUNDEN` | `24` | Lebensdauer eines Links |
|
||||
| `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
@@ -61,6 +61,13 @@ x-app-env: &app-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODELL: ${LLM_MODELL:-}
|
||||
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
|
||||
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
||||
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
||||
GEOCODING_LAENDER: ${GEOCODING_LAENDER:-de,at,ch}
|
||||
PORTAL_ENABLED: ${PORTAL_ENABLED:-true}
|
||||
PORTAL_GUELTIGKEIT_STUNDEN: ${PORTAL_GUELTIGKEIT_STUNDEN:-24}
|
||||
PORTAL_MAX_ZUGRIFFE: ${PORTAL_MAX_ZUGRIFFE:-120}
|
||||
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
|
||||
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 2 (Version 0.3.0). Die Bilder zeigen die
|
||||
Bedienung von FLORIAN, Stand Phase 3 (Version 0.4.0). Die Bilder zeigen die
|
||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||
|
||||
> Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den folgenden Phasen
|
||||
> Atemschutz, Karten und die Kiosk-Oberfläche kommen in den folgenden Phasen
|
||||
> dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
@@ -70,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
|
||||
### Die Seitenleiste
|
||||
|
||||
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt
|
||||
rechts sein Phasenkürzel — `P3` heißt „kommt in Phase 3".
|
||||
rechts sein Phasenkürzel — `P4` heißt „kommt in Phase 4".
|
||||
|
||||
Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
|
||||
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
|
||||
@@ -585,7 +585,200 @@ Ersatz da ist. Der Knopf dazu steht in der Zeile.
|
||||
Voraussetzung ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist
|
||||
(Kapitel 5).
|
||||
|
||||
## 18. Rollen und was sie dürfen
|
||||
---
|
||||
|
||||
## 18. Einen Einsatz erfassen
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
Eine Übung besteht aus Thema, Art, Datum und den **Lernzielen** — was danach
|
||||
sitzen soll.
|
||||
|
||||

|
||||
|
||||
Die Anwesenheit wird als Liste abgehakt: alle aktiven Mitglieder auf einmal,
|
||||
nicht einzeln gesucht. Bei einer Übung ist die halbe Wehr da.
|
||||
|
||||
Die Teilnahme fließt in die Beteiligungsstatistik ein — das ist die Zahl, nach
|
||||
der in der Jahreshauptversammlung gefragt wird und die bei Beförderungen zählt.
|
||||
|
||||
---
|
||||
|
||||
## 24. Adressen zu Koordinaten (Nominatim)
|
||||
|
||||
FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
|
||||
betreibt:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
GEOCODING_ENABLED=true
|
||||
NOMINATIM_URL=http://nominatim:8080
|
||||
GEOCODING_LAENDER=de,at,ch
|
||||
```
|
||||
|
||||
> **Kein öffentlicher Dienst.** Einsatzadressen sagen, wo es gebrannt hat und
|
||||
> wer betroffen war. Solche Anfragen gehören nicht an einen fremden Server —
|
||||
> und die Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin
|
||||
> nicht.
|
||||
|
||||
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
||||
eintragen oder weglassen.
|
||||
|
||||
## 25. Rollen und was sie dürfen
|
||||
|
||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||
@@ -614,7 +807,7 @@ Das Terminal selbst ist nur die Tür.
|
||||
|
||||
---
|
||||
|
||||
## 19. Anmeldung über authentik einrichten
|
||||
## 26. Anmeldung über authentik einrichten
|
||||
|
||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||
|
||||
@@ -667,7 +860,7 @@ ganze Wehr aus.
|
||||
|
||||
---
|
||||
|
||||
## 20. Terminal-Anmeldung im Gerätehaus
|
||||
## 27. Terminal-Anmeldung im Gerätehaus
|
||||
|
||||
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
|
||||
|
||||
@@ -689,7 +882,7 @@ Huber), PIN `471203`.
|
||||
|
||||
---
|
||||
|
||||
## 21. Was protokolliert wird
|
||||
## 28. Was protokolliert wird
|
||||
|
||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||
@@ -714,7 +907,7 @@ jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 22. Wenn etwas nicht geht
|
||||
## 29. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
|
||||
@@ -33,7 +33,7 @@ Faktoren, die in die Schätzung eingehen:
|
||||
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
|
||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
|
||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
|
||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
|
||||
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
|
||||
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
||||
@@ -149,7 +149,7 @@ Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
|
||||
**Vergleichsaufwand: 12–16 PT**
|
||||
**Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 2:10 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -166,6 +166,30 @@ Das externe Portal für Fremdwehren ist sicherheitsrelevant: ein Zugang ohne
|
||||
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
|
||||
steckt weniger in der Funktion als in der Absicherung.
|
||||
|
||||
**Rückblick.** Die Schätzung lag richtig darin, wo der Aufwand steckt — nur
|
||||
nicht in welcher Reihenfolge.
|
||||
|
||||
Billiger als gedacht war das Portal. Der Grund liegt in Phase 0 und 2: die
|
||||
Mandantentrennung, das Hashen von Geheimnissen und die Ablage von Dateien gab
|
||||
es schon, und der Zugang war am Ende ein Datensatz mit Hash, ein Zeitfenster
|
||||
und ein Zähler in Redis. Was blieb, war die Frage, was das Portal **nicht**
|
||||
zeigen darf — und die ist fachlicher Natur, nicht technischer.
|
||||
|
||||
Teurer war die Statistik. Nicht das Rechnen, sondern die Entscheidung, welche
|
||||
Zahl gilt: der Durchschnitt der Eintreffzeiten sagt nichts, weil ein einzelner
|
||||
Einsatz auf der Autobahn ihn verschiebt. Es zählt der 80-Prozent-Wert, weil die
|
||||
Gemeinde danach fragt — und das steht in keinem Pflichtenheft, das muss man
|
||||
wissen.
|
||||
|
||||
Zwei Dinge fielen erst beim Tun auf:
|
||||
|
||||
- Koordinaten müssen doppelt liegen. Prisma kann den PostGIS-Typ nicht lesen;
|
||||
ohne die Dezimalgrade daneben wäre jede Anzeige eine Rohabfrage geworden. Ein
|
||||
Trigger hält beides zusammen.
|
||||
- Der Nummernkreis war schon zweimal da (Prüfprotokolle, jetzt Einsätze und
|
||||
Übungen). Beim dritten Mal wurde daraus ein eigener Baustein — die passende
|
||||
Gelegenheit war genau diese, keine früher.
|
||||
|
||||
## Phase 4 — Kiosk-PWA
|
||||
|
||||
**Vergleichsaufwand: 16–22 PT**
|
||||
|
||||
@@ -14,7 +14,109 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
|
||||
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
|
||||
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **5:30 h** | **4** | |
|
||||
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **7:40 h** | **5** | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 · Phase 3 — Einsätze und Übungen
|
||||
|
||||
**12:25 – 14:35 · 2:10 h · Version 0.4.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Ab hier dokumentiert die Wehr, was sie tut. Acht neue Tabellen, ein öffentlich
|
||||
erreichbares Portal und die Zahlen, nach denen die Gemeinde fragt.
|
||||
|
||||
**Schnellerfassung.** Im Alarmfall fragt FLORIAN drei Dinge: Stichwort, Ort,
|
||||
welche Fahrzeuge raus sind. Alarmzeit ist der Moment des Anlegens. Alles
|
||||
Weitere wird nachgetragen, wenn wieder Ruhe ist.
|
||||
|
||||
**Zeitstempel und Kennzahlen.** Sechs Stempel, je ein Knopf. Daraus rechnet
|
||||
sich Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
|
||||
|
||||
**Stärke nach FwDV 3.** Aus den Funktionen der Mannschaft, in der üblichen
|
||||
Schreibweise, mit Zuordnung zur taktischen Einheit.
|
||||
|
||||
**Einsatzbericht.** Als PDF über Gotenberg, mit der Freigabe festgeschrieben.
|
||||
Dazu der Jahresexport quer gedruckt für die Gemeinde.
|
||||
|
||||
**Statistik.** Kategorien nach Landesstatistik, Verteilung der Hilfsfristen mit
|
||||
dem 80-Prozent-Wert, Beteiligung je Person, Auslastung je Fahrzeug.
|
||||
|
||||
**Portal für Fremdwehren.** Ein signierter Link ohne Anmeldung: Stand sehen,
|
||||
Lagemeldung mit Foto absetzen. Zeitlich begrenzt, mengenbegrenzt, widerrufbar.
|
||||
|
||||
**Übungen.** Thema, Lernziele, Anwesenheit zum Abhaken — die Grundlage für die
|
||||
Teilnahmequote in der Jahreshauptversammlung.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Die Hilfsfrist wird als 80-Prozent-Wert ausgewiesen, nicht als Durchschnitt.**
|
||||
Der Durchschnitt sagt bei Eintreffzeiten nichts: ein einzelner Einsatz auf der
|
||||
Autobahn zieht ihn nach oben, während neunzig Prozent der Fälle im Ort liegen.
|
||||
In Bayern gilt als Ziel, in 80 Prozent der Fälle binnen zehn Minuten da zu
|
||||
sein — also ist genau das die Zahl.
|
||||
|
||||
**Hinweise statt Sperren bei Zeiten und Stärke.** Eine verdrehte Reihenfolge
|
||||
oder eine fehlende Führungskraft wird angezeigt, aber nicht verhindert.
|
||||
Einsätze werden Tage später aus dem Gedächtnis nachgetragen; ein Einsatz mit
|
||||
unsicheren Zeiten ist immer noch besser als einer, den niemand erfasst, weil
|
||||
das Formular ihn nicht angenommen hat.
|
||||
|
||||
**Der freigegebene Bericht ist unveränderbar — Lagemeldungen nicht.** Der
|
||||
Bericht geht an die Gemeinde, die Meldungen sind das Protokoll des Verlaufs.
|
||||
Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
|
||||
geschrieben ist; sie nachträglich abzuweisen wäre falsch.
|
||||
|
||||
**Das Portal zeigt bewusst wenig.** Stichwort, Ort, Zeitstempel und Meldungen —
|
||||
also das, was an der Einsatzstelle ohnehin jeder sieht. Keine Namen von
|
||||
Einsatzkräften, kein Bericht, keine Navigation. Der Link ist ein Fenster auf
|
||||
einen Einsatz, keine Tür in die Anwendung.
|
||||
|
||||
**Koordinaten liegen doppelt.** Als Dezimalgrad, die Prisma lesen kann, und als
|
||||
PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides zusammen —
|
||||
sonst wäre jede Schreiboperation Roh-SQL geworden, und ein über einen anderen
|
||||
Weg angelegter Einsatz fehlte später auf der Karte.
|
||||
|
||||
**Geocoding nur gegen einen eigenen Nominatim.** Einsatzadressen sagen, wo es
|
||||
gebrannt hat. Solche Anfragen gehören nicht an einen fremden Dienst — und die
|
||||
Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin nicht.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Prisma wollte wieder die Suchindizes löschen | wie in Phase 2: gin_trgm und gist kennt das Schema nicht | DROP INDEX-Zeilen aus der erzeugten Migration entfernt, mit Kommentar |
|
||||
| Der Prisma-Client ließ sich nicht neu erzeugen | die Engine-DLL war vom laufenden Entwicklungsserver gesperrt | Server und Worker beenden, dann erzeugen — unter Windows unvermeidlich |
|
||||
| Die Anwesenheitsliste blieb leer | sie forderte 300 Mitglieder an, erlaubt sind 200 je Seite | auf 200 gesetzt; aufgefallen in der Server-Ausgabe während der E2E-Tests |
|
||||
| Zwei E2E-Tests schlugen im Gesamtlauf fehl, einzeln aber nicht | die erste Übersetzung einer Route dauert unter Last länger als das Erwartungslimit | Limit von 15 auf 25 Sekunden — Wartezeit des Entwicklungsservers, kein Fehler der Anwendung |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**Keine.** Alle acht Tabellen tragen `organisationId` und stehen unter Row
|
||||
Level Security, jede schreibende Operation protokolliert, gelöscht wird weich.
|
||||
|
||||
Eine Ergänzung: die Portalprozeduren sind die **ersten öffentlich erreichbaren
|
||||
Endpunkte** in FLORIAN. Sie prüfen den Token selbst, laufen für die Suche des
|
||||
Zugangs bewusst ohne Mandantenfilter (begründet im Code) und arbeiten danach im
|
||||
Mandantenkontext der gefundenen Wehr.
|
||||
|
||||
### Offen und bewusst so gelassen
|
||||
|
||||
Die Zuordnung der Funktionen zur Stärke ist **regional unterschiedlich**. Hier
|
||||
zählt der Maschinist zur Mannschaft und nur der Truppführer als Unterführer;
|
||||
eine Gruppe ergibt damit 1/3/5/9. Andere Wehren rechnen anders. Die Zuordnung
|
||||
steht an einer Stelle in `lib/einsatz/staerke.ts` und ist leicht zu ändern —
|
||||
bestätigt ist sie noch nicht.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npm run typecheck` — ohne Ausgabe
|
||||
- `npm run test` — 207 Tests grün, davon 32 gegen eine echte Datenbank
|
||||
- `npm run test:e2e` — 95 Tests grün, 2 übersprungen
|
||||
- `npm run build` — 38 Routen
|
||||
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
|
||||
Phase 0 ████████████████████ fertig 0.1.0
|
||||
Phase 1 ████████████████████ fertig 0.2.0
|
||||
Phase 2 ████████████████████ fertig 0.3.0
|
||||
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
|
||||
Phase 3 ████████████████████ fertig 0.4.0
|
||||
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
|
||||
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
|
||||
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
||||
@@ -83,7 +83,7 @@ Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
|
||||
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
|
||||
die Unfallkasse standhält.
|
||||
|
||||
## Phase 3 — Einsätze und Übungen · als Nächstes
|
||||
## Phase 3 — Einsätze und Übungen · fertig
|
||||
|
||||
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
|
||||
Einsatzende, eingerückt, einsatzbereit
|
||||
@@ -97,7 +97,7 @@ die Unfallkasse standhält.
|
||||
|
||||
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
|
||||
|
||||
## Phase 4 — Kiosk-PWA
|
||||
## Phase 4 — Kiosk-PWA · als Nächstes
|
||||
|
||||
Für die Wandtablets in der Fahrzeughalle.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 409 KiB After Width: | Height: | Size: 412 KiB |
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 728 KiB After Width: | Height: | Size: 730 KiB |
|
Before Width: | Height: | Size: 751 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 962 KiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 1008 KiB After Width: | Height: | Size: 1010 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"engines": {
|
||||
|
||||
@@ -24,7 +24,13 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
/**
|
||||
* Ab Phase 3 großzügiger: mit fünfzig Routen dauert die erste Übersetzung
|
||||
* einer bisher unbesuchten Seite unter Last auch mal zwanzig Sekunden. Das
|
||||
* sind Wartezeiten des Entwicklungsservers, keine der Anwendung — vorher
|
||||
* schlugen dadurch gelegentlich Tests fehl, die einzeln laufen und grün sind.
|
||||
*/
|
||||
expect: { timeout: 25_000 },
|
||||
|
||||
use: {
|
||||
baseURL: BASIS_URL,
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -180,6 +180,16 @@ model Organisation {
|
||||
rueckrufBetroffenheiten RueckrufBetroffenheit[]
|
||||
erinnerungen Erinnerung[]
|
||||
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
einsaetze Einsatz[]
|
||||
einsatzfahrzeuge Einsatzfahrzeug[]
|
||||
einsatzkraefte Einsatzkraft[]
|
||||
materialverbrauch Materialverbrauch[]
|
||||
lagemeldungen Lagemeldung[]
|
||||
portalzugaenge PortalZugang[]
|
||||
uebungen Uebung[]
|
||||
uebungsteilnahmen Uebungsteilnahme[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("organisation")
|
||||
}
|
||||
@@ -253,6 +263,15 @@ model Person {
|
||||
zugewieseneMaengel Mangel[] @relation("MangelZustaendig")
|
||||
behobeneMaengel Mangel[] @relation("MangelBehebung")
|
||||
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
geleiteteEinsaetze Einsatz[] @relation("EinsatzLeitung")
|
||||
freigegebeneBerichte Einsatz[] @relation("EinsatzFreigabe")
|
||||
einsatzteilnahmen Einsatzkraft[] @relation("EinsatzTeilnahme")
|
||||
lagemeldungen Lagemeldung[] @relation("LagemeldungVerfasser")
|
||||
portalzugaenge PortalZugang[] @relation("PortalzugangErsteller")
|
||||
geleiteteUebungen Uebung[] @relation("UebungLeitung")
|
||||
uebungsteilnahmen Uebungsteilnahme[] @relation("UebungTeilnahme")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
@@unique([organisationId, email])
|
||||
@@ -482,6 +501,11 @@ model Datei {
|
||||
alsSignaturbild Signatur[] @relation("SignaturBild")
|
||||
alsMangelFoto Mangel[] @relation("MangelFoto")
|
||||
|
||||
// Phase 3
|
||||
alsEinsatzbericht Einsatz[] @relation("EinsatzBericht")
|
||||
alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto")
|
||||
alsUebungsbericht Uebung[] @relation("UebungBericht")
|
||||
|
||||
@@unique([bucket, objektSchluessel])
|
||||
@@index([organisationId, entitaet, entitaetId])
|
||||
@@index([organisationId, kategorie])
|
||||
@@ -648,6 +672,10 @@ model Fahrzeug {
|
||||
geraeteraeume Geraeteraum[]
|
||||
geraete Geraet[]
|
||||
|
||||
// Phase 3
|
||||
einsaetze Einsatzfahrzeug[]
|
||||
einsatzkraefte Einsatzkraft[]
|
||||
|
||||
@@unique([organisationId, funkrufname])
|
||||
@@unique([organisationId, kennzeichen])
|
||||
@@index([organisationId, aktiv])
|
||||
@@ -783,6 +811,7 @@ model Geraet {
|
||||
pruefungen Pruefung[]
|
||||
maengel Mangel[]
|
||||
rueckrufBetroffenheiten RueckrufBetroffenheit[]
|
||||
materialverbrauch Materialverbrauch[]
|
||||
|
||||
@@unique([organisationId, inventarnummer])
|
||||
@@index([organisationId, status])
|
||||
@@ -1403,3 +1432,427 @@ model Erinnerung {
|
||||
@@index([organisationId, versendetAm(sort: Desc)])
|
||||
@@map("erinnerung")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
//
|
||||
// Ordnungsprinzip:
|
||||
// Einsatz der Vorgang selbst, mit Ort, Zeitstempeln und Bericht
|
||||
// Einsatzfahrzeug welches Fahrzeug wann ausgerückt und eingerückt ist
|
||||
// Einsatzkraft wer mitgefahren ist, in welcher Funktion, wie lange
|
||||
// Materialverbrauch was dabei verbraucht oder beschädigt wurde
|
||||
// Lagemeldung was währenddessen gemeldet wurde, mit Foto
|
||||
// PortalZugang ein zeitlich begrenzter Link für eine Fremdwehr
|
||||
// Uebung dasselbe für Übungen, mit Thema und Lernzielen
|
||||
//
|
||||
// Drei Festlegungen, die den Aufbau erklären:
|
||||
//
|
||||
// 1. Die Zeitstempel sind der Kern. Alarmierung, Ausrücken, Eintreffen,
|
||||
// Einsatzende, Einrücken, Einsatzbereitschaft — daraus rechnet sich die
|
||||
// Ausrückzeit, die Anfahrtszeit und die Einsatzdauer. Sie stehen deshalb
|
||||
// einzeln und nicht als Sammelfeld.
|
||||
//
|
||||
// 2. Koordinaten liegen doppelt: als Dezimalgrad, die Prisma lesen kann, und
|
||||
// als PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides
|
||||
// zusammen — sonst müsste jede Schreiboperation Roh-SQL sein.
|
||||
//
|
||||
// 3. Ein freigegebener Einsatzbericht ist unveränderbar, dieselbe Logik wie
|
||||
// beim Prüfprotokoll: er geht an die Gemeinde und an die Versicherung.
|
||||
// =============================================================================
|
||||
|
||||
/// Einsatzkategorie nach der bayerischen Feuerwehrstatistik. Danach meldet die
|
||||
/// Wehr ihre Zahlen an die Gemeinde und den Landkreis.
|
||||
enum EinsatzKategorie {
|
||||
BRAND_KLEIN
|
||||
BRAND_MITTEL
|
||||
BRAND_GROSS
|
||||
TECHNISCHE_HILFE_KLEIN
|
||||
TECHNISCHE_HILFE_MITTEL
|
||||
TECHNISCHE_HILFE_GROSS
|
||||
GEFAHRGUT
|
||||
/// Unterstützung des Rettungsdienstes, z. B. Türöffnung, Tragehilfe.
|
||||
RETTUNGSDIENST
|
||||
/// Auslösung einer Brandmeldeanlage ohne Schadenfeuer.
|
||||
BRANDMELDEANLAGE
|
||||
BOESWILLIGER_ALARM
|
||||
SICHERHEITSWACHE
|
||||
/// Amtshilfe, Nachbarschaftshilfe, Sonstiges.
|
||||
UNTERSTUETZUNG
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Lebenszyklus eines Einsatzes.
|
||||
enum EinsatzStatus {
|
||||
/// Schnellerfassung: Stichwort und Fahrzeuge stehen, der Rest fehlt noch.
|
||||
ENTWURF
|
||||
/// Läuft gerade — der Alarmmonitor und das Portal zeigen ihn an.
|
||||
LAUFEND
|
||||
/// Nachbereitet und abgeschlossen.
|
||||
ABGESCHLOSSEN
|
||||
/// Bericht freigegeben und festgeschrieben.
|
||||
FREIGEGEBEN
|
||||
}
|
||||
|
||||
/// Wie alarmiert wurde. Für die Auswertung, ob die Alarmierungswege greifen.
|
||||
enum Alarmierungsart {
|
||||
SIRENE
|
||||
MELDEEMPFAENGER
|
||||
TELEFON
|
||||
APP
|
||||
PERSOENLICH
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Funktion einer Einsatzkraft. Aus ihr ergibt sich die Stärke nach FwDV 3.
|
||||
enum EinsatzFunktion {
|
||||
EINSATZLEITER
|
||||
ZUGFUEHRER
|
||||
GRUPPENFUEHRER
|
||||
STAFFELFUEHRER
|
||||
TRUPPFUEHRER
|
||||
MASCHINIST
|
||||
MELDER
|
||||
TRUPPMANN
|
||||
ATEMSCHUTZTRAEGER
|
||||
SICHERHEITSTRUPP
|
||||
SANITAETER
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Art einer Übung.
|
||||
enum UebungsArt {
|
||||
PRAXIS
|
||||
THEORIE
|
||||
ATEMSCHUTZ
|
||||
MASCHINIST
|
||||
FUNK
|
||||
ERSTE_HILFE
|
||||
/// Gemeinsam mit Nachbarwehren, THW oder Rettungsdienst.
|
||||
GEMEINSCHAFTSUEBUNG
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Woher eine Lagemeldung kommt.
|
||||
enum LagemeldungQuelle {
|
||||
/// Aus der Anwendung, von einer angemeldeten Person.
|
||||
INTERN
|
||||
/// Über den Portal-Link, von einer Fremdwehr ohne Konto.
|
||||
PORTAL
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Ein Einsatz.
|
||||
model Einsatz {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
/// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN".
|
||||
einsatznummer String @db.VarChar(40)
|
||||
/// Einsatzstichwort der Leitstelle, z. B. "B 3 Wohnhausbrand".
|
||||
stichwort String @db.VarChar(160)
|
||||
kategorie EinsatzKategorie @default(SONSTIGES)
|
||||
status EinsatzStatus @default(ENTWURF)
|
||||
|
||||
/// Einsatznummer der Integrierten Leitstelle — die Klammer zu deren Akte.
|
||||
ilsNummer String? @db.VarChar(60)
|
||||
alarmierungsart Alarmierungsart?
|
||||
|
||||
// Ort
|
||||
strasse String? @db.VarChar(200)
|
||||
hausnummer String? @db.VarChar(20)
|
||||
plz String? @db.VarChar(10)
|
||||
ort String? @db.VarChar(120)
|
||||
ortsteil String? @db.VarChar(120)
|
||||
/// Objektbezeichnung, z. B. "Grundschule", "Autobahn A9, km 312".
|
||||
objekt String? @db.VarChar(200)
|
||||
|
||||
/// Koordinaten als Dezimalgrad — von Prisma lesbar.
|
||||
breitengrad Decimal? @db.Decimal(9, 6)
|
||||
laengengrad Decimal? @db.Decimal(9, 6)
|
||||
/// Dieselben Koordinaten als PostGIS-Punkt, von einem Trigger gepflegt.
|
||||
/// Ab Phase 6 zeichnet die Karte daraus.
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
/// Woher sie stammen: "nominatim", "manuell", "leitstelle".
|
||||
koordinatenQuelle String? @db.VarChar(40)
|
||||
|
||||
// Zeitstempel — der Kern jeder Einsatzauswertung
|
||||
alarmiertUm DateTime? @db.Timestamptz(3)
|
||||
ausgeruecktUm DateTime? @db.Timestamptz(3)
|
||||
eingetroffenUm DateTime? @db.Timestamptz(3)
|
||||
einsatzendeUm DateTime? @db.Timestamptz(3)
|
||||
eingeruecktUm DateTime? @db.Timestamptz(3)
|
||||
einsatzbereitUm DateTime? @db.Timestamptz(3)
|
||||
|
||||
einsatzleiterId String? @db.Uuid
|
||||
|
||||
beschreibung String? @db.VarChar(4000)
|
||||
bericht String?
|
||||
|
||||
wetter String? @db.VarChar(200)
|
||||
temperatur Int?
|
||||
/// Wer sonst vor Ort war: Polizei, Rettungsdienst, THW, Nachbarwehren.
|
||||
weitereKraefte String? @db.VarChar(500)
|
||||
|
||||
/// Zahlen für die Statistik der Gemeinde.
|
||||
geretteteePersonen Int @default(0)
|
||||
verletztePersonen Int @default(0)
|
||||
totePersonen Int @default(0)
|
||||
sachschaden Decimal? @db.Decimal(12, 2)
|
||||
/// Kostenersatzpflichtiger Einsatz (Art. 28 BayFwG).
|
||||
kostenpflichtig Boolean @default(false)
|
||||
|
||||
/// Der erzeugte Einsatzbericht als PDF.
|
||||
berichtDateiId String? @db.Uuid
|
||||
freigegebenAm DateTime? @db.Timestamptz(3)
|
||||
freigegebenVonId String? @db.Uuid
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatzleiter Person? @relation("EinsatzLeitung", fields: [einsatzleiterId], references: [id], onDelete: SetNull)
|
||||
freigegebenVon Person? @relation("EinsatzFreigabe", fields: [freigegebenVonId], references: [id], onDelete: SetNull)
|
||||
berichtDatei Datei? @relation("EinsatzBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
|
||||
fahrzeuge Einsatzfahrzeug[]
|
||||
kraefte Einsatzkraft[]
|
||||
material Materialverbrauch[]
|
||||
lagemeldungen Lagemeldung[]
|
||||
portalzugaenge PortalZugang[]
|
||||
|
||||
@@unique([organisationId, einsatznummer])
|
||||
@@index([organisationId, alarmiertUm(sort: Desc)])
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, kategorie])
|
||||
@@map("einsatz")
|
||||
}
|
||||
|
||||
/// Ein Fahrzeug in einem Einsatz, mit eigenen Zeiten: nicht jedes Fahrzeug
|
||||
/// rückt gleichzeitig aus, und die Nachalarmierung soll sichtbar bleiben.
|
||||
model Einsatzfahrzeug {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String @db.Uuid
|
||||
fahrzeugId String @db.Uuid
|
||||
|
||||
ausgeruecktUm DateTime? @db.Timestamptz(3)
|
||||
eingetroffenUm DateTime? @db.Timestamptz(3)
|
||||
eingeruecktUm DateTime? @db.Timestamptz(3)
|
||||
|
||||
kilometerAbfahrt Int?
|
||||
kilometerRueckkehr Int?
|
||||
/// Verbrauchtes Löschwasser in Litern.
|
||||
wasserabgabe Int?
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||
fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([einsatzId, fahrzeugId])
|
||||
@@index([organisationId, einsatzId])
|
||||
@@index([organisationId, fahrzeugId])
|
||||
@@map("einsatzfahrzeug")
|
||||
}
|
||||
|
||||
/// Wer mitgefahren ist. Grundlage für die Stärkemeldung nach FwDV 3 und für
|
||||
/// den Beteiligungsnachweis je Person.
|
||||
model Einsatzkraft {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String @db.Uuid
|
||||
personId String @db.Uuid
|
||||
|
||||
funktion EinsatzFunktion @default(TRUPPMANN)
|
||||
/// Auf welchem Fahrzeug — leer, wenn jemand selbst angefahren ist.
|
||||
fahrzeugId String? @db.Uuid
|
||||
|
||||
vonUm DateTime? @db.Timestamptz(3)
|
||||
bisUm DateTime? @db.Timestamptz(3)
|
||||
|
||||
/// War unter Atemschutz im Einsatz. Zählt auf die Nachweise nach FwDV 7.
|
||||
atemschutz Boolean @default(false)
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||
person Person @relation("EinsatzTeilnahme", fields: [personId], references: [id], onDelete: Cascade)
|
||||
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([einsatzId, personId])
|
||||
@@index([organisationId, einsatzId])
|
||||
@@index([organisationId, personId])
|
||||
@@map("einsatzkraft")
|
||||
}
|
||||
|
||||
/// Was verbraucht oder beschädigt wurde — Schaummittel, Bindemittel, ein
|
||||
/// zerschnittener Schlauch. Grundlage für Nachbeschaffung und Kostenersatz.
|
||||
model Materialverbrauch {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String @db.Uuid
|
||||
/// Verweis auf ein erfasstes Gerät, wo es eines gibt.
|
||||
geraetId String? @db.Uuid
|
||||
|
||||
bezeichnung String @db.VarChar(200)
|
||||
menge Decimal @db.Decimal(10, 2)
|
||||
einheit String @db.VarChar(20)
|
||||
kosten Decimal? @db.Decimal(10, 2)
|
||||
/// Nicht verbraucht, sondern kaputtgegangen — dann folgt ein Mangel.
|
||||
beschaedigt Boolean @default(false)
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, einsatzId])
|
||||
@@map("materialverbrauch")
|
||||
}
|
||||
|
||||
/// Eine Meldung während des Einsatzes. Aus der Anwendung oder über den
|
||||
/// Portal-Link einer Fremdwehr.
|
||||
model Lagemeldung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String @db.Uuid
|
||||
|
||||
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
|
||||
text String @db.VarChar(2000)
|
||||
quelle LagemeldungQuelle @default(INTERN)
|
||||
|
||||
verfasserId String? @db.Uuid
|
||||
/// Klarname oder Wehrname — bei Portalmeldungen die einzige Angabe.
|
||||
verfasserName String? @db.VarChar(200)
|
||||
|
||||
fotoDateiId String? @db.Uuid
|
||||
/// Hebt die Meldung im Verlauf hervor: Menschenrettung, Nachforderung,
|
||||
/// Lageänderung.
|
||||
wichtig Boolean @default(false)
|
||||
|
||||
/// Über welchen Portalzugang die Meldung kam.
|
||||
portalZugangId String? @db.Uuid
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||
verfasser Person? @relation("LagemeldungVerfasser", fields: [verfasserId], references: [id], onDelete: SetNull)
|
||||
foto Datei? @relation("LagemeldungFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||
portalZugang PortalZugang? @relation(fields: [portalZugangId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, einsatzId, zeitpunkt])
|
||||
@@map("lagemeldung")
|
||||
}
|
||||
|
||||
/// Ein zeitlich begrenzter Zugang zum Einsatzportal für eine Fremdwehr.
|
||||
///
|
||||
/// Der Link enthält die Kennung und ein Geheimnis; gespeichert wird nur dessen
|
||||
/// Hash. Er gilt bis gueltigBis, lässt sich jederzeit widerrufen und zählt
|
||||
/// jeden Aufruf mit — ein Link, der nach dem Einsatz noch benutzt wird, fällt
|
||||
/// so auf.
|
||||
model PortalZugang {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String @db.Uuid
|
||||
|
||||
/// Für wen der Link ist, z. B. "FF Nachbarhausen".
|
||||
bezeichnung String @db.VarChar(160)
|
||||
/// bcrypt-Hash des Geheimnisses aus dem Link.
|
||||
tokenHash String @db.VarChar(120)
|
||||
|
||||
gueltigBis DateTime @db.Timestamptz(3)
|
||||
widerrufenAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
aufrufe Int @default(0)
|
||||
letzterAufruf DateTime? @db.Timestamptz(3)
|
||||
/// Zuletzt sehende IP — für den Fall, dass ein Link weitergegeben wurde.
|
||||
letzteIp String? @db.Inet
|
||||
|
||||
erstelltVonId String? @db.Uuid
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||
erstelltVon Person? @relation("PortalzugangErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||
lagemeldungen Lagemeldung[]
|
||||
|
||||
@@index([organisationId, einsatzId])
|
||||
@@index([gueltigBis])
|
||||
@@map("portal_zugang")
|
||||
}
|
||||
|
||||
/// Eine Übung. Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und
|
||||
/// Lernzielen statt Schadenlage — und ohne Portal.
|
||||
model Uebung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
/// Fortlaufend je Wehr und Jahr, Format "JJJJ-NNNN".
|
||||
nummer String @db.VarChar(40)
|
||||
thema String @db.VarChar(200)
|
||||
art UebungsArt @default(PRAXIS)
|
||||
|
||||
datum DateTime @db.Date
|
||||
beginnUm DateTime? @db.Timestamptz(3)
|
||||
endeUm DateTime? @db.Timestamptz(3)
|
||||
|
||||
ort String? @db.VarChar(200)
|
||||
leiterId String? @db.Uuid
|
||||
|
||||
/// Was gelernt werden soll — die Lernziele der Ausbildung.
|
||||
lernziele String? @db.VarChar(2000)
|
||||
beschreibung String? @db.VarChar(4000)
|
||||
bericht String?
|
||||
|
||||
berichtDateiId String? @db.Uuid
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
|
||||
berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
|
||||
teilnahmen Uebungsteilnahme[]
|
||||
|
||||
@@unique([organisationId, nummer])
|
||||
@@index([organisationId, datum(sort: Desc)])
|
||||
@@index([organisationId, art])
|
||||
@@map("uebung")
|
||||
}
|
||||
|
||||
/// Wer an einer Übung teilgenommen hat. Der Nachweis, den eine Wehr für die
|
||||
/// Beförderung und für die Statistik braucht.
|
||||
model Uebungsteilnahme {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
uebungId String @db.Uuid
|
||||
personId String @db.Uuid
|
||||
|
||||
funktion EinsatzFunktion?
|
||||
vonUm DateTime? @db.Timestamptz(3)
|
||||
bisUm DateTime? @db.Timestamptz(3)
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
uebung Uebung @relation(fields: [uebungId], references: [id], onDelete: Cascade)
|
||||
person Person @relation("UebungTeilnahme", fields: [personId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([uebungId, personId])
|
||||
@@index([organisationId, personId])
|
||||
@@map("uebungsteilnahme")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
|
||||
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
|
||||
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
|
||||
* Beispielrückruf.
|
||||
* Beispielrückruf. Ab Phase 3 dazu: acht Einsätze über das Jahr verteilt,
|
||||
* sechs Übungen und die Beteiligung der Mannschaft.
|
||||
*
|
||||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||
@@ -20,8 +21,12 @@
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import {
|
||||
Alarmierungsart,
|
||||
AusweisTokenTyp,
|
||||
ChecklistenpunktTyp,
|
||||
EinsatzFunktion,
|
||||
EinsatzKategorie,
|
||||
EinsatzStatus,
|
||||
Dienstgrad,
|
||||
FahrzeugArt,
|
||||
GeraeteKategorie,
|
||||
@@ -38,6 +43,7 @@ import {
|
||||
RolleSchluessel,
|
||||
SignaturRolle,
|
||||
StandortArt,
|
||||
UebungsArt,
|
||||
} from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
@@ -1512,6 +1518,437 @@ async function rueckrufAnlegen(organisationId: string) {
|
||||
log("Rückruf: 1 Beispiel");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
//
|
||||
// Ein Jahr im Leben einer Wehr: Brände, technische Hilfeleistungen, ein
|
||||
// Fehlalarm einer Brandmeldeanlage und die Übungen dazwischen. Die Zahlen sind
|
||||
// so gewählt, dass die Statistik etwas zeigt — auch eine Hilfsfrist, die
|
||||
// gerade noch passt.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface EinsatzVorlage {
|
||||
/** Tage vor heute. */
|
||||
vorTagen: number;
|
||||
uhrzeit: [number, number];
|
||||
stichwort: string;
|
||||
kategorie: EinsatzKategorie;
|
||||
strasse: string;
|
||||
hausnummer?: string;
|
||||
objekt?: string;
|
||||
/** Minuten nach der Alarmierung: ausgerückt, eingetroffen, Ende, eingerückt. */
|
||||
ablauf: [number, number, number, number];
|
||||
fahrzeuge: number[];
|
||||
/** Wie viele Kräfte, und wer davon führt. */
|
||||
mannschaft: number;
|
||||
gerettet?: number;
|
||||
verletzt?: number;
|
||||
sachschaden?: number;
|
||||
beschreibung: string;
|
||||
bericht?: string;
|
||||
meldungen?: Array<{ nachMinuten: number; text: string; wichtig?: boolean }>;
|
||||
material?: Array<{ bezeichnung: string; menge: number; einheit: string }>;
|
||||
status?: EinsatzStatus;
|
||||
}
|
||||
|
||||
const EINSAETZE: EinsatzVorlage[] = [
|
||||
{
|
||||
vorTagen: 12,
|
||||
uhrzeit: [3, 42],
|
||||
stichwort: "B 3 Wohnhausbrand",
|
||||
kategorie: EinsatzKategorie.BRAND_GROSS,
|
||||
strasse: "Bahnhofstraße",
|
||||
hausnummer: "14",
|
||||
objekt: "Reihenhaus, zwei Vollgeschosse",
|
||||
ablauf: [5, 9, 143, 168],
|
||||
fahrzeuge: [0, 1],
|
||||
mannschaft: 14,
|
||||
gerettet: 2,
|
||||
verletzt: 1,
|
||||
sachschaden: 85000,
|
||||
beschreibung:
|
||||
"Bei Eintreffen Rauch aus dem Dachgeschoss, zwei Personen im Obergeschoss gemeldet.",
|
||||
bericht:
|
||||
"Menschenrettung über die Steckleiter durch den Angriffstrupp. Zwei Personen unverletzt gerettet, eine Person mit Rauchgasintoxikation an den Rettungsdienst übergeben.\n\nBrandbekämpfung mit einem C-Rohr unter Atemschutz vom Treppenraum aus. Nachlöscharbeiten und Kontrolle mit der Wärmebildkamera bis 06:05 Uhr. Einsatzstelle an die Polizei übergeben.",
|
||||
meldungen: [
|
||||
{ nachMinuten: 10, text: "Rauch aus Dachgeschoss, zwei Personen vermisst.", wichtig: true },
|
||||
{ nachMinuten: 22, text: "Beide Personen gerettet, Menschenrettung abgeschlossen.", wichtig: true },
|
||||
{ nachMinuten: 48, text: "Feuer unter Kontrolle." },
|
||||
{ nachMinuten: 140, text: "Nachlöscharbeiten beendet, Kontrolle mit Wärmebildkamera." },
|
||||
],
|
||||
material: [
|
||||
{ bezeichnung: "Atemluftflaschen 300 bar", menge: 6, einheit: "Stück" },
|
||||
{ bezeichnung: "Löschwasser", menge: 4500, einheit: "Liter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
vorTagen: 26,
|
||||
uhrzeit: [14, 8],
|
||||
stichwort: "THL 1 Ölspur",
|
||||
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
||||
strasse: "Hauptstraße",
|
||||
ablauf: [6, 11, 55, 68],
|
||||
fahrzeuge: [1],
|
||||
mannschaft: 6,
|
||||
beschreibung: "Ölspur über etwa 300 Meter, ausgehend von einem Traktor.",
|
||||
bericht: "Ölspur mit Bindemittel abgestreut und aufgenommen. Straßenmeisterei verständigt.",
|
||||
material: [{ bezeichnung: "Ölbindemittel", menge: 40, einheit: "kg" }],
|
||||
},
|
||||
{
|
||||
vorTagen: 41,
|
||||
uhrzeit: [19, 55],
|
||||
stichwort: "THL 2 Verkehrsunfall",
|
||||
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_MITTEL,
|
||||
strasse: "Kreisstraße MÜ 12",
|
||||
objekt: "zwischen Musterhausen und Nachbarhausen",
|
||||
ablauf: [4, 12, 78, 96],
|
||||
fahrzeuge: [0],
|
||||
mannschaft: 9,
|
||||
verletzt: 2,
|
||||
sachschaden: 24000,
|
||||
beschreibung: "Zwei Fahrzeuge, eine Person eingeklemmt.",
|
||||
bericht:
|
||||
"Person mit dem hydraulischen Rettungssatz befreit und an den Rettungsdienst übergeben. Brandschutz sichergestellt, Fahrzeuge gegen Wegrollen gesichert, Betriebsstoffe aufgenommen.",
|
||||
meldungen: [
|
||||
{ nachMinuten: 14, text: "Eine Person eingeklemmt, Rettungsdienst vor Ort.", wichtig: true },
|
||||
{ nachMinuten: 39, text: "Person befreit und übergeben.", wichtig: true },
|
||||
],
|
||||
material: [{ bezeichnung: "Ölbindemittel", menge: 15, einheit: "kg" }],
|
||||
},
|
||||
{
|
||||
vorTagen: 58,
|
||||
uhrzeit: [9, 12],
|
||||
stichwort: "BMA Auslösung",
|
||||
kategorie: EinsatzKategorie.BRANDMELDEANLAGE,
|
||||
strasse: "Schulstraße",
|
||||
hausnummer: "3",
|
||||
objekt: "Grundschule",
|
||||
ablauf: [7, 13, 32, 44],
|
||||
fahrzeuge: [0],
|
||||
mannschaft: 8,
|
||||
beschreibung: "Auslösung eines Rauchmelders im Werkraum.",
|
||||
bericht: "Kein Schadenfeuer. Auslösung durch Staub bei Reinigungsarbeiten. Anlage zurückgestellt.",
|
||||
},
|
||||
{
|
||||
vorTagen: 73,
|
||||
uhrzeit: [16, 30],
|
||||
stichwort: "B 1 Kleinbrand",
|
||||
kategorie: EinsatzKategorie.BRAND_KLEIN,
|
||||
strasse: "Am Anger",
|
||||
hausnummer: "7",
|
||||
ablauf: [5, 8, 41, 52],
|
||||
fahrzeuge: [1],
|
||||
mannschaft: 6,
|
||||
sachschaden: 1200,
|
||||
beschreibung: "Brennende Mülltonne an der Hauswand.",
|
||||
bericht: "Ablöschen mit Schnellangriff, Kontrolle der Fassade mit der Wärmebildkamera.",
|
||||
},
|
||||
{
|
||||
vorTagen: 95,
|
||||
uhrzeit: [21, 47],
|
||||
stichwort: "THL 1 Sturmschaden",
|
||||
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
||||
strasse: "Lindenweg",
|
||||
ablauf: [8, 16, 62, 74],
|
||||
fahrzeuge: [0],
|
||||
mannschaft: 7,
|
||||
beschreibung: "Baum auf Fahrbahn nach Gewitterfront.",
|
||||
bericht: "Baum mit der Motorkettensäge zerlegt und beiseitegeräumt. Fahrbahn gereinigt.",
|
||||
material: [{ bezeichnung: "Kettensägenbenzin", menge: 2, einheit: "Liter" }],
|
||||
},
|
||||
{
|
||||
vorTagen: 118,
|
||||
uhrzeit: [11, 20],
|
||||
stichwort: "Sicherheitswache Pfarrfest",
|
||||
kategorie: EinsatzKategorie.SICHERHEITSWACHE,
|
||||
strasse: "Kirchplatz",
|
||||
ablauf: [10, 20, 300, 310],
|
||||
fahrzeuge: [2],
|
||||
mannschaft: 4,
|
||||
beschreibung: "Brandsicherheitswache beim Pfarrfest.",
|
||||
bericht: "Ohne besondere Vorkommnisse.",
|
||||
},
|
||||
{
|
||||
vorTagen: 140,
|
||||
uhrzeit: [6, 5],
|
||||
stichwort: "THL 1 Türöffnung",
|
||||
kategorie: EinsatzKategorie.RETTUNGSDIENST,
|
||||
strasse: "Rosenstraße",
|
||||
hausnummer: "22",
|
||||
ablauf: [6, 10, 28, 38],
|
||||
fahrzeuge: [1],
|
||||
mannschaft: 5,
|
||||
gerettet: 1,
|
||||
beschreibung: "Hilflose Person hinter verschlossener Tür, Rettungsdienst vor Ort.",
|
||||
bericht: "Tür schonend geöffnet, Person an den Rettungsdienst übergeben.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Funktionen einer Gruppe in der Reihenfolge, in der sie besetzt werden. */
|
||||
const FUNKTIONSFOLGE: EinsatzFunktion[] = [
|
||||
EinsatzFunktion.GRUPPENFUEHRER,
|
||||
EinsatzFunktion.MASCHINIST,
|
||||
EinsatzFunktion.MELDER,
|
||||
EinsatzFunktion.TRUPPFUEHRER,
|
||||
EinsatzFunktion.TRUPPMANN,
|
||||
EinsatzFunktion.TRUPPFUEHRER,
|
||||
EinsatzFunktion.TRUPPMANN,
|
||||
EinsatzFunktion.TRUPPFUEHRER,
|
||||
EinsatzFunktion.TRUPPMANN,
|
||||
];
|
||||
|
||||
function minutenSpaeter(basis: Date, minuten: number): Date {
|
||||
return new Date(basis.getTime() + minuten * 60_000);
|
||||
}
|
||||
|
||||
async function einsaetzeAnlegen(
|
||||
organisationId: string,
|
||||
fahrzeuge: Array<{ id: string; funkrufname: string }>,
|
||||
) {
|
||||
const vorhanden = await prisma.einsatz.count({ where: { organisationId } });
|
||||
if (vorhanden > 0) {
|
||||
log(`Einsätze: bereits ${vorhanden} vorhanden`);
|
||||
return;
|
||||
}
|
||||
|
||||
const personen = await prisma.person.findMany({
|
||||
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||||
select: { id: true, vorname: true, nachname: true },
|
||||
orderBy: { mitgliedsnummer: "asc" },
|
||||
});
|
||||
|
||||
if (personen.length === 0 || fahrzeuge.length === 0) return;
|
||||
|
||||
let laufend = 0;
|
||||
|
||||
for (const vorlage of EINSAETZE) {
|
||||
laufend += 1;
|
||||
|
||||
const alarmiert = new Date();
|
||||
alarmiert.setUTCDate(alarmiert.getUTCDate() - vorlage.vorTagen);
|
||||
alarmiert.setUTCHours(vorlage.uhrzeit[0], vorlage.uhrzeit[1], 0, 0);
|
||||
|
||||
const jahr = alarmiert.getUTCFullYear();
|
||||
const [ausgerueckt, eingetroffen, ende, eingerueckt] = vorlage.ablauf;
|
||||
|
||||
const einsatz = await prisma.einsatz.create({
|
||||
data: {
|
||||
organisationId,
|
||||
einsatznummer: `${jahr}-${String(laufend).padStart(4, "0")}`,
|
||||
stichwort: vorlage.stichwort,
|
||||
kategorie: vorlage.kategorie,
|
||||
status: vorlage.status ?? EinsatzStatus.ABGESCHLOSSEN,
|
||||
alarmierungsart: Alarmierungsart.MELDEEMPFAENGER,
|
||||
strasse: vorlage.strasse,
|
||||
hausnummer: vorlage.hausnummer ?? null,
|
||||
plz: "84543",
|
||||
ort: "Musterhausen",
|
||||
objekt: vorlage.objekt ?? null,
|
||||
alarmiertUm: alarmiert,
|
||||
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt),
|
||||
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen),
|
||||
einsatzendeUm: minutenSpaeter(alarmiert, ende),
|
||||
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
||||
einsatzbereitUm: minutenSpaeter(alarmiert, eingerueckt + 30),
|
||||
einsatzleiterId: personen[0]?.id ?? null,
|
||||
beschreibung: vorlage.beschreibung,
|
||||
bericht: vorlage.bericht ?? null,
|
||||
geretteteePersonen: vorlage.gerettet ?? 0,
|
||||
verletztePersonen: vorlage.verletzt ?? 0,
|
||||
sachschaden: vorlage.sachschaden ?? null,
|
||||
fahrzeuge: {
|
||||
create: vorlage.fahrzeuge
|
||||
.map((index) => fahrzeuge[index])
|
||||
.filter((fahrzeug): fahrzeug is { id: string; funkrufname: string } => Boolean(fahrzeug))
|
||||
.map((fahrzeug, position) => ({
|
||||
organisationId,
|
||||
fahrzeugId: fahrzeug.id,
|
||||
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt + position),
|
||||
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen + position),
|
||||
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
||||
kilometerAbfahrt: 12000 + laufend * 40,
|
||||
kilometerRueckkehr: 12000 + laufend * 40 + 8 + position * 3,
|
||||
wasserabgabe: vorlage.kategorie.startsWith("BRAND") ? 1500 : null,
|
||||
})),
|
||||
},
|
||||
kraefte: {
|
||||
create: Array.from({ length: Math.min(vorlage.mannschaft, personen.length) }).map(
|
||||
(_, index) => ({
|
||||
organisationId,
|
||||
// Die Mannschaft rotiert, damit die Beteiligung je Person
|
||||
// unterschiedlich ausfällt.
|
||||
personId: personen[(index + laufend * 3) % personen.length]!.id,
|
||||
funktion: FUNKTIONSFOLGE[index % FUNKTIONSFOLGE.length]!,
|
||||
atemschutz: vorlage.kategorie.startsWith("BRAND") && index >= 3 && index <= 6,
|
||||
vonUm: minutenSpaeter(alarmiert, ausgerueckt),
|
||||
bisUm: minutenSpaeter(alarmiert, eingerueckt),
|
||||
}),
|
||||
),
|
||||
},
|
||||
lagemeldungen: {
|
||||
create: (vorlage.meldungen ?? []).map((meldung) => ({
|
||||
organisationId,
|
||||
zeitpunkt: minutenSpaeter(alarmiert, meldung.nachMinuten),
|
||||
text: meldung.text,
|
||||
wichtig: meldung.wichtig ?? false,
|
||||
verfasserId: personen[0]?.id ?? null,
|
||||
verfasserName: personen[0]
|
||||
? `${personen[0].vorname} ${personen[0].nachname}`
|
||||
: "Einsatzleitung",
|
||||
})),
|
||||
},
|
||||
material: {
|
||||
create: (vorlage.material ?? []).map((posten) => ({
|
||||
organisationId,
|
||||
bezeichnung: posten.bezeichnung,
|
||||
menge: posten.menge,
|
||||
einheit: posten.einheit,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Der älteste Einsatz ist bereits freigegeben — damit die Ansicht eines
|
||||
// festgeschriebenen Berichts nicht leer bleibt.
|
||||
if (vorlage.vorTagen >= 140) {
|
||||
await prisma.einsatz.update({
|
||||
where: { id: einsatz.id },
|
||||
data: {
|
||||
status: EinsatzStatus.FREIGEGEBEN,
|
||||
freigegebenAm: minutenSpaeter(alarmiert, 2880),
|
||||
freigegebenVonId: personen[0]?.id ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(`Einsätze: ${EINSAETZE.length} mit Fahrzeugen, Mannschaft und Lagemeldungen`);
|
||||
}
|
||||
|
||||
interface UebungVorlage {
|
||||
vorTagen: number;
|
||||
thema: string;
|
||||
art: UebungsArt;
|
||||
ort: string;
|
||||
lernziele: string;
|
||||
teilnehmer: number;
|
||||
dauer: number;
|
||||
}
|
||||
|
||||
const UEBUNGEN: UebungVorlage[] = [
|
||||
{
|
||||
vorTagen: 7,
|
||||
thema: "Innenangriff mit Atemschutz",
|
||||
art: UebungsArt.ATEMSCHUTZ,
|
||||
ort: "Übungscontainer Nachbarhausen",
|
||||
lernziele:
|
||||
"Vorgehen im Trupp unter Nullsicht, Türöffnungsprozedur, Rückweg sichern, Kommunikation mit dem Gruppenführer.",
|
||||
teilnehmer: 9,
|
||||
dauer: 150,
|
||||
},
|
||||
{
|
||||
vorTagen: 21,
|
||||
thema: "Wasserförderung über lange Wegstrecke",
|
||||
art: UebungsArt.PRAXIS,
|
||||
ort: "Löschwasserentnahmestelle Mühlbach",
|
||||
lernziele: "Aufbau einer B-Leitung über 600 m, Verstärkerpumpe setzen, Druckverluste erkennen.",
|
||||
teilnehmer: 12,
|
||||
dauer: 120,
|
||||
},
|
||||
{
|
||||
vorTagen: 35,
|
||||
thema: "Technische Hilfeleistung nach Verkehrsunfall",
|
||||
art: UebungsArt.PRAXIS,
|
||||
ort: "Übungsgelände am Bauhof",
|
||||
lernziele:
|
||||
"Fahrzeug sichern, Glasmanagement, patientengerechte Rettung, Zusammenarbeit mit dem Rettungsdienst.",
|
||||
teilnehmer: 11,
|
||||
dauer: 180,
|
||||
},
|
||||
{
|
||||
vorTagen: 49,
|
||||
thema: "Funkausbildung nach BOS-Richtlinie",
|
||||
art: UebungsArt.FUNK,
|
||||
ort: "Gerätehaus, Schulungsraum",
|
||||
lernziele: "Funkdisziplin, Meldeschema, Umgang mit dem Handsprechfunkgerät.",
|
||||
teilnehmer: 8,
|
||||
dauer: 90,
|
||||
},
|
||||
{
|
||||
vorTagen: 63,
|
||||
thema: "Maschinistenausbildung Tragkraftspritze",
|
||||
art: UebungsArt.MASCHINIST,
|
||||
ort: "Gerätehaus",
|
||||
lernziele: "Inbetriebnahme, Entlüften, Störungssuche, Wartung nach dem Einsatz.",
|
||||
teilnehmer: 5,
|
||||
dauer: 120,
|
||||
},
|
||||
{
|
||||
vorTagen: 84,
|
||||
thema: "Gemeinschaftsübung mit der FF Nachbarhausen",
|
||||
art: UebungsArt.GEMEINSCHAFTSUEBUNG,
|
||||
ort: "Sägewerk Nachbarhausen",
|
||||
lernziele:
|
||||
"Zusammenarbeit zweier Wehren, gemeinsame Einsatzleitung, Wasserversorgung aus offenem Gewässer.",
|
||||
teilnehmer: 14,
|
||||
dauer: 210,
|
||||
},
|
||||
];
|
||||
|
||||
async function uebungenAnlegen(organisationId: string) {
|
||||
const vorhanden = await prisma.uebung.count({ where: { organisationId } });
|
||||
if (vorhanden > 0) {
|
||||
log(`Übungen: bereits ${vorhanden} vorhanden`);
|
||||
return;
|
||||
}
|
||||
|
||||
const personen = await prisma.person.findMany({
|
||||
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||||
select: { id: true },
|
||||
orderBy: { mitgliedsnummer: "asc" },
|
||||
});
|
||||
|
||||
if (personen.length === 0) return;
|
||||
|
||||
let laufend = 0;
|
||||
|
||||
for (const vorlage of UEBUNGEN) {
|
||||
laufend += 1;
|
||||
|
||||
const datum = new Date();
|
||||
datum.setUTCDate(datum.getUTCDate() - vorlage.vorTagen);
|
||||
datum.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const beginn = new Date(datum);
|
||||
beginn.setUTCHours(19, 0, 0, 0);
|
||||
|
||||
await prisma.uebung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
nummer: `${datum.getUTCFullYear()}-${String(laufend).padStart(4, "0")}`,
|
||||
thema: vorlage.thema,
|
||||
art: vorlage.art,
|
||||
datum,
|
||||
beginnUm: beginn,
|
||||
endeUm: minutenSpaeter(beginn, vorlage.dauer),
|
||||
ort: vorlage.ort,
|
||||
leiterId: personen[0]?.id ?? null,
|
||||
lernziele: vorlage.lernziele,
|
||||
teilnahmen: {
|
||||
create: Array.from({ length: Math.min(vorlage.teilnehmer, personen.length) }).map(
|
||||
(_, index) => ({
|
||||
organisationId,
|
||||
personId: personen[(index + laufend * 2) % personen.length]!.id,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
log(`Übungen: ${UEBUNGEN.length} mit Teilnahmen`);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ablauf
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -1539,6 +1976,10 @@ async function main(): Promise<void> {
|
||||
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
|
||||
await rueckrufAnlegen(organisation.id);
|
||||
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
await einsaetzeAnlegen(organisation.id, fahrzeuge);
|
||||
await uebungenAnlegen(organisation.id);
|
||||
|
||||
log("Fertig.");
|
||||
log("");
|
||||
log("Anmeldung für den Erststart:");
|
||||
|
||||
@@ -118,6 +118,26 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "Abgeschlossene Prüfprotokolle",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
{
|
||||
name: "19-einsaetze",
|
||||
pfad: "/einsaetze",
|
||||
beschreibung: "Einsatzliste mit Schnellerfassung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "20-statistik",
|
||||
pfad: "/einsaetze/statistik",
|
||||
beschreibung: "Einsatzstatistik mit Hilfsfristen und Beteiligung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "21-uebungen",
|
||||
pfad: "/uebungen",
|
||||
beschreibung: "Übungen mit Thema und Teilnehmerzahl",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -163,6 +183,18 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
|
||||
// Nur fertige Protokolle: die tragen eine Nummer, Entwürfe nicht.
|
||||
textMuster: /^\d{4}-\d{4}$/,
|
||||
},
|
||||
{
|
||||
name: "22-einsatz",
|
||||
beschreibung: "Einsatzdetail mit Zeitleiste, Stärke und Verlauf",
|
||||
von: "/einsaetze",
|
||||
linkMuster: /^\/einsaetze\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
{
|
||||
name: "23-uebung",
|
||||
beschreibung: "Übung mit Anwesenheitsliste",
|
||||
von: "/uebungen",
|
||||
linkMuster: /^\/uebungen\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -201,6 +233,49 @@ async function anmelden(seite: Page): Promise<void> {
|
||||
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> {
|
||||
await mkdir(ZIEL, { recursive: true });
|
||||
|
||||
@@ -266,6 +341,8 @@ async function main(): Promise<void> {
|
||||
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
||||
}
|
||||
|
||||
await portalAufnehmen(seite, darstellung);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Flame,
|
||||
Gauge,
|
||||
GraduationCap,
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
@@ -81,6 +82,13 @@ const EINTRAEGE: Eintrag[] = [
|
||||
abPhase: 2,
|
||||
},
|
||||
{ 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",
|
||||
pfad: "/kiosk",
|
||||
@@ -115,7 +123,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 2;
|
||||
const AKTUELLE_PHASE = 3;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -106,6 +106,23 @@ const schema = z.object({
|
||||
LLM_API_KEY: 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)
|
||||
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
|
||||
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { env } from "@/lib/env";
|
||||
|
||||
export const WARTESCHLANGE = "florian";
|
||||
|
||||
/** Bekannte Auftragsarten. Ab Phase 2 kommen die fachlichen Jobs dazu. */
|
||||
/** Bekannte Auftragsarten. */
|
||||
export const AUFTRAG = {
|
||||
/** Prüft alle Geräte auf fällige Fristen und setzt den Status. */
|
||||
FRISTEN_PRUEFEN: "fristen.pruefen",
|
||||
@@ -24,6 +24,10 @@ export const AUFTRAG = {
|
||||
ERINNERUNG_SENDEN: "erinnerung.senden",
|
||||
/** Erzeugt ein PDF über Gotenberg. */
|
||||
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. */
|
||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
tagesabstand,
|
||||
utcTag,
|
||||
} from "@/lib/stammdaten/fristen";
|
||||
import { nummerBilden, nummerLesen } from "@/lib/nummernkreis";
|
||||
|
||||
/**
|
||||
* Fristenlogik der Wartung.
|
||||
@@ -148,19 +149,11 @@ export function innerhalbPlanungshorizont(
|
||||
* 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
|
||||
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz,
|
||||
* sprechbar und ohne Bindestrichwüste.
|
||||
* das, was in einer Rückfrage der Unfallkasse genannt wird.
|
||||
*
|
||||
* 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 {
|
||||
return `${jahr}-${String(laufend).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/** 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]) };
|
||||
}
|
||||
export const protokollNummer = nummerBilden;
|
||||
export const protokollNummerLesen = nummerLesen;
|
||||
|
||||
@@ -14,6 +14,10 @@ import { wartungsterminRouter } from "@/server/routers/wartung/termin";
|
||||
import { pruefungRouter } from "@/server/routers/wartung/pruefung";
|
||||
import { mangelRouter } from "@/server/routers/wartung/mangel";
|
||||
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.
|
||||
@@ -151,6 +155,13 @@ export const appRouter = router({
|
||||
pruefung: pruefungRouter,
|
||||
mangel: mangelRouter,
|
||||
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;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/warteschlange";
|
||||
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
|
||||
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
|
||||
import { einsatzberichtErzeugen } from "@/lib/einsatz/bericht-erzeugen";
|
||||
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
|
||||
* Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
|
||||
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die
|
||||
* Aufbewahrung folgt in Phase 8.
|
||||
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle, ab Phase 3
|
||||
* dazu die Einsatzberichte. Die Aufbewahrung folgt in Phase 8.
|
||||
*
|
||||
* 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,
|
||||
@@ -81,6 +82,29 @@ async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
|
||||
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 }> {
|
||||
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// 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.ERINNERUNG_SENDEN]: erinnerungSenden,
|
||||
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
|
||||
[AUFTRAG.EINSATZBERICHT_ERZEUGEN]: einsatzberichtErzeugenAuftrag,
|
||||
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||