diff --git a/.env.example b/.env.example index a753c20..60c6417 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,25 @@ KIOSK_SPERRDAUER=300 # Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden. KIOSK_IDLE_TIMEOUT=120 +# ----------------------------------------------------------------------------- +# Atemschutz nach FwDV 7 (Phase 5) +# ----------------------------------------------------------------------------- +# Der Sicherheitshinweis ueber jeder Ansicht der Atemschutzueberwachung: +# "Ergaenzendes System — die analoge Atemschutzueberwachung bleibt fuehrend." +# Er gehoert dorthin. Auf false setzen darf ihn nur, wer eine schriftliche +# Freigabe des Kommandanten hat — und deren Fundstelle gehoert in +# ATEMSCHUTZ_FREIGABE, sonst bleibt der Hinweis stehen. +ATEMSCHUTZ_HINWEIS=true +# Aktenzeichen oder Fundstelle der Freigabe, z. B. "Kommandantenbeschluss vom 12.03.2026". +ATEMSCHUTZ_FREIGABE= +# Abstand der Druckabfrage in Sekunden. FwDV 7 nennt keinen festen Wert; +# zehn Minuten sind die uebliche Praxis. +ATEMSCHUTZ_ABFRAGE_INTERVALL=600 +# Ansprechdruck der Warneinrichtung in bar. Je nach Baujahr 50 bis 60. +ATEMSCHUTZ_WARNDRUCK=55 +# Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar. +ATEMSCHUTZ_ZUSCHLAG=10 + # ----------------------------------------------------------------------------- # Wetterwarnungen auf dem Hallenmonitor (optional, DWD) # Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut — diff --git a/CHANGELOG.md b/CHANGELOG.md index b8354ae..4c61599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,127 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). ## [Unveröffentlicht] -Nächster Schritt ist Phase 5 (Atemschutzverwaltung nach FwDV 7). +Nächster Schritt ist Phase 6 (Kartenmodule). + +--- + +## [0.6.0] — 2026-09-03 + +Phase 5: Atemschutzüberwachung nach FwDV 7. Das sicherheitsrelevanteste Modul +des Projekts — und deshalb das mit dem höchsten Testanteil. + +Über jeder Ansicht steht: **Ergänzendes System — die analoge +Atemschutzüberwachung bleibt führend.** Der Hinweis lässt sich nur abschalten, +wenn zusätzlich die Fundstelle einer Freigabe des Kommandanten hinterlegt ist; +ohne sie bleibt er stehen. + +### Hinzugefügt + +**Datenmodell** + +- `Atemschutzeinsatz` — eine Überwachung mit Überwacher, Abfrageintervall, + Warndruck und Sicherheitszuschlag. Hängt an einem Einsatz oder einer Übung, + kann aber auch für sich stehen: im Ernstfall wird zuerst überwacht und + später zugeordnet. +- `Atemschutztrupp` mit Status (bereit, im Einsatz, Rückweg, draußen), den + Zeitstempeln dazu, dem errechneten Rückzugsdruck und dem Notfallzeitpunkt. +- `Truppmitglied` — zwei oder drei Personen je Trupp, genau eine davon + Truppführer, optional mit dem verwendeten Pressluftatmer. +- `Druckmessung` mit Art (Eingang, Einsatzziel, Zwischenmeldung, Ausgang). +- `AtemschutzEreignis` — jede Zeile des Protokolls mit Zeitpunkt. +- `G26Untersuchung` und `Belastungsuebung` als eigene Nachweise. + +**Die Zeit gehört dem Server** + +- Nirgends wird eine Restzeit heruntergezählt. Jede Dauer entsteht aus zwei + Zeitstempeln — einem aus der Datenbank und der aktuellen Uhr. Deshalb + übersteht die Anzeige ein Neuladen, einen zweiten Bildschirm und einen + Verbindungsabbruch: es gibt nichts, das dabei verloren gehen könnte. +- Server und Browser rechnen mit **denselben** Funktionen aus + `lib/atemschutz/berechnung.ts`. Was das Tablet anzeigt, weist das Protokoll + später genauso aus. + +**Rückzugsdruck nach dem doppelten Anmarschverbrauch** + +- Der Trupp meldet beim Erreichen des Einsatzziels einmal seinen Druck. Daraus: + `2 × Anmarschverbrauch + Ansprechdruck der Warneinrichtung + Zuschlag`. +- Solange keine Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt + höher und ist damit die vorsichtigere Annahme. +- Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht + gemeinsam zurück. +- Ampel in drei Stufen mit Klartext daneben, Gelb schon zehn Prozent über der + Grenze. Bei Notfall zusätzlich ein erzeugter Ton — ausdrücklich eine Zugabe. + +**Überwachungsschirm** + +- Trupp anlegen mit Eingangsdruck je Person; daneben steht, ob die Person nach + FwDV 7 überhaupt einsetzbar ist. **Gesperrt wird nichts** — im Einsatz + entscheidet der Einheitsführer, und ein Programm, das ihm im entscheidenden + Moment eine Person verweigert, wird umgangen. +- Druckabfrage für den ganzen Trupp in einer Zeile, Erinnerung im + konfigurierbaren Intervall (Vorgabe zehn Minuten). +- Mayday je Trupp, ausdrücklich aufzuheben statt wegzuklicken. +- Rückzug anordnen, Statuswechsel, Notizen ins Protokoll. +- Die Überwachung lässt sich erst beenden, wenn alle Trupps als draußen + gemeldet sind. + +**Ereignisstrom über Server-Sent Events** + +- Ein Route Handler unter `/api/atemschutz/strom` meldet Änderungen; der + Browser holt daraufhin den Stand. Kein eigener Server nötig, `EventSource` + verbindet nach einem Abbruch von selbst neu. +- Der Zustand der Verbindung steht sichtbar auf dem Schirm. Bleibt das + Lebenszeichen des Servers aus, gilt die Verbindung nach acht Sekunden als + tot — auch wenn der Browser keinen Fehler meldet. +- Fällt der Strom ganz aus, bleibt die Ansicht bedienbar: sie fragt zusätzlich + im festen Takt nach. + +**Protokoll** + +- Lückenloses PDF mit Rahmen, Trupps und dem Verlauf aus Ereignissen und + Druckmessungen in einer gemeinsamen Zeitachse. +- Entsteht beim Beenden im Hintergrundprozess und bei Bedarf beim Abruf — ein + Nachweis, den man nicht bekommt, weil ein Dienst einen schlechten Tag hatte, + wäre keiner. +- Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt + später liest, soll wissen, was er in der Hand hält. + +**Tauglichkeit nach FwDV 7** + +- Drei Nachweise, die gleichzeitig gelten müssen: gültige G26.3, + Belastungsübung im laufenden Jahr, Einsatzübung im laufenden Jahr. Ein + Einsatz unter Atemschutz ersetzt die Einsatzübung. +- Ampel mit **Klartext**, was fehlt — eine rote Ampel ohne Begründung hilft am + Fahrzeug niemandem. +- Fristvorschlag für die G26 nach Alter: drei Jahre, ab fünfzig eines. +- Nachweise pflegen: G26 je Person, Belastungsübung für die ganze Gruppe auf + einmal. +- Kachel auf der Startseite: wie viele Träger heute einsetzbar sind und wie + viele davon knapp davor stehen, es nicht mehr zu sein. + +**Sonstiges** + +- Der Seed legt acht G26-Untersuchungen an — darunter eine abgelaufene und + eine bald ablaufende —, zwölf Übungsnachweise und eine vollständig + durchgespielte Überwachung mit zwei Trupps. +- 68 neue Tests: 51 Unit-Tests zu Rückzugsdruck, Ampel, Restzeit, Statusfolge + und Tauglichkeit, 17 gegen eine echte Datenbank und 8 Playwright-Durchläufe + je Browser — darunter die beiden, um die es in dieser Phase geht: die Uhr + nach einem Neuladen und bei abgerissenem Ereignisstrom. + +### Geändert + +- Die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` auf + `druckmessung` und `atemschutz_ereignis`; zusätzlich sitzt auf beiden ein + Trigger. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht + durch Ändern — sonst wäre jede Aufzeichnung nachträglich glättbar. +- Neue Bedingungen: Druck zwischen 0 und 400 bar, kein Rückzug vor dem + Einstieg, kein Ende vor dem Beginn, genau ein Truppführer je Trupp. +- Die Startseite stand noch auf dem Stand von Phase 1 („Als Nächstes kommt das + Prüfmanagement"). Sie nennt jetzt den tatsächlichen Ausbaustand. +- Der Navigationseintrag „Protokoll" führte auf eine Seite, die es nicht gibt. + Die Ansicht des Audit-Logs gehört zum Löschkonzept in Phase 8; bis dahin + steht der Eintrag ausgegraut da statt ins Leere zu führen. --- diff --git a/README.md b/README.md index 25a85ac..016d457 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 2 — Wartung und Prüfung | fertig | | Phase 3 — Einsätze und Übungen | fertig | | Phase 4 — Kiosk-PWA im Gerätehaus | fertig | -| Phase 5 — Atemschutz (FwDV 7) | offen | +| Phase 5 — Atemschutz (FwDV 7) | fertig | | Phase 6 — Kartenmodule | offen | | Phase 7 — Benachrichtigungen und Termine | offen | | Phase 8 — Lokale KI | offen | @@ -529,6 +529,141 @@ Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile. --- +## Phase 5 — Atemschutz nach FwDV 7 + +Das sicherheitsrelevanteste Modul des Projekts. Über jeder Ansicht steht +deshalb dauerhaft: + +> **Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.** + +Der Hinweis lässt sich abschalten, aber nur zusammen mit einer Fundstelle: +`ATEMSCHUTZ_HINWEIS=false` wirkt erst, wenn unter `ATEMSCHUTZ_FREIGABE` steht, +worauf sich die Freigabe stützt — etwa „Kommandantenbeschluss vom 12.03.2026". +Fehlt sie, bleibt der Hinweis stehen. Eine Freigabe ohne Beschluss ist keine. + +### Die Zeit gehört dem Server + +Das ist die eine Entscheidung, aus der alles Weitere folgt. **Nirgends wird +eine Restzeit heruntergezählt.** In der Datenbank stehen Zeitstempel; wie lange +ein Trupp drin ist, rechnet jede Ansicht bei jedem Bild neu aus dem +Einstiegszeitpunkt und der aktuellen Uhr. + +Daraus ergibt sich von selbst, was Phase-5.md als wichtigste Anforderung nennt: + +| Was passiert | Was die Anzeige macht | +|---|---| +| Browser neu laden | Zeigt dieselbe Zeit weiter — es gab nichts zu verlieren | +| Zweiter Bildschirm daneben | Zeigt dieselbe Zeit, ohne Abgleich | +| Verbindung bricht ab | Die Uhr läuft weiter, und der Abriss steht sichtbar da | +| Server neu gestartet | Nach der ersten Antwort ist alles wieder da | + +Server und Browser rechnen mit **denselben** Funktionen +(`src/lib/atemschutz/berechnung.ts`). Was das Tablet anzeigt, weist das +Protokoll später genauso aus. + +### Rückzugsdruck + +Gerechnet wird nach dem **doppelten Anmarschverbrauch**: was der Weg hinein an +Luft gekostet hat, kostet der Weg zurück noch einmal. + +``` +Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag +``` + +Dafür meldet der Trupp beim Erreichen des Einsatzziels einmal seinen Druck. +Solange das nicht geschehen ist, greift die **Zwei-Drittel-Regel** — sie liegt +in aller Regel höher und ist damit die vorsichtigere Annahme; die Karte sagt +dann „vorläufig" dazu. + +Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht gemeinsam +zurück, also zählt, wer am wenigsten Luft hat. + +Die Ampel steht schon zehn Prozent über der Grenze auf Gelb — der Trupp soll +losgehen, bevor der Wert erreicht ist, nicht wenn er erreicht ist. Farbe **und** +Text, wie überall in FLORIAN. + +### Der Weg durch eine Überwachung + +1. **Eröffnen** — Bezeichnung genügt. Die Zuordnung zu einem Einsatz lässt sich + nachtragen; im Ernstfall wird zuerst überwacht. +2. **Trupp anlegen** — zwei oder drei Personen, für jede der Eingangsdruck, eine + davon Truppführer. Daneben steht, ob die Person nach FwDV 7 einsetzbar ist. + **Gesperrt wird nichts:** im Einsatz entscheidet der Einheitsführer, und ein + Programm, das ihm im entscheidenden Moment eine Person verweigert, wird + umgangen — dann steht am Ende gar nichts im Protokoll. +3. **Einstieg** — ab hier läuft die Uhr. +4. **Druckabfrage** — für den ganzen Trupp in einer Zeile. Nach + `ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden ohne Meldung wird erinnert; gerechnet + ab der letzten Meldung, nicht ab dem Einstieg. +5. **Rückweg und heraus** — beim Herauskommen die Ausgangsdrücke. +6. **Beenden** — geht erst, wenn alle Trupps als draußen gemeldet sind. Eine + beendete Überwachung mit einem Trupp, der noch drin steht, wäre die + gefährlichste Zeile im ganzen Protokoll. + +Dazu jederzeit: **Rückzug anordnen**, **Mayday** (aufzuheben, nicht +wegzuklicken) und Notizen ins Protokoll. + +### Der Ereignisstrom + +Änderungen kommen über **Server-Sent Events** unter `/api/atemschutz/strom` — +kein WebSocket, kein eigener Node-Server, `next start` bleibt wie es ist. Der +Strom meldet nur „es hat sich etwas geändert"; die Ansicht holt daraufhin den +Stand. Geschrieben wird wie überall über tRPC. + +Zwei Dinge machen ihn belastbar: + +- **Er verbindet sich selbst neu.** `EventSource` bringt das mit. +- **Stille gilt als Abriss.** Der Server schickt alle zwei Sekunden ein + Lebenszeichen; bleibt es acht Sekunden aus, steht die Verbindung sichtbar auf + „unterbrochen" — auch wenn der Browser keinen Fehler meldet. Dieser Fall ist + real: wird das Netz gekappt, während der Strom offen ist, schweigt + `EventSource` einfach. + +Fällt der Strom ganz aus, bleibt die Ansicht bedienbar — sie fragt zusätzlich im +festen Takt nach. Der Strom macht sie schneller, nicht erst funktionsfähig. + +### Das Protokoll + +Ein PDF mit Rahmen, Trupps und dem Verlauf — Ereignisse und Druckmessungen in +**einer** Zeitachse, damit niemand zwei Spalten gleichzeitig lesen muss. Es +entsteht beim Beenden im Hintergrundprozess und, falls das nicht geklappt hat, +beim Abruf unter `/api/atemschutz/protokoll?id=…`. + +Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt +später in der Hand hält, soll wissen, was er da hat. + +Verändern lässt sich nichts: `druckmessung` und `atemschutz_ereignis` sind für +den Anwendungszugang gegen `UPDATE` und `DELETE` gesperrt, und zusätzlich sitzt +auf beiden ein Trigger. Ein Fehler wird durch einen neuen Eintrag +richtiggestellt. + +### Wer darf unter Atemschutz? + +Drei Nachweise, die gleichzeitig gelten müssen: + +| Nachweis | Frist | +|---|---| +| **G26.3** | nach Alter: bis 50 Jahre drei Jahre, danach eines | +| **Belastungsübung** | jährlich (Übungsstrecke) | +| **Einsatzübung unter Atemschutz** | jährlich — ein echter Einsatz zählt | + +Dazu die Qualifikation als Atemschutzgeräteträger aus Phase 1. Die Übersicht +zeigt eine Ampel **mit Klartext**, was fehlt: eine rote Ampel ohne Begründung +hilft am Fahrzeug niemandem. Auf der Startseite steht die Zahl, nach der im +Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind. + +### Zusätzliche Umgebungsvariablen + +| Variable | Vorgabe | Bedeutung | +|---|---|---| +| `ATEMSCHUTZ_HINWEIS` | `true` | Sicherheitshinweis über jeder Ansicht | +| `ATEMSCHUTZ_FREIGABE` | — | Fundstelle der Freigabe; ohne sie bleibt der Hinweis | +| `ATEMSCHUTZ_ABFRAGE_INTERVALL` | `600` | Abstand der Druckabfrage in Sekunden | +| `ATEMSCHUTZ_WARNDRUCK` | `55` | Ansprechdruck der Warneinrichtung in bar | +| `ATEMSCHUTZ_ZUSCHLAG` | `10` | Sicherheitszuschlag auf den Rückzugsdruck | + +--- + ## Befehle ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 90a38ac..0bd4176 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,11 @@ x-app-env: &app-env KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5} KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300} KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120} + ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true} + ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-} + ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600} + ATEMSCHUTZ_WARNDRUCK: ${ATEMSCHUTZ_WARNDRUCK:-55} + ATEMSCHUTZ_ZUSCHLAG: ${ATEMSCHUTZ_ZUSCHLAG:-10} WETTER_ENABLED: ${WETTER_ENABLED:-false} DWD_WARNCELL: ${DWD_WARNCELL:-} DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json} diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 91b1d22..92c5334 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,10 +1,9 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 4 (Version 0.5.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 5 (Version 0.6.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. -> Atemschutz und Karten kommen in den folgenden Phasen dazu. Siehe -> [ROADMAP.md](ROADMAP.md). +> Die Kartenmodule kommen in Phase 6 dazu. Siehe [ROADMAP.md](ROADMAP.md). --- @@ -603,7 +602,7 @@ Bericht — wird später nachgetragen. Der Einsatz bekommt sofort eine fortlaufende Nummer im Format `JJJJ-NNNN`. **Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr -einen eigenen Nominatim betreibt (Kapitel 30). Ohne den bleibt das Feld leer — +einen eigenen Nominatim betreibt (Kapitel 34). Ohne den bleibt das Feld leer — das ist in Ordnung, die Karte kommt in Phase 6. --- @@ -945,7 +944,163 @@ nützt eine Minute später nichts mehr. --- -## 30. Adressen zu Koordinaten (Nominatim) +## 30. Atemschutz — was das Modul ist und was nicht + +![Atemschutzübersicht](screenshots/31-atemschutz-light.png) + +> **Über jeder Ansicht dieses Moduls steht:** +> *Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.* + +Das ist kein Kleingedrucktes. Die Überwachung, die im Ernstfall zählt, hängt am +Fahrzeug und wird mit Stift geführt. FLORIAN schreibt mit, rechnet mit und +druckt am Ende ein Protokoll — aber wenn dieses Tablet ausfällt, läuft der +Einsatz unverändert weiter. + +Der Hinweis lässt sich abschalten, aber nur zusammen mit einem Beleg: + +```bash +# .env +ATEMSCHUTZ_HINWEIS=false +ATEMSCHUTZ_FREIGABE=Kommandantenbeschluss vom 12.03.2026 +``` + +Fehlt die Fundstelle, bleibt der Hinweis stehen — egal was in +`ATEMSCHUTZ_HINWEIS` steht. Eine Freigabe ohne Beschluss ist keine. Ist er +abgeschaltet, verschwindet er nicht spurlos: dann steht dort, worauf sich die +Freigabe stützt, und dasselbe steht später im Protokoll. + +Die Startseite zeigt die Zahl, nach der im Einsatz als Erstes gefragt wird: +**wie viele Atemschutzgeräteträger heute einsetzbar sind** — und wie viele +davon knapp davor stehen, es nicht mehr zu sein. + +--- + +## 31. Eine Überwachung führen + +![Überwachungsschirm](screenshots/33-atemschutz-ueberwachung-light.png) + +### Eröffnen + +Auf `/atemschutz` genügt eine Bezeichnung — „Zimmerbrand Hauptstraße 12". Die +Zuordnung zu einem Einsatz lässt sich nachtragen; im Ernstfall wird zuerst +überwacht und später sortiert. + +### Trupp anlegen + +Zwei oder drei Personen, für jede der Eingangsdruck vom Manometer, eine davon +Truppführer. Neben jedem Namen steht, ob die Person nach FwDV 7 einsetzbar ist +und was gegebenenfalls fehlt. + +> **Verweigert wird nichts.** Im Einsatz entscheidet der Einheitsführer. Ein +> Programm, das ihm im entscheidenden Moment eine Person sperrt, wird umgangen — +> und dann steht am Ende gar nichts im Protokoll. Der Hinweis nützt, die Sperre +> schadet. + +### Was auf der Karte steht + +| Anzeige | Bedeutung | +|---|---| +| **unter Atemschutz** | Zeit seit dem Einstieg — läuft, solange der Trupp drin ist | +| **niedrigster Druck** | Der ungünstigste Wert im Trupp. Der Trupp geht gemeinsam zurück | +| **Rückzug bei** | Der errechnete Rückzugsdruck | + +Die Farbe der Karte ist die Ampel, und daneben steht immer der Text dazu: +„im Rahmen", „Rückzugsdruck bald erreicht", „Rückzugsdruck erreicht — Trupp +zurückziehen". Gelb kommt schon zehn Prozent vor der Grenze — der Trupp soll +losgehen, bevor der Wert erreicht ist. + +### Der Rückzugsdruck + +Gerechnet wird nach dem **doppelten Anmarschverbrauch**: + +``` +Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag +``` + +Dafür meldet der Trupp einmal, wenn er das Einsatzziel erreicht hat — dafür ist +der Knopf **„Einsatzziel erreicht"** da. Bis dahin steht ein vorläufiger Wert +nach der Zwei-Drittel-Regel; er liegt höher und ist damit die vorsichtigere +Annahme. Die Karte sagt das auch dazu. + +Beispiel: 300 bar rein, 260 bar am Ziel — 40 bar für den Hinweg, also 80 für +hin und zurück, plus 55 bar Warndruck und 10 bar Zuschlag: **Rückzug bei +145 bar.** + +### Druckabfrage + +Alle Werte des Trupps in einer Zeile, ein Knopf. Bleibt eine Meldung aus, steht +nach `ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden (Vorgabe zehn Minuten) ein +deutlicher Hinweis auf der Karte — gerechnet ab der letzten Meldung, nicht ab +dem Einstieg. + +### Mayday + +Ein Knopf je Trupp. Er ändert nichts an der Lage: er hält fest, wann es losging, +macht es auf jedem Bildschirm sichtbar und lässt einen Ton hören. Aufgehoben +wird er **ausdrücklich**, nicht durch Wegklicken — und beides steht im +Protokoll. + +### Beenden + +Geht erst, wenn alle Trupps als draußen gemeldet sind. Das ist keine Schikane: +eine beendete Überwachung mit einem Trupp, der noch drin steht, wäre die +gefährlichste Zeile im ganzen Protokoll. + +--- + +## 32. Was passiert, wenn die Technik streikt + +Die wichtigste Eigenschaft dieser Ansicht ist, dass man ihr nichts anhaben +kann. Der Grund dafür ist einfach: **es gibt keinen Timer.** In der Datenbank +steht, wann der Trupp eingestiegen ist; wie lange er drin ist, rechnet der +Bildschirm jede Sekunde neu aus. + +| Was passiert | Was Sie sehen | +|---|---| +| Seite neu laden | Dieselbe Zeit läuft weiter | +| Zweites Tablet daneben | Dieselbe Zeit, ohne Abgleich | +| WLAN bricht ab | Die Uhr läuft weiter; oben steht **Verbindung unterbrochen** | +| Server neu gestartet | Nach der ersten Antwort ist alles wieder da | + +Oben rechts steht dauerhaft, ob die Verbindung steht. Das ist Absicht: wer +nicht sieht, dass der Draht weg ist, hält einen alten Stand für den aktuellen. +Meldet sich der Server acht Sekunden lang nicht, springt die Anzeige auf +„unterbrochen" — auch dann, wenn der Browser selbst keinen Fehler meldet. + +Ist die Verbindung weg, bleibt die Ansicht bedienbar; sie fragt weiter im Takt +nach und holt auf, sobald es wieder geht. + +--- + +## 33. Nachweise und Tauglichkeit + +![Nachweise](screenshots/32-atemschutz-nachweise-light.png) + +Drei Nachweise müssen **gleichzeitig** gelten, sonst ist jemand nicht +einsetzbar: + +| Nachweis | Frist | Wo erfasst | +|---|---|---| +| **G26.3** | drei Jahre, ab 50 Jahren eines | `/atemschutz/nachweise` | +| **Belastungsübung** | jährlich | dort, für die ganze Gruppe auf einmal | +| **Einsatzübung unter Atemschutz** | jährlich | dort — ein echter Einsatz zählt ebenso | + +Dazu die Qualifikation als Atemschutzgeräteträger aus Kapitel 9. + +Das Ablaufdatum der G26 wird nach dem Alter vorgeschlagen; was auf der +Bescheinigung steht, gilt und lässt sich überschreiben. Eine Untersuchung mit +dem Ergebnis „Nachuntersuchung" oder „nicht tauglich" zählt nicht als Nachweis. + +In der Übersicht steht neben jedem Namen **im Klartext**, was fehlt — „G26.3 ist +abgelaufen", „Belastungsübung in diesem Jahr fehlt". Eine rote Ampel ohne +Begründung hilft am Fahrzeug niemandem. + +Drei Monate vor Ablauf der G26 wechselt die Ampel auf Gelb: die Person ist +weiter einsetzbar, aber es wird Zeit für einen Termin. + +--- + +## 34. Adressen zu Koordinaten (Nominatim) FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim betreibt: @@ -965,7 +1120,7 @@ GEOCODING_LAENDER=de,at,ch Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand eintragen oder weglassen. -## 31. Rollen und was sie dürfen +## 35. Rollen und was sie dürfen Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in `src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr @@ -994,7 +1149,7 @@ Das Terminal selbst ist nur die Tür. --- -## 32. Anmeldung über authentik einrichten +## 36. Anmeldung über authentik einrichten authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. @@ -1047,7 +1202,7 @@ ganze Wehr aus. --- -## 33. PIN und Ausweiskarte verwalten +## 37. PIN und Ausweiskarte verwalten Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum, was dahinter liegt. @@ -1070,7 +1225,7 @@ Huber), PIN `471203`. --- -## 34. Was protokolliert wird +## 38. Was protokolliert wird Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. @@ -1095,7 +1250,7 @@ jeweiligen Detailseite sichtbar. --- -## 35. Wenn etwas nicht geht +## 39. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| @@ -1115,6 +1270,10 @@ jeweiligen Detailseite sichtbar. | Die Leiste zeigt dauerhaft wartende Änderungen | Server erreichbar, aber der Vorgang wird abgelehnt | Über „anzeigen" den Grund lesen; landet er als Konflikt, steht er unter `/terminal` | | „Der Code wurde nicht erkannt" beim QR-Login | Marke älter als 60 Sekunden | Auf dem Handy die Seite Anmeldemarke offen halten, sie erneuert den Code selbst | | Der Monitor zeigt keine Wetterwarnung | `WETTER_ENABLED` aus oder `DWD_WARNCELL` leer | Beides setzen; ohne Warnzelle wird nichts abgerufen | +| Der Sicherheitshinweis im Atemschutz bleibt trotz `ATEMSCHUTZ_HINWEIS=false` | `ATEMSCHUTZ_FREIGABE` ist leer | Fundstelle der Freigabe eintragen — ohne sie bleibt der Hinweis absichtlich stehen | +| Die Überwachung lässt sich nicht beenden | Ein Trupp ist noch nicht als draußen gemeldet | Trupp abmelden, dann beenden. Die Meldung nennt die Anzahl | +| Oben steht „Verbindung unterbrochen", obwohl das Netz da ist | Der Ereignisstrom wurde von einem Proxy gepuffert oder gekappt | Die Ansicht arbeitet weiter; bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen | +| Das Atemschutzprotokoll fehlt | Die Überwachung ist noch nicht beendet | Ein Protokoll entsteht erst mit dem Ende — vorher wäre es unvollständig und sähe vollständig aus | Zustand aller Randsysteme auf einen Blick: diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index 2b272e7..18bc954 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -249,7 +249,7 @@ soll, sind es nicht. ## Phase 5 — Atemschutz nach FwDV 7 -**Vergleichsaufwand: 14–18 PT** +**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:40 h Sitzungszeit** | Baustein | PT | |---|---:| @@ -268,6 +268,33 @@ Timer, der einen Reload überlebt, ist Pflicht — deshalb muss die Zeit serverseitig geführt werden und der Browser nur anzeigen. Diese Phase bekommt den höchsten Testanteil im Projekt. +**Rückblick.** Der Satz stimmt, aber die Schlussfolgerung war die falsche. Die +Aufstellung veranschlagt 3,5 PT für „serverseitig autoritative Timer" — als +wäre ein Timer zu bauen. Gebraucht wurde das Gegenteil: **kein** Timer. Wer die +Zeit als Zeitstempel in die Datenbank schreibt und jede Ansicht selbst rechnen +lässt, hat nichts, was einen Reload nicht überlebt. Der teuerste Posten der +Schätzung war der billigste der Umsetzung, weil die richtige Entscheidung ihn +auflöst statt ihn zu lösen. + +Auch Socket.IO fiel weg. Es geht nur in eine Richtung — der Server meldet eine +Änderung, der Browser holt den Stand über den Weg, den es ohnehin gibt. Ein +Route Handler mit Server-Sent Events ist siebzig Zeilen und braucht kein +verändertes Deployment. + +Teurer war das, was in keiner Zeile der Aufstellung steht: **die Frage, was +passiert, wenn nichts passiert.** Ein abgerissener Ereignisstrom meldet sich +nicht — `EventSource` schweigt einfach. Eine Anzeige, die deshalb weiter +„Verbunden" behauptet, ist gefährlicher als eine, die gar nichts anzeigt: sie +lässt einen alten Stand wie einen aktuellen aussehen. Aufgefallen ist das nur, +weil der E2E-Test das Netz gekappt und danach nachgesehen hat — die 2,0 PT für +„Tests: Reload und Verbindungsabbruch überstehen" waren wieder der +bestangelegte Posten der Phase. + +Billiger als gedacht war die Tauglichkeit. Nicht das Rechnen — die +Entscheidung, **nicht** zu sperren. Damit entfielen alle Sonderfälle, die eine +Sperre nach sich zieht: Übersteuern, Begründungspflicht, Freigabe durch wen. +Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert. + ## Phase 6 — Kartenmodule **Vergleichsaufwand: 14–18 PT** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 778a7cc..d5cc142 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -16,7 +16,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 | | Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 | | Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 | -| **Summe** | **10:40 h** | **6** | | +| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 | +| **Summe** | **13:20 h** | **7** | | + +--- + +## 2026-09-03 · Phase 5 — Atemschutz nach FwDV 7 + +**19:40 – 22:20 · 2:40 h · Version 0.6.0** + +### Was entstanden ist + +Die Atemschutzüberwachung. Sieben Tabellen, ein Ereignisstrom, ein +lückenloses Protokoll — und der Hinweis über allem, dass die analoge +Überwachung führend bleibt. + +| Bereich | Dateien | +|---|---| +| Rechnen | `lib/atemschutz/berechnung.ts`, `tauglichkeit.ts`, `verfuegbarkeit.ts` | +| Server | `server/routers/atemschutz/ueberwachung.ts`, `traeger.ts` | +| Strom | `app/api/atemschutz/strom/route.ts`, `components/atemschutz/stand.ts` | +| Protokoll | `lib/atemschutz/protokoll.ts`, `protokoll-erzeugen.ts`, `api/atemschutz/protokoll` | +| Oberfläche | `app/(app)/atemschutz/`, `components/atemschutz/` | +| Tests | 51 Unit, 17 Datenbank, 8 E2E je Browser | + +### Entscheidungen + +**Server-Sent Events statt Socket.IO.** Phase-5.md nennt Socket.IO; das +verlangt in Next.js einen eigenen Node-Server und damit ein anderes +Deployment. Gebraucht wird aber nur eine Richtung: der Server meldet „es hat +sich etwas geändert", der Browser holt den Stand über tRPC. Ein Route Handler +genügt, `EventSource` verbindet nach einem Abbruch von selbst neu, und +`next start` bleibt wie es ist. Nach Rücksprache so entschieden. + +**Es gibt keinen Timer.** Kein Zähler, kein Intervall, das Zeit führt. Jede +Dauer entsteht aus zwei Zeitstempeln — einem aus der Datenbank, einem von der +Uhr. Damit ist die Anforderung „übersteht Reload und Verbindungsabbruch" keine +Funktion, die man testen muss, sondern eine Eigenschaft, die sich nicht +verlieren lässt. Getestet wird sie trotzdem. + +**Der Rückzugsdruck nach dem doppelten Anmarschverbrauch.** Nach Rücksprache: +die Wehr rechnet mit dem tatsächlichen Verbrauch auf dem Hinweg. Solange keine +Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt höher, ist also +die vorsichtigere Annahme, und das steht auch so auf der Karte. + +**Die Tauglichkeit sperrt niemanden.** Neben jedem Namen steht, ob die Person +nach FwDV 7 einsetzbar wäre und was fehlt. Verweigert wird nichts. Ein +Programm, das im Einsatz eine Person blockiert, wird umgangen — und dann steht +am Ende gar nichts im Protokoll. Der Hinweis nützt, die Sperre schadet. + +**Das Protokoll ist doppelt gesichert.** Entzogene Rechte **und** ein Trigger +auf `druckmessung` und `atemschutz_ereignis`. Eine Sperre hätte gereicht; bei +einer Aufzeichnung, an der Menschenleben hängen, ist die zweite ihren Preis +wert. + +### Wo es gehakt hat + +| Problem | Ursache | Lösung | +|---|---|---| +| Ein gekapptes Netz ließ die Anzeige „Verbunden" melden | `context.setOffline()` trennt einen **offenen** SSE-Strom in Chromium nicht — er liefert weiter. Der Test prüfte damit etwas anderes als gedacht. Schlimmer: bei einem echten Abriss schweigt `EventSource`, ohne einen Fehler zu melden | Der Server schickt alle zwei Sekunden ein Lebenszeichen; bleibt es acht Sekunden aus, gilt die Verbindung als tot. Der Test schneidet den Strom jetzt über eine abgewiesene Route ab, statt das Netz zu kappen | +| Prisma meldete den Teilindex nicht beim Namen | Ein `UNIQUE INDEX … WHERE` erscheint als verletzte Eindeutigkeit auf der Spalte | Zusicherung im Test daran angepasst, mit Verweis auf den Index | +| Die Bildschirmfotos zeigten Testleichen | 34 Überwachungen aus den E2E-Läufen standen über der einen echten aus dem Seed | Rückstände in der Entwicklungsdatenbank abgeräumt, Auswahl im Skript auf Nicht-Testdaten eingegrenzt. Die Ursache bleibt: die E2E-Suite schreibt in die Demowehr | + +### Abweichung von der Vorgabe + +**Kein Socket.IO.** Siehe oben — nach Rücksprache durch Server-Sent Events +ersetzt. Die Roadmap ist entsprechend berichtigt. + +**Kein RFID-Scan beim Truppanlegen.** Phase-5.md nennt „Truppanlage per +RFID-Scan oder Auswahl". Umgesetzt ist die Auswahl. Der Kartenleser hängt am +Terminal im Gerätehaus (Phase 4) und meldet dort Personen an; am +Überwachungsschirm steht der Überwacher mit einem Tablet und wählt zwei Namen +aus einer Liste, auf der ohnehin die Tauglichkeit steht. Der Scan wäre dort +ein zweiter Weg zum selben Ziel — nachrüstbar, wenn er im Betrieb vermisst +wird. + +### Nachweise + +- `npx tsc --noEmit` — ohne Ausgabe +- `npm run test` — 306 Tests grün, davon 36 gegen eine echte Datenbank +- `npx playwright test tests/e2e/atemschutz.spec.ts` — 16 grün (Desktop und Tablet) +- `npm run db:seed` — Nachweise und eine abgeschlossene Überwachung angelegt --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index be6f99a..47ae83a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ Phase 1 ████████████████████ fertig Phase 2 ████████████████████ fertig 0.3.0 Phase 3 ████████████████████ fertig 0.4.0 Phase 4 ████████████████████ fertig 0.5.0 -Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0 +Phase 5 ████████████████████ fertig 0.6.0 Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0 Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0 Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0 @@ -116,14 +116,16 @@ Für die Wandtablets in der Fahrzeughalle. **Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist. -## Phase 5 — Atemschutz nach FwDV 7 · als Nächstes +## Phase 5 — Atemschutz nach FwDV 7 · fertig Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die eine dokumentierte Freigabe durch den Kommandanten voraussetzt. - Truppanlage per RFID, Eingangsdruck erfassen -- Serverseitig autoritative Timer, per Socket.IO verteilt +- Serverseitig autoritative Zeitstempel; verteilt über Server-Sent Events + statt Socket.IO — es geht nur in eine Richtung, und so bleibt es bei + `next start` ohne eigenen Node-Server - Rückzugsdruck-Berechnung mit Ampel und akustischem Alarm - Erinnerung zur Druckabfrage im konfigurierbaren Intervall - Mayday-Knopf je Trupp @@ -133,7 +135,7 @@ eine dokumentierte Freigabe durch den Kommandanten voraussetzt. **Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur analogen, bis die Wehr ausdrücklich anders entscheidet. -## Phase 6 — Kartenmodule +## Phase 6 — Kartenmodule · als Nächstes - MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet - Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png index d489684..56216c4 100644 Binary files a/docs/screenshots/02-uebersicht-dark.png and b/docs/screenshots/02-uebersicht-dark.png differ diff --git a/docs/screenshots/02-uebersicht-light.png b/docs/screenshots/02-uebersicht-light.png index 2a8120a..1beff21 100644 Binary files a/docs/screenshots/02-uebersicht-light.png and b/docs/screenshots/02-uebersicht-light.png differ diff --git a/docs/screenshots/03-geraeteliste-dark.png b/docs/screenshots/03-geraeteliste-dark.png index 3e89cdb..74996d9 100644 Binary files a/docs/screenshots/03-geraeteliste-dark.png and b/docs/screenshots/03-geraeteliste-dark.png differ diff --git a/docs/screenshots/03-geraeteliste-light.png b/docs/screenshots/03-geraeteliste-light.png index 6c3d84a..cd8fa34 100644 Binary files a/docs/screenshots/03-geraeteliste-light.png and b/docs/screenshots/03-geraeteliste-light.png differ diff --git a/docs/screenshots/04-geraet-dark.png b/docs/screenshots/04-geraet-dark.png index 28cb389..e71c74d 100644 Binary files a/docs/screenshots/04-geraet-dark.png and b/docs/screenshots/04-geraet-dark.png differ diff --git a/docs/screenshots/04-geraet-light.png b/docs/screenshots/04-geraet-light.png index db21696..14d09e7 100644 Binary files a/docs/screenshots/04-geraet-light.png and b/docs/screenshots/04-geraet-light.png differ diff --git a/docs/screenshots/05-fahrzeuge-dark.png b/docs/screenshots/05-fahrzeuge-dark.png index 8349859..9f124c3 100644 Binary files a/docs/screenshots/05-fahrzeuge-dark.png and b/docs/screenshots/05-fahrzeuge-dark.png differ diff --git a/docs/screenshots/05-fahrzeuge-light.png b/docs/screenshots/05-fahrzeuge-light.png index 5cc4371..0af7ba3 100644 Binary files a/docs/screenshots/05-fahrzeuge-light.png and b/docs/screenshots/05-fahrzeuge-light.png differ diff --git a/docs/screenshots/06-fahrzeug-dark.png b/docs/screenshots/06-fahrzeug-dark.png index d92bba9..7d22572 100644 Binary files a/docs/screenshots/06-fahrzeug-dark.png and b/docs/screenshots/06-fahrzeug-dark.png differ diff --git a/docs/screenshots/06-fahrzeug-light.png b/docs/screenshots/06-fahrzeug-light.png index 6be581f..b3fb17e 100644 Binary files a/docs/screenshots/06-fahrzeug-light.png and b/docs/screenshots/06-fahrzeug-light.png differ diff --git a/docs/screenshots/07-mitglieder-dark.png b/docs/screenshots/07-mitglieder-dark.png index 404ba1c..e7bcd82 100644 Binary files a/docs/screenshots/07-mitglieder-dark.png and b/docs/screenshots/07-mitglieder-dark.png differ diff --git a/docs/screenshots/07-mitglieder-light.png b/docs/screenshots/07-mitglieder-light.png index 0c2f6e0..cc2c1c6 100644 Binary files a/docs/screenshots/07-mitglieder-light.png and b/docs/screenshots/07-mitglieder-light.png differ diff --git a/docs/screenshots/08-qualifikationen-dark.png b/docs/screenshots/08-qualifikationen-dark.png index 495a489..e463509 100644 Binary files a/docs/screenshots/08-qualifikationen-dark.png and b/docs/screenshots/08-qualifikationen-dark.png differ diff --git a/docs/screenshots/08-qualifikationen-light.png b/docs/screenshots/08-qualifikationen-light.png index 4ab0548..665b343 100644 Binary files a/docs/screenshots/08-qualifikationen-light.png and b/docs/screenshots/08-qualifikationen-light.png differ diff --git a/docs/screenshots/09-import-dark.png b/docs/screenshots/09-import-dark.png index a5d98de..6b47ca1 100644 Binary files a/docs/screenshots/09-import-dark.png and b/docs/screenshots/09-import-dark.png differ diff --git a/docs/screenshots/09-import-light.png b/docs/screenshots/09-import-light.png index 27ebe2e..924954b 100644 Binary files a/docs/screenshots/09-import-light.png and b/docs/screenshots/09-import-light.png differ diff --git a/docs/screenshots/10-geraet-neu-dark.png b/docs/screenshots/10-geraet-neu-dark.png index 0e68869..f11ff05 100644 Binary files a/docs/screenshots/10-geraet-neu-dark.png and b/docs/screenshots/10-geraet-neu-dark.png differ diff --git a/docs/screenshots/10-geraet-neu-light.png b/docs/screenshots/10-geraet-neu-light.png index 5eaf6c8..7090652 100644 Binary files a/docs/screenshots/10-geraet-neu-light.png and b/docs/screenshots/10-geraet-neu-light.png differ diff --git a/docs/screenshots/11-fristen-dark.png b/docs/screenshots/11-fristen-dark.png index 048e2f2..52ce5f8 100644 Binary files a/docs/screenshots/11-fristen-dark.png and b/docs/screenshots/11-fristen-dark.png differ diff --git a/docs/screenshots/11-fristen-light.png b/docs/screenshots/11-fristen-light.png index 6cf471d..17cfd9c 100644 Binary files a/docs/screenshots/11-fristen-light.png and b/docs/screenshots/11-fristen-light.png differ diff --git a/docs/screenshots/12-vorlagen-dark.png b/docs/screenshots/12-vorlagen-dark.png index 6ca9597..6e161f2 100644 Binary files a/docs/screenshots/12-vorlagen-dark.png and b/docs/screenshots/12-vorlagen-dark.png differ diff --git a/docs/screenshots/12-vorlagen-light.png b/docs/screenshots/12-vorlagen-light.png index 59942a9..8e98133 100644 Binary files a/docs/screenshots/12-vorlagen-light.png and b/docs/screenshots/12-vorlagen-light.png differ diff --git a/docs/screenshots/13-maengel-dark.png b/docs/screenshots/13-maengel-dark.png index 1f39af3..da8aede 100644 Binary files a/docs/screenshots/13-maengel-dark.png and b/docs/screenshots/13-maengel-dark.png differ diff --git a/docs/screenshots/13-maengel-light.png b/docs/screenshots/13-maengel-light.png index 2e81680..dcaf0ce 100644 Binary files a/docs/screenshots/13-maengel-light.png and b/docs/screenshots/13-maengel-light.png differ diff --git a/docs/screenshots/14-rueckrufe-dark.png b/docs/screenshots/14-rueckrufe-dark.png index 01d7af2..fee5a16 100644 Binary files a/docs/screenshots/14-rueckrufe-dark.png and b/docs/screenshots/14-rueckrufe-dark.png differ diff --git a/docs/screenshots/14-rueckrufe-light.png b/docs/screenshots/14-rueckrufe-light.png index 548943b..2054adf 100644 Binary files a/docs/screenshots/14-rueckrufe-light.png and b/docs/screenshots/14-rueckrufe-light.png differ diff --git a/docs/screenshots/15-lebensdauer-dark.png b/docs/screenshots/15-lebensdauer-dark.png index 456ca1c..6385d48 100644 Binary files a/docs/screenshots/15-lebensdauer-dark.png and b/docs/screenshots/15-lebensdauer-dark.png differ diff --git a/docs/screenshots/15-lebensdauer-light.png b/docs/screenshots/15-lebensdauer-light.png index 88d3ba8..1762094 100644 Binary files a/docs/screenshots/15-lebensdauer-light.png and b/docs/screenshots/15-lebensdauer-light.png differ diff --git a/docs/screenshots/16-protokolle-dark.png b/docs/screenshots/16-protokolle-dark.png index e77bc1b..790dd54 100644 Binary files a/docs/screenshots/16-protokolle-dark.png and b/docs/screenshots/16-protokolle-dark.png differ diff --git a/docs/screenshots/16-protokolle-light.png b/docs/screenshots/16-protokolle-light.png index 47cae28..b3d8634 100644 Binary files a/docs/screenshots/16-protokolle-light.png and b/docs/screenshots/16-protokolle-light.png differ diff --git a/docs/screenshots/17-checkliste-dark.png b/docs/screenshots/17-checkliste-dark.png index 0e54fb2..b19f65b 100644 Binary files a/docs/screenshots/17-checkliste-dark.png and b/docs/screenshots/17-checkliste-dark.png differ diff --git a/docs/screenshots/17-checkliste-light.png b/docs/screenshots/17-checkliste-light.png index bc04ff0..c80dabf 100644 Binary files a/docs/screenshots/17-checkliste-light.png and b/docs/screenshots/17-checkliste-light.png differ diff --git a/docs/screenshots/18-pruefprotokoll-dark.png b/docs/screenshots/18-pruefprotokoll-dark.png index 7ddfc8c..331611b 100644 Binary files a/docs/screenshots/18-pruefprotokoll-dark.png and b/docs/screenshots/18-pruefprotokoll-dark.png differ diff --git a/docs/screenshots/18-pruefprotokoll-light.png b/docs/screenshots/18-pruefprotokoll-light.png index 31306a8..0f18b37 100644 Binary files a/docs/screenshots/18-pruefprotokoll-light.png and b/docs/screenshots/18-pruefprotokoll-light.png differ diff --git a/docs/screenshots/19-einsaetze-dark.png b/docs/screenshots/19-einsaetze-dark.png index e0edebc..dea9748 100644 Binary files a/docs/screenshots/19-einsaetze-dark.png and b/docs/screenshots/19-einsaetze-dark.png differ diff --git a/docs/screenshots/19-einsaetze-light.png b/docs/screenshots/19-einsaetze-light.png index 675ab2b..eee14bf 100644 Binary files a/docs/screenshots/19-einsaetze-light.png and b/docs/screenshots/19-einsaetze-light.png differ diff --git a/docs/screenshots/20-statistik-dark.png b/docs/screenshots/20-statistik-dark.png index c195df7..26a6303 100644 Binary files a/docs/screenshots/20-statistik-dark.png and b/docs/screenshots/20-statistik-dark.png differ diff --git a/docs/screenshots/20-statistik-light.png b/docs/screenshots/20-statistik-light.png index 0edcdce..ae893eb 100644 Binary files a/docs/screenshots/20-statistik-light.png and b/docs/screenshots/20-statistik-light.png differ diff --git a/docs/screenshots/21-uebungen-dark.png b/docs/screenshots/21-uebungen-dark.png index 8c96285..084efe1 100644 Binary files a/docs/screenshots/21-uebungen-dark.png and b/docs/screenshots/21-uebungen-dark.png differ diff --git a/docs/screenshots/21-uebungen-light.png b/docs/screenshots/21-uebungen-light.png index 73b7793..8f41ef5 100644 Binary files a/docs/screenshots/21-uebungen-light.png and b/docs/screenshots/21-uebungen-light.png differ diff --git a/docs/screenshots/22-einsatz-dark.png b/docs/screenshots/22-einsatz-dark.png index 48da6c6..76c5a61 100644 Binary files a/docs/screenshots/22-einsatz-dark.png and b/docs/screenshots/22-einsatz-dark.png differ diff --git a/docs/screenshots/22-einsatz-light.png b/docs/screenshots/22-einsatz-light.png index f8a0639..7f44f12 100644 Binary files a/docs/screenshots/22-einsatz-light.png and b/docs/screenshots/22-einsatz-light.png differ diff --git a/docs/screenshots/23-uebung-dark.png b/docs/screenshots/23-uebung-dark.png index 2805499..adb4eec 100644 Binary files a/docs/screenshots/23-uebung-dark.png and b/docs/screenshots/23-uebung-dark.png differ diff --git a/docs/screenshots/23-uebung-light.png b/docs/screenshots/23-uebung-light.png index a732232..0195211 100644 Binary files a/docs/screenshots/23-uebung-light.png and b/docs/screenshots/23-uebung-light.png differ diff --git a/docs/screenshots/24-portal-dark.png b/docs/screenshots/24-portal-dark.png index a898c3d..7da334b 100644 Binary files a/docs/screenshots/24-portal-dark.png and b/docs/screenshots/24-portal-dark.png differ diff --git a/docs/screenshots/24-portal-light.png b/docs/screenshots/24-portal-light.png index a938afe..e56f0b1 100644 Binary files a/docs/screenshots/24-portal-light.png and b/docs/screenshots/24-portal-light.png differ diff --git a/docs/screenshots/26-kiosk-mangel-dark.png b/docs/screenshots/26-kiosk-mangel-dark.png index 314955c..dee352d 100644 Binary files a/docs/screenshots/26-kiosk-mangel-dark.png and b/docs/screenshots/26-kiosk-mangel-dark.png differ diff --git a/docs/screenshots/26-kiosk-mangel-light.png b/docs/screenshots/26-kiosk-mangel-light.png index 7a4864d..65bf0bb 100644 Binary files a/docs/screenshots/26-kiosk-mangel-light.png and b/docs/screenshots/26-kiosk-mangel-light.png differ diff --git a/docs/screenshots/27-kiosk-monitor-dark.png b/docs/screenshots/27-kiosk-monitor-dark.png index 7e63750..f07d8a7 100644 Binary files a/docs/screenshots/27-kiosk-monitor-dark.png and b/docs/screenshots/27-kiosk-monitor-dark.png differ diff --git a/docs/screenshots/27-kiosk-monitor-light.png b/docs/screenshots/27-kiosk-monitor-light.png index 9e1da93..ac6788f 100644 Binary files a/docs/screenshots/27-kiosk-monitor-light.png and b/docs/screenshots/27-kiosk-monitor-light.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-dark.png b/docs/screenshots/28-terminal-uebersicht-dark.png index d41640e..edfb5a0 100644 Binary files a/docs/screenshots/28-terminal-uebersicht-dark.png and b/docs/screenshots/28-terminal-uebersicht-dark.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-light.png b/docs/screenshots/28-terminal-uebersicht-light.png index 044379a..9fa0eae 100644 Binary files a/docs/screenshots/28-terminal-uebersicht-light.png and b/docs/screenshots/28-terminal-uebersicht-light.png differ diff --git a/docs/screenshots/29-anmeldemarke-dark.png b/docs/screenshots/29-anmeldemarke-dark.png index 70d8d36..7a7f409 100644 Binary files a/docs/screenshots/29-anmeldemarke-dark.png and b/docs/screenshots/29-anmeldemarke-dark.png differ diff --git a/docs/screenshots/29-anmeldemarke-light.png b/docs/screenshots/29-anmeldemarke-light.png index 2f0056d..fb01f28 100644 Binary files a/docs/screenshots/29-anmeldemarke-light.png and b/docs/screenshots/29-anmeldemarke-light.png differ diff --git a/docs/screenshots/31-atemschutz-dark.png b/docs/screenshots/31-atemschutz-dark.png new file mode 100644 index 0000000..7f12222 Binary files /dev/null and b/docs/screenshots/31-atemschutz-dark.png differ diff --git a/docs/screenshots/31-atemschutz-light.png b/docs/screenshots/31-atemschutz-light.png new file mode 100644 index 0000000..3b1aa18 Binary files /dev/null and b/docs/screenshots/31-atemschutz-light.png differ diff --git a/docs/screenshots/32-atemschutz-nachweise-dark.png b/docs/screenshots/32-atemschutz-nachweise-dark.png new file mode 100644 index 0000000..29d8bbb Binary files /dev/null and b/docs/screenshots/32-atemschutz-nachweise-dark.png differ diff --git a/docs/screenshots/32-atemschutz-nachweise-light.png b/docs/screenshots/32-atemschutz-nachweise-light.png new file mode 100644 index 0000000..3177b05 Binary files /dev/null and b/docs/screenshots/32-atemschutz-nachweise-light.png differ diff --git a/docs/screenshots/33-atemschutz-ueberwachung-dark.png b/docs/screenshots/33-atemschutz-ueberwachung-dark.png new file mode 100644 index 0000000..a90222a Binary files /dev/null and b/docs/screenshots/33-atemschutz-ueberwachung-dark.png differ diff --git a/docs/screenshots/33-atemschutz-ueberwachung-light.png b/docs/screenshots/33-atemschutz-ueberwachung-light.png new file mode 100644 index 0000000..511e2ae Binary files /dev/null and b/docs/screenshots/33-atemschutz-ueberwachung-light.png differ diff --git a/package-lock.json b/package-lock.json index ad6eeb9..eacbdcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "florian", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "florian", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@prisma/client": "^6.16.2", "@radix-ui/react-avatar": "^1.1.10", diff --git a/package.json b/package.json index 5ec9034..6bed8ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { diff --git a/playwright.config.ts b/playwright.config.ts index 973d225..d46437d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,8 +29,14 @@ export default defineConfig({ * Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange * kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann * nach einem Fehler in der Anwendung aus, ist aber keiner. + * + * Ab Phase 5 von vier auf zwei: mit über 130 Tests, bcrypt bei jeder + * Anmeldung und einer Datenbank daneben fielen bei vier Arbeitern in jedem + * Gesamtlauf zwei bis fünf Tests ins Zeitlimit — jedes Mal andere, jeder + * einzeln grün. Ein Ergebnis, das man nicht glauben kann, ist wertlos; der + * Lauf dauert dafür etwa ein Drittel länger. */ - workers: process.env.CI ? 1 : 4, + workers: process.env.CI ? 1 : 2, reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]], /** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */ timeout: 90_000, diff --git a/prisma/migrations/20260903190219_atemschutz_fwdv7/migration.sql b/prisma/migrations/20260903190219_atemschutz_fwdv7/migration.sql new file mode 100644 index 0000000..9c89077 --- /dev/null +++ b/prisma/migrations/20260903190219_atemschutz_fwdv7/migration.sql @@ -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; diff --git a/prisma/migrations/20260903190300_atemschutz_rls/migration.sql b/prisma/migrations/20260903190300_atemschutz_rls/migration.sql new file mode 100644 index 0000000..819551e --- /dev/null +++ b/prisma/migrations/20260903190300_atemschutz_rls/migration.sql @@ -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; +$$; diff --git a/prisma/migrations/20260903190904_atemschutz_zieldruck/migration.sql b/prisma/migrations/20260903190904_atemschutz_zieldruck/migration.sql new file mode 100644 index 0000000..2f1f001 --- /dev/null +++ b/prisma/migrations/20260903190904_atemschutz_zieldruck/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 861fb81..1399274 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -199,6 +199,15 @@ model Organisation { kioskvorgaenge KioskVorgang[] syncKonflikte SyncKonflikt[] + // Phase 5 — Atemschutz nach FwDV 7 + atemschutzeinsaetze Atemschutzeinsatz[] + atemschutztrupps Atemschutztrupp[] + truppmitglieder Truppmitglied[] + druckmessungen Druckmessung[] + atemschutzereignisse AtemschutzEreignis[] + g26Untersuchungen G26Untersuchung[] + belastungsuebungen Belastungsuebung[] + @@index([aktiv]) @@map("organisation") } @@ -292,6 +301,15 @@ model Person { verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher") erledigteKonflikte SyncKonflikt[] @relation("KonfliktErledigung") + // Phase 5 — Atemschutz nach FwDV 7 + ueberwachteEinsaetze Atemschutzeinsatz[] @relation("AtemschutzUeberwacher") + truppeinsaetze Truppmitglied[] @relation("TruppMitgliedPerson") + druckmessungen Druckmessung[] @relation("DruckmessungPerson") + erfassteDruckmessungen Druckmessung[] @relation("DruckmessungErfasser") + atemschutzereignisse AtemschutzEreignis[] @relation("AtemschutzEreignisPerson") + g26Untersuchungen G26Untersuchung[] @relation("G26Person") + belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson") + /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@ -526,6 +544,10 @@ model Datei { alsLagemeldungsfoto Lagemeldung[] @relation("LagemeldungFoto") alsUebungsbericht Uebung[] @relation("UebungBericht") + // Phase 5 + alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll") + alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis") + @@unique([bucket, objektSchluessel]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, kategorie]) @@ -842,6 +864,9 @@ model Geraet { waescheabgaben Waescheabgabe[] inventurposten Inventurposten[] + // Phase 5 + alsPressluftatmer Truppmitglied[] @relation("TruppPressluftatmer") + @@unique([organisationId, inventarnummer]) @@index([organisationId, status]) @@index([organisationId, naechstePruefung]) @@ -1649,8 +1674,11 @@ model Einsatz { material Materialverbrauch[] lagemeldungen Lagemeldung[] portalzugaenge PortalZugang[] - fahrts Fahrt[] - anwesenheits Anwesenheit[] + fahrten Fahrt[] + anwesenheiten Anwesenheit[] + + // Phase 5 + atemschutz Atemschutzeinsatz[] @@unique([organisationId, einsatznummer]) @@index([organisationId, alarmiertUm(sort: Desc)]) @@ -1860,6 +1888,10 @@ model Uebung { fahrten Fahrt[] anwesenheiten Anwesenheit[] + // Phase 5 + atemschutz Atemschutzeinsatz[] + belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungUebung") + @@unique([organisationId, nummer]) @@index([organisationId, datum(sort: Desc)]) @@index([organisationId, art]) @@ -2213,3 +2245,331 @@ model SyncKonflikt { @@index([organisationId, erledigtAm]) @@map("sync_konflikt") } + +// ============================================================================= +// Phase 5 — Atemschutz nach FwDV 7 +// +// Das sicherheitsrelevanteste Modul des Projekts. Zwei Dinge bestimmen den +// Aufbau: +// +// 1. **Die Zeit gehoert dem Server.** Jeder Zeitpunkt steht als Zeitstempel +// in der Datenbank, nie als heruntergezaehlte Restzeit. Der Browser +// rechnet daraus nur die Anzeige. Ein Neuladen, ein Verbindungsabbruch +// oder ein zweiter Bildschirm aendern daran nichts — es gibt keinen +// Timer, der verloren gehen koennte. +// 2. **Das Protokoll ist lueckenlos.** Jede Druckabfrage, jeder +// Statuswechsel und jedes Mayday steht als eigene Zeile mit Zeitpunkt. +// Gelöscht wird nichts; die Datenbank verweigert es (siehe Migration). +// +// Ueber allem steht der Hinweis, dass die analoge Ueberwachung fuehrend +// bleibt. Er ist in der Anwendung fest verdrahtet und nur ueber eine +// Konfigurationsoption abschaltbar, die eine dokumentierte Freigabe des +// Kommandanten voraussetzt. +// ============================================================================= + +/// Zustand eines Trupps. Die Reihenfolge ist der uebliche Verlauf. +enum TruppStatus { + /// Angelegt, Eingangsdruck erfasst, noch nicht unter Atemschutz. + BEREIT + /// Unter Atemschutz im Einsatz. + IM_EINSATZ + /// Auf dem Rueckweg — ab hier zaehlt die Zeit doppelt. + RUECKWEG + /// Draussen, Ausgangsdruck erfasst. Endzustand. + DRAUSSEN +} + +/// Wann ein Druck gemessen wurde. +enum DruckArt { + /// Vor dem Anlegen, am Geraet abgelesen. + EINGANG + /// Beim Erreichen des Einsatzziels. Aus dem Verbrauch bis hierher errechnet + /// sich der Rueckzugsdruck — der Weg zurueck ist so lang wie der Weg hin. + ZIEL + /// Waehrend des Einsatzes, ueblicherweise alle zehn Minuten. + ZWISCHEN + /// Nach dem Herauskommen. + AUSGANG +} + +/// Was im Protokoll steht. +enum EreignisArt { + TRUPP_ANGELEGT + EINSTIEG + DRUCKABFRAGE + RUECKZUG_ANGEORDNET + RUECKZUG_ANGETRETEN + HERAUSGEKOMMEN + MAYDAY + MAYDAY_AUFGEHOBEN + HINWEIS +} + +/// Ergebnis einer Untersuchung nach G26.3. +enum G26Ergebnis { + TAUGLICH + BEFRISTET_TAUGLICH + NACHUNTERSUCHUNG + NICHT_TAUGLICH +} + +/// Art der jaehrlichen Uebung nach FwDV 7. +enum UebungsartAtemschutz { + /// Belastungsuebung in der Atemschutzuebungsstrecke. + UEBUNGSSTRECKE + /// Einsatzuebung unter Atemschutz. + EINSATZUEBUNG + /// Unterweisung, theoretisch. + UNTERWEISUNG +} + +/// Eine Atemschutzueberwachung. +/// +/// Haengt an einem Einsatz oder einer Uebung — oder an keinem von beidem, denn +/// im Ernstfall wird zuerst ueberwacht und spaeter zugeordnet. +model Atemschutzeinsatz { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + /// Wozu die Ueberwachung gehoert. Beides optional, siehe oben. + einsatzId String? @db.Uuid + uebungId String? @db.Uuid + + /// Wer die Ueberwachung fuehrt. Nach FwDV 7 eine benannte Person. + ueberwacherId String @db.Uuid + + bezeichnung String @db.VarChar(200) + + beginnUm DateTime @default(now()) @db.Timestamptz(3) + endeUm DateTime? @db.Timestamptz(3) + + /// Intervall der Druckabfrage in Sekunden. Vorgabe kommt aus der + /// Konfiguration, laesst sich je Ueberwachung anpassen. + abfrageIntervall Int @default(600) + + /// Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar. + /// Der Kommandant legt ihn fest; die Formel steht in lib/atemschutz. + sicherheitszuschlag Int @default(10) + + /// Ansprechdruck der Warneinrichtung des verwendeten Geraets, in bar. + /// Steht je Ueberwachung im Datensatz, weil eine Wehr Geraete + /// unterschiedlicher Baujahre im Bestand hat. + warndruck Int @default(55) + + bemerkung String? @db.VarChar(2000) + + /// Das abgeschlossene Protokoll als PDF, liegt in MinIO. + protokollDateiId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull) + uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull) + ueberwacher Person @relation("AtemschutzUeberwacher", fields: [ueberwacherId], references: [id], onDelete: Restrict) + protokollDatei Datei? @relation("AtemschutzProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull) + trupps Atemschutztrupp[] + ereignisse AtemschutzEreignis[] + + @@index([organisationId, endeUm]) + @@index([organisationId, beginnUm(sort: Desc)]) + @@map("atemschutzeinsatz") +} + +/// Ein Trupp unter Atemschutz. +/// +/// Die Zeiten stehen als Zeitstempel, nicht als Dauer: `einstiegUm` ist der +/// Moment, ab dem die Uhr laeuft. Wie lange ein Trupp drin ist, rechnet jede +/// Ansicht aus der aktuellen Zeit selbst aus — deshalb ueberlebt die Anzeige +/// jeden Neustart. +model Atemschutztrupp { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + atemschutzeinsatzId String @db.Uuid + + funkrufname String @db.VarChar(60) + auftrag String? @db.VarChar(500) + status TruppStatus @default(BEREIT) + + angelegtUm DateTime @default(now()) @db.Timestamptz(3) + einstiegUm DateTime? @db.Timestamptz(3) + rueckzugUm DateTime? @db.Timestamptz(3) + drausenUm DateTime? @db.Timestamptz(3) + + /// Errechnet beim Einstieg aus den Eingangsdruecken. Steht fest im Datensatz, + /// damit spaeter nachvollziehbar ist, mit welchem Wert gearbeitet wurde — + /// auch wenn sich die Formel einmal aendert. + rueckzugsdruck Int? + + /// Laeuft ein Notfall? Zeitpunkt statt Kennzeichen, damit die Dauer im + /// Protokoll steht. + maydayUm DateTime? @db.Timestamptz(3) + maydayAufgehobenUm DateTime? @db.Timestamptz(3) + + bemerkung String? @db.VarChar(1000) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade) + mitglieder Truppmitglied[] + druckmessungen Druckmessung[] + ereignisse AtemschutzEreignis[] + + @@index([organisationId, atemschutzeinsatzId]) + @@index([organisationId, status]) + @@map("atemschutztrupp") +} + +/// Wer im Trupp ist. +/// +/// Ueblich sind zwei, erlaubt sind drei — die Zuordnung steht hier und nicht +/// als zwei Spalten am Trupp, weil sonst der dritte Mann nicht abbildbar waere. +model Truppmitglied { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + truppId String @db.Uuid + personId String @db.Uuid + + /// true beim Truppfuehrer. Genau einer je Trupp. + truppfuehrer Boolean @default(false) + + /// Kennung des Pressluftatmers, falls erfasst — hilft beim Zuordnen der + /// Flaschenfuellung hinterher. + geraetId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade) + person Person @relation("TruppMitgliedPerson", fields: [personId], references: [id], onDelete: Restrict) + geraet Geraet? @relation("TruppPressluftatmer", fields: [geraetId], references: [id], onDelete: SetNull) + + @@unique([truppId, personId]) + @@index([organisationId, personId]) + @@map("truppmitglied") +} + +/// Eine Druckmessung. +/// +/// Append-only: einmal erfasst, bleibt sie stehen. Ein Tippfehler wird durch +/// eine neue Messung richtiggestellt, nicht durch Ueberschreiben — im +/// Zweifelsfall zaehlt, was zum Zeitpunkt X abgelesen wurde. +model Druckmessung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + truppId String @db.Uuid + personId String @db.Uuid + + zeitpunkt DateTime @default(now()) @db.Timestamptz(3) + /// Druck in bar. Ganzzahlig — feiner liest niemand ab. + druck Int + art DruckArt @default(ZWISCHEN) + + erfasstVonId String? @db.Uuid + bemerkung String? @db.VarChar(300) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + trupp Atemschutztrupp @relation(fields: [truppId], references: [id], onDelete: Cascade) + person Person @relation("DruckmessungPerson", fields: [personId], references: [id], onDelete: Restrict) + erfasstVon Person? @relation("DruckmessungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) + + @@index([organisationId, truppId, zeitpunkt]) + @@map("druckmessung") +} + +/// Das Protokoll. +/// +/// Jede Zeile ein Ereignis mit Zeitpunkt. Zusammen mit den Druckmessungen ist +/// das der lueckenlose Nachweis, den FwDV 7 verlangt — und der Grund, warum +/// hier nichts geaendert und nichts geloescht werden kann. +model AtemschutzEreignis { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + atemschutzeinsatzId String @db.Uuid + truppId String? @db.Uuid + + zeitpunkt DateTime @default(now()) @db.Timestamptz(3) + art EreignisArt + text String @db.VarChar(500) + + /// Wer es ausgeloest hat. + personId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + atemschutzeinsatz Atemschutzeinsatz @relation(fields: [atemschutzeinsatzId], references: [id], onDelete: Cascade) + trupp Atemschutztrupp? @relation(fields: [truppId], references: [id], onDelete: Cascade) + person Person? @relation("AtemschutzEreignisPerson", fields: [personId], references: [id], onDelete: SetNull) + + @@index([organisationId, atemschutzeinsatzId, zeitpunkt]) + @@map("atemschutz_ereignis") +} + +/// Untersuchung nach G26.3. +/// +/// Steht bewusst nicht als Qualifikation aus Phase 1 da: eine G26 hat ein +/// Ergebnis, einen Arzt und eine Frist, die sich nach dem Alter richtet — +/// und sie ist die harte Voraussetzung dafuer, ueberhaupt eingesetzt zu +/// werden. +model G26Untersuchung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + personId String @db.Uuid + + untersuchtAm DateTime @db.Date + gueltigBis DateTime @db.Date + ergebnis G26Ergebnis @default(TAUGLICH) + + arzt String? @db.VarChar(200) + bemerkung String? @db.VarChar(1000) + /// Eingescannte Bescheinigung, liegt in MinIO. + nachweisDateiId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person @relation("G26Person", fields: [personId], references: [id], onDelete: Cascade) + nachweis Datei? @relation("G26Nachweis", fields: [nachweisDateiId], references: [id], onDelete: SetNull) + + @@index([organisationId, personId, gueltigBis(sort: Desc)]) + @@index([organisationId, gueltigBis]) + @@map("g26_untersuchung") +} + +/// Belastungsuebung, Einsatzuebung oder Unterweisung. +/// +/// FwDV 7 verlangt beides jaehrlich. Wer eines davon versaeumt, ist nicht +/// einsetzbar — auch mit gueltiger G26 nicht. +model Belastungsuebung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + personId String @db.Uuid + + datum DateTime @db.Date + art UebungsartAtemschutz @default(UEBUNGSSTRECKE) + + ort String? @db.VarChar(200) + dauerMinuten Int? + /// Wenn die Uebung im System als Uebung erfasst ist. + uebungId String? @db.Uuid + bemerkung String? @db.VarChar(1000) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + geloeschtAm DateTime? @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person @relation("BelastungsuebungPerson", fields: [personId], references: [id], onDelete: Cascade) + uebung Uebung? @relation("BelastungsuebungUebung", fields: [uebungId], references: [id], onDelete: SetNull) + + @@index([organisationId, personId, datum(sort: Desc)]) + @@map("belastungsuebung") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 115f7cf..ed772a8 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -11,7 +11,9 @@ * sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein * Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am * Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht - * beim ersten Start nicht leer ist. + * beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und + * Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene + * Überwachung mit vollständigem Protokoll. * * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang, * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt @@ -32,8 +34,11 @@ import { EinsatzKategorie, EinsatzStatus, Dienstgrad, + DruckArt, + EreignisArt, FahrtZweck, FahrzeugArt, + G26Ergebnis, GeraeteKategorie, GeraetStatus, LebensdauerBezug, @@ -48,7 +53,9 @@ import { RolleSchluessel, SignaturRolle, StandortArt, + TruppStatus, UebungsArt, + UebungsartAtemschutz, WaescheStatus, } from "@prisma/client"; import bcrypt from "bcryptjs"; @@ -2178,6 +2185,300 @@ async function terminaldatenAnlegen(organisationId: string) { ); } +// ----------------------------------------------------------------------------- +// Phase 5 — Atemschutz nach FwDV 7 +// ----------------------------------------------------------------------------- + +/** + * Atemschutz: Nachweise und eine abgeschlossene Überwachung. + * + * Die Nachweise sind bewusst nicht bei allen vollständig — genau so sieht es + * in einer Wehr aus. Einer hat eine abgelaufene G26, einem fehlt die + * Belastungsübung, einer ist bald fällig. Die Tauglichkeitsampel zeigt + * dadurch beim ersten Start alle drei Zustände, statt nur Grün. + * + * Dazu ein vollständig durchgespielter Einsatz: zwei Trupps, Eingangsdrücke, + * Zielmeldung, Zwischenmeldungen, Rückweg, Ausgangsdrücke — das ist die + * Vorlage, an der sich das Protokoll ansehen lässt, ohne selbst einen Einsatz + * zu fahren. + */ +async function atemschutzAnlegen(organisationId: string) { + const vorhanden = await prisma.atemschutzeinsatz.count({ where: { organisationId } }); + if (vorhanden > 0) { + log(`Atemschutz: bereits ${vorhanden} Überwachungen vorhanden`); + return; + } + + const personen = await prisma.person.findMany({ + where: { organisationId, aktiv: true, geloeschtAm: null }, + select: { id: true, vorname: true, nachname: true }, + orderBy: { mitgliedsnummer: "asc" }, + }); + + if (personen.length < 6) return; + + const jetzt = new Date(); + + function vorTagen(tage: number): Date { + const datum = new Date(jetzt); + datum.setUTCDate(datum.getUTCDate() - tage); + datum.setUTCHours(0, 0, 0, 0); + return datum; + } + + function inTagen(tage: number): Date { + return vorTagen(-tage); + } + + // --- G26.3 ---------------------------------------------------------------- + // + // Die ersten acht sind Atemschutzgeräteträger. Drei Zustände absichtlich: + // gültig, läuft bald ab, abgelaufen. + const G26: Array<{ person: number; untersuchtVorTagen: number; gueltigInTagen: number; ergebnis: G26Ergebnis }> = [ + { person: 0, untersuchtVorTagen: 400, gueltigInTagen: 690, ergebnis: G26Ergebnis.TAUGLICH }, + { person: 2, untersuchtVorTagen: 200, gueltigInTagen: 890, ergebnis: G26Ergebnis.TAUGLICH }, + { person: 3, untersuchtVorTagen: 120, gueltigInTagen: 975, ergebnis: G26Ergebnis.TAUGLICH }, + { person: 5, untersuchtVorTagen: 90, gueltigInTagen: 1005, ergebnis: G26Ergebnis.TAUGLICH }, + { person: 7, untersuchtVorTagen: 30, gueltigInTagen: 1065, ergebnis: G26Ergebnis.TAUGLICH }, + // Läuft in gut zwei Monaten ab — die Ampel steht auf Gelb. + { person: 6, untersuchtVorTagen: 1030, gueltigInTagen: 65, ergebnis: G26Ergebnis.TAUGLICH }, + // Abgelaufen: die Person ist nicht einsetzbar, und das soll man sehen. + { person: 8, untersuchtVorTagen: 1200, gueltigInTagen: -105, ergebnis: G26Ergebnis.TAUGLICH }, + // Befristet tauglich — zählt, aber der Arzt hat etwas dazugeschrieben. + { person: 1, untersuchtVorTagen: 250, gueltigInTagen: 480, ergebnis: G26Ergebnis.BEFRISTET_TAUGLICH }, + ]; + + for (const eintrag of G26) { + const person = personen[eintrag.person % personen.length]!; + await prisma.g26Untersuchung.create({ + data: { + organisationId, + personId: person.id, + untersuchtAm: vorTagen(eintrag.untersuchtVorTagen), + gueltigBis: inTagen(eintrag.gueltigInTagen), + ergebnis: eintrag.ergebnis, + arzt: "Betriebsarzt Dr. Wagner, Landshut", + bemerkung: + eintrag.ergebnis === G26Ergebnis.BEFRISTET_TAUGLICH + ? "Nachuntersuchung des Blutdrucks in einem Jahr empfohlen." + : null, + }, + }); + } + + // --- Belastungs- und Einsatzübungen --------------------------------------- + // + // Person 5 fehlt bewusst in der Belastungsübung: Tauglichkeit ist nicht + // allein eine Frage der G26. + const UEBUNGSTEILNEHMER = [0, 1, 2, 3, 6, 7]; + + await prisma.belastungsuebung.createMany({ + data: UEBUNGSTEILNEHMER.map((nummer) => ({ + organisationId, + personId: personen[nummer % personen.length]!.id, + datum: vorTagen(120), + art: UebungsartAtemschutz.UEBUNGSSTRECKE, + ort: "Atemschutzübungsstrecke Landshut", + dauerMinuten: 45, + })), + }); + + await prisma.belastungsuebung.createMany({ + data: [0, 1, 2, 3, 5, 7].map((nummer) => ({ + organisationId, + personId: personen[nummer % personen.length]!.id, + datum: vorTagen(60), + art: UebungsartAtemschutz.EINSATZUEBUNG, + ort: "Übungsobjekt ehemalige Schreinerei", + dauerMinuten: 90, + })), + }); + + // --- Eine abgeschlossene Überwachung -------------------------------------- + const beginn = new Date(jetzt.getTime() - 26 * 24 * 60 * 60 * 1000); + beginn.setUTCHours(14, 12, 0, 0); + + function nach(minuten: number): Date { + return new Date(beginn.getTime() + minuten * 60 * 1000); + } + + const einsatz = await prisma.einsatz.findFirst({ + where: { organisationId, geloeschtAm: null }, + orderBy: { alarmiertUm: "desc" }, + select: { id: true }, + }); + + const ueberwacher = personen[1]!; + + const ueberwachung = await prisma.atemschutzeinsatz.create({ + data: { + organisationId, + bezeichnung: "Zimmerbrand Hauptstraße 12", + einsatzId: einsatz?.id ?? null, + ueberwacherId: ueberwacher.id, + beginnUm: beginn, + endeUm: nach(38), + abfrageIntervall: 600, + sicherheitszuschlag: 10, + warndruck: 55, + bemerkung: "Regulärer Verlauf, keine besonderen Vorkommnisse.", + }, + }); + + /** Legt einen Trupp samt Messungen und Protokollzeilen an. */ + async function truppAnlegen(angaben: { + funkrufname: string; + auftrag: string; + fuehrer: number; + mann: number; + eingang: [number, number]; + ziel: [number, number] | null; + zwischen: Array<[number, number, number]>; + ausgang: [number, number]; + einstiegMinute: number; + rueckwegMinute: number; + draussenMinute: number; + rueckzugsdruck: number; + }) { + const fuehrer = personen[angaben.fuehrer % personen.length]!; + const mann = personen[angaben.mann % personen.length]!; + + const trupp = await prisma.atemschutztrupp.create({ + data: { + organisationId, + atemschutzeinsatzId: ueberwachung.id, + funkrufname: angaben.funkrufname, + auftrag: angaben.auftrag, + status: TruppStatus.DRAUSSEN, + angelegtUm: nach(angaben.einstiegMinute - 3), + einstiegUm: nach(angaben.einstiegMinute), + rueckzugUm: nach(angaben.rueckwegMinute), + drausenUm: nach(angaben.draussenMinute), + rueckzugsdruck: angaben.rueckzugsdruck, + mitglieder: { + create: [ + { organisationId, personId: fuehrer.id, truppfuehrer: true }, + { organisationId, personId: mann.id, truppfuehrer: false }, + ], + }, + }, + }); + + const messungen: Array<{ personId: string; druck: number; art: DruckArt; zeitpunkt: Date }> = [ + { personId: fuehrer.id, druck: angaben.eingang[0], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) }, + { personId: mann.id, druck: angaben.eingang[1], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) }, + ]; + + if (angaben.ziel) { + messungen.push( + { personId: fuehrer.id, druck: angaben.ziel[0], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) }, + { personId: mann.id, druck: angaben.ziel[1], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) }, + ); + } + + for (const [minute, fuehrerDruck, mannDruck] of angaben.zwischen) { + messungen.push( + { personId: fuehrer.id, druck: fuehrerDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) }, + { personId: mann.id, druck: mannDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) }, + ); + } + + messungen.push( + { personId: fuehrer.id, druck: angaben.ausgang[0], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) }, + { personId: mann.id, druck: angaben.ausgang[1], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) }, + ); + + await prisma.druckmessung.createMany({ + data: messungen.map((messung) => ({ + organisationId, + truppId: trupp.id, + personId: messung.personId, + druck: messung.druck, + art: messung.art, + zeitpunkt: messung.zeitpunkt, + erfasstVonId: ueberwacher.id, + })), + }); + + const ereignisse: Array<{ art: EreignisArt; text: string; minute: number }> = [ + { art: EreignisArt.TRUPP_ANGELEGT, text: `Trupp ${angaben.funkrufname} angelegt, Rückzugsdruck ${angaben.rueckzugsdruck} bar.`, minute: angaben.einstiegMinute - 3 }, + { art: EreignisArt.EINSTIEG, text: `Trupp ${angaben.funkrufname} unter Atemschutz eingestiegen.`, minute: angaben.einstiegMinute }, + { art: EreignisArt.RUECKZUG_ANGETRETEN, text: `Trupp ${angaben.funkrufname} tritt den Rückweg an.`, minute: angaben.rueckwegMinute }, + { art: EreignisArt.HERAUSGEKOMMEN, text: `Trupp ${angaben.funkrufname} ist draußen.`, minute: angaben.draussenMinute }, + ]; + + await prisma.atemschutzEreignis.createMany({ + data: ereignisse.map((ereignis) => ({ + organisationId, + atemschutzeinsatzId: ueberwachung.id, + truppId: trupp.id, + art: ereignis.art, + text: ereignis.text, + zeitpunkt: nach(ereignis.minute), + personId: ueberwacher.id, + })), + }); + } + + await truppAnlegen({ + funkrufname: "Angriffstrupp", + auftrag: "Menschenrettung 1. OG, Brandbekämpfung", + fuehrer: 0, + mann: 7, + eingang: [300, 290], + ziel: [255, 240], + zwischen: [ + [16, 205, 185], + [26, 165, 140], + ], + ausgang: [120, 95], + einstiegMinute: 6, + rueckwegMinute: 27, + draussenMinute: 32, + rueckzugsdruck: 165, + }); + + await truppAnlegen({ + funkrufname: "Sicherheitstrupp", + auftrag: "Bereitstellung, auf Abruf", + fuehrer: 2, + mann: 3, + eingang: [295, 300], + ziel: null, + zwischen: [[22, 270, 275]], + ausgang: [250, 258], + einstiegMinute: 14, + rueckwegMinute: 28, + draussenMinute: 30, + rueckzugsdruck: 207, + }); + + await prisma.atemschutzEreignis.createMany({ + data: [ + { + organisationId, + atemschutzeinsatzId: ueberwachung.id, + art: EreignisArt.HINWEIS, + text: `Überwachung eröffnet durch ${ueberwacher.vorname} ${ueberwacher.nachname}.`, + zeitpunkt: beginn, + personId: ueberwacher.id, + }, + { + organisationId, + atemschutzeinsatzId: ueberwachung.id, + art: EreignisArt.HINWEIS, + text: "Überwachung beendet. Alle Trupps zurück, keine besonderen Vorkommnisse.", + zeitpunkt: nach(38), + personId: ueberwacher.id, + }, + ], + }); + + log( + `Atemschutz: ${G26.length} G26-Untersuchungen, ${UEBUNGSTEILNEHMER.length + 6} Übungsnachweise, 1 abgeschlossene Überwachung mit 2 Trupps`, + ); +} + // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- @@ -2212,6 +2513,9 @@ async function main(): Promise { // Phase 4 — Terminal im Gerätehaus await terminaldatenAnlegen(organisation.id); + // Phase 5 — Atemschutz nach FwDV 7 + await atemschutzAnlegen(organisation.id); + log("Fertig."); log(""); log("Anmeldung für den Erststart:"); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 34b13f4..b41bde7 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -170,6 +170,20 @@ const AUFNAHMEN: Aufnahme[] = [ beschreibung: "Anmeldemarke fürs Handy", angemeldet: true, }, + + // Phase 5 — Atemschutz nach FwDV 7 + { + name: "31-atemschutz", + pfad: "/atemschutz", + beschreibung: "Atemschutz: laufende Überwachung und Tauglichkeit der Geräteträger", + angemeldet: true, + }, + { + name: "32-atemschutz-nachweise", + pfad: "/atemschutz/nachweise", + beschreibung: "G26.3 und Belastungsübungen erfassen", + angemeldet: true, + }, ]; /** @@ -227,6 +241,16 @@ const DYNAMISCHE_AUFNAHMEN: Array<{ von: "/uebungen", linkMuster: /^\/uebungen\/[0-9a-f-]{36}$/, }, + { + name: "33-atemschutz-ueberwachung", + beschreibung: "Überwachungsschirm mit Trupps, Drücken und Rückzugsdruck", + von: "/atemschutz", + linkMuster: /^\/atemschutz\/[0-9a-f-]{36}$/, + // Nicht die Überwachungen aus den E2E-Tests: die tragen "E2E" im Namen und + // stehen ganz oben, weil sie nie beendet werden. Die Beschriftung des + // Verweises enthält noch anderes, deshalb wird der ganze Text geprüft. + textMuster: /^(?!.*E2E)/s, + }, ]; /** diff --git a/src/app/(app)/atemschutz/[id]/page.tsx b/src/app/(app)/atemschutz/[id]/page.tsx new file mode 100644 index 0000000..f4486c4 --- /dev/null +++ b/src/app/(app)/atemschutz/[id]/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/app/(app)/atemschutz/[id]/ueberwachung.tsx b/src/app/(app)/atemschutz/[id]/ueberwachung.tsx new file mode 100644 index 0000000..b253bc6 --- /dev/null +++ b/src/app/(app)/atemschutz/[id]/ueberwachung.tsx @@ -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(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

