Phase 5: Atemschutzüberwachung nach FwDV 7 #6
@@ -185,6 +185,25 @@ KIOSK_SPERRDAUER=300
|
|||||||
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
|
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
|
||||||
KIOSK_IDLE_TIMEOUT=120
|
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)
|
# Wetterwarnungen auf dem Hallenmonitor (optional, DWD)
|
||||||
# Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut —
|
# Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut —
|
||||||
|
|||||||
@@ -8,7 +8,127 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
|||||||
| Phase 2 — Wartung und Prüfung | fertig |
|
| Phase 2 — Wartung und Prüfung | fertig |
|
||||||
| Phase 3 — Einsätze und Übungen | fertig |
|
| Phase 3 — Einsätze und Übungen | fertig |
|
||||||
| Phase 4 — Kiosk-PWA im Gerätehaus | 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 6 — Kartenmodule | offen |
|
||||||
| Phase 7 — Benachrichtigungen und Termine | offen |
|
| Phase 7 — Benachrichtigungen und Termine | offen |
|
||||||
| Phase 8 — Lokale KI | 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
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ x-app-env: &app-env
|
|||||||
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
||||||
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
||||||
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
|
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}
|
WETTER_ENABLED: ${WETTER_ENABLED:-false}
|
||||||
DWD_WARNCELL: ${DWD_WARNCELL:-}
|
DWD_WARNCELL: ${DWD_WARNCELL:-}
|
||||||
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
|
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
# Anleitung
|
# 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.
|
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||||
|
|
||||||
> Atemschutz und Karten kommen in den folgenden Phasen dazu. Siehe
|
> Die Kartenmodule kommen in Phase 6 dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
> [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -603,7 +602,7 @@ Bericht — wird später nachgetragen. Der Einsatz bekommt sofort eine
|
|||||||
fortlaufende Nummer im Format `JJJJ-NNNN`.
|
fortlaufende Nummer im Format `JJJJ-NNNN`.
|
||||||
|
|
||||||
**Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr
|
**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.
|
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
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
|
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
|
FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
|
||||||
betreibt:
|
betreibt:
|
||||||
@@ -965,7 +1120,7 @@ GEOCODING_LAENDER=de,at,ch
|
|||||||
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
||||||
eintragen oder weglassen.
|
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
|
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||||
@@ -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.
|
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,
|
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||||
was dahinter liegt.
|
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,
|
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||||
@@ -1095,7 +1250,7 @@ jeweiligen Detailseite sichtbar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 35. Wenn etwas nicht geht
|
## 39. Wenn etwas nicht geht
|
||||||
|
|
||||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
| 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` |
|
| 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 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 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:
|
Zustand aller Randsysteme auf einen Blick:
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ soll, sind es nicht.
|
|||||||
|
|
||||||
## Phase 5 — Atemschutz nach FwDV 7
|
## Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
|
||||||
**Vergleichsaufwand: 14–18 PT**
|
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:40 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| 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
|
serverseitig geführt werden und der Browser nur anzeigen. Diese Phase bekommt
|
||||||
den höchsten Testanteil im Projekt.
|
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
|
## Phase 6 — Kartenmodule
|
||||||
|
|
||||||
**Vergleichsaufwand: 14–18 PT**
|
**Vergleichsaufwand: 14–18 PT**
|
||||||
|
|||||||
@@ -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 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 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 |
|
| 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Phase 1 ████████████████████ fertig
|
|||||||
Phase 2 ████████████████████ fertig 0.3.0
|
Phase 2 ████████████████████ fertig 0.3.0
|
||||||
Phase 3 ████████████████████ fertig 0.4.0
|
Phase 3 ████████████████████ fertig 0.4.0
|
||||||
Phase 4 ████████████████████ fertig 0.5.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 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
||||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
||||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.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
|
**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.
|
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
|
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
|
||||||
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
|
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
|
||||||
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
||||||
|
|
||||||
- Truppanlage per RFID, Eingangsdruck erfassen
|
- 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
|
- Rückzugsdruck-Berechnung mit Ampel und akustischem Alarm
|
||||||
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
|
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
|
||||||
- Mayday-Knopf je Trupp
|
- 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
|
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
|
||||||
analogen, bis die Wehr ausdrücklich anders entscheidet.
|
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
|
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
|
||||||
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 415 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 733 KiB After Width: | Height: | Size: 733 KiB |
|
Before Width: | Height: | Size: 756 KiB After Width: | Height: | Size: 756 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 968 KiB After Width: | Height: | Size: 969 KiB |
|
Before Width: | Height: | Size: 1013 KiB After Width: | Height: | Size: 1014 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 1005 KiB |
|
Before Width: | Height: | Size: 555 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 547 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 268 KiB |
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ export default defineConfig({
|
|||||||
* Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange
|
* Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange
|
||||||
* kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann
|
* kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann
|
||||||
* nach einem Fehler in der Anwendung aus, ist aber keiner.
|
* 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"]],
|
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||||
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
|
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -199,6 +199,15 @@ model Organisation {
|
|||||||
kioskvorgaenge KioskVorgang[]
|
kioskvorgaenge KioskVorgang[]
|
||||||
syncKonflikte SyncKonflikt[]
|
syncKonflikte SyncKonflikt[]
|
||||||
|
|
||||||
|
// Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
atemschutzeinsaetze Atemschutzeinsatz[]
|
||||||
|
atemschutztrupps Atemschutztrupp[]
|
||||||
|
truppmitglieder Truppmitglied[]
|
||||||
|
druckmessungen Druckmessung[]
|
||||||
|
atemschutzereignisse AtemschutzEreignis[]
|
||||||
|
g26Untersuchungen G26Untersuchung[]
|
||||||
|
belastungsuebungen Belastungsuebung[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("organisation")
|
@@map("organisation")
|
||||||
}
|
}
|
||||||
@@ -292,6 +301,15 @@ model Person {
|
|||||||
verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher")
|
verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher")
|
||||||
erledigteKonflikte SyncKonflikt[] @relation("KonfliktErledigung")
|
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
|
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||||
/// Person kann in zwei Wehren Mitglied sein.
|
/// Person kann in zwei Wehren Mitglied sein.
|
||||||
@@unique([organisationId, email])
|
@@unique([organisationId, email])
|
||||||
@@ -526,6 +544,10 @@ model Datei {
|
|||||||
alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto")
|
alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto")
|
||||||
alsUebungsbericht Uebung[] @relation("UebungBericht")
|
alsUebungsbericht Uebung[] @relation("UebungBericht")
|
||||||
|
|
||||||
|
// Phase 5
|
||||||
|
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
|
||||||
|
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
|
||||||
|
|
||||||
@@unique([bucket, objektSchluessel])
|
@@unique([bucket, objektSchluessel])
|
||||||
@@index([organisationId, entitaet, entitaetId])
|
@@index([organisationId, entitaet, entitaetId])
|
||||||
@@index([organisationId, kategorie])
|
@@index([organisationId, kategorie])
|
||||||
@@ -842,6 +864,9 @@ model Geraet {
|
|||||||
waescheabgaben Waescheabgabe[]
|
waescheabgaben Waescheabgabe[]
|
||||||
inventurposten Inventurposten[]
|
inventurposten Inventurposten[]
|
||||||
|
|
||||||
|
// Phase 5
|
||||||
|
alsPressluftatmer Truppmitglied[] @relation("TruppPressluftatmer")
|
||||||
|
|
||||||
@@unique([organisationId, inventarnummer])
|
@@unique([organisationId, inventarnummer])
|
||||||
@@index([organisationId, status])
|
@@index([organisationId, status])
|
||||||
@@index([organisationId, naechstePruefung])
|
@@index([organisationId, naechstePruefung])
|
||||||
@@ -1649,8 +1674,11 @@ model Einsatz {
|
|||||||
material Materialverbrauch[]
|
material Materialverbrauch[]
|
||||||
lagemeldungen Lagemeldung[]
|
lagemeldungen Lagemeldung[]
|
||||||
portalzugaenge PortalZugang[]
|
portalzugaenge PortalZugang[]
|
||||||
fahrts Fahrt[]
|
fahrten Fahrt[]
|
||||||
anwesenheits Anwesenheit[]
|
anwesenheiten Anwesenheit[]
|
||||||
|
|
||||||
|
// Phase 5
|
||||||
|
atemschutz Atemschutzeinsatz[]
|
||||||
|
|
||||||
@@unique([organisationId, einsatznummer])
|
@@unique([organisationId, einsatznummer])
|
||||||
@@index([organisationId, alarmiertUm(sort: Desc)])
|
@@index([organisationId, alarmiertUm(sort: Desc)])
|
||||||
@@ -1860,6 +1888,10 @@ model Uebung {
|
|||||||
fahrten Fahrt[]
|
fahrten Fahrt[]
|
||||||
anwesenheiten Anwesenheit[]
|
anwesenheiten Anwesenheit[]
|
||||||
|
|
||||||
|
// Phase 5
|
||||||
|
atemschutz Atemschutzeinsatz[]
|
||||||
|
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungUebung")
|
||||||
|
|
||||||
@@unique([organisationId, nummer])
|
@@unique([organisationId, nummer])
|
||||||
@@index([organisationId, datum(sort: Desc)])
|
@@index([organisationId, datum(sort: Desc)])
|
||||||
@@index([organisationId, art])
|
@@index([organisationId, art])
|
||||||
@@ -2213,3 +2245,331 @@ model SyncKonflikt {
|
|||||||
@@index([organisationId, erledigtAm])
|
@@index([organisationId, erledigtAm])
|
||||||
@@map("sync_konflikt")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
* sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein
|
* sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein
|
||||||
* Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am
|
* Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am
|
||||||
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
|
* 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,
|
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||||
@@ -32,8 +34,11 @@ import {
|
|||||||
EinsatzKategorie,
|
EinsatzKategorie,
|
||||||
EinsatzStatus,
|
EinsatzStatus,
|
||||||
Dienstgrad,
|
Dienstgrad,
|
||||||
|
DruckArt,
|
||||||
|
EreignisArt,
|
||||||
FahrtZweck,
|
FahrtZweck,
|
||||||
FahrzeugArt,
|
FahrzeugArt,
|
||||||
|
G26Ergebnis,
|
||||||
GeraeteKategorie,
|
GeraeteKategorie,
|
||||||
GeraetStatus,
|
GeraetStatus,
|
||||||
LebensdauerBezug,
|
LebensdauerBezug,
|
||||||
@@ -48,7 +53,9 @@ import {
|
|||||||
RolleSchluessel,
|
RolleSchluessel,
|
||||||
SignaturRolle,
|
SignaturRolle,
|
||||||
StandortArt,
|
StandortArt,
|
||||||
|
TruppStatus,
|
||||||
UebungsArt,
|
UebungsArt,
|
||||||
|
UebungsartAtemschutz,
|
||||||
WaescheStatus,
|
WaescheStatus,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
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
|
// Ablauf
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -2212,6 +2513,9 @@ async function main(): Promise<void> {
|
|||||||
// Phase 4 — Terminal im Gerätehaus
|
// Phase 4 — Terminal im Gerätehaus
|
||||||
await terminaldatenAnlegen(organisation.id);
|
await terminaldatenAnlegen(organisation.id);
|
||||||
|
|
||||||
|
// Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
await atemschutzAnlegen(organisation.id);
|
||||||
|
|
||||||
log("Fertig.");
|
log("Fertig.");
|
||||||
log("");
|
log("");
|
||||||
log("Anmeldung für den Erststart:");
|
log("Anmeldung für den Erststart:");
|
||||||
|
|||||||
@@ -170,6 +170,20 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
beschreibung: "Anmeldemarke fürs Handy",
|
beschreibung: "Anmeldemarke fürs Handy",
|
||||||
angemeldet: true,
|
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",
|
von: "/uebungen",
|
||||||
linkMuster: /^\/uebungen\/[0-9a-f-]{36}$/,
|
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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { aktuelleSitzung } from "@/lib/auth";
|
||||||
import { fuerOrganisation } from "@/lib/db";
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
|
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { verfuegbarkeit } from "@/lib/atemschutz/verfuegbarkeit";
|
||||||
import { alsDatumZeit } from "@/lib/utils";
|
import { alsDatumZeit } from "@/lib/utils";
|
||||||
|
|
||||||
export const metadata = { title: "Übersicht" };
|
export const metadata = { title: "Übersicht" };
|
||||||
@@ -14,7 +15,7 @@ export default async function UebersichtSeite() {
|
|||||||
if (!sitzung) return null;
|
if (!sitzung) return null;
|
||||||
|
|
||||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
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([
|
await Promise.all([
|
||||||
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
|
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
|
||||||
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
|
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: { geloeschtAm: null } }),
|
||||||
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
|
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
|
||||||
db.geraet.count({ where: { status: "GESPERRT", 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 = [
|
const kacheln = [
|
||||||
@@ -31,6 +41,18 @@ export default async function UebersichtSeite() {
|
|||||||
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
|
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
|
||||||
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
|
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
|
||||||
{ titel: "Prüfung fällig", wert: daten.pruefungFaellig, symbol: AlertTriangle },
|
{ 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 (
|
return (
|
||||||
@@ -80,6 +102,9 @@ export default async function UebersichtSeite() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-semibold tabellenzahlen">{kachel.wert}</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -90,19 +115,19 @@ export default async function UebersichtSeite() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Stand des Aufbaus</CardTitle>
|
<CardTitle>Stand des Aufbaus</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Phase 1 ist abgeschlossen: Standorte, Fahrzeuge mit Geräteräumen,
|
Stammdaten, Prüfmanagement, Einsätze und Übungen, das Terminal im Gerätehaus und
|
||||||
Gerätetypen, Geräte mit QR-Etiketten, Qualifikationen und der Import aus
|
die Atemschutzüberwachung stehen. Als Nächstes kommen die Kartenmodule.
|
||||||
vorhandenen Tabellen stehen. Als Nächstes kommt das Prüfmanagement.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
|
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
|
||||||
<Badge variant="einsatzbereit">Phase 1 — Stammdaten</Badge>
|
<Badge variant="einsatzbereit">Phase 1 — Stammdaten</Badge>
|
||||||
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
|
<Badge variant="einsatzbereit">Phase 2 — Wartung und Prüfung</Badge>
|
||||||
<Badge variant="outline">Phase 3 — Einsätze</Badge>
|
<Badge variant="einsatzbereit">Phase 3 — Einsätze</Badge>
|
||||||
<Badge variant="outline">Phase 4 — Terminal</Badge>
|
<Badge variant="einsatzbereit">Phase 4 — Terminal</Badge>
|
||||||
<Badge variant="outline">Phase 5 — Atemschutz</Badge>
|
<Badge variant="einsatzbereit">Phase 5 — Atemschutz</Badge>
|
||||||
|
<Badge variant="outline">Phase 6 — Karten</Badge>
|
||||||
</div>
|
</div>
|
||||||
<dl className="grid gap-2 sm:grid-cols-2">
|
<dl className="grid gap-2 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -124,17 +124,20 @@ const EINTRAEGE: Eintrag[] = [
|
|||||||
},
|
},
|
||||||
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
|
{ 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",
|
titel: "Protokoll",
|
||||||
pfad: "/protokoll",
|
pfad: "/protokoll",
|
||||||
symbol: Album,
|
symbol: Album,
|
||||||
rechte: ["system.auditlog"],
|
rechte: ["system.auditlog"],
|
||||||
abPhase: 1,
|
abPhase: 8,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||||
const AKTUELLE_PHASE = 4;
|
const AKTUELLE_PHASE = 5;
|
||||||
|
|
||||||
export interface SeitenleisteProps {
|
export interface SeitenleisteProps {
|
||||||
rechte: Recht[];
|
rechte: Recht[];
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -120,6 +120,28 @@ const schema = z.object({
|
|||||||
/// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird.
|
/// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird.
|
||||||
WETTER_ZWISCHENSPEICHER: zahl(300),
|
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)
|
// Einsätze und Übungen (Phase 3)
|
||||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||||
GEOCODING_ENABLED: boolAusText(false),
|
GEOCODING_ENABLED: boolAusText(false),
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const AUFTRAG = {
|
|||||||
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
|
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
|
||||||
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
|
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
|
||||||
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
||||||
|
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
|
||||||
|
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
|
||||||
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
||||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { uebungRouter } from "@/server/routers/einsatz/uebung";
|
|||||||
import { statistikRouter } from "@/server/routers/einsatz/statistik";
|
import { statistikRouter } from "@/server/routers/einsatz/statistik";
|
||||||
import { portalRouter } from "@/server/routers/einsatz/portal";
|
import { portalRouter } from "@/server/routers/einsatz/portal";
|
||||||
import { kioskRouter } from "@/server/routers/kiosk/kiosk";
|
import { kioskRouter } from "@/server/routers/kiosk/kiosk";
|
||||||
|
import { atemschutzRouter } from "@/server/routers/atemschutz/ueberwachung";
|
||||||
|
import { atemschutzTraegerRouter } from "@/server/routers/atemschutz/traeger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wurzel-Router.
|
* Wurzel-Router.
|
||||||
@@ -166,6 +168,10 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// Phase 4 — Terminal im Gerätehaus
|
// Phase 4 — Terminal im Gerätehaus
|
||||||
kiosk: kioskRouter,
|
kiosk: kioskRouter,
|
||||||
|
|
||||||
|
// Phase 5 — Atemschutz nach FwDV 7
|
||||||
|
atemschutz: atemschutzRouter,
|
||||||
|
atemschutzTraeger: atemschutzTraegerRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||