Phase 5: Atemschutzüberwachung nach FwDV 7 #6

Merged
florian.wilde merged 1 commits from feat/phase-5-atemschutz into main 2026-09-03 21:42:21 +00:00
107 changed files with 7214 additions and 40 deletions
Showing only changes of commit 4e2daaafd4 - Show all commits
+19
View File
@@ -185,6 +185,25 @@ KIOSK_SPERRDAUER=300
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
KIOSK_IDLE_TIMEOUT=120
# -----------------------------------------------------------------------------
# Atemschutz nach FwDV 7 (Phase 5)
# -----------------------------------------------------------------------------
# Der Sicherheitshinweis ueber jeder Ansicht der Atemschutzueberwachung:
# "Ergaenzendes System — die analoge Atemschutzueberwachung bleibt fuehrend."
# Er gehoert dorthin. Auf false setzen darf ihn nur, wer eine schriftliche
# Freigabe des Kommandanten hat — und deren Fundstelle gehoert in
# ATEMSCHUTZ_FREIGABE, sonst bleibt der Hinweis stehen.
ATEMSCHUTZ_HINWEIS=true
# Aktenzeichen oder Fundstelle der Freigabe, z. B. "Kommandantenbeschluss vom 12.03.2026".
ATEMSCHUTZ_FREIGABE=
# Abstand der Druckabfrage in Sekunden. FwDV 7 nennt keinen festen Wert;
# zehn Minuten sind die uebliche Praxis.
ATEMSCHUTZ_ABFRAGE_INTERVALL=600
# Ansprechdruck der Warneinrichtung in bar. Je nach Baujahr 50 bis 60.
ATEMSCHUTZ_WARNDRUCK=55
# Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar.
ATEMSCHUTZ_ZUSCHLAG=10
# -----------------------------------------------------------------------------
# Wetterwarnungen auf dem Hallenmonitor (optional, DWD)
# Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut —
+121 -1
View File
@@ -8,7 +8,127 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht]
Nächster Schritt ist Phase 5 (Atemschutzverwaltung nach FwDV 7).
Nächster Schritt ist Phase 6 (Kartenmodule).
---
## [0.6.0] — 2026-09-03
Phase 5: Atemschutzüberwachung nach FwDV 7. Das sicherheitsrelevanteste Modul
des Projekts — und deshalb das mit dem höchsten Testanteil.
Über jeder Ansicht steht: **Ergänzendes System — die analoge
Atemschutzüberwachung bleibt führend.** Der Hinweis lässt sich nur abschalten,
wenn zusätzlich die Fundstelle einer Freigabe des Kommandanten hinterlegt ist;
ohne sie bleibt er stehen.
### Hinzugefügt
**Datenmodell**
- `Atemschutzeinsatz` — eine Überwachung mit Überwacher, Abfrageintervall,
Warndruck und Sicherheitszuschlag. Hängt an einem Einsatz oder einer Übung,
kann aber auch für sich stehen: im Ernstfall wird zuerst überwacht und
später zugeordnet.
- `Atemschutztrupp` mit Status (bereit, im Einsatz, Rückweg, draußen), den
Zeitstempeln dazu, dem errechneten Rückzugsdruck und dem Notfallzeitpunkt.
- `Truppmitglied` — zwei oder drei Personen je Trupp, genau eine davon
Truppführer, optional mit dem verwendeten Pressluftatmer.
- `Druckmessung` mit Art (Eingang, Einsatzziel, Zwischenmeldung, Ausgang).
- `AtemschutzEreignis` — jede Zeile des Protokolls mit Zeitpunkt.
- `G26Untersuchung` und `Belastungsuebung` als eigene Nachweise.
**Die Zeit gehört dem Server**
- Nirgends wird eine Restzeit heruntergezählt. Jede Dauer entsteht aus zwei
Zeitstempeln — einem aus der Datenbank und der aktuellen Uhr. Deshalb
übersteht die Anzeige ein Neuladen, einen zweiten Bildschirm und einen
Verbindungsabbruch: es gibt nichts, das dabei verloren gehen könnte.
- Server und Browser rechnen mit **denselben** Funktionen aus
`lib/atemschutz/berechnung.ts`. Was das Tablet anzeigt, weist das Protokoll
später genauso aus.
**Rückzugsdruck nach dem doppelten Anmarschverbrauch**
- Der Trupp meldet beim Erreichen des Einsatzziels einmal seinen Druck. Daraus:
`2 × Anmarschverbrauch + Ansprechdruck der Warneinrichtung + Zuschlag`.
- Solange keine Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt
höher und ist damit die vorsichtigere Annahme.
- Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht
gemeinsam zurück.
- Ampel in drei Stufen mit Klartext daneben, Gelb schon zehn Prozent über der
Grenze. Bei Notfall zusätzlich ein erzeugter Ton — ausdrücklich eine Zugabe.
**Überwachungsschirm**
- Trupp anlegen mit Eingangsdruck je Person; daneben steht, ob die Person nach
FwDV 7 überhaupt einsetzbar ist. **Gesperrt wird nichts** — im Einsatz
entscheidet der Einheitsführer, und ein Programm, das ihm im entscheidenden
Moment eine Person verweigert, wird umgangen.
- Druckabfrage für den ganzen Trupp in einer Zeile, Erinnerung im
konfigurierbaren Intervall (Vorgabe zehn Minuten).
- Mayday je Trupp, ausdrücklich aufzuheben statt wegzuklicken.
- Rückzug anordnen, Statuswechsel, Notizen ins Protokoll.
- Die Überwachung lässt sich erst beenden, wenn alle Trupps als draußen
gemeldet sind.
**Ereignisstrom über Server-Sent Events**
- Ein Route Handler unter `/api/atemschutz/strom` meldet Änderungen; der
Browser holt daraufhin den Stand. Kein eigener Server nötig, `EventSource`
verbindet nach einem Abbruch von selbst neu.
- Der Zustand der Verbindung steht sichtbar auf dem Schirm. Bleibt das
Lebenszeichen des Servers aus, gilt die Verbindung nach acht Sekunden als
tot — auch wenn der Browser keinen Fehler meldet.
- Fällt der Strom ganz aus, bleibt die Ansicht bedienbar: sie fragt zusätzlich
im festen Takt nach.
**Protokoll**
- Lückenloses PDF mit Rahmen, Trupps und dem Verlauf aus Ereignissen und
Druckmessungen in einer gemeinsamen Zeitachse.
- Entsteht beim Beenden im Hintergrundprozess und bei Bedarf beim Abruf — ein
Nachweis, den man nicht bekommt, weil ein Dienst einen schlechten Tag hatte,
wäre keiner.
- Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
später liest, soll wissen, was er in der Hand hält.
**Tauglichkeit nach FwDV 7**
- Drei Nachweise, die gleichzeitig gelten müssen: gültige G26.3,
Belastungsübung im laufenden Jahr, Einsatzübung im laufenden Jahr. Ein
Einsatz unter Atemschutz ersetzt die Einsatzübung.
- Ampel mit **Klartext**, was fehlt — eine rote Ampel ohne Begründung hilft am
Fahrzeug niemandem.
- Fristvorschlag für die G26 nach Alter: drei Jahre, ab fünfzig eines.
- Nachweise pflegen: G26 je Person, Belastungsübung für die ganze Gruppe auf
einmal.
- Kachel auf der Startseite: wie viele Träger heute einsetzbar sind und wie
viele davon knapp davor stehen, es nicht mehr zu sein.
**Sonstiges**
- Der Seed legt acht G26-Untersuchungen an — darunter eine abgelaufene und
eine bald ablaufende —, zwölf Übungsnachweise und eine vollständig
durchgespielte Überwachung mit zwei Trupps.
- 68 neue Tests: 51 Unit-Tests zu Rückzugsdruck, Ampel, Restzeit, Statusfolge
und Tauglichkeit, 17 gegen eine echte Datenbank und 8 Playwright-Durchläufe
je Browser — darunter die beiden, um die es in dieser Phase geht: die Uhr
nach einem Neuladen und bei abgerissenem Ereignisstrom.
### Geändert
- Die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` auf
`druckmessung` und `atemschutz_ereignis`; zusätzlich sitzt auf beiden ein
Trigger. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht
durch Ändern — sonst wäre jede Aufzeichnung nachträglich glättbar.
- Neue Bedingungen: Druck zwischen 0 und 400 bar, kein Rückzug vor dem
Einstieg, kein Ende vor dem Beginn, genau ein Truppführer je Trupp.
- Die Startseite stand noch auf dem Stand von Phase 1 („Als Nächstes kommt das
Prüfmanagement"). Sie nennt jetzt den tatsächlichen Ausbaustand.
- Der Navigationseintrag „Protokoll" führte auf eine Seite, die es nicht gibt.
Die Ansicht des Audit-Logs gehört zum Löschkonzept in Phase 8; bis dahin
steht der Eintrag ausgegraut da statt ins Leere zu führen.
---
+136 -1
View File
@@ -28,7 +28,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 2 — Wartung und Prüfung | fertig |
| Phase 3 — Einsätze und Übungen | fertig |
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
| Phase 5 — Atemschutz (FwDV 7) | offen |
| Phase 5 — Atemschutz (FwDV 7) | fertig |
| Phase 6 — Kartenmodule | offen |
| Phase 7 — Benachrichtigungen und Termine | offen |
| Phase 8 — Lokale KI | offen |
@@ -529,6 +529,141 @@ Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile.
---
## Phase 5 — Atemschutz nach FwDV 7
Das sicherheitsrelevanteste Modul des Projekts. Über jeder Ansicht steht
deshalb dauerhaft:
> **Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.**
Der Hinweis lässt sich abschalten, aber nur zusammen mit einer Fundstelle:
`ATEMSCHUTZ_HINWEIS=false` wirkt erst, wenn unter `ATEMSCHUTZ_FREIGABE` steht,
worauf sich die Freigabe stützt — etwa „Kommandantenbeschluss vom 12.03.2026".
Fehlt sie, bleibt der Hinweis stehen. Eine Freigabe ohne Beschluss ist keine.
### Die Zeit gehört dem Server
Das ist die eine Entscheidung, aus der alles Weitere folgt. **Nirgends wird
eine Restzeit heruntergezählt.** In der Datenbank stehen Zeitstempel; wie lange
ein Trupp drin ist, rechnet jede Ansicht bei jedem Bild neu aus dem
Einstiegszeitpunkt und der aktuellen Uhr.
Daraus ergibt sich von selbst, was Phase-5.md als wichtigste Anforderung nennt:
| Was passiert | Was die Anzeige macht |
|---|---|
| Browser neu laden | Zeigt dieselbe Zeit weiter — es gab nichts zu verlieren |
| Zweiter Bildschirm daneben | Zeigt dieselbe Zeit, ohne Abgleich |
| Verbindung bricht ab | Die Uhr läuft weiter, und der Abriss steht sichtbar da |
| Server neu gestartet | Nach der ersten Antwort ist alles wieder da |
Server und Browser rechnen mit **denselben** Funktionen
(`src/lib/atemschutz/berechnung.ts`). Was das Tablet anzeigt, weist das
Protokoll später genauso aus.
### Rückzugsdruck
Gerechnet wird nach dem **doppelten Anmarschverbrauch**: was der Weg hinein an
Luft gekostet hat, kostet der Weg zurück noch einmal.
```
Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag
```
Dafür meldet der Trupp beim Erreichen des Einsatzziels einmal seinen Druck.
Solange das nicht geschehen ist, greift die **Zwei-Drittel-Regel** — sie liegt
in aller Regel höher und ist damit die vorsichtigere Annahme; die Karte sagt
dann „vorläufig" dazu.
Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht gemeinsam
zurück, also zählt, wer am wenigsten Luft hat.
Die Ampel steht schon zehn Prozent über der Grenze auf Gelb — der Trupp soll
losgehen, bevor der Wert erreicht ist, nicht wenn er erreicht ist. Farbe **und**
Text, wie überall in FLORIAN.
### Der Weg durch eine Überwachung
1. **Eröffnen** — Bezeichnung genügt. Die Zuordnung zu einem Einsatz lässt sich
nachtragen; im Ernstfall wird zuerst überwacht.
2. **Trupp anlegen** — zwei oder drei Personen, für jede der Eingangsdruck, eine
davon Truppführer. Daneben steht, ob die Person nach FwDV 7 einsetzbar ist.
**Gesperrt wird nichts:** im Einsatz entscheidet der Einheitsführer, und ein
Programm, das ihm im entscheidenden Moment eine Person verweigert, wird
umgangen — dann steht am Ende gar nichts im Protokoll.
3. **Einstieg** — ab hier läuft die Uhr.
4. **Druckabfrage** — für den ganzen Trupp in einer Zeile. Nach
`ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden ohne Meldung wird erinnert; gerechnet
ab der letzten Meldung, nicht ab dem Einstieg.
5. **Rückweg und heraus** — beim Herauskommen die Ausgangsdrücke.
6. **Beenden** — geht erst, wenn alle Trupps als draußen gemeldet sind. Eine
beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
gefährlichste Zeile im ganzen Protokoll.
Dazu jederzeit: **Rückzug anordnen**, **Mayday** (aufzuheben, nicht
wegzuklicken) und Notizen ins Protokoll.
### Der Ereignisstrom
Änderungen kommen über **Server-Sent Events** unter `/api/atemschutz/strom` —
kein WebSocket, kein eigener Node-Server, `next start` bleibt wie es ist. Der
Strom meldet nur „es hat sich etwas geändert"; die Ansicht holt daraufhin den
Stand. Geschrieben wird wie überall über tRPC.
Zwei Dinge machen ihn belastbar:
- **Er verbindet sich selbst neu.** `EventSource` bringt das mit.
- **Stille gilt als Abriss.** Der Server schickt alle zwei Sekunden ein
Lebenszeichen; bleibt es acht Sekunden aus, steht die Verbindung sichtbar auf
„unterbrochen" — auch wenn der Browser keinen Fehler meldet. Dieser Fall ist
real: wird das Netz gekappt, während der Strom offen ist, schweigt
`EventSource` einfach.
Fällt der Strom ganz aus, bleibt die Ansicht bedienbar — sie fragt zusätzlich im
festen Takt nach. Der Strom macht sie schneller, nicht erst funktionsfähig.
### Das Protokoll
Ein PDF mit Rahmen, Trupps und dem Verlauf — Ereignisse und Druckmessungen in
**einer** Zeitachse, damit niemand zwei Spalten gleichzeitig lesen muss. Es
entsteht beim Beenden im Hintergrundprozess und, falls das nicht geklappt hat,
beim Abruf unter `/api/atemschutz/protokoll?id=…`.
Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
später in der Hand hält, soll wissen, was er da hat.
Verändern lässt sich nichts: `druckmessung` und `atemschutz_ereignis` sind für
den Anwendungszugang gegen `UPDATE` und `DELETE` gesperrt, und zusätzlich sitzt
auf beiden ein Trigger. Ein Fehler wird durch einen neuen Eintrag
richtiggestellt.
### Wer darf unter Atemschutz?
Drei Nachweise, die gleichzeitig gelten müssen:
| Nachweis | Frist |
|---|---|
| **G26.3** | nach Alter: bis 50 Jahre drei Jahre, danach eines |
| **Belastungsübung** | jährlich (Übungsstrecke) |
| **Einsatzübung unter Atemschutz** | jährlich — ein echter Einsatz zählt |
Dazu die Qualifikation als Atemschutzgeräteträger aus Phase 1. Die Übersicht
zeigt eine Ampel **mit Klartext**, was fehlt: eine rote Ampel ohne Begründung
hilft am Fahrzeug niemandem. Auf der Startseite steht die Zahl, nach der im
Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `ATEMSCHUTZ_HINWEIS` | `true` | Sicherheitshinweis über jeder Ansicht |
| `ATEMSCHUTZ_FREIGABE` | — | Fundstelle der Freigabe; ohne sie bleibt der Hinweis |
| `ATEMSCHUTZ_ABFRAGE_INTERVALL` | `600` | Abstand der Druckabfrage in Sekunden |
| `ATEMSCHUTZ_WARNDRUCK` | `55` | Ansprechdruck der Warneinrichtung in bar |
| `ATEMSCHUTZ_ZUSCHLAG` | `10` | Sicherheitszuschlag auf den Rückzugsdruck |
---
## Befehle
```bash
+5
View File
@@ -47,6 +47,11 @@ x-app-env: &app-env
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true}
ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-}
ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600}
ATEMSCHUTZ_WARNDRUCK: ${ATEMSCHUTZ_WARNDRUCK:-55}
ATEMSCHUTZ_ZUSCHLAG: ${ATEMSCHUTZ_ZUSCHLAG:-10}
WETTER_ENABLED: ${WETTER_ENABLED:-false}
DWD_WARNCELL: ${DWD_WARNCELL:-}
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
+169 -10
View File
@@ -1,10 +1,9 @@
# Anleitung
Bedienung von FLORIAN, Stand Phase 4 (Version 0.5.0). Die Bilder zeigen die
Bedienung von FLORIAN, Stand Phase 5 (Version 0.6.0). Die Bilder zeigen die
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Atemschutz und Karten kommen in den folgenden Phasen dazu. Siehe
> [ROADMAP.md](ROADMAP.md).
> Die Kartenmodule kommen in Phase 6 dazu. Siehe [ROADMAP.md](ROADMAP.md).
---
@@ -603,7 +602,7 @@ 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 30). Ohne den bleibt das Feld leer —
einen eigenen Nominatim betreibt (Kapitel 34). Ohne den bleibt das Feld leer —
das ist in Ordnung, die Karte kommt in Phase 6.
---
@@ -945,7 +944,163 @@ nützt eine Minute später nichts mehr.
---
## 30. Adressen zu Koordinaten (Nominatim)
## 30. Atemschutz — was das Modul ist und was nicht
![Atemschutzübersicht](screenshots/31-atemschutz-light.png)
> **Über jeder Ansicht dieses Moduls steht:**
> *Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.*
Das ist kein Kleingedrucktes. Die Überwachung, die im Ernstfall zählt, hängt am
Fahrzeug und wird mit Stift geführt. FLORIAN schreibt mit, rechnet mit und
druckt am Ende ein Protokoll — aber wenn dieses Tablet ausfällt, läuft der
Einsatz unverändert weiter.
Der Hinweis lässt sich abschalten, aber nur zusammen mit einem Beleg:
```bash
# .env
ATEMSCHUTZ_HINWEIS=false
ATEMSCHUTZ_FREIGABE=Kommandantenbeschluss vom 12.03.2026
```
Fehlt die Fundstelle, bleibt der Hinweis stehen — egal was in
`ATEMSCHUTZ_HINWEIS` steht. Eine Freigabe ohne Beschluss ist keine. Ist er
abgeschaltet, verschwindet er nicht spurlos: dann steht dort, worauf sich die
Freigabe stützt, und dasselbe steht später im Protokoll.
Die Startseite zeigt die Zahl, nach der im Einsatz als Erstes gefragt wird:
**wie viele Atemschutzgeräteträger heute einsetzbar sind** — und wie viele
davon knapp davor stehen, es nicht mehr zu sein.
---
## 31. Eine Überwachung führen
![Überwachungsschirm](screenshots/33-atemschutz-ueberwachung-light.png)
### Eröffnen
Auf `/atemschutz` genügt eine Bezeichnung — „Zimmerbrand Hauptstraße 12". Die
Zuordnung zu einem Einsatz lässt sich nachtragen; im Ernstfall wird zuerst
überwacht und später sortiert.
### Trupp anlegen
Zwei oder drei Personen, für jede der Eingangsdruck vom Manometer, eine davon
Truppführer. Neben jedem Namen steht, ob die Person nach FwDV 7 einsetzbar ist
und was gegebenenfalls fehlt.
> **Verweigert wird nichts.** Im Einsatz entscheidet der Einheitsführer. Ein
> Programm, das ihm im entscheidenden Moment eine Person sperrt, wird umgangen —
> und dann steht am Ende gar nichts im Protokoll. Der Hinweis nützt, die Sperre
> schadet.
### Was auf der Karte steht
| Anzeige | Bedeutung |
|---|---|
| **unter Atemschutz** | Zeit seit dem Einstieg — läuft, solange der Trupp drin ist |
| **niedrigster Druck** | Der ungünstigste Wert im Trupp. Der Trupp geht gemeinsam zurück |
| **Rückzug bei** | Der errechnete Rückzugsdruck |
Die Farbe der Karte ist die Ampel, und daneben steht immer der Text dazu:
„im Rahmen", „Rückzugsdruck bald erreicht", „Rückzugsdruck erreicht — Trupp
zurückziehen". Gelb kommt schon zehn Prozent vor der Grenze — der Trupp soll
losgehen, bevor der Wert erreicht ist.
### Der Rückzugsdruck
Gerechnet wird nach dem **doppelten Anmarschverbrauch**:
```
Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag
```
Dafür meldet der Trupp einmal, wenn er das Einsatzziel erreicht hat — dafür ist
der Knopf **„Einsatzziel erreicht"** da. Bis dahin steht ein vorläufiger Wert
nach der Zwei-Drittel-Regel; er liegt höher und ist damit die vorsichtigere
Annahme. Die Karte sagt das auch dazu.
Beispiel: 300 bar rein, 260 bar am Ziel — 40 bar für den Hinweg, also 80 für
hin und zurück, plus 55 bar Warndruck und 10 bar Zuschlag: **Rückzug bei
145 bar.**
### Druckabfrage
Alle Werte des Trupps in einer Zeile, ein Knopf. Bleibt eine Meldung aus, steht
nach `ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden (Vorgabe zehn Minuten) ein
deutlicher Hinweis auf der Karte — gerechnet ab der letzten Meldung, nicht ab
dem Einstieg.
### Mayday
Ein Knopf je Trupp. Er ändert nichts an der Lage: er hält fest, wann es losging,
macht es auf jedem Bildschirm sichtbar und lässt einen Ton hören. Aufgehoben
wird er **ausdrücklich**, nicht durch Wegklicken — und beides steht im
Protokoll.
### Beenden
Geht erst, wenn alle Trupps als draußen gemeldet sind. Das ist keine Schikane:
eine beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
gefährlichste Zeile im ganzen Protokoll.
---
## 32. Was passiert, wenn die Technik streikt
Die wichtigste Eigenschaft dieser Ansicht ist, dass man ihr nichts anhaben
kann. Der Grund dafür ist einfach: **es gibt keinen Timer.** In der Datenbank
steht, wann der Trupp eingestiegen ist; wie lange er drin ist, rechnet der
Bildschirm jede Sekunde neu aus.
| Was passiert | Was Sie sehen |
|---|---|
| Seite neu laden | Dieselbe Zeit läuft weiter |
| Zweites Tablet daneben | Dieselbe Zeit, ohne Abgleich |
| WLAN bricht ab | Die Uhr läuft weiter; oben steht **Verbindung unterbrochen** |
| Server neu gestartet | Nach der ersten Antwort ist alles wieder da |
Oben rechts steht dauerhaft, ob die Verbindung steht. Das ist Absicht: wer
nicht sieht, dass der Draht weg ist, hält einen alten Stand für den aktuellen.
Meldet sich der Server acht Sekunden lang nicht, springt die Anzeige auf
„unterbrochen" — auch dann, wenn der Browser selbst keinen Fehler meldet.
Ist die Verbindung weg, bleibt die Ansicht bedienbar; sie fragt weiter im Takt
nach und holt auf, sobald es wieder geht.
---
## 33. Nachweise und Tauglichkeit
![Nachweise](screenshots/32-atemschutz-nachweise-light.png)
Drei Nachweise müssen **gleichzeitig** gelten, sonst ist jemand nicht
einsetzbar:
| Nachweis | Frist | Wo erfasst |
|---|---|---|
| **G26.3** | drei Jahre, ab 50 Jahren eines | `/atemschutz/nachweise` |
| **Belastungsübung** | jährlich | dort, für die ganze Gruppe auf einmal |
| **Einsatzübung unter Atemschutz** | jährlich | dort — ein echter Einsatz zählt ebenso |
Dazu die Qualifikation als Atemschutzgeräteträger aus Kapitel 9.
Das Ablaufdatum der G26 wird nach dem Alter vorgeschlagen; was auf der
Bescheinigung steht, gilt und lässt sich überschreiben. Eine Untersuchung mit
dem Ergebnis „Nachuntersuchung" oder „nicht tauglich" zählt nicht als Nachweis.
In der Übersicht steht neben jedem Namen **im Klartext**, was fehlt — „G26.3 ist
abgelaufen", „Belastungsübung in diesem Jahr fehlt". Eine rote Ampel ohne
Begründung hilft am Fahrzeug niemandem.
Drei Monate vor Ablauf der G26 wechselt die Ampel auf Gelb: die Person ist
weiter einsetzbar, aber es wird Zeit für einen Termin.
---
## 34. Adressen zu Koordinaten (Nominatim)
FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
betreibt:
@@ -965,7 +1120,7 @@ GEOCODING_LAENDER=de,at,ch
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
eintragen oder weglassen.
## 31. Rollen und was sie dürfen
## 35. 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
@@ -994,7 +1149,7 @@ Das Terminal selbst ist nur die Tür.
---
## 32. Anmeldung über authentik einrichten
## 36. Anmeldung über authentik einrichten
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
@@ -1047,7 +1202,7 @@ ganze Wehr aus.
---
## 33. PIN und Ausweiskarte verwalten
## 37. PIN und Ausweiskarte verwalten
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt.
@@ -1070,7 +1225,7 @@ Huber), PIN `471203`.
---
## 34. Was protokolliert wird
## 38. Was protokolliert wird
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
@@ -1095,7 +1250,7 @@ jeweiligen Detailseite sichtbar.
---
## 35. Wenn etwas nicht geht
## 39. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---|
@@ -1115,6 +1270,10 @@ jeweiligen Detailseite sichtbar.
| Die Leiste zeigt dauerhaft wartende Änderungen | Server erreichbar, aber der Vorgang wird abgelehnt | Über „anzeigen" den Grund lesen; landet er als Konflikt, steht er unter `/terminal` |
| „Der Code wurde nicht erkannt" beim QR-Login | Marke älter als 60 Sekunden | Auf dem Handy die Seite Anmeldemarke offen halten, sie erneuert den Code selbst |
| Der Monitor zeigt keine Wetterwarnung | `WETTER_ENABLED` aus oder `DWD_WARNCELL` leer | Beides setzen; ohne Warnzelle wird nichts abgerufen |
| Der Sicherheitshinweis im Atemschutz bleibt trotz `ATEMSCHUTZ_HINWEIS=false` | `ATEMSCHUTZ_FREIGABE` ist leer | Fundstelle der Freigabe eintragen — ohne sie bleibt der Hinweis absichtlich stehen |
| Die Überwachung lässt sich nicht beenden | Ein Trupp ist noch nicht als draußen gemeldet | Trupp abmelden, dann beenden. Die Meldung nennt die Anzahl |
| Oben steht „Verbindung unterbrochen", obwohl das Netz da ist | Der Ereignisstrom wurde von einem Proxy gepuffert oder gekappt | Die Ansicht arbeitet weiter; bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
| Das Atemschutzprotokoll fehlt | Die Überwachung ist noch nicht beendet | Ein Protokoll entsteht erst mit dem Ende — vorher wäre es unvollständig und sähe vollständig aus |
Zustand aller Randsysteme auf einen Blick:
+28 -1
View File
@@ -249,7 +249,7 @@ soll, sind es nicht.
## Phase 5 — Atemschutz nach FwDV 7
**Vergleichsaufwand: 14–18 PT**
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:40 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -268,6 +268,33 @@ Timer, der einen Reload überlebt, ist Pflicht — deshalb muss die Zeit
serverseitig geführt werden und der Browser nur anzeigen. Diese Phase bekommt
den höchsten Testanteil im Projekt.
**Rückblick.** Der Satz stimmt, aber die Schlussfolgerung war die falsche. Die
Aufstellung veranschlagt 3,5 PT für „serverseitig autoritative Timer" — als
wäre ein Timer zu bauen. Gebraucht wurde das Gegenteil: **kein** Timer. Wer die
Zeit als Zeitstempel in die Datenbank schreibt und jede Ansicht selbst rechnen
lässt, hat nichts, was einen Reload nicht überlebt. Der teuerste Posten der
Schätzung war der billigste der Umsetzung, weil die richtige Entscheidung ihn
auflöst statt ihn zu lösen.
Auch Socket.IO fiel weg. Es geht nur in eine Richtung — der Server meldet eine
Änderung, der Browser holt den Stand über den Weg, den es ohnehin gibt. Ein
Route Handler mit Server-Sent Events ist siebzig Zeilen und braucht kein
verändertes Deployment.
Teurer war das, was in keiner Zeile der Aufstellung steht: **die Frage, was
passiert, wenn nichts passiert.** Ein abgerissener Ereignisstrom meldet sich
nicht — `EventSource` schweigt einfach. Eine Anzeige, die deshalb weiter
„Verbunden" behauptet, ist gefährlicher als eine, die gar nichts anzeigt: sie
lässt einen alten Stand wie einen aktuellen aussehen. Aufgefallen ist das nur,
weil der E2E-Test das Netz gekappt und danach nachgesehen hat — die 2,0 PT für
„Tests: Reload und Verbindungsabbruch überstehen" waren wieder der
bestangelegte Posten der Phase.
Billiger als gedacht war die Tauglichkeit. Nicht das Rechnen — die
Entscheidung, **nicht** zu sperren. Damit entfielen alle Sonderfälle, die eine
Sperre nach sich zieht: Übersteuern, Begründungspflicht, Freigabe durch wen.
Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert.
## Phase 6 — Kartenmodule
**Vergleichsaufwand: 14–18 PT**
+81 -1
View File
@@ -16,7 +16,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
| **Summe** | **10:40 h** | **6** | |
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
| **Summe** | **13:20 h** | **7** | |
---
## 2026-09-03 · Phase 5 — Atemschutz nach FwDV 7
**19:40 – 22:20 · 2:40 h · Version 0.6.0**
### Was entstanden ist
Die Atemschutzüberwachung. Sieben Tabellen, ein Ereignisstrom, ein
lückenloses Protokoll — und der Hinweis über allem, dass die analoge
Überwachung führend bleibt.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/atemschutz/berechnung.ts`, `tauglichkeit.ts`, `verfuegbarkeit.ts` |
| Server | `server/routers/atemschutz/ueberwachung.ts`, `traeger.ts` |
| Strom | `app/api/atemschutz/strom/route.ts`, `components/atemschutz/stand.ts` |
| Protokoll | `lib/atemschutz/protokoll.ts`, `protokoll-erzeugen.ts`, `api/atemschutz/protokoll` |
| Oberfläche | `app/(app)/atemschutz/`, `components/atemschutz/` |
| Tests | 51 Unit, 17 Datenbank, 8 E2E je Browser |
### Entscheidungen
**Server-Sent Events statt Socket.IO.** Phase-5.md nennt Socket.IO; das
verlangt in Next.js einen eigenen Node-Server und damit ein anderes
Deployment. Gebraucht wird aber nur eine Richtung: der Server meldet „es hat
sich etwas geändert", der Browser holt den Stand über tRPC. Ein Route Handler
genügt, `EventSource` verbindet nach einem Abbruch von selbst neu, und
`next start` bleibt wie es ist. Nach Rücksprache so entschieden.
**Es gibt keinen Timer.** Kein Zähler, kein Intervall, das Zeit führt. Jede
Dauer entsteht aus zwei Zeitstempeln — einem aus der Datenbank, einem von der
Uhr. Damit ist die Anforderung „übersteht Reload und Verbindungsabbruch" keine
Funktion, die man testen muss, sondern eine Eigenschaft, die sich nicht
verlieren lässt. Getestet wird sie trotzdem.
**Der Rückzugsdruck nach dem doppelten Anmarschverbrauch.** Nach Rücksprache:
die Wehr rechnet mit dem tatsächlichen Verbrauch auf dem Hinweg. Solange keine
Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt höher, ist also
die vorsichtigere Annahme, und das steht auch so auf der Karte.
**Die Tauglichkeit sperrt niemanden.** Neben jedem Namen steht, ob die Person
nach FwDV 7 einsetzbar wäre und was fehlt. Verweigert wird nichts. Ein
Programm, das im Einsatz eine Person blockiert, wird umgangen — und dann steht
am Ende gar nichts im Protokoll. Der Hinweis nützt, die Sperre schadet.
**Das Protokoll ist doppelt gesichert.** Entzogene Rechte **und** ein Trigger
auf `druckmessung` und `atemschutz_ereignis`. Eine Sperre hätte gereicht; bei
einer Aufzeichnung, an der Menschenleben hängen, ist die zweite ihren Preis
wert.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Ein gekapptes Netz ließ die Anzeige „Verbunden" melden | `context.setOffline()` trennt einen **offenen** SSE-Strom in Chromium nicht — er liefert weiter. Der Test prüfte damit etwas anderes als gedacht. Schlimmer: bei einem echten Abriss schweigt `EventSource`, ohne einen Fehler zu melden | Der Server schickt alle zwei Sekunden ein Lebenszeichen; bleibt es acht Sekunden aus, gilt die Verbindung als tot. Der Test schneidet den Strom jetzt über eine abgewiesene Route ab, statt das Netz zu kappen |
| Prisma meldete den Teilindex nicht beim Namen | Ein `UNIQUE INDEX … WHERE` erscheint als verletzte Eindeutigkeit auf der Spalte | Zusicherung im Test daran angepasst, mit Verweis auf den Index |
| Die Bildschirmfotos zeigten Testleichen | 34 Überwachungen aus den E2E-Läufen standen über der einen echten aus dem Seed | Rückstände in der Entwicklungsdatenbank abgeräumt, Auswahl im Skript auf Nicht-Testdaten eingegrenzt. Die Ursache bleibt: die E2E-Suite schreibt in die Demowehr |
### Abweichung von der Vorgabe
**Kein Socket.IO.** Siehe oben — nach Rücksprache durch Server-Sent Events
ersetzt. Die Roadmap ist entsprechend berichtigt.
**Kein RFID-Scan beim Truppanlegen.** Phase-5.md nennt „Truppanlage per
RFID-Scan oder Auswahl". Umgesetzt ist die Auswahl. Der Kartenleser hängt am
Terminal im Gerätehaus (Phase 4) und meldet dort Personen an; am
Überwachungsschirm steht der Überwacher mit einem Tablet und wählt zwei Namen
aus einer Liste, auf der ohnehin die Tauglichkeit steht. Der Scan wäre dort
ein zweiter Weg zum selben Ziel — nachrüstbar, wenn er im Betrieb vermisst
wird.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 306 Tests grün, davon 36 gegen eine echte Datenbank
- `npx playwright test tests/e2e/atemschutz.spec.ts` — 16 grün (Desktop und Tablet)
- `npm run db:seed` — Nachweise und eine abgeschlossene Überwachung angelegt
---
+6 -4
View File
@@ -14,7 +14,7 @@ Phase 1 ████████████████████ fertig
Phase 2 ████████████████████ fertig 0.3.0
Phase 3 ████████████████████ fertig 0.4.0
Phase 4 ████████████████████ fertig 0.5.0
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
Phase 5 ████████████████████ fertig 0.6.0
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
@@ -116,14 +116,16 @@ Für die Wandtablets in der Fahrzeughalle.
**Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den
Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist.
## Phase 5 — Atemschutz nach FwDV 7 · als Nächstes
## Phase 5 — Atemschutz nach FwDV 7 · fertig
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
- Truppanlage per RFID, Eingangsdruck erfassen
- Serverseitig autoritative Timer, per Socket.IO verteilt
- Serverseitig autoritative Zeitstempel; verteilt über Server-Sent Events
statt Socket.IO — es geht nur in eine Richtung, und so bleibt es bei
`next start` ohne eigenen Node-Server
- Rückzugsdruck-Berechnung mit Ampel und akustischem Alarm
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
- Mayday-Knopf je Trupp
@@ -133,7 +135,7 @@ eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
analogen, bis die Wehr ausdrücklich anders entscheidet.
## Phase 6 — Kartenmodule
## Phase 6 — Kartenmodule · als Nächstes
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 733 KiB

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 968 KiB

After

Width:  |  Height:  |  Size: 969 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1013 KiB

After

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "florian",
"version": "0.5.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "florian",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "florian",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": {
+7 -1
View File
@@ -29,8 +29,14 @@ export default defineConfig({
* Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange
* kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann
* nach einem Fehler in der Anwendung aus, ist aber keiner.
*
* Ab Phase 5 von vier auf zwei: mit über 130 Tests, bcrypt bei jeder
* Anmeldung und einer Datenbank daneben fielen bei vier Arbeitern in jedem
* Gesamtlauf zwei bis fünf Tests ins Zeitlimit — jedes Mal andere, jeder
* einzeln grün. Ein Ergebnis, das man nicht glauben kann, ist wertlos; der
* Lauf dauert dafür etwa ein Drittel länger.
*/
workers: process.env.CI ? 1 : 4,
workers: process.env.CI ? 1 : 2,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
timeout: 90_000,
@@ -0,0 +1,252 @@
-- Phase 5 — Atemschutz nach FwDV 7
--
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert:
-- die Volltext- und GiST-Indizes aus den Phasen 1 und 3 stehen nicht im
-- Schema, weil Prisma sie nicht abbilden kann. Ohne diesen Eingriff wuerde
-- jede neue Migration sie abraeumen. Rechte, Mandantentrennung und die
-- Unveraenderbarkeit des Protokolls stehen in der Migration daneben.
-- CreateEnum
CREATE TYPE "TruppStatus" AS ENUM ('BEREIT', 'IM_EINSATZ', 'RUECKWEG', 'DRAUSSEN');
-- CreateEnum
CREATE TYPE "DruckArt" AS ENUM ('EINGANG', 'ZWISCHEN', 'AUSGANG');
-- CreateEnum
CREATE TYPE "EreignisArt" AS ENUM ('TRUPP_ANGELEGT', 'EINSTIEG', 'DRUCKABFRAGE', 'RUECKZUG_ANGEORDNET', 'RUECKZUG_ANGETRETEN', 'HERAUSGEKOMMEN', 'MAYDAY', 'MAYDAY_AUFGEHOBEN', 'HINWEIS');
-- CreateEnum
CREATE TYPE "G26Ergebnis" AS ENUM ('TAUGLICH', 'BEFRISTET_TAUGLICH', 'NACHUNTERSUCHUNG', 'NICHT_TAUGLICH');
-- CreateEnum
CREATE TYPE "UebungsartAtemschutz" AS ENUM ('UEBUNGSSTRECKE', 'EINSATZUEBUNG', 'UNTERWEISUNG');
-- CreateTable
CREATE TABLE "atemschutzeinsatz" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID,
"uebungId" UUID,
"ueberwacherId" UUID NOT NULL,
"bezeichnung" VARCHAR(200) NOT NULL,
"beginnUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endeUm" TIMESTAMPTZ(3),
"abfrageIntervall" INTEGER NOT NULL DEFAULT 600,
"sicherheitszuschlag" INTEGER NOT NULL DEFAULT 10,
"bemerkung" VARCHAR(2000),
"protokollDateiId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "atemschutzeinsatz_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "atemschutztrupp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"atemschutzeinsatzId" UUID NOT NULL,
"funkrufname" VARCHAR(60) NOT NULL,
"auftrag" VARCHAR(500),
"status" "TruppStatus" NOT NULL DEFAULT 'BEREIT',
"angelegtUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"einstiegUm" TIMESTAMPTZ(3),
"rueckzugUm" TIMESTAMPTZ(3),
"drausenUm" TIMESTAMPTZ(3),
"rueckzugsdruck" INTEGER,
"maydayUm" TIMESTAMPTZ(3),
"maydayAufgehobenUm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(1000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "atemschutztrupp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "truppmitglied" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"truppId" UUID NOT NULL,
"personId" UUID NOT NULL,
"truppfuehrer" BOOLEAN NOT NULL DEFAULT false,
"geraetId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "truppmitglied_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "druckmessung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"truppId" UUID NOT NULL,
"personId" UUID NOT NULL,
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"druck" INTEGER NOT NULL,
"art" "DruckArt" NOT NULL DEFAULT 'ZWISCHEN',
"erfasstVonId" UUID,
"bemerkung" VARCHAR(300),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "druckmessung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "atemschutz_ereignis" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"atemschutzeinsatzId" UUID NOT NULL,
"truppId" UUID,
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"art" "EreignisArt" NOT NULL,
"text" VARCHAR(500) NOT NULL,
"personId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "atemschutz_ereignis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "g26_untersuchung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"untersuchtAm" DATE NOT NULL,
"gueltigBis" DATE NOT NULL,
"ergebnis" "G26Ergebnis" NOT NULL DEFAULT 'TAUGLICH',
"arzt" VARCHAR(200),
"bemerkung" VARCHAR(1000),
"nachweisDateiId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "g26_untersuchung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "belastungsuebung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"datum" DATE NOT NULL,
"art" "UebungsartAtemschutz" NOT NULL DEFAULT 'UEBUNGSSTRECKE',
"ort" VARCHAR(200),
"dauerMinuten" INTEGER,
"uebungId" UUID,
"bemerkung" VARCHAR(1000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "belastungsuebung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "atemschutzeinsatz_organisationId_endeUm_idx" ON "atemschutzeinsatz"("organisationId", "endeUm");
-- CreateIndex
CREATE INDEX "atemschutzeinsatz_organisationId_beginnUm_idx" ON "atemschutzeinsatz"("organisationId", "beginnUm" DESC);
-- CreateIndex
CREATE INDEX "atemschutztrupp_organisationId_atemschutzeinsatzId_idx" ON "atemschutztrupp"("organisationId", "atemschutzeinsatzId");
-- CreateIndex
CREATE INDEX "atemschutztrupp_organisationId_status_idx" ON "atemschutztrupp"("organisationId", "status");
-- CreateIndex
CREATE INDEX "truppmitglied_organisationId_personId_idx" ON "truppmitglied"("organisationId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "truppmitglied_truppId_personId_key" ON "truppmitglied"("truppId", "personId");
-- CreateIndex
CREATE INDEX "druckmessung_organisationId_truppId_zeitpunkt_idx" ON "druckmessung"("organisationId", "truppId", "zeitpunkt");
-- CreateIndex
CREATE INDEX "atemschutz_ereignis_organisationId_atemschutzeinsatzId_zeit_idx" ON "atemschutz_ereignis"("organisationId", "atemschutzeinsatzId", "zeitpunkt");
-- CreateIndex
CREATE INDEX "g26_untersuchung_organisationId_personId_gueltigBis_idx" ON "g26_untersuchung"("organisationId", "personId", "gueltigBis" DESC);
-- CreateIndex
CREATE INDEX "g26_untersuchung_organisationId_gueltigBis_idx" ON "g26_untersuchung"("organisationId", "gueltigBis");
-- CreateIndex
CREATE INDEX "belastungsuebung_organisationId_personId_datum_idx" ON "belastungsuebung"("organisationId", "personId", "datum" DESC);
-- AddForeignKey
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_ueberwacherId_fkey" FOREIGN KEY ("ueberwacherId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutzeinsatz_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "atemschutztrupp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "atemschutztrupp_atemschutzeinsatzId_fkey" FOREIGN KEY ("atemschutzeinsatzId") REFERENCES "atemschutzeinsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_truppId_fkey" FOREIGN KEY ("truppId") REFERENCES "atemschutztrupp"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "truppmitglied" ADD CONSTRAINT "truppmitglied_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_truppId_fkey" FOREIGN KEY ("truppId") REFERENCES "atemschutztrupp"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_atemschutzeinsatzId_fkey" FOREIGN KEY ("atemschutzeinsatzId") REFERENCES "atemschutzeinsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_truppId_fkey" FOREIGN KEY ("truppId") REFERENCES "atemschutztrupp"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "atemschutz_ereignis" ADD CONSTRAINT "atemschutz_ereignis_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_untersuchung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_untersuchung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_untersuchung_nachweisDateiId_fkey" FOREIGN KEY ("nachweisDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belastungsuebung" ADD CONSTRAINT "belastungsuebung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belastungsuebung" ADD CONSTRAINT "belastungsuebung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belastungsuebung" ADD CONSTRAINT "belastungsuebung_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,158 @@
-- =============================================================================
-- Phase 5: Mandantentrennung und die Regeln, die das Protokoll tragen.
--
-- Dieses Modul ist sicherheitsrelevant. Was hier steht, ist im Zweifelsfall
-- das, was vor der Unfallkasse zaehlt — deshalb liegen die Regeln in der
-- Datenbank und nicht im Anwendungscode, wo ein vergessener Zweig genuegt:
--
-- 1. Row Level Security fuer die sieben neuen Tabellen.
--
-- 2. Das Protokoll ist unveraenderlich. Druckmessungen und Ereignisse
-- lassen sich anlegen, aber nicht aendern und nicht loeschen. Ein
-- Tippfehler wird durch eine neue Messung richtiggestellt, nicht durch
-- Ueberschreiben — sonst waere jede Aufzeichnung nachtraeglich
-- glaettbar, und genau das darf sie nicht sein.
--
-- 3. Bedingungen, die Unmoegliches abweisen: ein negativer Druck, ein
-- Rueckzug vor dem Einstieg, ein Trupp ohne Zeitfolge.
--
-- 4. Teilindizes fuer die einzige Abfrage, die im Einsatz im Sekundentakt
-- laeuft: welche Trupps sind gerade drin.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'atemschutzeinsatz',
'atemschutztrupp',
'truppmitglied',
'druckmessung',
'atemschutz_ereignis',
'g26_untersuchung',
'belastungsuebung'
]
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;
$$;
-- -----------------------------------------------------------------------------
-- Was es nicht geben darf
--
-- Ein Pressluftatmer haelt 300 bar; alles darueber ist ein Tippfehler, alles
-- unter null gibt es nicht. Die Zeitfolge eines Trupps ist immer dieselbe:
-- angelegt, eingestiegen, Rueckzug, draussen. Sie darf sich nicht verdrehen.
-- -----------------------------------------------------------------------------
ALTER TABLE "druckmessung" ADD CONSTRAINT "druckmessung_druck_moeglich" CHECK (
"druck" >= 0 AND "druck" <= 400
);
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_einstieg_nach_anlage" CHECK (
"einstiegUm" IS NULL OR "einstiegUm" >= "angelegtUm"
);
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_rueckzug_nach_einstieg" CHECK (
"rueckzugUm" IS NULL OR ("einstiegUm" IS NOT NULL AND "rueckzugUm" >= "einstiegUm")
);
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_draussen_nach_einstieg" CHECK (
"drausenUm" IS NULL OR ("einstiegUm" IS NOT NULL AND "drausenUm" >= "einstiegUm")
);
ALTER TABLE "atemschutztrupp" ADD CONSTRAINT "trupp_mayday_aufhebung_danach" CHECK (
"maydayAufgehobenUm" IS NULL OR ("maydayUm" IS NOT NULL AND "maydayAufgehobenUm" >= "maydayUm")
);
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutz_ende_nach_beginn" CHECK (
"endeUm" IS NULL OR "endeUm" >= "beginnUm"
);
ALTER TABLE "atemschutzeinsatz" ADD CONSTRAINT "atemschutz_intervall_sinnvoll" CHECK (
"abfrageIntervall" BETWEEN 60 AND 3600
);
ALTER TABLE "g26_untersuchung" ADD CONSTRAINT "g26_gueltig_nach_untersuchung" CHECK (
"gueltigBis" > "untersuchtAm"
);
-- -----------------------------------------------------------------------------
-- Genau ein Truppfuehrer je Trupp
--
-- Zwei Fuehrer sind keine Kleinigkeit: im Protokoll stuende dann nicht mehr,
-- wer verantwortlich war.
-- -----------------------------------------------------------------------------
CREATE UNIQUE INDEX "truppmitglied_ein_fuehrer" ON "truppmitglied" ("truppId")
WHERE "truppfuehrer";
-- -----------------------------------------------------------------------------
-- Teilindizes fuer den laufenden Einsatz
-- -----------------------------------------------------------------------------
CREATE INDEX "atemschutz_laufend" ON "atemschutzeinsatz" ("organisationId", "beginnUm" DESC)
WHERE "endeUm" IS NULL;
CREATE INDEX "trupp_drinnen" ON "atemschutztrupp" ("organisationId", "atemschutzeinsatzId")
WHERE "status" IN ('IM_EINSATZ', 'RUECKWEG');
CREATE INDEX "g26_gueltig" ON "g26_untersuchung" ("organisationId", "gueltigBis" DESC)
WHERE "geloeschtAm" IS NULL;
-- -----------------------------------------------------------------------------
-- Das Protokoll ist unveraenderlich
--
-- Zwei Tabellen sind reine Aufzeichnung: was gemessen und was passiert ist.
-- Beides wird angelegt und danach nie wieder angefasst. Der Trigger ist die
-- zweite Sperre neben dem entzogenen Recht weiter unten — eine davon reicht,
-- aber bei einer Aufzeichnung, an der Menschenleben haengen, ist die zweite
-- ihren Preis wert.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION atemschutz_protokoll_unveraenderlich()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION
'Das Atemschutzprotokoll ist unveraenderlich. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht durch Aendern (Tabelle %).',
TG_TABLE_NAME
USING ERRCODE = 'check_violation';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER druckmessung_unveraenderlich
BEFORE UPDATE OR DELETE ON "druckmessung"
FOR EACH ROW EXECUTE FUNCTION atemschutz_protokoll_unveraenderlich();
CREATE TRIGGER atemschutz_ereignis_unveraenderlich
BEFORE UPDATE OR DELETE ON "atemschutz_ereignis"
FOR EACH ROW EXECUTE FUNCTION atemschutz_protokoll_unveraenderlich();
-- -----------------------------------------------------------------------------
-- Rechte fuer die Anwendungsrolle
-- -----------------------------------------------------------------------------
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';
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
-- Aufzeichnung, kein Datenbestand: anlegen ja, aendern nein.
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
@@ -0,0 +1,18 @@
-- Nachtrag zu Phase 5.
--
-- Der Rueckzugsdruck wird nach dem doppelten Anmarschverbrauch gerechnet:
-- so viel Luft, wie der Weg hinein gekostet hat, braucht auch der Weg
-- zurueck. Dafuer meldet der Trupp beim Erreichen des Einsatzziels einmal
-- seinen Druck — das ist die neue Art ZIEL.
--
-- Der Ansprechdruck der Warneinrichtung steht je Ueberwachung im Datensatz,
-- weil eine Wehr Geraete unterschiedlicher Baujahre im Bestand hat.
--
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert
-- (siehe Kopf der Migration 20260903190219_atemschutz_fwdv7).
-- AlterEnum
ALTER TYPE "DruckArt" ADD VALUE 'ZIEL';
-- AlterTable
ALTER TABLE "atemschutzeinsatz" ADD COLUMN "warndruck" INTEGER NOT NULL DEFAULT 55;
+362 -2
View File
@@ -199,6 +199,15 @@ model Organisation {
kioskvorgaenge KioskVorgang[]
syncKonflikte SyncKonflikt[]
// Phase 5 — Atemschutz nach FwDV 7
atemschutzeinsaetze Atemschutzeinsatz[]
atemschutztrupps Atemschutztrupp[]
truppmitglieder Truppmitglied[]
druckmessungen Druckmessung[]
atemschutzereignisse AtemschutzEreignis[]
g26Untersuchungen G26Untersuchung[]
belastungsuebungen Belastungsuebung[]
@@index([aktiv])
@@map("organisation")
}
@@ -292,6 +301,15 @@ model Person {
verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher")
erledigteKonflikte SyncKonflikt[] @relation("KonfliktErledigung")
// Phase 5 — Atemschutz nach FwDV 7
ueberwachteEinsaetze Atemschutzeinsatz[] @relation("AtemschutzUeberwacher")
truppeinsaetze Truppmitglied[] @relation("TruppMitgliedPerson")
druckmessungen Druckmessung[] @relation("DruckmessungPerson")
erfassteDruckmessungen Druckmessung[] @relation("DruckmessungErfasser")
atemschutzereignisse AtemschutzEreignis[] @relation("AtemschutzEreignisPerson")
g26Untersuchungen G26Untersuchung[] @relation("G26Person")
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -526,6 +544,10 @@ model Datei {
alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto")
alsUebungsbericht Uebung[] @relation("UebungBericht")
// Phase 5
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@ -842,6 +864,9 @@ model Geraet {
waescheabgaben Waescheabgabe[]
inventurposten Inventurposten[]
// Phase 5
alsPressluftatmer Truppmitglied[] @relation("TruppPressluftatmer")
@@unique([organisationId, inventarnummer])
@@index([organisationId, status])
@@index([organisationId, naechstePruefung])
@@ -1649,8 +1674,11 @@ model Einsatz {
material Materialverbrauch[]
lagemeldungen Lagemeldung[]
portalzugaenge PortalZugang[]
fahrts Fahrt[]
anwesenheits Anwesenheit[]
fahrten Fahrt[]
anwesenheiten Anwesenheit[]
// Phase 5
atemschutz Atemschutzeinsatz[]
@@unique([organisationId, einsatznummer])
@@index([organisationId, alarmiertUm(sort: Desc)])
@@ -1860,6 +1888,10 @@ model Uebung {
fahrten Fahrt[]
anwesenheiten Anwesenheit[]
// Phase 5
atemschutz Atemschutzeinsatz[]
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungUebung")
@@unique([organisationId, nummer])
@@index([organisationId, datum(sort: Desc)])
@@index([organisationId, art])
@@ -2213,3 +2245,331 @@ model SyncKonflikt {
@@index([organisationId, erledigtAm])
@@map("sync_konflikt")
}
// =============================================================================
// Phase 5 — Atemschutz nach FwDV 7
//
// Das sicherheitsrelevanteste Modul des Projekts. Zwei Dinge bestimmen den
// Aufbau:
//
// 1. **Die Zeit gehoert dem Server.** Jeder Zeitpunkt steht als Zeitstempel
// in der Datenbank, nie als heruntergezaehlte Restzeit. Der Browser
// rechnet daraus nur die Anzeige. Ein Neuladen, ein Verbindungsabbruch
// oder ein zweiter Bildschirm aendern daran nichts — es gibt keinen
// Timer, der verloren gehen koennte.
// 2. **Das Protokoll ist lueckenlos.** Jede Druckabfrage, jeder
// Statuswechsel und jedes Mayday steht als eigene Zeile mit Zeitpunkt.
// Gelöscht wird nichts; die Datenbank verweigert es (siehe Migration).
//
// Ueber allem steht der Hinweis, dass die analoge Ueberwachung fuehrend
// bleibt. Er ist in der Anwendung fest verdrahtet und nur ueber eine
// Konfigurationsoption abschaltbar, die eine dokumentierte Freigabe des
// Kommandanten voraussetzt.
// =============================================================================
/// Zustand eines Trupps. Die Reihenfolge ist der uebliche Verlauf.
enum TruppStatus {
/// Angelegt, Eingangsdruck erfasst, noch nicht unter Atemschutz.
BEREIT
/// Unter Atemschutz im Einsatz.
IM_EINSATZ
/// Auf dem Rueckweg — ab hier zaehlt die Zeit doppelt.
RUECKWEG
/// Draussen, Ausgangsdruck erfasst. Endzustand.
DRAUSSEN
}
/// Wann ein Druck gemessen wurde.
enum DruckArt {
/// Vor dem Anlegen, am Geraet abgelesen.
EINGANG
/// Beim Erreichen des Einsatzziels. Aus dem Verbrauch bis hierher errechnet
/// sich der Rueckzugsdruck — der Weg zurueck ist so lang wie der Weg hin.
ZIEL
/// Waehrend des Einsatzes, ueblicherweise alle zehn Minuten.
ZWISCHEN
/// Nach dem Herauskommen.
AUSGANG
}
/// Was im Protokoll steht.
enum EreignisArt {
TRUPP_ANGELEGT
EINSTIEG
DRUCKABFRAGE
RUECKZUG_ANGEORDNET
RUECKZUG_ANGETRETEN
HERAUSGEKOMMEN
MAYDAY
MAYDAY_AUFGEHOBEN
HINWEIS
}
/// Ergebnis einer Untersuchung nach G26.3.
enum G26Ergebnis {
TAUGLICH
BEFRISTET_TAUGLICH
NACHUNTERSUCHUNG
NICHT_TAUGLICH
}
/// Art der jaehrlichen Uebung nach FwDV 7.
enum UebungsartAtemschutz {
/// Belastungsuebung in der Atemschutzuebungsstrecke.
UEBUNGSSTRECKE
/// Einsatzuebung unter Atemschutz.
EINSATZUEBUNG
/// Unterweisung, theoretisch.
UNTERWEISUNG
}
/// Eine Atemschutzueberwachung.
///
/// Haengt an einem Einsatz oder einer Uebung — oder an keinem von beidem, denn
/// im Ernstfall wird zuerst ueberwacht und spaeter zugeordnet.
model Atemschutzeinsatz {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Wozu die Ueberwachung gehoert. Beides optional, siehe oben.
einsatzId String? @db.Uuid
uebungId String? @db.Uuid
/// Wer die Ueberwachung fuehrt. Nach FwDV 7 eine benannte Person.
ueberwacherId String @db.Uuid
bezeichnung String @db.VarChar(200)
beginnUm DateTime @default(now()) @db.Timestamptz(3)
endeUm DateTime? @db.Timestamptz(3)
/// Intervall der Druckabfrage in Sekunden. Vorgabe kommt aus der
/// Konfiguration, laesst sich je Ueberwachung anpassen.
abfrageIntervall Int @default(600)
/// Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar.
/// Der Kommandant legt ihn fest; die Formel steht in lib/atemschutz.
sicherheitszuschlag Int @default(10)
/// Ansprechdruck der Warneinrichtung des verwendeten Geraets, in bar.
/// Steht je Ueberwachung im Datensatz, weil eine Wehr Geraete
/// unterschiedlicher Baujahre im Bestand hat.
warndruck Int @default(55)
bemerkung String? @db.VarChar(2000)
/// Das abgeschlossene Protokoll als PDF, liegt in MinIO.
protokollDateiId 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: SetNull)
uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull)
ueberwacher Person @relation("AtemschutzUeberwacher", fields: [ueberwacherId], references: [id], onDelete: Restrict)
protokollDatei Datei? @relation("AtemschutzProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull)
trupps Atemschutztrupp[]
ereignisse AtemschutzEreignis[]
@@index([organisationId, endeUm])
@@index([organisationId, beginnUm(sort: Desc)])
@@map("atemschutzeinsatz")
}
/// Ein Trupp unter Atemschutz.
///
/// Die Zeiten stehen als Zeitstempel, nicht als Dauer: `einstiegUm` ist der
/// Moment, ab dem die Uhr laeuft. Wie lange ein Trupp drin ist, rechnet jede
/// Ansicht aus der aktuellen Zeit selbst aus — deshalb ueberlebt die Anzeige
/// jeden Neustart.
model Atemschutztrupp {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
atemschutzeinsatzId String @db.Uuid
funkrufname String @db.VarChar(60)
auftrag String? @db.VarChar(500)
status TruppStatus @default(BEREIT)
angelegtUm DateTime @default(now()) @db.Timestamptz(3)
einstiegUm DateTime? @db.Timestamptz(3)
rueckzugUm DateTime? @db.Timestamptz(3)
drausenUm DateTime? @db.Timestamptz(3)
/// Errechnet beim Einstieg aus den Eingangsdruecken. Steht fest im Datensatz,
/// damit spaeter nachvollziehbar ist, mit welchem Wert gearbeitet wurde —
/// auch wenn sich die Formel einmal aendert.
rueckzugsdruck Int?
/// Laeuft ein Notfall? Zeitpunkt statt Kennzeichen, damit die Dauer im
/// Protokoll steht.
maydayUm DateTime? @db.Timestamptz(3)
maydayAufgehobenUm DateTime? @db.Timestamptz(3)
bemerkung String? @db.VarChar(1000)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade)
mitglieder Truppmitglied[]
druckmessungen Druckmessung[]
ereignisse AtemschutzEreignis[]
@@index([organisationId, atemschutzeinsatzId])
@@index([organisationId, status])
@@map("atemschutztrupp")
}
/// Wer im Trupp ist.
///
/// Ueblich sind zwei, erlaubt sind drei — die Zuordnung steht hier und nicht
/// als zwei Spalten am Trupp, weil sonst der dritte Mann nicht abbildbar waere.
model Truppmitglied {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
truppId String @db.Uuid
personId String @db.Uuid
/// true beim Truppfuehrer. Genau einer je Trupp.
truppfuehrer Boolean @default(false)
/// Kennung des Pressluftatmers, falls erfasst — hilft beim Zuordnen der
/// Flaschenfuellung hinterher.
geraetId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade)
person Person @relation("TruppMitgliedPerson", fields: [personId], references: [id], onDelete: Restrict)
geraet Geraet? @relation("TruppPressluftatmer", fields: [geraetId], references: [id], onDelete: SetNull)
@@unique([truppId, personId])
@@index([organisationId, personId])
@@map("truppmitglied")
}
/// Eine Druckmessung.
///
/// Append-only: einmal erfasst, bleibt sie stehen. Ein Tippfehler wird durch
/// eine neue Messung richtiggestellt, nicht durch Ueberschreiben — im
/// Zweifelsfall zaehlt, was zum Zeitpunkt X abgelesen wurde.
model Druckmessung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
truppId String @db.Uuid
personId String @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
/// Druck in bar. Ganzzahlig — feiner liest niemand ab.
druck Int
art DruckArt @default(ZWISCHEN)
erfasstVonId String? @db.Uuid
bemerkung String? @db.VarChar(300)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade)
person Person @relation("DruckmessungPerson", fields: [personId], references: [id], onDelete: Restrict)
erfasstVon Person? @relation("DruckmessungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
@@index([organisationId, truppId, zeitpunkt])
@@map("druckmessung")
}
/// Das Protokoll.
///
/// Jede Zeile ein Ereignis mit Zeitpunkt. Zusammen mit den Druckmessungen ist
/// das der lueckenlose Nachweis, den FwDV 7 verlangt — und der Grund, warum
/// hier nichts geaendert und nichts geloescht werden kann.
model AtemschutzEreignis {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
atemschutzeinsatzId String @db.Uuid
truppId String? @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
art EreignisArt
text String @db.VarChar(500)
/// Wer es ausgeloest hat.
personId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade)
trupp Atemschutztrupp? @relation(fields: [truppId], references: [id], onDelete: Cascade)
person Person? @relation("AtemschutzEreignisPerson", fields: [personId], references: [id], onDelete: SetNull)
@@index([organisationId, atemschutzeinsatzId, zeitpunkt])
@@map("atemschutz_ereignis")
}
/// Untersuchung nach G26.3.
///
/// Steht bewusst nicht als Qualifikation aus Phase 1 da: eine G26 hat ein
/// Ergebnis, einen Arzt und eine Frist, die sich nach dem Alter richtet —
/// und sie ist die harte Voraussetzung dafuer, ueberhaupt eingesetzt zu
/// werden.
model G26Untersuchung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
untersuchtAm DateTime @db.Date
gueltigBis DateTime @db.Date
ergebnis G26Ergebnis @default(TAUGLICH)
arzt String? @db.VarChar(200)
bemerkung String? @db.VarChar(1000)
/// Eingescannte Bescheinigung, liegt in MinIO.
nachweisDateiId 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)
person Person @relation("G26Person", fields: [personId], references: [id], onDelete: Cascade)
nachweis Datei? @relation("G26Nachweis", fields: [nachweisDateiId], references: [id], onDelete: SetNull)
@@index([organisationId, personId, gueltigBis(sort: Desc)])
@@index([organisationId, gueltigBis])
@@map("g26_untersuchung")
}
/// Belastungsuebung, Einsatzuebung oder Unterweisung.
///
/// FwDV 7 verlangt beides jaehrlich. Wer eines davon versaeumt, ist nicht
/// einsetzbar — auch mit gueltiger G26 nicht.
model Belastungsuebung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
datum DateTime @db.Date
art UebungsartAtemschutz @default(UEBUNGSSTRECKE)
ort String? @db.VarChar(200)
dauerMinuten Int?
/// Wenn die Uebung im System als Uebung erfasst ist.
uebungId String? @db.Uuid
bemerkung String? @db.VarChar(1000)
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)
person Person @relation("BelastungsuebungPerson", fields: [personId], references: [id], onDelete: Cascade)
uebung Uebung? @relation("BelastungsuebungUebung", fields: [uebungId], references: [id], onDelete: SetNull)
@@index([organisationId, personId, datum(sort: Desc)])
@@map("belastungsuebung")
}
+305 -1
View File
@@ -11,7 +11,9 @@
* sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein
* Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
* beim ersten Start nicht leer ist.
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
* Überwachung mit vollständigem Protokoll.
*
* 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
@@ -32,8 +34,11 @@ import {
EinsatzKategorie,
EinsatzStatus,
Dienstgrad,
DruckArt,
EreignisArt,
FahrtZweck,
FahrzeugArt,
G26Ergebnis,
GeraeteKategorie,
GeraetStatus,
LebensdauerBezug,
@@ -48,7 +53,9 @@ import {
RolleSchluessel,
SignaturRolle,
StandortArt,
TruppStatus,
UebungsArt,
UebungsartAtemschutz,
WaescheStatus,
} from "@prisma/client";
import bcrypt from "bcryptjs";
@@ -2178,6 +2185,300 @@ async function terminaldatenAnlegen(organisationId: string) {
);
}
// -----------------------------------------------------------------------------
// Phase 5 — Atemschutz nach FwDV 7
// -----------------------------------------------------------------------------
/**
* Atemschutz: Nachweise und eine abgeschlossene Überwachung.
*
* Die Nachweise sind bewusst nicht bei allen vollständig — genau so sieht es
* in einer Wehr aus. Einer hat eine abgelaufene G26, einem fehlt die
* Belastungsübung, einer ist bald fällig. Die Tauglichkeitsampel zeigt
* dadurch beim ersten Start alle drei Zustände, statt nur Grün.
*
* Dazu ein vollständig durchgespielter Einsatz: zwei Trupps, Eingangsdrücke,
* Zielmeldung, Zwischenmeldungen, Rückweg, Ausgangsdrücke — das ist die
* Vorlage, an der sich das Protokoll ansehen lässt, ohne selbst einen Einsatz
* zu fahren.
*/
async function atemschutzAnlegen(organisationId: string) {
const vorhanden = await prisma.atemschutzeinsatz.count({ where: { organisationId } });
if (vorhanden > 0) {
log(`Atemschutz: bereits ${vorhanden} Überwachungen 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 < 6) return;
const jetzt = new Date();
function vorTagen(tage: number): Date {
const datum = new Date(jetzt);
datum.setUTCDate(datum.getUTCDate() - tage);
datum.setUTCHours(0, 0, 0, 0);
return datum;
}
function inTagen(tage: number): Date {
return vorTagen(-tage);
}
// --- G26.3 ----------------------------------------------------------------
//
// Die ersten acht sind Atemschutzgeräteträger. Drei Zustände absichtlich:
// gültig, läuft bald ab, abgelaufen.
const G26: Array<{ person: number; untersuchtVorTagen: number; gueltigInTagen: number; ergebnis: G26Ergebnis }> = [
{ person: 0, untersuchtVorTagen: 400, gueltigInTagen: 690, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 2, untersuchtVorTagen: 200, gueltigInTagen: 890, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 3, untersuchtVorTagen: 120, gueltigInTagen: 975, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 5, untersuchtVorTagen: 90, gueltigInTagen: 1005, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 7, untersuchtVorTagen: 30, gueltigInTagen: 1065, ergebnis: G26Ergebnis.TAUGLICH },
// Läuft in gut zwei Monaten ab — die Ampel steht auf Gelb.
{ person: 6, untersuchtVorTagen: 1030, gueltigInTagen: 65, ergebnis: G26Ergebnis.TAUGLICH },
// Abgelaufen: die Person ist nicht einsetzbar, und das soll man sehen.
{ person: 8, untersuchtVorTagen: 1200, gueltigInTagen: -105, ergebnis: G26Ergebnis.TAUGLICH },
// Befristet tauglich — zählt, aber der Arzt hat etwas dazugeschrieben.
{ person: 1, untersuchtVorTagen: 250, gueltigInTagen: 480, ergebnis: G26Ergebnis.BEFRISTET_TAUGLICH },
];
for (const eintrag of G26) {
const person = personen[eintrag.person % personen.length]!;
await prisma.g26Untersuchung.create({
data: {
organisationId,
personId: person.id,
untersuchtAm: vorTagen(eintrag.untersuchtVorTagen),
gueltigBis: inTagen(eintrag.gueltigInTagen),
ergebnis: eintrag.ergebnis,
arzt: "Betriebsarzt Dr. Wagner, Landshut",
bemerkung:
eintrag.ergebnis === G26Ergebnis.BEFRISTET_TAUGLICH
? "Nachuntersuchung des Blutdrucks in einem Jahr empfohlen."
: null,
},
});
}
// --- Belastungs- und Einsatzübungen ---------------------------------------
//
// Person 5 fehlt bewusst in der Belastungsübung: Tauglichkeit ist nicht
// allein eine Frage der G26.
const UEBUNGSTEILNEHMER = [0, 1, 2, 3, 6, 7];
await prisma.belastungsuebung.createMany({
data: UEBUNGSTEILNEHMER.map((nummer) => ({
organisationId,
personId: personen[nummer % personen.length]!.id,
datum: vorTagen(120),
art: UebungsartAtemschutz.UEBUNGSSTRECKE,
ort: "Atemschutzübungsstrecke Landshut",
dauerMinuten: 45,
})),
});
await prisma.belastungsuebung.createMany({
data: [0, 1, 2, 3, 5, 7].map((nummer) => ({
organisationId,
personId: personen[nummer % personen.length]!.id,
datum: vorTagen(60),
art: UebungsartAtemschutz.EINSATZUEBUNG,
ort: "Übungsobjekt ehemalige Schreinerei",
dauerMinuten: 90,
})),
});
// --- Eine abgeschlossene Überwachung --------------------------------------
const beginn = new Date(jetzt.getTime() - 26 * 24 * 60 * 60 * 1000);
beginn.setUTCHours(14, 12, 0, 0);
function nach(minuten: number): Date {
return new Date(beginn.getTime() + minuten * 60 * 1000);
}
const einsatz = await prisma.einsatz.findFirst({
where: { organisationId, geloeschtAm: null },
orderBy: { alarmiertUm: "desc" },
select: { id: true },
});
const ueberwacher = personen[1]!;
const ueberwachung = await prisma.atemschutzeinsatz.create({
data: {
organisationId,
bezeichnung: "Zimmerbrand Hauptstraße 12",
einsatzId: einsatz?.id ?? null,
ueberwacherId: ueberwacher.id,
beginnUm: beginn,
endeUm: nach(38),
abfrageIntervall: 600,
sicherheitszuschlag: 10,
warndruck: 55,
bemerkung: "Regulärer Verlauf, keine besonderen Vorkommnisse.",
},
});
/** Legt einen Trupp samt Messungen und Protokollzeilen an. */
async function truppAnlegen(angaben: {
funkrufname: string;
auftrag: string;
fuehrer: number;
mann: number;
eingang: [number, number];
ziel: [number, number] | null;
zwischen: Array<[number, number, number]>;
ausgang: [number, number];
einstiegMinute: number;
rueckwegMinute: number;
draussenMinute: number;
rueckzugsdruck: number;
}) {
const fuehrer = personen[angaben.fuehrer % personen.length]!;
const mann = personen[angaben.mann % personen.length]!;
const trupp = await prisma.atemschutztrupp.create({
data: {
organisationId,
atemschutzeinsatzId: ueberwachung.id,
funkrufname: angaben.funkrufname,
auftrag: angaben.auftrag,
status: TruppStatus.DRAUSSEN,
angelegtUm: nach(angaben.einstiegMinute - 3),
einstiegUm: nach(angaben.einstiegMinute),
rueckzugUm: nach(angaben.rueckwegMinute),
drausenUm: nach(angaben.draussenMinute),
rueckzugsdruck: angaben.rueckzugsdruck,
mitglieder: {
create: [
{ organisationId, personId: fuehrer.id, truppfuehrer: true },
{ organisationId, personId: mann.id, truppfuehrer: false },
],
},
},
});
const messungen: Array<{ personId: string; druck: number; art: DruckArt; zeitpunkt: Date }> = [
{ personId: fuehrer.id, druck: angaben.eingang[0], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) },
{ personId: mann.id, druck: angaben.eingang[1], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) },
];
if (angaben.ziel) {
messungen.push(
{ personId: fuehrer.id, druck: angaben.ziel[0], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) },
{ personId: mann.id, druck: angaben.ziel[1], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) },
);
}
for (const [minute, fuehrerDruck, mannDruck] of angaben.zwischen) {
messungen.push(
{ personId: fuehrer.id, druck: fuehrerDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) },
{ personId: mann.id, druck: mannDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) },
);
}
messungen.push(
{ personId: fuehrer.id, druck: angaben.ausgang[0], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) },
{ personId: mann.id, druck: angaben.ausgang[1], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) },
);
await prisma.druckmessung.createMany({
data: messungen.map((messung) => ({
organisationId,
truppId: trupp.id,
personId: messung.personId,
druck: messung.druck,
art: messung.art,
zeitpunkt: messung.zeitpunkt,
erfasstVonId: ueberwacher.id,
})),
});
const ereignisse: Array<{ art: EreignisArt; text: string; minute: number }> = [
{ art: EreignisArt.TRUPP_ANGELEGT, text: `Trupp ${angaben.funkrufname} angelegt, Rückzugsdruck ${angaben.rueckzugsdruck} bar.`, minute: angaben.einstiegMinute - 3 },
{ art: EreignisArt.EINSTIEG, text: `Trupp ${angaben.funkrufname} unter Atemschutz eingestiegen.`, minute: angaben.einstiegMinute },
{ art: EreignisArt.RUECKZUG_ANGETRETEN, text: `Trupp ${angaben.funkrufname} tritt den Rückweg an.`, minute: angaben.rueckwegMinute },
{ art: EreignisArt.HERAUSGEKOMMEN, text: `Trupp ${angaben.funkrufname} ist draußen.`, minute: angaben.draussenMinute },
];
await prisma.atemschutzEreignis.createMany({
data: ereignisse.map((ereignis) => ({
organisationId,
atemschutzeinsatzId: ueberwachung.id,
truppId: trupp.id,
art: ereignis.art,
text: ereignis.text,
zeitpunkt: nach(ereignis.minute),
personId: ueberwacher.id,
})),
});
}
await truppAnlegen({
funkrufname: "Angriffstrupp",
auftrag: "Menschenrettung 1. OG, Brandbekämpfung",
fuehrer: 0,
mann: 7,
eingang: [300, 290],
ziel: [255, 240],
zwischen: [
[16, 205, 185],
[26, 165, 140],
],
ausgang: [120, 95],
einstiegMinute: 6,
rueckwegMinute: 27,
draussenMinute: 32,
rueckzugsdruck: 165,
});
await truppAnlegen({
funkrufname: "Sicherheitstrupp",
auftrag: "Bereitstellung, auf Abruf",
fuehrer: 2,
mann: 3,
eingang: [295, 300],
ziel: null,
zwischen: [[22, 270, 275]],
ausgang: [250, 258],
einstiegMinute: 14,
rueckwegMinute: 28,
draussenMinute: 30,
rueckzugsdruck: 207,
});
await prisma.atemschutzEreignis.createMany({
data: [
{
organisationId,
atemschutzeinsatzId: ueberwachung.id,
art: EreignisArt.HINWEIS,
text: `Überwachung eröffnet durch ${ueberwacher.vorname} ${ueberwacher.nachname}.`,
zeitpunkt: beginn,
personId: ueberwacher.id,
},
{
organisationId,
atemschutzeinsatzId: ueberwachung.id,
art: EreignisArt.HINWEIS,
text: "Überwachung beendet. Alle Trupps zurück, keine besonderen Vorkommnisse.",
zeitpunkt: nach(38),
personId: ueberwacher.id,
},
],
});
log(
`Atemschutz: ${G26.length} G26-Untersuchungen, ${UEBUNGSTEILNEHMER.length + 6} Übungsnachweise, 1 abgeschlossene Überwachung mit 2 Trupps`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -2212,6 +2513,9 @@ async function main(): Promise<void> {
// Phase 4 — Terminal im Gerätehaus
await terminaldatenAnlegen(organisation.id);
// Phase 5 — Atemschutz nach FwDV 7
await atemschutzAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");
+24
View File
@@ -170,6 +170,20 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Anmeldemarke fürs Handy",
angemeldet: true,
},
// Phase 5 — Atemschutz nach FwDV 7
{
name: "31-atemschutz",
pfad: "/atemschutz",
beschreibung: "Atemschutz: laufende Überwachung und Tauglichkeit der Geräteträger",
angemeldet: true,
},
{
name: "32-atemschutz-nachweise",
pfad: "/atemschutz/nachweise",
beschreibung: "G26.3 und Belastungsübungen erfassen",
angemeldet: true,
},
];
/**
@@ -227,6 +241,16 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
von: "/uebungen",
linkMuster: /^\/uebungen\/[0-9a-f-]{36}$/,
},
{
name: "33-atemschutz-ueberwachung",
beschreibung: "Überwachungsschirm mit Trupps, Drücken und Rückzugsdruck",
von: "/atemschutz",
linkMuster: /^\/atemschutz\/[0-9a-f-]{36}$/,
// Nicht die Überwachungen aus den E2E-Tests: die tragen "E2E" im Namen und
// stehen ganz oben, weil sie nie beendet werden. Die Beschriftung des
// Verweises enthält noch anderes, deshalb wird der ganze Text geprüft.
textMuster: /^(?!.*E2E)/s,
},
];
/**
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Sicherheitshinweis } from "@/components/atemschutz/hinweis";
import { Ueberwachungsschirm } from "@/app/(app)/atemschutz/[id]/ueberwachung";
export const metadata = { title: "Atemschutzüberwachung" };
/**
* Eine laufende Überwachung.
*
* Der Hinweis steht ganz oben und bleibt beim Scrollen im Blick — er ist der
* erste und der letzte Satz dieser Seite.
*/
export default async function UeberwachungSeite({
params,
}: {
params: Promise<{ id: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "atemschutz.lesen")) redirect("/");
const { id } = await params;
return (
<div className="space-y-6">
<Sicherheitshinweis />
<Ueberwachungsschirm id={id} darfBedienen={hatRecht(sitzung, "atemschutz.ueberwachung")} />
</div>
);
}
@@ -0,0 +1,316 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, FileText, Plus, Radio, WifiOff } from "lucide-react";
import { DruckArt, TruppStatus } 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 { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { TruppKarte } from "@/components/atemschutz/trupp-karte";
import { useEreignisstrom, useJetzt } from "@/components/atemschutz/stand";
import { TruppAnlegen } from "@/components/atemschutz/trupp-anlegen";
import { alsDatumZeit } from "@/lib/utils";
import { alsUhr } from "@/lib/atemschutz/berechnung";
/**
* Der Überwachungsschirm.
*
* Was diese Ansicht aushalten muss, steht in Phase-5.md als wichtigste
* Testanforderung: **ein Neuladen und ein Verbindungsabbruch dürfen ihr nichts
* anhaben.** Drei Entscheidungen sorgen dafür:
*
* 1. **Kein Zustand, der nur hier lebt.** Die Uhr eines Trupps wird nicht
* hochgezählt, sondern jede Sekunde neu aus zwei Zeitstempeln gerechnet.
* Nach einem Neuladen kommen dieselben Zeitstempel zurück, also steht
* dieselbe Zahl da.
* 2. **Zwei Wege zum Stand.** Der Ereignisstrom meldet Änderungen sofort;
* unabhängig davon fragt die Ansicht alle paar Sekunden nach. Fällt der
* Strom aus, wird die Anzeige langsamer — nicht falsch.
* 3. **Die Verbindung ist sichtbar.** Wer nicht sieht, dass der Draht weg
* ist, hält einen alten Stand für den aktuellen. Das wäre der
* gefährlichste Fehler, den diese Ansicht machen könnte.
*/
/** Wie oft ohne Ereignis nachgefragt wird. Netz vorausgesetzt, ist das die Rückfallebene. */
const NACHFRAGE_MS = 5000;
export function Ueberwachungsschirm({
id,
darfBedienen,
}: {
id: string;
darfBedienen: boolean;
}) {
const jetzt = useJetzt();
const [meldung, setMeldung] = React.useState<string | null>(null);
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [notiz, setNotiz] = React.useState("");
const werkzeuge = trpc.useUtils();
const stand = trpc.atemschutz.stand.useQuery(
{ id },
{
refetchInterval: NACHFRAGE_MS,
// Beim Zurückkehren an das Tablet soll sofort der echte Stand da sein.
refetchOnWindowFocus: true,
retry: 2,
},
);
const neuLaden = React.useCallback(() => {
void werkzeuge.atemschutz.stand.invalidate({ id });
}, [id, werkzeuge]);
const { verbunden, letzteMeldung } = useEreignisstrom(id, neuLaden);
const beiFehler = (fehler: { message: string }) => setMeldung(fehler.message);
const beiErfolg = () => {
setMeldung(null);
neuLaden();
};
const druck = trpc.atemschutz.druckErfassen.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const status = trpc.atemschutz.statusSetzen.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const mayday = trpc.atemschutz.mayday.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const rueckzug = trpc.atemschutz.rueckzugAnordnen.useMutation({
onSuccess: beiErfolg,
onError: beiFehler,
});
const notizSenden = trpc.atemschutz.notiz.useMutation({
onSuccess: () => {
setNotiz("");
beiErfolg();
},
onError: beiFehler,
});
const beenden = trpc.atemschutz.beenden.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const daten = stand.data;
const laeuft = daten && !daten.endeUm;
const trupps = daten?.trupps ?? [];
const notfall = trupps.some((trupp) => trupp.maydayUm && !trupp.maydayAufgehobenUm);
/**
* Der Ton beim Notfall.
*
* Kein Tonfile, sondern ein erzeugter Ton: eine Datei müsste geladen werden,
* und im Zweifel ist sie genau dann nicht im Zwischenspeicher, wenn sie
* gebraucht wird. Der Ton ist ausdrücklich eine Zugabe — er ersetzt nicht,
* dass jemand hinsieht.
*/
React.useEffect(() => {
if (!notfall || typeof window === "undefined") return;
let abgebrochen = false;
const zeitgeber = setInterval(() => {
if (abgebrochen) return;
try {
const AudioKontext =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioKontext) return;
const kontext = new AudioKontext();
const ton = kontext.createOscillator();
const lautstaerke = kontext.createGain();
ton.frequency.value = 880;
lautstaerke.gain.value = 0.1;
ton.connect(lautstaerke).connect(kontext.destination);
ton.start();
ton.stop(kontext.currentTime + 0.4);
setTimeout(() => void kontext.close(), 600);
} catch {
// Kein Ton möglich (Browserregel, kein Ausgabegerät) — die Anzeige
// bleibt trotzdem eindeutig.
}
}, 2000);
return () => {
abgebrochen = true;
clearInterval(zeitgeber);
};
}, [notfall]);
if (stand.isPending) {
return <p className="text-[var(--muted-foreground)]">Stand wird geladen …</p>;
}
if (stand.isError || !daten) {
return <Fehlermeldung meldung={stand.error?.message ?? "Unbekannter Fehler"} kennung="standfehler" />;
}
return (
<div className="space-y-6" data-testid="ueberwachung">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/atemschutz">
<ArrowLeft aria-hidden />
Alle Überwachungen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{daten.bezeichnung}</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Überwacher: {daten.ueberwacher.vorname} {daten.ueberwacher.nachname} · Beginn{" "}
{alsDatumZeit(daten.beginnUm)}
{daten.einsatz ? ` · Einsatz ${daten.einsatz.einsatznummer}` : ""}
{daten.endeUm ? ` · beendet ${alsDatumZeit(daten.endeUm)}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Zustand der Verbindung — sichtbar, nicht versteckt. */}
<span
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
verbunden
? "border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 text-[var(--einsatzbereit)]"
: "border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 text-[var(--gesperrt)]"
}`}
role="status"
data-testid="verbindung"
data-verbunden={verbunden}
>
{verbunden ? <Radio className="size-4" aria-hidden /> : <WifiOff className="size-4" aria-hidden />}
{verbunden ? "Verbunden" : "Verbindung unterbrochen"}
{letzteMeldung && (
<span className="text-xs opacity-80">
zuletzt{" "}
{letzteMeldung.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
)}
</span>
{/*
Sobald die Überwachung beendet ist — nicht erst, wenn die Datei
schon liegt: der Endpunkt erzeugt sie bei Bedarf. Ein Nachweis, den
man nicht bekommt, weil ein Hintergrunddienst einen schlechten Tag
hatte, wäre keiner.
*/}
{daten.endeUm && (
<Button variant="outline" asChild>
<a href={`/api/atemschutz/protokoll?id=${daten.id}`} target="_blank" rel="noreferrer">
<FileText aria-hidden />
Protokoll
</a>
</Button>
)}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="ueberwachungsfehler" />}
{!verbunden && (
<p className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 px-4 py-3 text-sm">
Der Ereignisstrom ist unterbrochen. Die Ansicht fragt weiter im Takt nach und rechnet
die Zeiten aus den Zeitstempeln des Servers — sie bleibt also richtig, nur nicht mehr
sekundenaktuell. <strong>Die analoge Überwachung bleibt ohnehin führend.</strong>
</p>
)}
{laeuft && darfBedienen && (
<div className="flex flex-wrap gap-2">
<Button onClick={() => setAnlegenOffen((wert) => !wert)}>
<Plus aria-hidden />
Trupp anlegen
</Button>
<Button
variant="outline"
onClick={() => {
if (
window.confirm(
"Überwachung beenden? Das geht nur, wenn alle Trupps als draußen gemeldet sind.",
)
) {
beenden.mutate({ id });
}
}}
>
Überwachung beenden
</Button>
</div>
)}
{anlegenOffen && laeuft && (
<TruppAnlegen
atemschutzeinsatzId={id}
onFertig={() => {
setAnlegenOffen(false);
beiErfolg();
}}
onFehler={setMeldung}
/>
)}
{trupps.length === 0 ? (
<NichtsGefunden
titel="Noch kein Trupp angelegt"
hinweis="Ein Trupp braucht mindestens zwei Personen und für jede den Eingangsdruck."
/>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{trupps.map((trupp) => (
<TruppKarte
key={trupp.id}
trupp={trupp}
jetzt={jetzt}
intervallSekunden={daten.abfrageIntervall}
darfBedienen={darfBedienen && Boolean(laeuft)}
onDruck={(messungen, art) =>
druck.mutate({ truppId: trupp.id, messungen, art: art as DruckArt })
}
onStatus={(neuerStatus, ausgangsdruecke) =>
status.mutate({
truppId: trupp.id,
status: neuerStatus as TruppStatus,
ausgangsdruecke:
neuerStatus === TruppStatus.DRAUSSEN ? ausgangsdruecke : undefined,
})
}
onMayday={(aufheben) => mayday.mutate({ truppId: trupp.id, aufheben })}
onRueckzug={() => rueckzug.mutate({ truppId: trupp.id })}
/>
))}
</div>
)}
{laeuft && darfBedienen && (
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (notiz.trim()) {
notizSenden.mutate({ atemschutzeinsatzId: id, text: notiz.trim() });
}
}}
>
<div className="flex-1 space-y-1">
<Label htmlFor="notiz">Notiz ins Protokoll</Label>
<Input
id="notiz"
value={notiz}
onChange={(ereignis) => setNotiz(ereignis.target.value)}
placeholder="Funkverkehr, Beobachtung, Anweisung …"
/>
</div>
<Button type="submit" variant="outline">
Eintragen
</Button>
</form>
)}
<p className="text-xs text-[var(--muted-foreground)]">
Stand vom Server: {alsDatumZeit(daten.stand)} · Anzeige läuft seit{" "}
{alsUhr(Math.floor((jetzt.getTime() - daten.stand.getTime()) / 1000))} weiter
</p>
</div>
);
}
@@ -0,0 +1,321 @@
"use client";
import * as React from "react";
import { G26Ergebnis, UebungsartAtemschutz } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { G26_ERGEBNIS_TEXT, UEBUNGSART_TEXT } from "@/lib/atemschutz/tauglichkeit";
import { alsDatum } from "@/lib/utils";
/**
* Nachweise pflegen.
*
* Zwei Formulare, die im Jahreslauf zusammengehören: die G26 kommt einzeln
* vom Arzt, die Belastungsübung macht die halbe Wehr an einem Abend. Deshalb
* ist das eine ein Formular für eine Person und das andere eines für viele.
*/
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
export function Nachweispflege() {
const [meldung, setMeldung] = React.useState<string | null>(null);
const [erfolg, setErfolg] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const traeger = trpc.atemschutzTraeger.uebersicht.useQuery();
// --- G26 -----------------------------------------------------------------
const [personId, setPersonId] = React.useState("");
const [untersuchtAm, setUntersuchtAm] = React.useState(heute());
const [gueltigBis, setGueltigBis] = React.useState("");
const [ergebnis, setErgebnis] = React.useState<G26Ergebnis>(G26Ergebnis.TAUGLICH);
const [arzt, setArzt] = React.useState("");
const [bemerkung, setBemerkung] = React.useState("");
// Der Vorschlag richtet sich nach dem Alter — drei Jahre, ab fünfzig eines.
const vorschlag = trpc.atemschutzTraeger.fristVorschlag.useQuery(
{ personId, untersuchtAm: new Date(untersuchtAm) },
{ enabled: personId !== "" && untersuchtAm !== "" },
);
React.useEffect(() => {
if (vorschlag.data?.gueltigBis) {
setGueltigBis(vorschlag.data.gueltigBis.toISOString().slice(0, 10));
}
}, [vorschlag.data]);
const g26 = trpc.atemschutzTraeger.g26Erfassen.useMutation({
onSuccess: () => {
setErfolg("Untersuchung erfasst.");
setMeldung(null);
setArzt("");
setBemerkung("");
void werkzeuge.atemschutzTraeger.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
// --- Belastungsübung -----------------------------------------------------
const [uebungsdatum, setUebungsdatum] = React.useState(heute());
const [art, setArt] = React.useState<UebungsartAtemschutz>(UebungsartAtemschutz.UEBUNGSSTRECKE);
const [ort, setOrt] = React.useState("");
const [dauer, setDauer] = React.useState("");
const [teilnehmer, setTeilnehmer] = React.useState<string[]>([]);
const uebung = trpc.atemschutzTraeger.uebungErfassen.useMutation({
onSuccess: (ergebnisDaten) => {
setErfolg(`Für ${ergebnisDaten.erfasst} Personen erfasst.`);
setMeldung(null);
setTeilnehmer([]);
void werkzeuge.atemschutzTraeger.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="nachweisfehler" />}
{erfolg && (
<p
className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 px-4 py-3 text-sm"
role="status"
data-testid="nachweis-erfolg"
>
{erfolg}
</p>
)}
<Card>
<CardHeader>
<CardTitle>G26.3-Untersuchung</CardTitle>
<CardDescription>
Die harte Voraussetzung. Das Ablaufdatum wird nach dem Alter vorgeschlagen — drei
Jahre, ab fünfzig eines. Was auf der Bescheinigung steht, gilt.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (!personId || !gueltigBis) return;
g26.mutate({
personId,
untersuchtAm: new Date(untersuchtAm),
gueltigBis: new Date(gueltigBis),
ergebnis,
arzt: arzt.trim() || null,
bemerkung: bemerkung.trim() || null,
});
}}
>
<div className="space-y-1">
<Label htmlFor="g26-person">Person</Label>
<Select value={personId} onValueChange={setPersonId}>
<SelectTrigger id="g26-person">
<SelectValue placeholder="Mitglied wählen" />
</SelectTrigger>
<SelectContent>
{traeger.data?.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="g26-ergebnis">Ergebnis</Label>
<Select value={ergebnis} onValueChange={(wert) => setErgebnis(wert as G26Ergebnis)}>
<SelectTrigger id="g26-ergebnis">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(G26_ERGEBNIS_TEXT) as G26Ergebnis[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{G26_ERGEBNIS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="g26-datum">Untersucht am</Label>
<Input
id="g26-datum"
type="date"
value={untersuchtAm}
onChange={(ereignis) => setUntersuchtAm(ereignis.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="g26-bis">Gültig bis</Label>
<Input
id="g26-bis"
type="date"
value={gueltigBis}
onChange={(ereignis) => setGueltigBis(ereignis.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="g26-arzt">Arzt oder Stelle</Label>
<Input
id="g26-arzt"
value={arzt}
onChange={(ereignis) => setArzt(ereignis.target.value)}
placeholder="Betriebsarzt Dr. Meier, Landshut"
/>
</div>
<div className="space-y-1">
<Label htmlFor="g26-bemerkung">Bemerkung</Label>
<Textarea
id="g26-bemerkung"
rows={2}
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
/>
</div>
<div className="sm:col-span-2">
<Button type="submit" disabled={!personId || g26.isPending}>
{g26.isPending ? "wird gespeichert …" : "Untersuchung erfassen"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Belastungsübung oder Einsatzübung</CardTitle>
<CardDescription>
Für alle auf einmal — eine Übungsstrecke macht selten eine Person allein. Beides ist
jährlich nötig; ein Einsatz unter Atemschutz ersetzt die Einsatzübung.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-4"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (teilnehmer.length === 0) return;
uebung.mutate({
personIds: teilnehmer,
datum: new Date(uebungsdatum),
art,
ort: ort.trim() || null,
dauerMinuten: dauer ? Number(dauer) : null,
});
}}
>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="ue-datum">Datum</Label>
<Input
id="ue-datum"
type="date"
value={uebungsdatum}
onChange={(ereignis) => setUebungsdatum(ereignis.target.value)}
required
/>
</div>
<div className="space-y-1 sm:col-span-2">
<Label htmlFor="ue-art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as UebungsartAtemschutz)}>
<SelectTrigger id="ue-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(UEBUNGSART_TEXT) as UebungsartAtemschutz[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{UEBUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="ue-dauer">Dauer (Minuten)</Label>
<Input
id="ue-dauer"
type="number"
inputMode="numeric"
value={dauer}
onChange={(ereignis) => setDauer(ereignis.target.value)}
placeholder="45"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="ue-ort">Ort</Label>
<Input
id="ue-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
placeholder="Atemschutzübungsstrecke Landshut"
/>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
Teilnehmer ({teilnehmer.length} gewählt)
</legend>
<div className="grid gap-1 sm:grid-cols-3">
{traeger.data?.map((person) => (
<label key={person.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={teilnehmer.includes(person.id)}
onChange={(ereignis) =>
setTeilnehmer((bisher) =>
ereignis.target.checked
? [...bisher, person.id]
: bisher.filter((eintrag) => eintrag !== person.id),
)
}
/>
{person.nachname}, {person.vorname}
{person.belastungsuebungAm && (
<span className="text-xs text-[var(--muted-foreground)]">
zuletzt {alsDatum(person.belastungsuebungAm)}
</span>
)}
</label>
))}
</div>
</fieldset>
<Button type="submit" disabled={teilnehmer.length === 0 || uebung.isPending}>
{uebung.isPending ? "wird gespeichert …" : `Für ${teilnehmer.length} erfassen`}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,43 @@
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 { Sicherheitshinweis } from "@/components/atemschutz/hinweis";
import { Nachweispflege } from "@/app/(app)/atemschutz/nachweise/nachweise";
export const metadata = { title: "Atemschutz-Nachweise" };
/**
* G26-Untersuchungen und Übungen pflegen.
*
* Der ruhige Teil des Moduls — und der, an dem sich später entscheidet, ob im
* Einsatz jemand fehlt.
*/
export default async function NachweiseSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "atemschutz.bearbeiten")) redirect("/atemschutz");
return (
<div className="space-y-6">
<Sicherheitshinweis />
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/atemschutz">
<ArrowLeft aria-hidden />
Atemschutz
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nachweise</h1>
<p className="text-sm text-[var(--muted-foreground)]">
G26.3-Untersuchungen, Belastungs- und Einsatzübungen.
</p>
</div>
<Nachweispflege />
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ClipboardList } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { Sicherheitshinweis } from "@/components/atemschutz/hinweis";
import { AtemschutzUebersicht } from "@/app/(app)/atemschutz/uebersicht";
export const metadata = { title: "Atemschutz" };
/**
* Atemschutz — die Übersicht.
*
* Die Seite beantwortet zwei Fragen: was läuft gerade, und wen hätten wir.
* Alles Weitere hängt an den Verweisen.
*/
export default async function AtemschutzSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "atemschutz.lesen")) redirect("/");
return (
<div className="space-y-6">
<Sicherheitshinweis />
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Atemschutz</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Überwachung nach FwDV 7, Tauglichkeit der Geräteträger, Nachweise.
</p>
</div>
{hatRecht(sitzung, "atemschutz.bearbeiten") && (
<Button variant="outline" asChild>
<Link href="/atemschutz/nachweise">
<ClipboardList aria-hidden />
Nachweise pflegen
</Link>
</Button>
)}
</div>
<AtemschutzUebersicht darfUeberwachen={hatRecht(sitzung, "atemschutz.ueberwachung")} />
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { CircleAlert, CircleCheck, CircleDot, Play, Radio } 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 { Badge } from "@/components/ui/badge";
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 { STUFE_TEXT, type Tauglichkeitsstufe } from "@/lib/atemschutz/tauglichkeit";
import { alsDatum, alsDatumZeit } from "@/lib/utils";
/**
* Die Übersicht.
*
* Zwei Fragen, in dieser Reihenfolge: **Läuft gerade eine Überwachung?** —
* wenn ja, gehört sie ganz nach oben, alles andere kann warten. Und: **Wen
* hätten wir überhaupt?** Die zweite Frage stellt sich am Dienstabend, die
* erste im Einsatz.
*/
const STUFE_SYMBOL: Record<Tauglichkeitsstufe, typeof CircleCheck> = {
tauglich: CircleCheck,
laeuft_ab: CircleDot,
nicht_tauglich: CircleAlert,
};
const STUFE_FARBE: Record<Tauglichkeitsstufe, string> = {
tauglich: "text-[var(--einsatzbereit)]",
laeuft_ab: "text-[var(--pruefung-faellig)]",
nicht_tauglich: "text-[var(--gesperrt)]",
};
export function AtemschutzUebersicht({ darfUeberwachen }: { darfUeberwachen: boolean }) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
// Der laufende Betrieb wird nachgeführt, die Stammdaten nicht: eine
// Überwachung kann sich jederzeit öffnen, eine G26 nicht.
const laufende = trpc.atemschutz.laufende.useQuery(undefined, { refetchInterval: 10_000 });
const liste = trpc.atemschutz.liste.useQuery({ nurLaufende: false, anzahl: 20 });
const traeger = trpc.atemschutzTraeger.uebersicht.useQuery();
const eroeffnen = trpc.atemschutz.eroeffnen.useMutation({
onSuccess: () => {
setBezeichnung("");
void werkzeuge.atemschutz.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const einsetzbar = traeger.data?.filter((person) => person.stufe !== "nicht_tauglich").length ?? 0;
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="atemschutzfehler" />}
{/* Was gerade läuft. */}
{(laufende.data?.length ?? 0) > 0 && (
<section className="space-y-3" data-testid="laufende-ueberwachungen">
<h2 className="text-lg font-semibold">Läuft gerade</h2>
<div className="grid gap-3 sm:grid-cols-2">
{laufende.data?.map((ueberwachung) => (
<Link
key={ueberwachung.id}
href={`/atemschutz/${ueberwachung.id}`}
className="rounded-xl border-2 border-[var(--gesperrt)] bg-[var(--gesperrt)]/10 p-4 transition-colors hover:bg-[var(--gesperrt)]/20"
>
<p className="flex items-center gap-2 text-sm font-medium text-[var(--gesperrt)]">
<Radio className="size-4" aria-hidden />
Überwachung läuft
</p>
<p className="text-xl font-semibold">{ueberwachung.bezeichnung}</p>
<p className="text-sm text-[var(--muted-foreground)]">
seit {alsDatumZeit(ueberwachung.beginnUm)} ·{" "}
{ueberwachung.ueberwacher.nachname} ·{" "}
{ueberwachung.truppsDrinnen === 0
? "kein Trupp unter Atemschutz"
: `${ueberwachung.truppsDrinnen} ${ueberwachung.truppsDrinnen === 1 ? "Trupp" : "Trupps"} unter Atemschutz`}
</p>
</Link>
))}
</div>
</section>
)}
{darfUeberwachen && (
<Card>
<CardHeader>
<CardTitle>Überwachung eröffnen</CardTitle>
<CardDescription>
Zuerst eröffnen, dann Trupps anlegen. Die Zuordnung zu einem Einsatz lässt sich
nachtragen — im Ernstfall wird zuerst überwacht.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (bezeichnung.trim()) eroeffnen.mutate({ bezeichnung: bezeichnung.trim() });
}}
>
<div className="flex-1 space-y-1">
<Label htmlFor="bezeichnung">Bezeichnung</Label>
<Input
id="bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Zimmerbrand Hauptstraße 12"
required
/>
</div>
<Button type="submit" disabled={eroeffnen.isPending}>
<Play aria-hidden />
{eroeffnen.isPending ? "wird eröffnet …" : "Eröffnen"}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Wer einsetzbar ist. */}
<Card>
<CardHeader>
<CardTitle>Atemschutzgeräteträger</CardTitle>
<CardDescription>
{einsetzbar} von {traeger.data?.length ?? 0} Mitgliedern sind nach FwDV 7 einsetzbar —
G26.3 gültig, Belastungsübung und Einsatzübung im laufenden Jahr.
</CardDescription>
</CardHeader>
<CardContent>
{(traeger.data?.length ?? 0) === 0 ? (
<NichtsGefunden titel="Keine Mitglieder" hinweis="Ohne Mannschaft kein Atemschutz." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Stand</TableHead>
<TableHead>G26.3 bis</TableHead>
<TableHead>Belastungsübung</TableHead>
<TableHead>Einsatzübung</TableHead>
<TableHead className="text-right">Einsätze {new Date().getFullYear()}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{traeger.data
?.filter((person) => person.stufe !== "nicht_tauglich" || person.g26GueltigBis)
.map((person) => {
const Symbol = STUFE_SYMBOL[person.stufe];
return (
<TableRow key={person.id} data-testid={`traeger-${person.id}`}>
<TableCell>
<Link href={`/mitglieder/${person.id}`} className="hover:underline">
{person.nachname}, {person.vorname}
</Link>
</TableCell>
<TableCell>
<span className={`flex items-center gap-2 text-sm ${STUFE_FARBE[person.stufe]}`}>
<Symbol className="size-4" aria-hidden />
{STUFE_TEXT[person.stufe]}
</span>
{person.gruende.length > 0 && (
<span className="text-xs text-[var(--muted-foreground)]">
{person.gruende.join(" · ")}
</span>
)}
</TableCell>
<TableCell className="tabellenzahlen text-sm">
{person.g26GueltigBis ? alsDatum(person.g26GueltigBis) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-sm">
{person.belastungsuebungAm ? alsDatum(person.belastungsuebungAm) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-sm">
{person.einsatzuebungAm ? alsDatum(person.einsatzuebungAm) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-right">
{person.einsaetzeImJahr}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Die Nachschau. */}
<Card>
<CardHeader>
<CardTitle>Frühere Überwachungen</CardTitle>
<CardDescription>Mit Protokoll, sobald die Überwachung beendet ist.</CardDescription>
</CardHeader>
<CardContent>
{(liste.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Überwachung"
hinweis="Die erste entsteht beim nächsten Einsatz unter Atemschutz — oder bei der nächsten Übung."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Bezeichnung</TableHead>
<TableHead>Überwacher</TableHead>
<TableHead>Beginn</TableHead>
<TableHead>Ende</TableHead>
<TableHead className="text-right">Trupps</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{liste.data?.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell>
<Link href={`/atemschutz/${eintrag.id}`} className="font-medium hover:underline">
{eintrag.bezeichnung}
</Link>
{!eintrag.endeUm && (
<Badge variant="gesperrt" className="ml-2">
läuft
</Badge>
)}
</TableCell>
<TableCell className="text-sm">{eintrag.ueberwacher.nachname}</TableCell>
<TableCell className="tabellenzahlen whitespace-nowrap text-sm">
{alsDatumZeit(eintrag.beginnUm)}
</TableCell>
<TableCell className="tabellenzahlen whitespace-nowrap text-sm">
{eintrag.endeUm ? alsDatumZeit(eintrag.endeUm) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-right">
{eintrag._count.trupps}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+35 -10
View File
@@ -1,9 +1,10 @@
import { AlertTriangle, Truck, Users, Wrench } from "lucide-react";
import { AlertTriangle, ShieldCheck, Truck, Users, Wrench } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { verfuegbarkeit } from "@/lib/atemschutz/verfuegbarkeit";
import { alsDatumZeit } from "@/lib/utils";
export const metadata = { title: "Übersicht" };
@@ -14,7 +15,7 @@ export default async function UebersichtSeite() {
if (!sitzung) return null;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt] =
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt, atemschutz] =
await Promise.all([
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
@@ -22,8 +23,17 @@ export default async function UebersichtSeite() {
db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
db.geraet.count({ where: { status: "GESPERRT", geloeschtAm: null } }),
verfuegbarkeit(db),
]);
return { organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt };
return {
organisation,
mitglieder,
fahrzeuge,
geraete,
pruefungFaellig,
gesperrt,
atemschutz,
};
});
const kacheln = [
@@ -31,6 +41,18 @@ export default async function UebersichtSeite() {
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
{ titel: "Prüfung fällig", wert: daten.pruefungFaellig, symbol: AlertTriangle },
{
// Die Zahl, nach der im Einsatz als Erstes gefragt wird: wie viele
// Atemschutzgeräteträger hat die Wehr heute wirklich? Der Zusatz nennt,
// wie viele davon knapp davor sind, es nicht mehr zu sein.
titel: "Atemschutz einsetzbar",
wert: daten.atemschutz.einsetzbar,
symbol: ShieldCheck,
zusatz:
daten.atemschutz.laeuftAb > 0
? `${daten.atemschutz.laeuftAb} davon mit ablaufender G26.3`
: null,
},
];
return (
@@ -80,6 +102,9 @@ export default async function UebersichtSeite() {
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold tabellenzahlen">{kachel.wert}</p>
{"zusatz" in kachel && kachel.zusatz && (
<p className="text-xs text-[var(--pruefung-faellig)]">{kachel.zusatz}</p>
)}
</CardContent>
</Card>
);
@@ -90,19 +115,19 @@ export default async function UebersichtSeite() {
<CardHeader>
<CardTitle>Stand des Aufbaus</CardTitle>
<CardDescription>
Phase 1 ist abgeschlossen: Standorte, Fahrzeuge mit Geräteräumen,
Gerätetypen, Geräte mit QR-Etiketten, Qualifikationen und der Import aus
vorhandenen Tabellen stehen. Als Nächstes kommt das Prüfmanagement.
Stammdaten, Prüfmanagement, Einsätze und Übungen, das Terminal im Gerätehaus und
die Atemschutzüberwachung stehen. Als Nächstes kommen die Kartenmodule.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
<Badge variant="einsatzbereit">Phase 1 — Stammdaten</Badge>
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
<Badge variant="outline">Phase 3 — Einsätze</Badge>
<Badge variant="outline">Phase 4 — Terminal</Badge>
<Badge variant="outline">Phase 5 — Atemschutz</Badge>
<Badge variant="einsatzbereit">Phase 2 — Wartung und Prüfung</Badge>
<Badge variant="einsatzbereit">Phase 3 — Einsätze</Badge>
<Badge variant="einsatzbereit">Phase 4 — Terminal</Badge>
<Badge variant="einsatzbereit">Phase 5 — Atemschutz</Badge>
<Badge variant="outline">Phase 6 — Karten</Badge>
</div>
<dl className="grid gap-2 sm:grid-cols-2">
<div>
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from "next/server";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { herunterladen } from "@/lib/storage";
import { AuditAktion, protokolliere } from "@/lib/audit";
import { atemschutzprotokollErzeugen } from "@/lib/atemschutz/protokoll-erzeugen";
/**
* Das Atemschutzprotokoll herunterladen.
*
* Liegt es schon, kommt es aus MinIO. Liegt es noch nicht — etwa weil der
* Hintergrundprozess gerade nicht lief, als die Überwachung beendet wurde —,
* wird es hier erzeugt. Ein Nachweis, den man nicht bekommt, weil ein Dienst
* einen schlechten Tag hatte, wäre kein Nachweis.
*
* Jeder Abruf steht im Audit-Log: wer ein Atemschutzprotokoll ansieht, ist
* eine Information, die im Ernstfall zählt.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(anfrage: Request) {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
}
if (!hatRecht(sitzung, "atemschutz.lesen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
}
const id = new URL(anfrage.url).searchParams.get("id");
if (!id) {
return NextResponse.json({ fehler: "Keine Überwachung angegeben." }, { status: 400 });
}
const vorhanden = await fuerOrganisation(sitzung.organisationId, (db) =>
db.atemschutzeinsatz.findUnique({
where: { id },
select: {
id: true,
endeUm: true,
protokollDatei: { select: { objektSchluessel: true, dateiname: true } },
},
}),
);
if (!vorhanden) {
return NextResponse.json({ fehler: "Diese Überwachung gibt es nicht." }, { status: 404 });
}
if (!vorhanden.endeUm) {
return NextResponse.json(
{
fehler:
"Die Überwachung läuft noch. Das Protokoll entsteht, wenn sie beendet ist — vorher wäre es unvollständig und sähe vollständig aus.",
},
{ status: 409 },
);
}
let schluessel = vorhanden.protokollDatei?.objektSchluessel;
let dateiname = vorhanden.protokollDatei?.dateiname;
if (!schluessel) {
const erzeugt = await atemschutzprotokollErzeugen(sitzung.organisationId, id);
if (!erzeugt) {
return NextResponse.json(
{ fehler: "Das Protokoll ließ sich nicht erzeugen." },
{ status: 500 },
);
}
const datei = await fuerOrganisation(sitzung.organisationId, (db) =>
db.datei.findUnique({
where: { id: erzeugt.dateiId },
select: { objektSchluessel: true, dateiname: true },
}),
);
schluessel = datei?.objektSchluessel;
dateiname = datei?.dateiname;
}
if (!schluessel) {
return NextResponse.json({ fehler: "Das Protokoll fehlt im Speicher." }, { status: 500 });
}
const inhalt = await herunterladen(schluessel);
await protokolliere({
organisationId: sitzung.organisationId,
aktion: AuditAktion.DATEI_ABGERUFEN,
entitaet: "Atemschutzeinsatz",
entitaetId: id,
sitzung,
beschreibung: "Atemschutzprotokoll abgerufen",
});
return new NextResponse(new Uint8Array(inhalt), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${dateiname ?? "Atemschutzprotokoll.pdf"}"`,
"Cache-Control": "private, no-store",
},
});
}
+176
View File
@@ -0,0 +1,176 @@
import { NextResponse } from "next/server";
import { TruppStatus } from "@prisma/client";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { hatRecht } from "@/lib/authz";
/**
* Der Ereignisstrom der Atemschutzüberwachung.
*
* Server-Sent Events statt WebSocket: es geht nur in eine Richtung. Der
* Browser meldet nichts über diesen Kanal — Druckwerte, Statuswechsel und
* Mayday laufen wie alles andere über tRPC. Was hier zurückkommt, ist nur die
* Nachricht „es hat sich etwas geändert, hol den Stand neu".
*
* Drei Gründe für diese Bauart:
*
* 1. **Sie überlebt Abbrüche.** `EventSource` verbindet sich von selbst neu,
* ohne dass eine Zeile Code dafür nötig wäre. Ein Repeater, der kurz
* ausfällt, unterbricht die Anzeige für Sekunden — danach läuft sie
* weiter, weil die Zeit ohnehin aus Zeitstempeln gerechnet wird und nicht
* aus einem Zähler, der hätte weiterlaufen müssen.
* 2. **Sie braucht keinen eigenen Server.** Ein Route Handler genügt; das
* Deployment bleibt `next start`.
* 3. **Sie überträgt nichts Wichtiges.** Fiele der Strom ganz aus, bliebe
* die Ansicht bedienbar: sie fragt den Stand zusätzlich im festen Takt
* ab. Der Strom macht sie schneller, nicht erst funktionsfähig.
*
* Erkannt wird eine Änderung an einem Zählwert aus der Datenbank — Trupps,
* Messungen, Ereignisse, Notfälle. Das ist eine Abfrage alle zwei Sekunden je
* offener Ansicht, und in einem Gerätehaus stehen davon zwei bis drei.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Wie oft nachgesehen wird, ob sich etwas geändert hat. */
const TAKT_MS = 2000;
/**
* Nach dieser Zeit endet der Strom von selbst.
*
* Nicht als Sparmaßnahme, sondern damit kein Handler unbemerkt für immer
* weiterläuft, wenn ein Tablet in der Ecke vergessen wird. Der Browser
* verbindet sich danach neu — davon merkt niemand etwas.
*/
const HOECHSTDAUER_MS = 30 * 60 * 1000;
/** Ein kurzer Fingerabdruck des Zustands. Ändert er sich, holt der Browser den Stand. */
async function fingerabdruck(organisationId: string, id: string): Promise<string> {
return fuerOrganisation(organisationId, async (db) => {
const [trupps, messungen, ereignisse, ueberwachung] = await Promise.all([
db.atemschutztrupp.findMany({
where: { atemschutzeinsatzId: id },
select: { id: true, status: true, maydayUm: true, maydayAufgehobenUm: true },
orderBy: { angelegtUm: "asc" },
}),
db.druckmessung.count({ where: { trupp: { atemschutzeinsatzId: id } } }),
db.atemschutzEreignis.count({ where: { atemschutzeinsatzId: id } }),
db.atemschutzeinsatz.findUnique({ where: { id }, select: { endeUm: true } }),
]);
const truppTeil = trupps
.map(
(trupp) =>
`${trupp.id}:${trupp.status}:${trupp.maydayUm?.getTime() ?? 0}:${trupp.maydayAufgehobenUm?.getTime() ?? 0}`,
)
.join("|");
return `${truppTeil}#${messungen}#${ereignisse}#${ueberwachung?.endeUm?.getTime() ?? 0}`;
});
}
/** Läuft für einen Trupp gerade ein Notfall? Das geht sofort raus, nicht erst beim Takt. */
async function notfallLaeuft(organisationId: string, id: string): Promise<boolean> {
return fuerOrganisation(organisationId, async (db) => {
const offen = await db.atemschutztrupp.count({
where: {
atemschutzeinsatzId: id,
maydayUm: { not: null },
maydayAufgehobenUm: null,
status: { not: TruppStatus.DRAUSSEN },
},
});
return offen > 0;
});
}
export async function GET(anfrage: Request): Promise<Response> {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
if (!hatRecht(sitzung, "atemschutz.lesen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
}
const id = new URL(anfrage.url).searchParams.get("id");
if (!id) {
return NextResponse.json({ fehler: "Keine Überwachung angegeben." }, { status: 400 });
}
const organisationId = sitzung.organisationId;
const kodierer = new TextEncoder();
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
let letzter = "";
const senden = (art: string, daten: unknown) => {
if (!offen) return;
try {
steuerung.enqueue(kodierer.encode(`event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`));
} catch {
offen = false;
}
};
// Der Browser soll nach einem Abbruch zügig wiederkommen, aber nicht im
// Sekundentakt anklopfen, wenn der Server gerade nicht da ist.
steuerung.enqueue(kodierer.encode("retry: 3000\n\n"));
const beenden = () => {
offen = false;
clearInterval(takt);
clearTimeout(schluss);
try {
steuerung.close();
} catch {
// Schon geschlossen — das ist der Normalfall beim Wegklicken.
}
};
anfrage.signal.addEventListener("abort", beenden);
const nachsehen = async () => {
if (!offen) return;
try {
const jetzt = await fingerabdruck(organisationId, id);
if (jetzt !== letzter) {
letzter = jetzt;
senden("aenderung", {
zeitpunkt: new Date().toISOString(),
notfall: await notfallLaeuft(organisationId, id),
});
} else {
// Ein Lebenszeichen, damit Zwischenstationen die Verbindung nicht
// für tot halten und schließen.
senden("takt", { zeitpunkt: new Date().toISOString() });
}
} catch (fehler) {
senden("fehler", {
meldung: fehler instanceof Error ? fehler.message : "unbekannter Fehler",
});
}
};
const takt = setInterval(() => void nachsehen(), TAKT_MS);
const schluss = setTimeout(beenden, HOECHSTDAUER_MS);
// Der erste Stand geht sofort raus, nicht erst nach zwei Sekunden.
await nachsehen();
},
});
return new Response(strom, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
// Verhindert, dass ein vorgeschalteter Proxy den Strom puffert.
"X-Accel-Buffering": "no",
},
});
}
+52
View File
@@ -0,0 +1,52 @@
import { ShieldAlert } from "lucide-react";
import { env } from "@/lib/env";
/**
* Der Hinweis, der über jeder Ansicht dieses Moduls steht.
*
* Er ist keine Verzierung und keine Voreinstellung, die man wegklickt: FLORIAN
* ist ein ergänzendes System. Die Überwachung, die im Ernstfall zählt, hängt
* am Fahrzeug und wird mit Stift geführt. Solange das so ist, muss es dort
* stehen, wo jemand hinsieht — und zwar dauerhaft, nicht als Meldung, die
* nach drei Sekunden verschwindet.
*
* Abschalten kann ihn nur, wer beides tut: `ATEMSCHUTZ_HINWEIS=false` setzen
* **und** unter `ATEMSCHUTZ_FREIGABE` eintragen, worauf sich die Freigabe
* stützt. Fehlt die Fundstelle, bleibt der Hinweis stehen. Das ist Absicht:
* eine Freigabe ohne Beschluss ist keine.
*
* Ist er abgeschaltet, verschwindet er nicht spurlos — dann steht dort, seit
* wann und worauf gestützt die Wehr digital überwacht. Auch das gehört auf den
* Bildschirm.
*/
export function Sicherheitshinweis() {
const freigabe = env.ATEMSCHUTZ_FREIGABE?.trim();
const abgeschaltet = !env.ATEMSCHUTZ_HINWEIS && Boolean(freigabe);
if (abgeschaltet) {
return (
<p className="text-xs text-[var(--muted-foreground)]" role="note">
Digitale Atemschutzüberwachung freigegeben — {freigabe}
</p>
);
}
return (
<div
role="note"
data-testid="atemschutz-hinweis"
className="flex items-start gap-3 rounded-lg border-2 border-[var(--gesperrt)]/50 bg-[var(--gesperrt)]/10 p-4"
>
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-[var(--gesperrt)]" aria-hidden />
<div className="text-sm">
<p className="font-medium">
Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.
</p>
<p className="text-[var(--muted-foreground)]">
Was hier steht, ersetzt weder die Überwachungstafel am Fahrzeug noch den
Atemschutzüberwacher. Fällt dieses Gerät aus, läuft der Einsatz unverändert weiter.
</p>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import * as React from "react";
/**
* Die laufende Uhr der Überwachung.
*
* Ein Hook, der einmal je Sekunde einen neuen Zeitpunkt liefert — mehr nicht.
* **Er zählt nichts herunter und merkt sich nichts.** Alle Dauern entstehen in
* den Ansichten aus zwei Zeitstempeln: dem vom Server und diesem hier.
*
* Genau daran hängt die wichtigste Anforderung dieser Phase: ein Neuladen des
* Browsers und ein Verbindungsabbruch dürfen die Anzeige nicht verfälschen.
* Ein heruntergezählter Wert wäre nach einem Neuladen verloren; ein
* Zeitstempel aus der Datenbank ist es nicht.
*/
export function useJetzt(taktMs = 1000): Date {
const [jetzt, setJetzt] = React.useState<Date>(() => new Date());
React.useEffect(() => {
const zeitgeber = setInterval(() => setJetzt(new Date()), taktMs);
return () => clearInterval(zeitgeber);
}, [taktMs]);
return jetzt;
}
/**
* Der Ereignisstrom der Überwachung.
*
* Verbindet sich mit /api/atemschutz/strom und ruft `beiAenderung` auf, sobald
* sich am Serverstand etwas getan hat. Bricht die Verbindung ab, verbindet
* `EventSource` von selbst neu — deshalb steht hier keine Zeile
* Wiederverbindungslogik.
*
* Zusätzlich meldet der Hook, ob die Verbindung gerade steht. Das ist kein
* Beiwerk: wer am Bildschirm nicht sieht, dass die Verbindung weg ist, hält
* einen alten Stand für den aktuellen. Steht sie nicht, fragt die Ansicht in
* einem festen Takt nach — der Strom macht sie schneller, nicht erst
* funktionsfähig.
*/
/**
* Nach dieser Stille gilt die Verbindung als tot.
*
* Der Server schickt alle zwei Sekunden ein Lebenszeichen. Bleibt es aus, ist
* der Draht weg — auch wenn der Browser das nicht meldet. Genau das ist der
* Fall, den ein E2E-Test aufgedeckt hat: wird das Netz gekappt, während der
* Strom offen ist, schweigt `EventSource` und meldet **keinen** Fehler. Ohne
* diese Frist sähe eine tote Verbindung aus wie eine gesunde, und das ist der
* gefährlichste Zustand, den diese Ansicht haben kann.
*/
const STILLE_MS = 8000;
export function useEreignisstrom(
ueberwachungId: string | null,
beiAenderung: () => void,
): { verbunden: boolean; letzteMeldung: Date | null } {
const [verbunden, setVerbunden] = React.useState(false);
const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null);
// Als Referenz, damit die Wache unten nicht bei jeder Meldung neu aufgezogen
// wird — dieselbe Falle wie in der Warteschlange des Terminals.
const letzteRef = React.useRef<number>(0);
// Über eine Referenz, damit ein neuer Rückruf nicht die Verbindung neu
// aufbaut — sonst risse der Strom bei jedem Rendern ab.
const rueckruf = React.useRef(beiAenderung);
rueckruf.current = beiAenderung;
React.useEffect(() => {
if (!ueberwachungId || typeof window === "undefined") return;
const quelle = new EventSource(
`/api/atemschutz/strom?id=${encodeURIComponent(ueberwachungId)}`,
);
const gehoert = () => {
letzteRef.current = Date.now();
setVerbunden(true);
setLetzteMeldung(new Date());
};
quelle.onopen = gehoert;
quelle.addEventListener("aenderung", () => {
gehoert();
rueckruf.current();
});
quelle.addEventListener("takt", gehoert);
quelle.onerror = () => {
// EventSource versucht es von selbst wieder. Hier wird nur vermerkt,
// dass der Stand gerade nicht nachgeführt wird.
setVerbunden(false);
};
// Die Wache: bleibt das Lebenszeichen aus, gilt die Verbindung als tot —
// unabhaengig davon, was EventSource meldet.
const wache = setInterval(() => {
if (letzteRef.current > 0 && Date.now() - letzteRef.current > STILLE_MS) {
setVerbunden(false);
}
}, 1000);
return () => {
clearInterval(wache);
quelle.close();
};
}, [ueberwachungId]);
return { verbunden, letzteMeldung };
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
import * as React from "react";
import { CheckCircle2, CircleAlert } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { STUFE_TEXT } from "@/lib/atemschutz/tauglichkeit";
/**
* Einen Trupp anlegen.
*
* Zwei oder drei Personen, für jede der Eingangsdruck, eine davon ist
* Truppführer. Mehr braucht es nicht — und weniger geht nicht: ohne
* Eingangsdruck lässt sich kein Rückzugsdruck rechnen, und ein Trupp ohne
* benannten Führer steht später nicht im Protokoll.
*
* Die Liste zeigt neben jedem Namen, ob die Person überhaupt einsetzbar ist.
* **Sie sperrt nicht.** Im Einsatz entscheidet der Einheitsführer, und ein
* Programm, das ihm im entscheidenden Moment eine Person verweigert, wird
* umgangen — dann steht am Ende gar nichts im Protokoll. Was hier steht, ist
* der Hinweis, den er kennen sollte.
*/
export function TruppAnlegen({
atemschutzeinsatzId,
onFertig,
onFehler,
}: {
atemschutzeinsatzId: string;
onFertig: () => void;
onFehler: (meldung: string) => void;
}) {
const [funkrufname, setFunkrufname] = React.useState("Angriffstrupp");
const [auftrag, setAuftrag] = React.useState("");
const [gewaehlt, setGewaehlt] = React.useState<string[]>([]);
const [fuehrer, setFuehrer] = React.useState<string | null>(null);
const [druecke, setDruecke] = React.useState<Record<string, string>>({});
const traeger = trpc.atemschutzTraeger.uebersicht.useQuery();
const anlegen = trpc.atemschutz.truppAnlegen.useMutation({
onSuccess: onFertig,
onError: (fehler) => onFehler(fehler.message),
});
function umschalten(personId: string) {
setGewaehlt((bisher) => {
if (bisher.includes(personId)) {
if (fuehrer === personId) setFuehrer(null);
return bisher.filter((eintrag) => eintrag !== personId);
}
if (bisher.length >= 3) return bisher;
// Der Erste ist der Vorschlag für den Truppführer — änderbar.
if (bisher.length === 0) setFuehrer(personId);
return [...bisher, personId];
});
}
const vollstaendig =
gewaehlt.length >= 2 &&
fuehrer !== null &&
gewaehlt.every((personId) => Number(druecke[personId]) > 0);
return (
<Card data-testid="trupp-anlegen">
<CardHeader>
<CardTitle>Trupp anlegen</CardTitle>
<CardDescription>
Zwei oder drei Personen, je Person der Eingangsdruck vom Manometer. Der Rückzugsdruck
errechnet sich daraus.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="funkrufname">Funkrufname</Label>
<Input
id="funkrufname"
value={funkrufname}
onChange={(ereignis) => setFunkrufname(ereignis.target.value)}
placeholder="Angriffstrupp"
/>
</div>
<div className="space-y-1">
<Label htmlFor="auftrag">Auftrag</Label>
<Input
id="auftrag"
value={auftrag}
onChange={(ereignis) => setAuftrag(ereignis.target.value)}
placeholder="Menschenrettung 1. OG links"
/>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Wer geht?</p>
<div className="grid gap-2 sm:grid-cols-2">
{traeger.data?.map((person) => {
const ausgewaehlt = gewaehlt.includes(person.id);
return (
<div
key={person.id}
className={`rounded-lg border p-3 ${
ausgewaehlt ? "border-[var(--primary)] bg-[var(--primary)]/5" : "border-[var(--border)]"
}`}
>
<div className="flex items-start justify-between gap-3">
<button
type="button"
className="text-left"
onClick={() => umschalten(person.id)}
aria-pressed={ausgewaehlt}
>
<span className="font-medium">
{person.nachname}, {person.vorname}
</span>
<span
className={`mt-0.5 flex items-center gap-1 text-xs ${
person.stufe === "nicht_tauglich"
? "text-[var(--gesperrt)]"
: person.stufe === "laeuft_ab"
? "text-[var(--pruefung-faellig)]"
: "text-[var(--einsatzbereit)]"
}`}
>
{person.stufe === "tauglich" ? (
<CheckCircle2 className="size-3" aria-hidden />
) : (
<CircleAlert className="size-3" aria-hidden />
)}
{STUFE_TEXT[person.stufe]}
{person.gruende.length > 0 ? ` — ${person.gruende[0]}` : ""}
</span>
</button>
{ausgewaehlt && (
<div className="space-y-1">
<Label
htmlFor={`eingang-${person.id}`}
className="text-xs"
>
Eingangsdruck
</Label>
<Input
id={`eingang-${person.id}`}
type="number"
inputMode="numeric"
className="h-10 w-24"
placeholder="300"
value={druecke[person.id] ?? ""}
onChange={(ereignis) =>
setDruecke((bisher) => ({
...bisher,
[person.id]: ereignis.target.value,
}))
}
/>
</div>
)}
</div>
{ausgewaehlt && (
<label className="mt-2 flex items-center gap-2 text-sm">
<input
type="radio"
name="truppfuehrer"
checked={fuehrer === person.id}
onChange={() => setFuehrer(person.id)}
/>
Truppführer
</label>
)}
</div>
);
})}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
disabled={!vollstaendig || anlegen.isPending}
onClick={() =>
anlegen.mutate({
atemschutzeinsatzId,
funkrufname: funkrufname.trim() || "Trupp",
auftrag: auftrag.trim() || null,
mitglieder: gewaehlt.map((personId) => ({
personId,
truppfuehrer: personId === fuehrer,
eingangsdruck: Number(druecke[personId]),
})),
})
}
>
{anlegen.isPending ? "wird angelegt …" : "Trupp anlegen"}
</Button>
{!vollstaendig && (
<p className="text-sm text-[var(--muted-foreground)]">
Es fehlen noch: mindestens zwei Personen, ein Truppführer und je ein
Eingangsdruck.
</p>
)}
</div>
</CardContent>
</Card>
);
}
+344
View File
@@ -0,0 +1,344 @@
"use client";
import * as React from "react";
import { AlertTriangle, Bell, Gauge, LogIn, LogOut, Undo2 } from "lucide-react";
import { DruckArt, TruppStatus } from "@prisma/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
AMPEL_TEXT,
STATUS_TEXT,
alsUhr,
ampel,
einsatzdauer,
abfrageUeberfaellig,
niedrigsterDruck,
type Ampel,
} from "@/lib/atemschutz/berechnung";
/**
* Ein Trupp auf dem Überwachungsschirm.
*
* Die Karte ist so gebaut, dass sie aus zwei Metern lesbar ist: die Uhr groß,
* der niedrigste Druck groß, alles andere klein. Im Einsatz steht jemand
* daneben und hat eine Hand am Funkgerät.
*
* **Die Zeit läuft hier, die Wahrheit kommt vom Server.** Die Karte bekommt
* Zeitstempel und rechnet daraus die Anzeige — bei jedem Rendern neu. Nach
* einem Neuladen sieht sie deshalb genauso aus wie vorher.
*/
type Messung = { id: string; personId: string; druck: number; art: DruckArt; zeitpunkt: Date };
export interface TruppAnsicht {
id: string;
funkrufname: string;
auftrag: string | null;
status: TruppStatus;
angelegtUm: Date;
einstiegUm: Date | null;
rueckzugUm: Date | null;
drausenUm: Date | null;
rueckzugsdruck: number | null;
maydayUm: Date | null;
maydayAufgehobenUm: Date | null;
mitglieder: Array<{
id: string;
truppfuehrer: boolean;
person: { id: string; vorname: string; nachname: string };
}>;
druckmessungen: Messung[];
}
const AMPEL_FARBE: Record<Ampel, string> = {
gruen: "border-[var(--einsatzbereit)] bg-[var(--einsatzbereit)]/10",
gelb: "border-[var(--pruefung-faellig)] bg-[var(--pruefung-faellig)]/10",
rot: "border-[var(--gesperrt)] bg-[var(--gesperrt)]/10",
};
const AMPEL_SCHRIFT: Record<Ampel, string> = {
gruen: "text-[var(--einsatzbereit)]",
gelb: "text-[var(--pruefung-faellig)]",
rot: "text-[var(--gesperrt)]",
};
export function TruppKarte({
trupp,
jetzt,
intervallSekunden,
darfBedienen,
onDruck,
onStatus,
onMayday,
onRueckzug,
}: {
trupp: TruppAnsicht;
jetzt: Date;
intervallSekunden: number;
darfBedienen: boolean;
onDruck: (messungen: Array<{ personId: string; druck: number }>, art: DruckArt) => void;
onStatus: (
status: TruppStatus,
ausgangsdruecke?: Array<{ personId: string; druck: number }>,
) => void;
onMayday: (aufheben: boolean) => void;
onRueckzug: () => void;
}) {
const [eingaben, setEingaben] = React.useState<Record<string, string>>({});
const laufende = trupp.druckmessungen.filter((messung) => messung.art !== DruckArt.EINGANG);
const letzteMessung = laufende.at(-1) ?? null;
const niedrigste = niedrigsterDruck(trupp.druckmessungen);
const dauer = einsatzdauer(trupp, jetzt);
const stufe = ampel({
aktuellerDruck: niedrigste?.druck ?? null,
rueckzugsdruck: trupp.rueckzugsdruck,
status: trupp.status,
});
const ueberfaellig = abfrageUeberfaellig({
einstiegUm: trupp.einstiegUm,
letzteMessungUm: letzteMessung?.zeitpunkt ?? null,
intervallSekunden,
status: trupp.status,
jetzt,
});
const notfall = Boolean(trupp.maydayUm) && !trupp.maydayAufgehobenUm;
const draussen = trupp.status === TruppStatus.DRAUSSEN;
const zielGemeldet = trupp.druckmessungen.some((messung) => messung.art === DruckArt.ZIEL);
/** Sammelt die eingetippten Werte ein und leert die Felder. */
function werteEinsammeln(): Array<{ personId: string; druck: number }> {
const werte = trupp.mitglieder
.map((mitglied) => ({
personId: mitglied.person.id,
druck: Number(eingaben[mitglied.person.id]),
}))
.filter((wert) => Number.isFinite(wert.druck) && wert.druck >= 0);
if (werte.length > 0) setEingaben({});
return werte;
}
return (
<section
className={`rounded-xl border-2 p-5 ${notfall ? "border-[var(--gesperrt)] bg-[var(--gesperrt)]/20 ring-4 ring-[var(--gesperrt)]/30" : AMPEL_FARBE[stufe]}`}
data-testid={`trupp-${trupp.id}`}
data-ampel={stufe}
data-status={trupp.status}
data-mayday={notfall}
>
{notfall && (
<p
className="mb-3 flex items-center gap-2 text-2xl font-bold text-[var(--gesperrt)]"
role="alert"
data-testid="mayday-anzeige"
>
<AlertTriangle className="size-7" aria-hidden />
MAYDAY seit {alsUhr(Math.floor((jetzt.getTime() - trupp.maydayUm!.getTime()) / 1000))}
</p>
)}
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-2xl font-semibold">{trupp.funkrufname}</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{trupp.mitglieder
.map(
(mitglied) =>
`${mitglied.person.nachname}${mitglied.truppfuehrer ? " (TF)" : ""}`,
)
.join(", ")}
{trupp.auftrag ? ` · ${trupp.auftrag}` : ""}
</p>
</div>
<span className={`text-sm font-medium ${AMPEL_SCHRIFT[stufe]}`}>
{STATUS_TEXT[trupp.status]} · {AMPEL_TEXT[stufe]}
</span>
</header>
{/* Die beiden Zahlen, auf die es ankommt. */}
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
unter Atemschutz
</p>
<p className="tabellenzahlen text-4xl font-semibold" data-testid="trupp-dauer">
{alsUhr(dauer)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
niedrigster Druck
</p>
<p className={`tabellenzahlen text-4xl font-semibold ${AMPEL_SCHRIFT[stufe]}`}>
{niedrigste ? `${niedrigste.druck}` : "—"}
<span className="ml-1 text-lg font-normal">bar</span>
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Rückzug bei
</p>
<p className="tabellenzahlen text-4xl font-semibold">
{trupp.rueckzugsdruck ?? "—"}
<span className="ml-1 text-lg font-normal">bar</span>
</p>
{/* Nur solange es etwas zu erwarten gibt: bei einem Trupp, der
draußen ist, wäre „vorläufig" bloß noch verwirrend. */}
{!zielGemeldet && !draussen && trupp.rueckzugsdruck !== null && (
<p className="text-xs text-[var(--muted-foreground)]">
vorläufig — Zwei-Drittel-Regel, bis das Einsatzziel gemeldet ist
</p>
)}
</div>
</div>
{ueberfaellig && (
<p
className="mt-3 flex items-center gap-2 rounded-md bg-[var(--pruefung-faellig)]/20 px-3 py-2 text-sm font-medium text-[var(--pruefung-faellig)]"
role="status"
data-testid="abfrage-faellig"
>
<Bell className="size-4" aria-hidden />
Druckabfrage überfällig — letzte Meldung vor{" "}
{letzteMessung
? alsUhr(Math.floor((jetzt.getTime() - letzteMessung.zeitpunkt.getTime()) / 1000))
: alsUhr(dauer)}
</p>
)}
{darfBedienen && !draussen && (
<div className="mt-4 space-y-3 border-t border-[var(--border)] pt-4">
{/* Druckeingabe je Person: eine Zeile, drei Felder, ein Knopf. */}
<div className="flex flex-wrap items-end gap-3">
{trupp.mitglieder.map((mitglied) => (
<div key={mitglied.id} className="space-y-1">
<Label htmlFor={`druck-${trupp.id}-${mitglied.person.id}`} className="text-xs">
{mitglied.person.nachname}
</Label>
<Input
id={`druck-${trupp.id}-${mitglied.person.id}`}
type="number"
inputMode="numeric"
className="h-12 w-24 text-xl"
placeholder="bar"
value={eingaben[mitglied.person.id] ?? ""}
onChange={(ereignis) =>
setEingaben((bisher) => ({
...bisher,
[mitglied.person.id]: ereignis.target.value,
}))
}
/>
</div>
))}
<Button
type="button"
className="h-12"
onClick={() => {
const werte = werteEinsammeln();
if (werte.length > 0) onDruck(werte, DruckArt.ZWISCHEN);
}}
>
<Gauge aria-hidden />
Druck melden
</Button>
{trupp.status === TruppStatus.IM_EINSATZ && !zielGemeldet && (
<Button
type="button"
variant="outline"
className="h-12"
onClick={() => {
const werte = werteEinsammeln();
if (werte.length > 0) onDruck(werte, DruckArt.ZIEL);
}}
>
Einsatzziel erreicht
</Button>
)}
</div>
{/* Statuswechsel und Notfall. */}
<div className="flex flex-wrap gap-2">
{trupp.status === TruppStatus.BEREIT && (
<Button type="button" className="h-12" onClick={() => onStatus(TruppStatus.IM_EINSATZ)}>
<LogIn aria-hidden />
Einstieg
</Button>
)}
{trupp.status === TruppStatus.IM_EINSATZ && (
<>
<Button
type="button"
variant="outline"
className="h-12"
onClick={() => onRueckzug()}
>
<Undo2 aria-hidden />
Rückzug anordnen
</Button>
<Button
type="button"
variant="outline"
className="h-12"
onClick={() => onStatus(TruppStatus.RUECKWEG)}
>
Rückweg angetreten
</Button>
</>
)}
{(trupp.status === TruppStatus.IM_EINSATZ ||
trupp.status === TruppStatus.RUECKWEG) && (
<Button
type="button"
variant="outline"
className="h-12"
onClick={() => onStatus(TruppStatus.DRAUSSEN, werteEinsammeln())}
>
<LogOut aria-hidden />
Trupp ist draußen
</Button>
)}
<Button
type="button"
variant={notfall ? "outline" : "destructive"}
className="ml-auto h-12"
data-testid={`mayday-${trupp.id}`}
onClick={() => onMayday(notfall)}
>
<AlertTriangle aria-hidden />
{notfall ? "Notfall aufheben" : "Mayday"}
</Button>
</div>
</div>
)}
{/* Der Verlauf. Klein, aber vollständig — das ist der Nachweis. */}
{laufende.length > 0 && (
<ul className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--muted-foreground)]">
{laufende.slice(-8).map((messung) => (
<li key={messung.id} className="tabellenzahlen">
{messung.zeitpunkt.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
})}{" "}
{messung.druck} bar
</li>
))}
</ul>
)}
</section>
);
}
+5 -2
View File
@@ -124,17 +124,20 @@ const EINTRAEGE: Eintrag[] = [
},
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
{
// Das Audit-Log wird seit Phase 0 geschrieben, eine Ansicht dafür gibt es
// noch nicht — sie gehört zum Löschkonzept in Phase 8. Bis dahin steht der
// Eintrag ausgegraut da statt ins Leere zu führen.
titel: "Protokoll",
pfad: "/protokoll",
symbol: Album,
rechte: ["system.auditlog"],
abPhase: 1,
abPhase: 8,
},
];
/** 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 = 4;
const AKTUELLE_PHASE = 5;
export interface SeitenleisteProps {
rechte: Recht[];
+263
View File
@@ -0,0 +1,263 @@
import { DruckArt, TruppStatus } from "@prisma/client";
/**
* Die Rechenregeln der Atemschutzüberwachung.
*
* Diese Datei hat bewusst keinen Datenbankzugriff und kein `server-only`: sie
* wird auf dem Server für die verbindlichen Werte gebraucht und im Browser für
* die laufende Anzeige zwischen zwei Aktualisierungen. Beide Seiten rechnen
* mit demselben Code — sonst zeigte das Tablet etwas anderes an als das
* Protokoll später ausweist.
*
* **Die Zeit gehört dem Server.** Nirgends wird eine Restzeit heruntergezählt.
* Jede Angabe entsteht aus zwei Zeitstempeln, und den späteren liefert der
* Aufrufer. Damit übersteht jede Anzeige einen Neustart, einen zweiten
* Bildschirm und einen Verbindungsabbruch — es gibt schlicht nichts, das
* verloren gehen könnte.
*
* **Und über allem:** was hier gerechnet wird, ist eine Hilfe, keine
* Überwachung. Die analoge Überwachung bleibt führend.
*/
/** Ein Pressluftatmer hält 300 bar; darüber liegt ein Ablesefehler. */
export const DRUCK_HOECHSTWERT = 400;
/**
* Die Regel für den Rückzugsdruck: **doppelter Anmarschverbrauch**.
*
* Was der Weg hinein an Luft gekostet hat, kostet der Weg zurück noch einmal.
* Dazu kommt der Druck, bei dem die Warneinrichtung anspricht, und ein
* Sicherheitszuschlag, den der Kommandant festlegt:
*
* Rückzug = 2 × (Eingangsdruck − Druck am Ziel) + Warndruck + Zuschlag
*
* Solange der Trupp das Einsatzziel noch nicht gemeldet hat, gibt es keinen
* Anmarschverbrauch — dann greift ersatzweise die Zwei-Drittel-Regel: Rückzug,
* sobald ein Drittel verbraucht ist. Sie liegt in aller Regel höher als die
* spätere Rechnung und ist damit die vorsichtigere Annahme.
*/
export function rueckzugsdruck(angaben: {
eingangsdruck: number;
/** Druck beim Erreichen des Einsatzziels, falls schon gemeldet. */
zieldruck?: number | null;
warndruck: number;
sicherheitszuschlag: number;
}): { druck: number; regel: "anmarschverbrauch" | "zweidrittel" } {
const { eingangsdruck, zieldruck, warndruck, sicherheitszuschlag } = angaben;
if (typeof zieldruck === "number" && zieldruck > 0 && zieldruck <= eingangsdruck) {
const anmarsch = eingangsdruck - zieldruck;
return {
druck: Math.min(eingangsdruck, 2 * anmarsch + warndruck + sicherheitszuschlag),
regel: "anmarschverbrauch",
};
}
return {
druck: Math.round((eingangsdruck * 2) / 3) + sicherheitszuschlag,
regel: "zweidrittel",
};
}
/**
* Der niedrigste Druck im Trupp.
*
* Ein Trupp geht gemeinsam zurück, und zwar wenn der **erste** den
* Rückzugsdruck erreicht. Deshalb zählt für jede Bewertung der kleinste Wert,
* nie der Durchschnitt.
*/
export function niedrigsterDruck(
messungen: Array<{ personId: string; druck: number; zeitpunkt: Date }>,
): { personId: string; druck: number; zeitpunkt: Date } | null {
const letzteJePerson = new Map<string, { personId: string; druck: number; zeitpunkt: Date }>();
for (const messung of messungen) {
const bisher = letzteJePerson.get(messung.personId);
if (!bisher || messung.zeitpunkt.getTime() > bisher.zeitpunkt.getTime()) {
letzteJePerson.set(messung.personId, messung);
}
}
let niedrigste: { personId: string; druck: number; zeitpunkt: Date } | null = null;
for (const messung of letzteJePerson.values()) {
if (!niedrigste || messung.druck < niedrigste.druck) niedrigste = messung;
}
return niedrigste;
}
/** Wie ein Trupp gerade dasteht. Farbe **und** Text — Farbe allein reicht nicht. */
export type Ampel = "gruen" | "gelb" | "rot";
export const AMPEL_TEXT: Record<Ampel, string> = {
gruen: "im Rahmen",
gelb: "Rückzugsdruck bald erreicht",
rot: "Rückzugsdruck erreicht — Trupp zurückziehen",
};
/**
* Die Ampel.
*
* Gelb ab zehn Prozent über dem Rückzugsdruck: der Trupp soll den Rückweg
* antreten, bevor der Wert erreicht ist, nicht wenn er erreicht ist.
*/
export function ampel(angaben: {
aktuellerDruck: number | null;
rueckzugsdruck: number | null;
status: TruppStatus;
}): Ampel {
const { aktuellerDruck, rueckzugsdruck: grenze, status } = angaben;
// Wer draußen ist, ist draußen.
if (status === TruppStatus.DRAUSSEN) return "gruen";
if (aktuellerDruck === null || grenze === null) return "gruen";
if (aktuellerDruck <= grenze) return "rot";
if (aktuellerDruck <= grenze * 1.1) return "gelb";
return "gruen";
}
/**
* Wie lange ein Trupp schon unter Atemschutz ist, in Sekunden.
*
* Aus zwei Zeitstempeln, nicht aus einem laufenden Zähler. `jetzt` gibt der
* Aufrufer mit — im Test ein fester Zeitpunkt, im Betrieb die Uhr.
*/
export function einsatzdauer(
trupp: { einstiegUm: Date | null; drausenUm: Date | null },
jetzt: Date,
): number | null {
if (!trupp.einstiegUm) return null;
const ende = trupp.drausenUm ?? jetzt;
return Math.max(0, Math.floor((ende.getTime() - trupp.einstiegUm.getTime()) / 1000));
}
/**
* Wann die nächste Druckabfrage fällig ist.
*
* Gerechnet ab der letzten Meldung, nicht ab dem Einstieg — sonst käme die
* Erinnerung nach einer verspäteten Meldung sofort wieder.
*/
export function naechsteAbfrage(angaben: {
einstiegUm: Date | null;
letzteMessungUm: Date | null;
intervallSekunden: number;
}): Date | null {
const basis = angaben.letzteMessungUm ?? angaben.einstiegUm;
if (!basis) return null;
return new Date(basis.getTime() + angaben.intervallSekunden * 1000);
}
/** Ist die Druckabfrage überfällig? */
export function abfrageUeberfaellig(angaben: {
einstiegUm: Date | null;
letzteMessungUm: Date | null;
intervallSekunden: number;
status: TruppStatus;
jetzt: Date;
}): boolean {
if (angaben.status !== TruppStatus.IM_EINSATZ && angaben.status !== TruppStatus.RUECKWEG) {
return false;
}
const faellig = naechsteAbfrage(angaben);
return faellig !== null && angaben.jetzt.getTime() >= faellig.getTime();
}
/**
* Der Luftverbrauch je Minute, aus den bisherigen Messungen.
*
* Nur eine Schätzung, und sie steht auch so in der Oberfläche: sie taugt für
* einen Anhaltspunkt, nicht für eine Entscheidung. Unter zwei Messungen oder
* unter einer Minute Abstand wird gar nichts gerechnet — eine Hochrechnung aus
* zwei Werten im Sekundenabstand wäre eine erfundene Zahl.
*/
export function verbrauchProMinute(
messungen: Array<{ druck: number; zeitpunkt: Date }>,
): number | null {
if (messungen.length < 2) return null;
const sortiert = [...messungen].sort((a, b) => a.zeitpunkt.getTime() - b.zeitpunkt.getTime());
const erste = sortiert[0]!;
const letzte = sortiert[sortiert.length - 1]!;
const minuten = (letzte.zeitpunkt.getTime() - erste.zeitpunkt.getTime()) / 60_000;
if (minuten < 1) return null;
const verbraucht = erste.druck - letzte.druck;
if (verbraucht <= 0) return null;
return Math.round((verbraucht / minuten) * 10) / 10;
}
/**
* Geschätzte Restzeit bis zum Rückzugsdruck, in Minuten.
*
* Ausdrücklich eine Schätzung: der Verbrauch steigt unter Belastung, und
* niemand soll sich darauf verlassen. Deshalb gibt die Funktion null zurück,
* sobald die Grundlage dünn wird — lieber keine Zahl als eine falsche.
*/
export function restzeitMinuten(angaben: {
aktuellerDruck: number | null;
rueckzugsdruck: number | null;
verbrauchProMinute: number | null;
}): number | null {
const { aktuellerDruck, rueckzugsdruck: grenze, verbrauchProMinute: rate } = angaben;
if (aktuellerDruck === null || grenze === null || rate === null || rate <= 0) return null;
if (aktuellerDruck <= grenze) return 0;
return Math.floor((aktuellerDruck - grenze) / rate);
}
/**
* Der nächste erlaubte Statusschritt.
*
* Ein Trupp geht immer denselben Weg: bereit, im Einsatz, Rückweg, draußen.
* Zurück geht es nicht — ein Trupp, der schon draußen war, ist ein neuer
* Trupp. Das hält das Protokoll gerade.
*/
export const STATUSFOLGE: Record<TruppStatus, TruppStatus[]> = {
[TruppStatus.BEREIT]: [TruppStatus.IM_EINSATZ],
[TruppStatus.IM_EINSATZ]: [TruppStatus.RUECKWEG, TruppStatus.DRAUSSEN],
[TruppStatus.RUECKWEG]: [TruppStatus.DRAUSSEN],
[TruppStatus.DRAUSSEN]: [],
};
export function statuswechselErlaubt(von: TruppStatus, nach: TruppStatus): boolean {
return STATUSFOLGE[von].includes(nach);
}
export const STATUS_TEXT: Record<TruppStatus, string> = {
BEREIT: "bereit",
IM_EINSATZ: "im Einsatz",
RUECKWEG: "auf dem Rückweg",
DRAUSSEN: "draußen",
};
export const DRUCKART_TEXT: Record<DruckArt, string> = {
EINGANG: "Eingangsdruck",
ZIEL: "am Einsatzziel",
ZWISCHEN: "Zwischenmeldung",
AUSGANG: "Ausgangsdruck",
};
/**
* Eine Sekundenzahl als `mm:ss` beziehungsweise `h:mm:ss`.
*
* Eigene Funktion statt der allgemeinen Formatierung: hier zählt jede Sekunde,
* und die Anzeige darf nicht runden.
*/
export function alsUhr(sekunden: number | null): string {
if (sekunden === null) return "—";
const stunden = Math.floor(sekunden / 3600);
const minuten = Math.floor((sekunden % 3600) / 60);
const rest = sekunden % 60;
const zweistellig = (wert: number) => String(wert).padStart(2, "0");
return stunden > 0
? `${stunden}:${zweistellig(minuten)}:${zweistellig(rest)}`
: `${zweistellig(minuten)}:${zweistellig(rest)}`;
}
+209
View File
@@ -0,0 +1,209 @@
import "server-only";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { hochladen, objektSchluessel } from "@/lib/storage";
import { env } from "@/lib/env";
import { protokollPdf, type ProtokollDaten, type ProtokollZeile } from "@/lib/atemschutz/protokoll";
import { DRUCKART_TEXT } from "@/lib/atemschutz/berechnung";
/**
* Von der Überwachung zum abgelegten Protokoll.
*
* Dreigeteilt wie beim Prüfprotokoll und beim Einsatzbericht — lesen, rendern,
* schreiben. Dazwischen liegt der Aufruf an Gotenberg, und der darf dauern;
* eine offene Transaktion darf das nicht.
*
* Zwei Besonderheiten gegenüber den anderen Erzeugern:
*
* * **Erst nach dem Ende.** Ein Protokoll einer laufenden Überwachung wäre
* unvollständig und würde als vollständig aussehen. Das ist genau der
* Fehler, den ein Nachweis nicht machen darf.
* * **Der Verlauf entsteht aus zwei Quellen.** Ereignisse und Druckmessungen
* werden getrennt gespeichert, gehören im Protokoll aber in eine einzige
* Reihenfolge — sonst müsste der Leser zwei Spalten gleichzeitig verfolgen.
*
* Wiederholbar: gibt es das Protokoll schon, passiert nichts.
*/
export async function atemschutzprotokollErzeugen(
organisationId: string,
ueberwachungId: string,
): Promise<{ dateiId: string; neu: boolean } | null> {
// ---------------------------------------------------------------- lesen
const geladen = await fuerOrganisation(organisationId, async (db) => {
const ueberwachung = await db.atemschutzeinsatz.findUnique({
where: { id: ueberwachungId },
include: {
organisation: { select: { name: true, slug: true, ort: true } },
ueberwacher: { select: { vorname: true, nachname: true } },
einsatz: { select: { einsatznummer: true, stichwort: true } },
uebung: { select: { nummer: true, thema: true } },
trupps: {
orderBy: { angelegtUm: "asc" },
include: {
mitglieder: {
include: {
person: { select: { id: true, vorname: true, nachname: true } },
geraet: { select: { inventarnummer: true } },
},
},
druckmessungen: {
orderBy: { zeitpunkt: "asc" },
include: { person: { select: { id: true, vorname: true, nachname: true } } },
},
},
},
ereignisse: {
orderBy: { zeitpunkt: "asc" },
include: {
trupp: { select: { funkrufname: true } },
person: { select: { vorname: true, nachname: true } },
},
},
},
});
if (!ueberwachung) return null;
// Ein Protokoll gibt es erst, wenn nichts mehr dazukommt.
if (!ueberwachung.endeUm) return null;
return ueberwachung;
});
if (!geladen) return null;
if (geladen.protokollDateiId) return { dateiId: geladen.protokollDateiId, neu: false };
// --------------------------------------------------------------- rendern
const name = (person: { vorname: string; nachname: string }) =>
`${person.nachname}, ${person.vorname}`;
/** Drücke einer Art als „Huber 300, Maier 290" — kurz genug für die Spalte. */
const drueckeAlsText = (
messungen: Array<{ art: string; druck: number; person: { vorname: string; nachname: string } }>,
art: string,
) =>
messungen
.filter((messung) => messung.art === art)
.map((messung) => `${messung.person.nachname} ${messung.druck}`)
.join(", ");
const verlauf: ProtokollZeile[] = [
...geladen.ereignisse.map((ereignis) => ({
zeitpunkt: ereignis.zeitpunkt,
trupp: ereignis.trupp?.funkrufname ?? null,
was: ereignis.text,
wer: ereignis.person ? name(ereignis.person) : null,
})),
...geladen.trupps.flatMap((trupp) =>
trupp.druckmessungen.map((messung) => ({
zeitpunkt: messung.zeitpunkt,
trupp: trupp.funkrufname,
was: `${DRUCKART_TEXT[messung.art]}: ${name(messung.person)} ${messung.druck} bar`,
wer: null,
})),
),
].sort((a, b) => a.zeitpunkt.getTime() - b.zeitpunkt.getTime());
const freigabe = env.ATEMSCHUTZ_FREIGABE?.trim();
const daten: ProtokollDaten = {
organisation: { name: geladen.organisation.name, ort: geladen.organisation.ort },
ueberwachung: {
bezeichnung: geladen.bezeichnung,
ueberwacher: name(geladen.ueberwacher),
beginnUm: geladen.beginnUm,
endeUm: geladen.endeUm,
abfrageIntervall: geladen.abfrageIntervall,
warndruck: geladen.warndruck,
sicherheitszuschlag: geladen.sicherheitszuschlag,
einsatz: geladen.einsatz
? `Einsatz ${geladen.einsatz.einsatznummer} — ${geladen.einsatz.stichwort}`
: null,
uebung: geladen.uebung ? `Übung ${geladen.uebung.nummer} — ${geladen.uebung.thema}` : null,
bemerkung: geladen.bemerkung,
},
trupps: geladen.trupps.map((trupp) => ({
funkrufname: trupp.funkrufname,
auftrag: trupp.auftrag,
status: trupp.status,
mitglieder: trupp.mitglieder.map((mitglied) => ({
name: name(mitglied.person),
truppfuehrer: mitglied.truppfuehrer,
geraet: mitglied.geraet?.inventarnummer ?? null,
})),
einstiegUm: trupp.einstiegUm,
rueckzugUm: trupp.rueckzugUm,
drausenUm: trupp.drausenUm,
rueckzugsdruck: trupp.rueckzugsdruck,
maydayUm: trupp.maydayUm,
maydayAufgehobenUm: trupp.maydayAufgehobenUm,
eingangsdruecke: drueckeAlsText(trupp.druckmessungen, "EINGANG"),
ausgangsdruecke: drueckeAlsText(trupp.druckmessungen, "AUSGANG"),
})),
verlauf,
// Nur wenn der Hinweis tatsächlich abgeschaltet ist, war die digitale
// Überwachung führend — sonst steht der ergänzende Charakter im Protokoll.
freigabe: !env.ATEMSCHUTZ_HINWEIS && freigabe ? freigabe : null,
erzeugtAm: new Date(),
};
const pdf = await protokollPdf(daten);
const dateiname = `Atemschutzprotokoll-${geladen.beginnUm.toISOString().slice(0, 10)}-${geladen.id.slice(0, 8)}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: geladen.organisation.slug,
entitaet: "Atemschutzeinsatz",
entitaetId: geladen.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
atemschutz: geladen.id,
});
// -------------------------------------------------------------- schreiben
return fuerOrganisation(organisationId, async (db) => {
const stand = await db.atemschutzeinsatz.findUnique({
where: { id: geladen.id },
select: { protokollDateiId: true },
});
// Zwei gleichzeitige Anfragen: die zweite nimmt, was die erste abgelegt hat.
if (stand?.protokollDateiId) return { dateiId: stand.protokollDateiId, 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: "Atemschutzeinsatz",
entitaetId: geladen.id,
kategorie: "atemschutzprotokoll",
hochgeladenVonId: geladen.ueberwacherId,
},
});
await db.atemschutzeinsatz.update({
where: { id: geladen.id },
data: { protokollDateiId: datei.id },
});
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: "Atemschutzeinsatz",
entitaetId: geladen.id,
beschreibung: `Atemschutzprotokoll erzeugt, ${verlauf.length} Einträge (${Math.round(
abgelegt.groesseBytes / 1024,
)} kB)`,
akteurTyp: AkteurTyp.SYSTEM,
akteurName: "FLORIAN",
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
});
return { dateiId: datei.id, neu: true };
});
}
+262
View File
@@ -0,0 +1,262 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
import { DRUCKART_TEXT, STATUS_TEXT, alsUhr } from "@/lib/atemschutz/berechnung";
/**
* Das Atemschutzprotokoll als PDF.
*
* Es ist der Nachweis, den FwDV 7 verlangt — und im Schadensfall das Papier,
* nach dem die Unfallkasse fragt. Daraus folgt der Aufbau:
*
* Kopf Wehr, Bezeichnung, Überwacher, Beginn und Ende
* Trupps wer, wann eingestiegen, wann draußen, mit welchen Drücken
* Verlauf **jede** Zeile in zeitlicher Reihenfolge, Ereignisse und
* Druckmeldungen gemeinsam — das ist der Kern
* Fuß wann erstellt, und der Hinweis, dass die analoge Überwachung
* führend war
*
* Der letzte Satz steht dort nicht aus Bescheidenheit, sondern weil er
* stimmen muss: solange die Wehr die digitale Überwachung nicht freigegeben
* hat, ist dieses Blatt eine Mitschrift und keine Überwachungsdokumentation.
* Wer das später liest, soll wissen, was er in der Hand hält.
*/
export interface ProtokollTrupp {
funkrufname: string;
auftrag: string | null;
status: string;
mitglieder: Array<{ name: string; truppfuehrer: boolean; geraet: string | null }>;
einstiegUm: Date | null;
rueckzugUm: Date | null;
drausenUm: Date | null;
rueckzugsdruck: number | null;
maydayUm: Date | null;
maydayAufgehobenUm: Date | null;
eingangsdruecke: string;
ausgangsdruecke: string;
}
export interface ProtokollZeile {
zeitpunkt: Date;
trupp: string | null;
was: string;
wer: string | null;
}
export interface ProtokollDaten {
organisation: { name: string; ort?: string | null };
ueberwachung: {
bezeichnung: string;
ueberwacher: string;
beginnUm: Date;
endeUm: Date | null;
abfrageIntervall: number;
warndruck: number;
sicherheitszuschlag: number;
einsatz?: string | null;
uebung?: string | null;
bemerkung?: string | null;
};
trupps: ProtokollTrupp[];
verlauf: ProtokollZeile[];
/** Steht im Fuß: ob die Wehr die digitale Überwachung freigegeben hat. */
freigabe: string | null;
erzeugtAm: Date;
}
const STIL = `
@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: 11pt; margin: 6mm 0 2mm; border-bottom: 0.4mm solid #111; padding-bottom: 1mm; }
.kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: 6mm; }
.wehr { text-align: right; font-size: 8.5pt; color: #444; }
.hinweis {
margin: 3mm 0 0;
border: 0.5mm solid #b91c1c;
background: #fdecec;
padding: 3mm;
font-size: 9pt;
}
table { width: 100%; border-collapse: collapse; margin-top: 2mm; }
th, td { border: 0.2mm solid #bbb; padding: 1.2mm 1.6mm; text-align: left; vertical-align: top; }
th { background: #eee; font-size: 8.5pt; }
td.zahl, th.zahl { text-align: right; font-variant-numeric: tabular-nums; }
td.zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
tr.mayday td { background: #fdecec; font-weight: bold; }
.fuss { margin-top: 6mm; font-size: 8pt; color: #444; border-top: 0.2mm solid #bbb; padding-top: 2mm; }
`;
function protokollHtml(daten: ProtokollDaten): string {
const { ueberwachung, organisation } = daten;
return `<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>Atemschutzprotokoll</title><style>${STIL}</style></head>
<body>
<div class="kopf">
<div>
<h1>Atemschutzüberwachung</h1>
<div>${html(ueberwachung.bezeichnung)}</div>
</div>
<div class="wehr">
<div><strong>${html(organisation.name)}</strong></div>
${organisation.ort ? `<div>${html(organisation.ort)}</div>` : ""}
</div>
</div>
<div class="hinweis">
${
daten.freigabe
? `Digitale Atemschutzüberwachung freigegeben — ${html(daten.freigabe)}.`
: "<strong>Ergänzendes System.</strong> Während dieser Überwachung war die analoge Atemschutzüberwachung führend. Dieses Blatt ist die Mitschrift des ergänzenden Systems."
}
</div>
<h2>Rahmen</h2>
<table>
<tbody>
<tr>
<th style="width:28%">Überwacher</th><td>${html(ueberwachung.ueberwacher)}</td>
<th style="width:20%">Beginn</th><td class="zeit">${html(alsDatumZeit(ueberwachung.beginnUm))}</td>
</tr>
<tr>
<th>Ende</th>
<td class="zeit">${ueberwachung.endeUm ? html(alsDatumZeit(ueberwachung.endeUm)) : "— (nicht beendet)"}</td>
<th>Abfrageintervall</th><td>${Math.round(ueberwachung.abfrageIntervall / 60)} Minuten</td>
</tr>
<tr>
<th>Rückzugsregel</th>
<td>doppelter Anmarschverbrauch + ${ueberwachung.warndruck} bar Warndruck + ${ueberwachung.sicherheitszuschlag} bar Zuschlag</td>
<th>Zuordnung</th>
<td>${html(ueberwachung.einsatz ?? ueberwachung.uebung ?? "keine")}</td>
</tr>
${
ueberwachung.bemerkung
? `<tr><th>Bemerkung</th><td colspan="3">${html(ueberwachung.bemerkung)}</td></tr>`
: ""
}
</tbody>
</table>
<h2>Trupps</h2>
<table>
<thead>
<tr>
<th>Trupp</th>
<th>Besetzung</th>
<th class="zeit">Einstieg</th>
<th class="zeit">Rückweg</th>
<th class="zeit">draußen</th>
<th class="zahl">Dauer</th>
<th class="zahl">Rückzug bei</th>
<th>Drücke ein/aus</th>
</tr>
</thead>
<tbody>
${daten.trupps
.map((trupp) => {
const dauer =
trupp.einstiegUm && trupp.drausenUm
? alsUhr(
Math.floor((trupp.drausenUm.getTime() - trupp.einstiegUm.getTime()) / 1000),
)
: "—";
return `
<tr${trupp.maydayUm ? ' class="mayday"' : ""}>
<td>
<strong>${html(trupp.funkrufname)}</strong>
${trupp.auftrag ? `<div>${html(trupp.auftrag)}</div>` : ""}
${trupp.maydayUm ? `<div>MAYDAY ${html(alsUhrzeit(trupp.maydayUm))}${trupp.maydayAufgehobenUm ? ` bis ${html(alsUhrzeit(trupp.maydayAufgehobenUm))}` : ""}</div>` : ""}
</td>
<td>
${trupp.mitglieder
.map(
(mitglied) =>
`${html(mitglied.name)}${mitglied.truppfuehrer ? " (TF)" : ""}${mitglied.geraet ? ` — ${html(mitglied.geraet)}` : ""}`,
)
.join("<br>")}
</td>
<td class="zeit">${trupp.einstiegUm ? html(alsUhrzeit(trupp.einstiegUm)) : "—"}</td>
<td class="zeit">${trupp.rueckzugUm ? html(alsUhrzeit(trupp.rueckzugUm)) : "—"}</td>
<td class="zeit">${trupp.drausenUm ? html(alsUhrzeit(trupp.drausenUm)) : "—"}</td>
<td class="zahl">${html(dauer)}</td>
<td class="zahl">${trupp.rueckzugsdruck ?? "—"} bar</td>
<td>${html(trupp.eingangsdruecke)} / ${html(trupp.ausgangsdruecke || "—")}</td>
</tr>`;
})
.join("")}
</tbody>
</table>
<h2>Verlauf</h2>
<table>
<thead>
<tr>
<th class="zeit" style="width:18mm">Uhrzeit</th>
<th style="width:30mm">Trupp</th>
<th>Vorgang</th>
<th style="width:35mm">erfasst von</th>
</tr>
</thead>
<tbody>
${daten.verlauf
.map(
(zeile) => `
<tr${zeile.was.startsWith("MAYDAY") ? ' class="mayday"' : ""}>
<td class="zeit">${html(alsUhrzeit(zeile.zeitpunkt))}</td>
<td>${html(zeile.trupp ?? "—")}</td>
<td>${html(zeile.was)}</td>
<td>${html(zeile.wer ?? "—")}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
<div class="fuss">
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Das Protokoll umfasst
${daten.verlauf.length} Einträge und ist nachträglich nicht veränderbar — Druckmessungen und
Ereignisse lassen sich in der Datenbank weder ändern noch löschen.
</div>
</body>
</html>`;
}
/** Erzeugt das Protokoll als PDF. */
export async function protokollPdf(daten: ProtokollDaten): Promise<Buffer> {
return htmlZuPdf(protokollHtml(daten), {
randOben: 0.6,
randUnten: 0.5,
randLinks: 0.5,
randRechts: 0.5,
hintergrundDrucken: true,
});
}
/** Klartext für eine Druckmessung im Verlauf. */
export function druckZeile(art: string, druck: number, person: string): string {
return `${DRUCKART_TEXT[art as keyof typeof DRUCKART_TEXT] ?? art}: ${person} ${druck} bar`;
}
/** Klartext für einen Status im Trupp-Block. */
export function statusText(status: string): string {
return STATUS_TEXT[status as keyof typeof STATUS_TEXT] ?? status;
}
+182
View File
@@ -0,0 +1,182 @@
import { G26Ergebnis, UebungsartAtemschutz } from "@prisma/client";
/**
* Wer darf unter Atemschutz?
*
* FwDV 7 verlangt dreierlei, und alle drei müssen gleichzeitig gelten:
*
* 1. **Gültige G26.3.** Die Untersuchung beim Arbeitsmediziner. Ohne sie
* geht gar nichts, auch nicht „einmalig ausnahmsweise".
* 2. **Belastungsübung im laufenden Jahr.** Übungsstrecke oder eine
* gleichwertige Belastung.
* 3. **Einsatzübung unter Atemschutz im laufenden Jahr.** Ein Einsatz unter
* PA zählt dafür ebenso — wer im Ernstfall drin war, hat mehr geleistet
* als in jeder Übung.
*
* Dazu die Qualifikation als Atemschutzgeräteträger, die aus Phase 1 kommt.
*
* Diese Datei rechnet nur. Sie entscheidet nicht, ob jemand eingesetzt wird —
* das tut der Einheitsführer, und das Ergebnis hier ist der Hinweis darauf,
* was ihm dabei bekannt sein sollte.
*/
/** Wie lange vor Ablauf gewarnt wird. Drei Monate reichen für einen Arzttermin. */
export const VORWARNUNG_TAGE = 90;
export type Tauglichkeitsstufe = "tauglich" | "laeuft_ab" | "nicht_tauglich";
export const STUFE_TEXT: Record<Tauglichkeitsstufe, string> = {
tauglich: "einsetzbar",
laeuft_ab: "einsetzbar, läuft bald ab",
nicht_tauglich: "nicht einsetzbar",
};
export interface Tauglichkeit {
stufe: Tauglichkeitsstufe;
/** Klartext, was fehlt — die Farbe allein sagt niemandem, was zu tun ist. */
gruende: string[];
g26GueltigBis: Date | null;
belastungsuebungAm: Date | null;
einsatzuebungAm: Date | null;
}
function tageBis(ziel: Date, jetzt: Date): number {
return Math.floor((ziel.getTime() - jetzt.getTime()) / 86_400_000);
}
/** Fällt ein Datum in dasselbe Kalenderjahr wie der Stichtag? */
function imSelbenJahr(datum: Date, jetzt: Date): boolean {
return datum.getUTCFullYear() === jetzt.getUTCFullYear();
}
/**
* Bewertet eine Person.
*
* Bewusst ohne Datenbankzugriff: die Aufrufer holen die Daten so, wie es an
* ihrer Stelle günstig ist — einzeln für eine Person, gebündelt für die
* Übersicht der ganzen Mannschaft.
*/
export function tauglichkeit(angaben: {
/** Untersuchungen der Person, beliebige Reihenfolge. */
untersuchungen: Array<{ gueltigBis: Date; ergebnis: G26Ergebnis }>;
/** Übungen und Unterweisungen, beliebige Reihenfolge. */
uebungen: Array<{ datum: Date; art: UebungsartAtemschutz }>;
/** Einsätze unter Atemschutz im laufenden Jahr — sie ersetzen die Einsatzübung. */
einsaetzeUnterPa: Date[];
/** Ist die Person überhaupt als Atemschutzgeräteträger qualifiziert? */
istAtemschutzgeraetetraeger: boolean;
jetzt: Date;
}): Tauglichkeit {
const { untersuchungen, uebungen, einsaetzeUnterPa, jetzt } = angaben;
const gruende: string[] = [];
// --- G26.3 ---------------------------------------------------------------
const brauchbar = untersuchungen.filter(
(untersuchung) =>
untersuchung.ergebnis === G26Ergebnis.TAUGLICH ||
untersuchung.ergebnis === G26Ergebnis.BEFRISTET_TAUGLICH,
);
const neueste = brauchbar.reduce<Date | null>(
(bisher, untersuchung) =>
!bisher || untersuchung.gueltigBis.getTime() > bisher.getTime()
? untersuchung.gueltigBis
: bisher,
null,
);
const abgelehnt = untersuchungen.some(
(untersuchung) => untersuchung.ergebnis === G26Ergebnis.NICHT_TAUGLICH,
);
let g26Laeuft = false;
if (!neueste) {
gruende.push(
abgelehnt ? "G26.3 ergab „nicht tauglich“" : "Keine G26.3-Untersuchung hinterlegt",
);
} else if (tageBis(neueste, jetzt) < 0) {
gruende.push("G26.3 ist abgelaufen");
} else if (tageBis(neueste, jetzt) <= VORWARNUNG_TAGE) {
g26Laeuft = true;
}
// --- Belastungsübung -----------------------------------------------------
const belastung = uebungen
.filter(
(uebung) =>
uebung.art === UebungsartAtemschutz.UEBUNGSSTRECKE && imSelbenJahr(uebung.datum, jetzt),
)
.sort((a, b) => b.datum.getTime() - a.datum.getTime())[0];
if (!belastung) gruende.push("Belastungsübung in diesem Jahr fehlt");
// --- Einsatzübung oder Einsatz unter PA ----------------------------------
const einsatzuebung = uebungen
.filter(
(uebung) =>
uebung.art === UebungsartAtemschutz.EINSATZUEBUNG && imSelbenJahr(uebung.datum, jetzt),
)
.sort((a, b) => b.datum.getTime() - a.datum.getTime())[0];
const echterEinsatz = einsaetzeUnterPa
.filter((datum) => imSelbenJahr(datum, jetzt))
.sort((a, b) => b.getTime() - a.getTime())[0];
if (!einsatzuebung && !echterEinsatz) {
gruende.push("Einsatzübung unter Atemschutz in diesem Jahr fehlt");
}
// --- Qualifikation -------------------------------------------------------
if (!angaben.istAtemschutzgeraetetraeger) {
gruende.push("Keine Qualifikation als Atemschutzgeräteträger");
}
const stufe: Tauglichkeitsstufe =
gruende.length > 0 ? "nicht_tauglich" : g26Laeuft ? "laeuft_ab" : "tauglich";
if (g26Laeuft && neueste) {
gruende.push(`G26.3 läuft in ${tageBis(neueste, jetzt)} Tagen ab`);
}
return {
stufe,
gruende,
g26GueltigBis: neueste,
belastungsuebungAm: belastung?.datum ?? null,
einsatzuebungAm: einsatzuebung?.datum ?? echterEinsatz ?? null,
};
}
/**
* Frist einer G26.3.
*
* Die Regel richtet sich nach dem Alter: bis 50 Jahre drei Jahre, danach ein
* Jahr. Der Arzt kann kürzer befristen — deshalb ist das hier nur der
* Vorschlag für das Eingabefeld, überschreibbar wie das Ablaufdatum einer
* Qualifikation aus Phase 1.
*/
export function g26FristVorschlag(geburtsdatum: Date | null, untersuchtAm: Date): Date {
const vorschlag = new Date(untersuchtAm);
const alter = geburtsdatum
? Math.floor((untersuchtAm.getTime() - geburtsdatum.getTime()) / (365.25 * 86_400_000))
: 0;
vorschlag.setUTCFullYear(vorschlag.getUTCFullYear() + (alter >= 50 ? 1 : 3));
return vorschlag;
}
export const G26_ERGEBNIS_TEXT: Record<G26Ergebnis, string> = {
TAUGLICH: "tauglich",
BEFRISTET_TAUGLICH: "befristet tauglich",
NACHUNTERSUCHUNG: "Nachuntersuchung nötig",
NICHT_TAUGLICH: "nicht tauglich",
};
export const UEBUNGSART_TEXT: Record<UebungsartAtemschutz, string> = {
UEBUNGSSTRECKE: "Belastungsübung (Übungsstrecke)",
EINSATZUEBUNG: "Einsatzübung unter Atemschutz",
UNTERWEISUNG: "Unterweisung",
};
+106
View File
@@ -0,0 +1,106 @@
import "server-only";
import type { MandantenClient } from "@/lib/db";
import { tauglichkeit, type Tauglichkeit } from "@/lib/atemschutz/tauglichkeit";
/**
* Wer ist heute einsetzbar?
*
* Die Frage stellt sich an zwei Stellen — auf der Startseite als Zahl und im
* Atemschutzmodul als Liste. Damit beide dasselbe sagen, steht die Beschaffung
* hier und nicht zweimal nebeneinander.
*
* Die Bewertung selbst liegt in lib/atemschutz/tauglichkeit.ts und ist dort
* ohne Datenbank getestet. Diese Datei holt nur zusammen, was sie braucht.
*/
/** Woran ein Atemschutzgeräteträger in den Qualifikationen erkannt wird. */
const AGT_MERKMALE = ["atemschutz", "agt", "atemschutzgeräteträger", "atemschutzgeraetetraeger"];
export function istAgtTyp(name: string): boolean {
const klein = name.toLowerCase();
return AGT_MERKMALE.some((merkmal) => klein.includes(merkmal));
}
export interface TraegerBewertung extends Tauglichkeit {
id: string;
vorname: string;
nachname: string;
dienstgrad: string | null;
einsaetzeImJahr: number;
}
/**
* Bewertet die ganze Mannschaft.
*
* Eine Abfrage für alle statt einer je Person: bei fünfzig Mitgliedern wären
* das sonst fünfzig Rundreisen zur Datenbank für eine Ansicht, die im
* Gerätehaus schnell dastehen soll.
*/
export async function traegerBewerten(
db: MandantenClient,
jetzt = new Date(),
): Promise<TraegerBewertung[]> {
const jahresbeginn = new Date(Date.UTC(jetzt.getUTCFullYear(), 0, 1));
const personen = await db.person.findMany({
where: { aktiv: true, geloeschtAm: null },
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
select: {
id: true,
vorname: true,
nachname: true,
dienstgrad: true,
qualifikationen: {
where: { geloeschtAm: null },
select: { gueltigBis: true, typ: { select: { name: true } } },
},
g26Untersuchungen: {
where: { geloeschtAm: null },
select: { gueltigBis: true, ergebnis: true },
},
belastungsuebungen: {
where: { geloeschtAm: null },
select: { datum: true, art: true },
},
truppeinsaetze: {
where: { trupp: { einstiegUm: { gte: jahresbeginn } } },
select: { trupp: { select: { einstiegUm: true } } },
},
},
});
return personen.map((person) => ({
id: person.id,
vorname: person.vorname,
nachname: person.nachname,
dienstgrad: person.dienstgrad,
einsaetzeImJahr: person.truppeinsaetze.length,
...tauglichkeit({
untersuchungen: person.g26Untersuchungen,
uebungen: person.belastungsuebungen,
einsaetzeUnterPa: person.truppeinsaetze
.map((eintrag) => eintrag.trupp.einstiegUm)
.filter((datum): datum is Date => datum !== null),
istAtemschutzgeraetetraeger: person.qualifikationen.some(
(qualifikation) =>
istAgtTyp(qualifikation.typ.name) &&
(!qualifikation.gueltigBis || qualifikation.gueltigBis >= jetzt),
),
jetzt,
}),
}));
}
/** Nur die Zahlen — für die Kachel auf der Startseite. */
export async function verfuegbarkeit(
db: MandantenClient,
jetzt = new Date(),
): Promise<{ einsetzbar: number; laeuftAb: number; nichtEinsetzbar: number }> {
const alle = await traegerBewerten(db, jetzt);
return {
einsetzbar: alle.filter((eintrag) => eintrag.stufe !== "nicht_tauglich").length,
laeuftAb: alle.filter((eintrag) => eintrag.stufe === "laeuft_ab").length,
nichtEinsetzbar: alle.filter((eintrag) => eintrag.stufe === "nicht_tauglich").length,
};
}
+22
View File
@@ -120,6 +120,28 @@ const schema = z.object({
/// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird.
WETTER_ZWISCHENSPEICHER: zahl(300),
// Atemschutz nach FwDV 7 (Phase 5)
/**
* Der Sicherheitshinweis über jeder Ansicht der Atemschutzüberwachung.
*
* Er ist standardmäßig an und **gehört an**: FLORIAN ist ein ergänzendes
* System, die analoge Überwachung bleibt führend. Wer ihn abschaltet,
* erklärt damit, dass die Wehr die digitale Überwachung freigegeben hat —
* das ist eine Entscheidung des Kommandanten, keine des Administrators.
* Deshalb verlangt das Abschalten zusätzlich einen Verweis auf die
* schriftliche Freigabe (ATEMSCHUTZ_FREIGABE), der in der Oberfläche und im
* Protokoll steht.
*/
ATEMSCHUTZ_HINWEIS: boolAusText(true),
/// Aktenzeichen oder Fundstelle der Freigabe durch den Kommandanten.
ATEMSCHUTZ_FREIGABE: z.string().optional(),
/// Vorgabe für den Abstand der Druckabfrage, in Sekunden.
ATEMSCHUTZ_ABFRAGE_INTERVALL: zahl(600),
/// Ansprechdruck der Warneinrichtung in bar, Vorgabe für neue Überwachungen.
ATEMSCHUTZ_WARNDRUCK: zahl(55),
/// Sicherheitszuschlag auf den Rückzugsdruck in bar.
ATEMSCHUTZ_ZUSCHLAG: zahl(10),
// Einsätze und Übungen (Phase 3)
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
GEOCODING_ENABLED: boolAusText(false),
+2
View File
@@ -28,6 +28,8 @@ export const AUFTRAG = {
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
} as const;
+6
View File
@@ -19,6 +19,8 @@ import { uebungRouter } from "@/server/routers/einsatz/uebung";
import { statistikRouter } from "@/server/routers/einsatz/statistik";
import { portalRouter } from "@/server/routers/einsatz/portal";
import { kioskRouter } from "@/server/routers/kiosk/kiosk";
import { atemschutzRouter } from "@/server/routers/atemschutz/ueberwachung";
import { atemschutzTraegerRouter } from "@/server/routers/atemschutz/traeger";
/**
* Wurzel-Router.
@@ -166,6 +168,10 @@ export const appRouter = router({
// Phase 4 — Terminal im Gerätehaus
kiosk: kioskRouter,
// Phase 5 — Atemschutz nach FwDV 7
atemschutz: atemschutzRouter,
atemschutzTraeger: atemschutzTraegerRouter,
});
export type AppRouter = typeof appRouter;
+246
View File
@@ -0,0 +1,246 @@
import { z } from "zod";
import { G26Ergebnis, UebungsartAtemschutz } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { g26FristVorschlag } from "@/lib/atemschutz/tauglichkeit";
import { traegerBewerten, verfuegbarkeit } from "@/lib/atemschutz/verfuegbarkeit";
/**
* Die Atemschutzgeräteträger.
*
* Der ruhige Teil des Moduls: G26-Untersuchungen, Belastungsübungen und die
* Frage, wer daraus folgend einsetzbar ist. Anders als die Überwachung läuft
* das nicht im Einsatz, sondern am Schreibtisch — und trotzdem hängt daran
* dasselbe: wer hier durchrutscht, steht im Ernstfall unter Atemschutz, ohne
* es zu dürfen.
*
* Die Bewertung steht in lib/atemschutz/tauglichkeit.ts und ist dort ohne
* Datenbank getestet; die Beschaffung in lib/atemschutz/verfuegbarkeit.ts,
* damit die Startseite dieselbe Zahl nennt wie diese Ansicht.
*/
export const atemschutzTraegerRouter = router({
/**
* Die Tauglichkeitsübersicht.
*
* Eine Zeile je Person mit Ampel und Klartext. Das ist die Ansicht, die der
* Atemschutzgerätewart vor einer Übung aufruft — und die dem Kommandanten
* sagt, wie viele Träger die Wehr an diesem Tag tatsächlich hat.
*/
uebersicht: mitRecht("atemschutz.lesen").query(async ({ ctx }) =>
ctx.db((db) => traegerBewerten(db)),
),
/**
* Wie viele Träger stehen heute zur Verfügung?
*
* Für die Kachel auf der Startseite. Die Zahl allein sagt wenig — deshalb
* steht daneben, wie viele knapp davor sind, es nicht mehr zu sein.
*/
verfuegbarkeit: mitRecht("atemschutz.lesen").query(async ({ ctx }) =>
ctx.db((db) => verfuegbarkeit(db)),
),
/** Alles zu einer Person: Untersuchungen, Übungen, Einsatzstunden. */
person: mitRecht("atemschutz.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const [untersuchungen, uebungen, trupps] = await Promise.all([
db.g26Untersuchung.findMany({
where: { personId: input.id, geloeschtAm: null },
orderBy: { untersuchtAm: "desc" },
}),
db.belastungsuebung.findMany({
where: { personId: input.id, geloeschtAm: null },
orderBy: { datum: "desc" },
}),
db.truppmitglied.findMany({
where: { personId: input.id, trupp: { einstiegUm: { not: null } } },
orderBy: { trupp: { einstiegUm: "desc" } },
take: 50,
select: {
trupp: {
select: {
id: true,
funkrufname: true,
einstiegUm: true,
drausenUm: true,
atemschutzeinsatz: { select: { id: true, bezeichnung: true } },
},
},
},
}),
]);
const einsatzminuten = trupps.reduce((summe, eintrag) => {
const { einstiegUm, drausenUm } = eintrag.trupp;
if (!einstiegUm || !drausenUm) return summe;
return summe + Math.round((drausenUm.getTime() - einstiegUm.getTime()) / 60_000);
}, 0);
return {
untersuchungen,
uebungen,
einsaetze: trupps.map((eintrag) => eintrag.trupp),
einsatzminuten,
};
}),
),
/** Vorschlag für das Ablaufdatum einer G26 — abhängig vom Alter. */
fristVorschlag: mitRecht("atemschutz.bearbeiten")
.input(z.object({ personId: z.string().uuid(), untersuchtAm: z.coerce.date() }))
.query(async ({ ctx, input }) => {
const person = await ctx.db((db) =>
db.person.findUnique({
where: { id: input.personId },
select: { geburtsdatum: true },
}),
);
return { gueltigBis: g26FristVorschlag(person?.geburtsdatum ?? null, input.untersuchtAm) };
}),
/** Eine Untersuchung erfassen. */
g26Erfassen: mitRecht("atemschutz.bearbeiten")
.input(
z.object({
personId: z.string().uuid(),
untersuchtAm: z.coerce.date(),
gueltigBis: z.coerce.date(),
ergebnis: z.nativeEnum(G26Ergebnis).default(G26Ergebnis.TAUGLICH),
arzt: z.string().trim().max(200).nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
nachweisDateiId: z.string().uuid().nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const untersuchung = await db.g26Untersuchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
personId: input.personId,
untersuchtAm: input.untersuchtAm,
gueltigBis: input.gueltigBis,
ergebnis: input.ergebnis,
arzt: input.arzt ?? null,
bemerkung: input.bemerkung ?? null,
nachweisDateiId: input.nachweisDateiId ?? null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "G26Untersuchung",
entitaetId: untersuchung.id,
sitzung: ctx.sitzung,
nachher: {
personId: input.personId,
ergebnis: input.ergebnis,
gueltigBis: input.gueltigBis,
},
});
return untersuchung;
}),
),
/**
* Eine Untersuchung zurückziehen.
*
* Soft-Delete wie überall bei Nachweisen: eine gelöschte G26 ist ein
* fehlender Nachweis, und der Vorgang selbst gehört ins Protokoll.
*/
g26Entfernen: mitRecht("atemschutz.bearbeiten")
.input(z.object({ id: z.string().uuid(), grund: z.string().trim().max(300) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.g26Untersuchung.findUniqueOrThrow({ where: { id: input.id } });
await db.g26Untersuchung.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "G26Untersuchung",
entitaetId: input.id,
sitzung: ctx.sitzung,
beschreibung: input.grund,
vorher: { gueltigBis: vorher.gueltigBis, ergebnis: vorher.ergebnis },
});
return { entfernt: true };
}),
),
/** Eine Belastungsübung, Einsatzübung oder Unterweisung erfassen. */
uebungErfassen: mitRecht("atemschutz.bearbeiten")
.input(
z.object({
personIds: z.array(z.string().uuid()).min(1).max(60),
datum: z.coerce.date(),
art: z.nativeEnum(UebungsartAtemschutz).default(UebungsartAtemschutz.UEBUNGSSTRECKE),
ort: z.string().trim().max(200).nullish(),
dauerMinuten: z.number().int().min(1).max(600).nullish(),
uebungId: z.string().uuid().nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
// Für eine ganze Gruppe auf einmal: eine Belastungsübung machen
// selten Einzelne, sondern die halbe Wehr an einem Abend.
await db.belastungsuebung.createMany({
data: input.personIds.map((personId) => ({
organisationId: ctx.sitzung.organisationId,
personId,
datum: input.datum,
art: input.art,
ort: input.ort ?? null,
dauerMinuten: input.dauerMinuten ?? null,
uebungId: input.uebungId ?? null,
bemerkung: input.bemerkung ?? null,
})),
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Belastungsuebung",
sitzung: ctx.sitzung,
beschreibung: `${input.personIds.length} Teilnehmer, ${input.art}`,
nachher: { datum: input.datum, art: input.art },
});
return { erfasst: input.personIds.length };
}),
),
/** Eine Übung zurückziehen. */
uebungEntfernen: mitRecht("atemschutz.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
await db.belastungsuebung.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Belastungsuebung",
entitaetId: input.id,
sitzung: ctx.sitzung,
});
return { entfernt: true };
}),
),
});

Some files were not shown because too many files have changed in this diff Show More