Stand wird geladen …

; + } + + if (stand.isError || !daten) { + return ; + } + + return ( +
+
+
+ +

{daten.bezeichnung}

+

+ Überwacher: {daten.ueberwacher.vorname} {daten.ueberwacher.nachname} · Beginn{" "} + {alsDatumZeit(daten.beginnUm)} + {daten.einsatz ? ` · Einsatz ${daten.einsatz.einsatznummer}` : ""} + {daten.endeUm ? ` · beendet ${alsDatumZeit(daten.endeUm)}` : ""} +

+
+ +
+ {/* Zustand der Verbindung — sichtbar, nicht versteckt. */} + + {verbunden ? : } + {verbunden ? "Verbunden" : "Verbindung unterbrochen"} + {letzteMeldung && ( + + zuletzt{" "} + {letzteMeldung.toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + + )} + + + {/* + 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 && ( + + )} +
+
+ + {meldung && } + + {!verbunden && ( +

+ 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. Die analoge Überwachung bleibt ohnehin führend. +

+ )} + + {laeuft && darfBedienen && ( +
+ + + +
+ )} + + {anlegenOffen && laeuft && ( + { + setAnlegenOffen(false); + beiErfolg(); + }} + onFehler={setMeldung} + /> + )} + + {trupps.length === 0 ? ( + + ) : ( +
+ {trupps.map((trupp) => ( + + 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 })} + /> + ))} +
+ )} + + {laeuft && darfBedienen && ( +
{ + ereignis.preventDefault(); + if (notiz.trim()) { + notizSenden.mutate({ atemschutzeinsatzId: id, text: notiz.trim() }); + } + }} + > +
+ + setNotiz(ereignis.target.value)} + placeholder="Funkverkehr, Beobachtung, Anweisung …" + /> +
+ +
+ )} + +

+ Stand vom Server: {alsDatumZeit(daten.stand)} · Anzeige läuft seit{" "} + {alsUhr(Math.floor((jetzt.getTime() - daten.stand.getTime()) / 1000))} weiter +

+
+ ); +} diff --git a/src/app/(app)/atemschutz/nachweise/nachweise.tsx b/src/app/(app)/atemschutz/nachweise/nachweise.tsx new file mode 100644 index 0000000..08c56de --- /dev/null +++ b/src/app/(app)/atemschutz/nachweise/nachweise.tsx @@ -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(null); + const [erfolg, setErfolg] = React.useState(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.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.UEBUNGSSTRECKE); + const [ort, setOrt] = React.useState(""); + const [dauer, setDauer] = React.useState(""); + const [teilnehmer, setTeilnehmer] = React.useState([]); + + 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 ( +
+ {meldung && } + {erfolg && ( +

+ {erfolg} +

+ )} + + + + G26.3-Untersuchung + + Die harte Voraussetzung. Das Ablaufdatum wird nach dem Alter vorgeschlagen — drei + Jahre, ab fünfzig eines. Was auf der Bescheinigung steht, gilt. + + + +
{ + 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, + }); + }} + > +
+ + +
+ +
+ + +
+ +
+ + setUntersuchtAm(ereignis.target.value)} + required + /> +
+ +
+ + setGueltigBis(ereignis.target.value)} + required + /> +
+ +
+ + setArzt(ereignis.target.value)} + placeholder="Betriebsarzt Dr. Meier, Landshut" + /> +
+ +
+ +