diff --git a/.env.example b/.env.example index 60c6417..61a94f3 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,23 @@ KIOSK_SPERRDAUER=300 # Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden. KIOSK_IDLE_TIMEOUT=120 +# ----------------------------------------------------------------------------- +# Kartenmodule (Phase 6) +# ----------------------------------------------------------------------------- +# FLORIAN laedt keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre +# Hydranten liegen, ist niemandes Geschaeft — der Kartenserver laeuft selbst: +# +# docker compose --profile karte up -d +# +# Dafuer wird eine MBTiles-Datei des Ausrueckegebiets gebraucht (siehe README). +# Ohne KARTE_STIL_URL zeigt die Karte einen leeren Hintergrund mit Gitternetz; +# Hydranten, Objekte und Lagekarte funktionieren trotzdem. +KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json +# Kartenmittelpunkt beim Oeffnen (Musterhausen). +KARTE_BREITENGRAD=48.5 +KARTE_LAENGENGRAD=12.15 +KARTE_ZOOM=13 + # ----------------------------------------------------------------------------- # Atemschutz nach FwDV 7 (Phase 5) # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c61599..703fb19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,117 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). ## [Unveröffentlicht] -Nächster Schritt ist Phase 6 (Kartenmodule). +Nächster Schritt ist Phase 7 (Benachrichtigungen und Termine). + +--- + +## [0.7.0] — 2026-09-04 + +Phase 6: Kartenmodule. Ab hier weiß die Wehr auf der Anfahrt, wo der nächste +Hydrant liegt und was er hergibt. + +**Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt, wo ihre Hydranten liegen +und welche Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver +läuft selbst (tileserver-gl im Compose-Profil `karte`); ohne ihn zeigt die +Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und +Umkreissuche funktionieren trotzdem. + +### Hinzugefügt + +**Datenmodell** + +- `Hydrant` mit Typ (Unterflur, Überflur, Saugstelle, Zisterne, offenes + Gewässer), Nennweite, Durchfluss, Zustand, letzter Prüfung und Hinweis für + das, was man vor Ort wissen muss. +- `Objekt` mit Kategorie, **Zufahrt, Gefahren und Schlüsseldepot als eigene + Felder** — im Einsatz sucht niemand in einem Absatz. Dazu `Objektkontakt` + (mit „jederzeit erreichbar") und `Objektplan` (DIN 14095). +- `Lagekarte` und `Lageelement` — eine Karte je Einsatz, jedes Element mit + Geometrie, Zeichen und Urheber. +- `Wasserfoerderung` und `Foerderpunkt` — die Eingaben einer Förderstrecke. + Das Ergebnis wird **nicht** gespeichert, sondern bei jeder Anzeige neu + gerechnet. +- Standorte haben jetzt Dezimalgrade neben dem PostGIS-Punkt und liegen damit + auf der Karte. + +**Karte** + +- MapLibre GL aus dem eigenen Paketstand, Kacheln aus dem eigenen + tileserver-gl. Kein Skript, kein Stylesheet, kein Bild von einem fremden + Server. +- Hydranten und Objekte als getrennt schaltbare Ebenen, Filter nach Art und + Zustand, Suche über Straße, Nummer, Objektname und Gefahrenstichwort. +- **Umkreissuche über PostGIS**: `ST_DWithin` auf der Geografie mit + GiST-Index — in die Karte tippen, und es steht da, was in 500 Metern + erreichbar ist, nach Entfernung sortiert. + +**Import** + +- GeoJSON und Tabellen der Stadtwerke. Spaltennamen werden erkannt („DN", + „Lage", „Latitude", „Pruefdatum"), deutsche Kommazahlen und Datumsangaben + ebenso, vertauschte Achsen werden erkannt und zurückgedreht. +- Zwei Schritte wie beim Geräteimport aus Phase 1: erst zeigen, was + herauskäme, dann übernehmen. Übersprungene Zeilen stehen mit Zeilennummer + und Grund da. +- Wiederholbar: Hydranten mit einer Nummer des Versorgers werden aktualisiert + statt doppelt angelegt. + +**Objekte und Feuerwehrpläne** + +- Zufahrt, Gefahren, Schlüsseldepot und Löschwasser am Objekt stehen ganz + oben — in dieser Reihenfolge werden sie auf der Anfahrt gelesen. +- Ansprechpartner mit Telefonnummern zum Antippen und der Angabe, wer nachts + ans Telefon geht. +- Planblätter nach DIN 14095 mit Art und Stand. Ein Plan von 2009 ist eine + Vermutung, und das steht dran. + +**Lagekarte** + +- 22 taktische Zeichen nach FwDV 102, **selbst gezeichnet** als SVG in + `lib/karte/taktische-zeichen.ts`: Fahrzeuge, Einheiten, Führung, Stellen, + Gefahren, Maßnahmen. +- Zeichen setzen, Linien ziehen, Flächen aufziehen, beschriften — ein + Werkzeug, ein Klick. +- Abgleich zwischen Geräten über denselben Ereignisstrom wie die + Atemschutzüberwachung aus Phase 5; der Zustand der Verbindung steht sichtbar + da. +- Zurücknehmen statt löschen: was um 14:12 eingezeichnet wurde, gehört zur + Mitschrift. + +**Wasserförderung** + +- Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen — die + Rechnung läuft mit: Reibungsverlust, Höhenunterschied, Zahl der + Verstärkerpumpen, Schlauchlängen je Abschnitt. +- Gerechnet wird im Browser **und** auf dem Server mit demselben Code. +- Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das, + statt eine Zahl auszuwerfen, die niemand halten kann. +- Unter jeder Rechnung steht, dass sie eine Planungshilfe ist — was zählt, ist + der Druck am Verteiler. + +**Dateien abrufen** + +- `/api/dateien/[id]` liefert eine abgelegte Datei aus, mandantengebunden und + mit Eintrag im Protokoll. Bis Phase 6 gab es nur den Upload und eigene + Endpunkte je Dokumentart. + +**Sonstiges** + +- Der Seed legt 24 Löschwasserstellen an — mit Weiher, Zisterne und + Saugstelle im Außenbereich, zwei defekten und zwei ungeprüften — sowie drei + Objekte mit Ansprechpartnern: Schule, Pflegeheim, Aussiedlerhof. +- 59 neue Tests: 48 Unit-Tests zu Druckverlust, Förderplan und Import, + 11 Playwright-Durchläufe je Browser. + +### Geändert + +- Der Koordinaten-Trigger aus Phase 3 ist jetzt allgemein: eine Funktion für + jede Tabelle mit Breiten- und Längengrad. Sie hängt an Standort, Hydrant und + Objekt. +- Der Ereignisstrom-Hook aus Phase 5 nimmt jetzt eine Adresse entgegen und + wird von Lagekarte und Atemschutzüberwachung gemeinsam benutzt. Zwei Ströme + mit unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler + verstecken kann. --- diff --git a/README.md b/README.md index 016d457..b9c9bca 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 3 — Einsätze und Übungen | fertig | | Phase 4 — Kiosk-PWA im Gerätehaus | fertig | | Phase 5 — Atemschutz (FwDV 7) | fertig | -| Phase 6 — Kartenmodule | offen | +| Phase 6 — Kartenmodule | fertig | | Phase 7 — Benachrichtigungen und Termine | offen | | Phase 8 — Lokale KI | offen | | Phase 9 — Kassenverwaltung | offen | @@ -664,6 +664,127 @@ Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind. --- +## Phase 6 — Kartenmodule + +Die Frage, wegen der es das Modul gibt, wird auf der Anfahrt gestellt: **Was +liegt in der Nähe, und was gibt es her?** + +### Keine Kacheln aus dem Netz + +Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche Objekte sie im Blick +hat, ist niemandes Geschäft. FLORIAN lädt deshalb **keine** Kartenkacheln von +einem öffentlichen Dienst — auch nicht „nur zum Ausprobieren". Der Kartenserver +läuft selbst: + +```bash +docker compose --profile karte up -d +``` + +Dafür wird eine MBTiles-Datei des eigenen Ausrückegebiets gebraucht; woher man +sie bekommt, steht in [docker/karten/LIESMICH.md](docker/karten/LIESMICH.md). +Danach in der `.env`: + +``` +KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json +``` + +**Ohne Kartenserver läuft alles trotzdem** — die Karte zeigt einen leeren Grund +mit einem Hinweis darauf. Punkte liegen an der richtigen Stelle, Entfernungen +stimmen, die Umkreissuche rechnet; es fehlen nur die Straßen darunter. Ist ein +Server eingetragen, aber nicht erreichbar, sieht es genauso aus — mit +entsprechendem Hinweis. + +### Löschwasser + +Hydranten, Saugstellen, Zisternen und offene Gewässer mit Typ, Nennweite, +Durchfluss, Zustand und dem, was man vor Ort wissen muss („im Winter oft +zugeschoben"). + +Die **Umkreissuche** ist der eigentliche Zweck: in die Karte tippen, und es +steht da, was in 500 Metern erreichbar ist — nach Entfernung sortiert, mit +Nennweite und Durchfluss. Sie läuft über PostGIS (`ST_DWithin` auf der +Geografie, GiST-Index), nicht über die Dezimalgrade: bei tausend Hydranten wäre +alles andere ein vollständiger Tabellendurchlauf, und gefragt wird während der +Fahrt. + +### Import + +Zwei Quellen, die eine Wehr tatsächlich bekommt: + +| Quelle | Was erkannt wird | +|---|---| +| **GeoJSON** | FeatureCollection mit Punkten, Eigenschaften wie in der CSV | +| **CSV der Stadtwerke** | Spaltennamen wie `DN`, `Lage`, `Latitude`, `Pruefdatum`, `Bauart` | + +Der Import ist nachsichtig, aber ehrlich: „DN 100" wird zu 100, „48,5031" zu +48.5031, „24.05.2026" zu einem Datum, und vertauschte Achsen werden erkannt und +zurückgedreht. Was nicht lesbar ist, wird mit Zeilennummer und Grund gemeldet — +und der Rest läuft durch. Eine Liste, die zu 90 Prozent stimmt, ist im Einsatz +mehr wert als keine. + +**Geraten wird nichts.** Fehlt die Position, wird die Zeile übersprungen: ein +Hydrant ohne Ort ist kein Hydrant. Und ohne Statusangabe steht „ungeprüft", +nicht „einsatzbereit". + +### Objekte und Feuerwehrpläne + +Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom Beifahrersitz, +oft in zwanzig Sekunden. Deshalb sind **Zufahrt**, **Gefahren** und +**Schlüsseldepot** eigene Felder und stehen ganz oben, nicht in einer +Fließtextnotiz. + +Dazu Ansprechpartner mit der einzigen Angabe, die im Einsatz zählt — wer nachts +ans Telefon geht — und die Planblätter nach DIN 14095 mit ihrem Stand. + +Gesucht wird über alles: Name, Straße, Gefahrenstichwort, Ansprechpartner. Wer +im Einsatz sucht, weiß selten, wie das Objekt im System heißt. + +### Lagekarte + +Eine je Einsatz, unterhalb der Einsatzdaten. 22 taktische Zeichen nach +FwDV 102, selbst gezeichnet als SVG — fertige Sätze sind selten frei +verwendbar, und nachgeladen wird ohnehin nichts. + +Ein Werkzeug, ein Klick: Zeichen wählen, in die Karte tippen. Dazu Linienzüge +(Schlauchleitung, Absperrung), Flächen (Bereitstellungsraum) und +Beschriftungen. + +Der Abgleich zwischen Geräten läuft über denselben Ereignisstrom wie die +Atemschutzüberwachung, und wie dort steht der Zustand der Verbindung sichtbar +da. Zurückgenommene Elemente verschwinden von der Karte, nicht aus dem +Datenbestand: eine Lagekarte ist die Mitschrift einer Entscheidung. + +### Wasserförderung + +Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht. +Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen: + +``` +Reibungsverlust je 100 m = Beiwert × (Fördermenge / 100)² +Höhenverlust = Höhenmeter × 0,1 bar +``` + +Daraus: Zahl der Verstärkerpumpen, Schlauchlängen je Abschnitt, Restdruck und +der Druck, der an der Einsatzstelle ankommt. Die Beiwerte treffen die +Tabellenwerte der Ausbildung — 1,5 bar je 100 m im B-Schlauch bei 800 l/min. + +Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das, +statt eine Zahl auszuwerfen, die niemand halten kann. Und unter jeder Rechnung +steht, was sie ist: **eine Planungshilfe. Was zählt, ist der Druck am +Verteiler.** + +### Zusätzliche Umgebungsvariablen + +| Variable | Vorgabe | Bedeutung | +|---|---|---| +| `KARTE_STIL_URL` | — | Stil des eigenen Kartenservers; leer = leerer Grund | +| `KARTE_BREITENGRAD` | `48.5` | Mittelpunkt, falls kein Standort verortet ist | +| `KARTE_LAENGENGRAD` | `12.15` | dito | +| `KARTE_ZOOM` | `13` | Zoomstufe beim Öffnen | +| `KARTE_PORT` | `8085` | Port des tileserver-gl (Profil `karte`) | + +--- + ## Befehle ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 0bd4176..c1a7c75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,10 @@ x-app-env: &app-env KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5} KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300} KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120} + KARTE_STIL_URL: ${KARTE_STIL_URL:-} + KARTE_BREITENGRAD: ${KARTE_BREITENGRAD:-48.5} + KARTE_LAENGENGRAD: ${KARTE_LAENGENGRAD:-12.15} + KARTE_ZOOM: ${KARTE_ZOOM:-13} ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true} ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-} ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600} @@ -272,6 +276,36 @@ services: - intern logging: *logging + # --------------------------------------------------------------------------- + # Kartenserver (Phase 6). + # + # Nur mit Profil: "docker compose --profile karte up -d". FLORIAN läuft ohne + # ihn — die Karte zeigt dann einen leeren Hintergrund, Hydranten und + # Lagekarte funktionieren trotzdem. + # + # Gebraucht wird eine MBTiles-Datei des Ausrückegebiets unter + # docker/karten/. Woher man sie bekommt, steht in der README; sie liegt + # bewusst nicht im Repository — das sind je nach Zuschnitt hunderte Megabyte + # bis Gigabyte, und jede Wehr braucht einen anderen Ausschnitt. + # + # Der Dienst hängt im internen Netz und im Edge-Netz: der Browser des + # Anwenders holt die Kacheln direkt, nicht über die Anwendung. + # --------------------------------------------------------------------------- + tileserver: + image: maptiler/tileserver-gl:v4.11.1 + container_name: florian-tileserver + restart: unless-stopped + profiles: [karte] + command: ["--port", "8080", "--public_url", "${KARTE_OEFFENTLICH:-http://localhost:8085/}"] + volumes: + - ./docker/karten:/data:ro + ports: + - "${KARTE_PORT:-8085}:8080" + networks: + - edge + - intern + logging: *logging + # --------------------------------------------------------------------------- # Die Anwendung selbst (Next.js, Standalone-Build). # Führt vor dem Start die ausstehenden Prisma-Migrationen aus. diff --git a/docker/karten/LIESMICH.md b/docker/karten/LIESMICH.md new file mode 100644 index 0000000..196ffa5 --- /dev/null +++ b/docker/karten/LIESMICH.md @@ -0,0 +1,46 @@ +# Kartenkacheln + +Hier gehört die MBTiles-Datei des Ausrückegebiets hinein. Sie liegt **nicht** +im Repository: je nach Zuschnitt sind das hunderte Megabyte bis Gigabyte, und +jede Wehr braucht einen anderen Ausschnitt. + +## Woher + +Zwei Wege, beide ohne laufende Kosten und ohne Anmeldung: + +1. **Fertiger Auszug.** Bei [maptiler.com/data](https://data.maptiler.com/downloads/planet/) + oder aus einem OpenMapTiles-Auszug den eigenen Landkreis herunterladen. +2. **Selbst schneiden.** Aus einem Geofabrik-Auszug (`.osm.pbf`) mit + [planetiler](https://github.com/onthegomap/planetiler) den eigenen + Ausschnitt erzeugen: + + ```bash + java -Xmx4g -jar planetiler.jar --download --area=oberbayern \ + --output=/pfad/zu/docker/karten/ausrueckegebiet.mbtiles + ``` + +Dazu gehört ein Stil. tileserver-gl bringt `basic` mit; ein eigener Stil kommt +als `styles//style.json` daneben. + +## Starten + +```bash +docker compose --profile karte up -d +``` + +Danach in der `.env`: + +``` +KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json +``` + +## Ohne Kacheln + +FLORIAN läuft auch ohne. Die Karte zeigt dann einen leeren Hintergrund mit +Gitternetz — Hydranten, Objekte und die Lagekarte liegen trotzdem an der +richtigen Stelle, nur ohne Straßen darunter. Für den Anfang reicht das, um +Daten zu erfassen; für den Einsatz nicht. + +**Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt, wo ihre Hydranten +liegen und welche Objekte sie im Blick hat, ist niemandes Geschäft. Deshalb +gibt es in FLORIAN keinen Rückfall auf einen öffentlichen Kartendienst. diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 92c5334..0bfe504 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,9 +1,10 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 5 (Version 0.6.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 6 (Version 0.7.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. -> Die Kartenmodule kommen in Phase 6 dazu. Siehe [ROADMAP.md](ROADMAP.md). +> Benachrichtigungen, lokale KI und die Kassenverwaltung kommen in den +> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md). --- @@ -602,7 +603,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 34). Ohne den bleibt das Feld leer — +einen eigenen Nominatim betreibt (Kapitel 38). Ohne den bleibt das Feld leer — das ist in Ordnung, die Karte kommt in Phase 6. --- @@ -1100,7 +1101,165 @@ weiter einsetzbar, aber es wird Zeit für einen Termin. --- -## 34. Adressen zu Koordinaten (Nominatim) +## 34. Die Karte + +![Karte](screenshots/34-karte-light.png) + +Die Karte beantwortet die Frage, die auf der Anfahrt gestellt wird: **Was liegt +in der Nähe, und was gibt es her?** + +### Kartenhintergrund einrichten + +FLORIAN lädt **keine** Kacheln aus dem Netz. Wo Ihre Wehr ausrückt und wo Ihre +Hydranten liegen, ist niemandes Geschäft — es gibt deshalb keinen Rückfall auf +einen öffentlichen Kartendienst. Der Kartenserver läuft selbst: + +```bash +docker compose --profile karte up -d +``` + +Dafür brauchen Sie eine MBTiles-Datei Ihres Ausrückegebiets; woher, steht in +`docker/karten/LIESMICH.md`. Danach in der `.env`: + +``` +KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json +``` + +> **Ohne Kartenserver funktioniert alles außer den Straßen.** Punkte liegen an +> der richtigen Stelle, Entfernungen stimmen, die Umkreissuche rechnet. Die +> Karte sagt oben, dass der Hintergrund fehlt — sie tut nicht so, als wäre +> nichts. + +### Was auf der Karte liegt + +| Ebene | Farbe | +|---|---| +| **Löschwasser** | grün einsatzbereit, gelb eingeschränkt, rot defekt, grau ungeprüft | +| **Objekte** | blau, mit Verweis auf den Feuerwehrplan | + +Beide lassen sich einzeln ab- und anschalten: wer Löschwasser sucht, will keine +Objekte sehen und umgekehrt. + +### Umkreissuche + +**In die Karte tippen** — rechts steht, was in 500 Metern erreichbar ist, nach +Entfernung sortiert, mit Nennweite und Durchfluss. Findet sich nichts, steht +das ausdrücklich da: eine Angabe, die man vor dem Einsatz wissen will, nicht +währenddessen. + +--- + +## 35. Hydranten pflegen und importieren + +### Import — der übliche Weg + +Die Stadtwerke oder das Landratsamt haben die Liste. FLORIAN nimmt sie in zwei +Formaten: + +- **GeoJSON** (aus QGIS, vom Landratsamt, aus einem OSM-Auszug) +- **CSV** — mit den Spaltennamen, die eben drinstehen + +Erkannt werden unter anderem `Nummer`, `Bauart`, `DN`, `Lage`, `Latitude`, +`Pruefdatum`, `Versorger` und ihre Geschwister. Auch „DN 100" in der +Zahlenspalte, „48,5031" mit Komma und „24.05.2026" als Datum. Sind Breite und +Länge vertauscht, wird das erkannt und zurückgedreht. + +Der Import läuft **in zwei Schritten**: + +1. Datei wählen — FLORIAN zeigt, was herauskäme: wie viele Hydranten gefunden + wurden, welche Zeilen übersprungen werden und warum. +2. Erst dann „übernehmen". + +> **Zweimal importieren ist ungefährlich.** Hydranten mit einer Nummer des +> Versorgers werden aktualisiert, nicht doppelt angelegt. Fehlt die Nummer, +> sagt der Import das vorher. + +Was FLORIAN **nicht** tut: raten. Fehlt die Position, wird die Zeile +übersprungen — ein Hydrant ohne Ort ist kein Hydrant. Und ohne Statusangabe +steht „ungeprüft", nicht „einsatzbereit". + +### Einzeln erfassen + +In die Karte tippen setzt den Ort, darunter kommen Bezeichnung, Art, Zustand +und Nennweite. Für den einen Hydranten, der beim Neubaugebiet dazugekommen ist. + +--- + +## 36. Objekte und Feuerwehrpläne + +![Objekte](screenshots/35-objekte-light.png) + +Für jedes Objekt, für das es einen Feuerwehrplan gibt oder geben sollte: +Schule, Pflegeheim, Aussiedlerhof, Betrieb. + +Die Detailseite ist so sortiert, wie auf der Anfahrt gelesen wird: + +| Block | Was hineingehört | +|---|---| +| **Zufahrt** | Tordurchfahrt, Höhenbegrenzung, Aufstellfläche, Schranke | +| **Gefahren** | Photovoltaik, Gasflaschen, Silo, Tiere, Personen | +| **Schlüsseldepot** | Wo der FSD hängt und was er aufsperrt | +| **Löschwasser am Objekt** | Wandhydranten, Steigleitung, Sprinklerzentrale | + +Darunter die **Ansprechpartner** — mit der einzigen Angabe, die im Einsatz +zählt: wer nachts ans Telefon geht. Die Nummern lassen sich antippen. + +Und die **Planblätter nach DIN 14095** mit Art und Stand. Der Stand gehört +dazu: ein Feuerwehrplan von 2009 ist eine Vermutung. + +Gesucht wird über alles — Name, Straße, Gefahrenstichwort, Ansprechpartner. Wer +im Einsatz sucht, weiß selten, wie das Objekt im System heißt, aber oft, was +dort ist: „Photovoltaik" findet die Schule. + +--- + +## 37. Lagekarte und Wasserförderung + +### Lagekarte + +Jeder Einsatz hat eine, unterhalb der Einsatzdaten. 22 taktische Zeichen nach +FwDV 102: Fahrzeuge, Einheiten, Führung, Stellen, Gefahren, Maßnahmen. + +**Ein Werkzeug, ein Klick**: Zeichen antippen, in die Karte tippen. Dazu +Linienzüge (Schlauchleitung, Absperrung), Flächen (Bereitstellungsraum) und +freie Beschriftungen. + +Was einer einträgt, sehen die anderen — auch auf einem zweiten Tablet. Oben +steht, ob der Abgleich läuft; steht er nicht, sehen Sie eine ältere Lage und +wissen das. + +Eingetragenes lässt sich **zurücknehmen**, nicht löschen: was um 14:12 +eingezeichnet wurde, gehört zur Mitschrift, auch wenn es um 14:20 nicht mehr +stimmte. + +### Wasserförderung + +![Wasserförderung](screenshots/36-wasserfoerderung-light.png) + +Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht. +Punkte in die Karte tippen — der erste ist die Entnahmestelle, der letzte die +Einsatzstelle —, dann Schlauchdurchmesser und Fördermenge einstellen. + +Sofort steht da: + +- **wie viele Pumpen** gebraucht werden und wo ein Verstärker stehen muss, +- **wie viele Schlauchlängen** je Abschnitt, +- **welcher Druck** am Ende ankommt. + +Zwei Dinge kosten Druck: die Reibung im Schlauch (steigt mit dem **Quadrat** der +Fördermenge — doppelte Menge, vierfacher Verlust) und die Steigung (zehn Meter +sind ein bar). Wo die Höhe bekannt ist, sollte sie eingetragen werden; ohne +rechnet FLORIAN mit ebenem Gelände, und das ist im Zweifel zu günstig. + +Geht eine Strecke nicht auf, sagt die Ausgabe das — statt eine Zahl +auszuwerfen, die niemand halten kann. + +> **Es bleibt eine Planungshilfe.** Was zählt, ist der Druck am Verteiler — und +> den liest jemand ab. + +--- + +## 38. Adressen zu Koordinaten (Nominatim) FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim betreibt: @@ -1120,7 +1279,7 @@ GEOCODING_LAENDER=de,at,ch Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand eintragen oder weglassen. -## 35. Rollen und was sie dürfen +## 39. 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 @@ -1149,7 +1308,7 @@ Das Terminal selbst ist nur die Tür. --- -## 36. Anmeldung über authentik einrichten +## 40. Anmeldung über authentik einrichten authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. @@ -1202,7 +1361,7 @@ ganze Wehr aus. --- -## 37. PIN und Ausweiskarte verwalten +## 41. PIN und Ausweiskarte verwalten Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum, was dahinter liegt. @@ -1225,7 +1384,7 @@ Huber), PIN `471203`. --- -## 38. Was protokolliert wird +## 42. Was protokolliert wird Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. @@ -1250,7 +1409,7 @@ jeweiligen Detailseite sichtbar. --- -## 39. Wenn etwas nicht geht +## 43. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| @@ -1274,6 +1433,11 @@ jeweiligen Detailseite sichtbar. | 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 | +| Die Karte zeigt keinen Hintergrund | Kein Kartenserver eingerichtet oder nicht erreichbar | `docker compose --profile karte up -d`, MBTiles nach `docker/karten/`, `KARTE_STIL_URL` setzen. Ohne läuft alles außer den Straßen | +| Der Import überspringt alle Zeilen | Keine Spalte wurde als Breite und Länge erkannt | Spalten umbenennen (`lat`/`lon`, `Breitengrad`/`Längengrad`) — die erkannten Namen stehen in `lib/karte/hydrant-import.ts` | +| Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen | +| Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen | +| Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern | Zustand aller Randsysteme auf einen Blick: diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index 18bc954..c545c4b 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -297,7 +297,7 @@ Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert. ## Phase 6 — Kartenmodule -**Vergleichsaufwand: 14–18 PT** +**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:50 h Sitzungszeit** | Baustein | PT | |---|---:| @@ -313,6 +313,30 @@ Das SVG-Set für die taktischen Zeichen ist Fleißarbeit, aber unvermeidbar — fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist Physik mit klaren Formeln und deshalb gut testbar. +**Rückblick.** Beide Sätze stimmen. Das Zeichenset war Fleißarbeit — 22 Zeichen +als SVG-Pfade, keine davon schwierig, alle zusammen eine Stunde. Und die +Wasserförderung war tatsächlich der am besten testbare Teil des ganzen +Projekts: 21 Tests, die alle etwas über die Physik aussagen statt über den Code. + +Dabei fiel auf, dass die Beiwerte im ersten Wurf um den Faktor zehn danebenlagen +— aufgefallen ist es nur, weil die Tests gegen die **Tabellenwerte der +Ausbildung** prüfen und nicht gegen das, was die Funktion gerade ausrechnet. +Ein Test, der die eigene Implementierung bestätigt, hätte hier fröhlich grün +gemeldet. + +Billiger als angesetzt war die Umkreissuche. 3,0 PT für „Hydranten mit PostGIS, +Cluster, Filter, Umkreissuche" — davon entfiel der Löwenanteil auf eine einzige +SQL-Abfrage mit `ST_DWithin` und einen GiST-Index. PostGIS war seit Phase 0 im +Container; genutzt wurde es erst jetzt. + +Teurer war die Frage, was passiert, wenn es **keine Karte** gibt. Eine MBTiles- +Datei des Ausrückegebiets ist hunderte Megabyte groß, gehört niemandem außer +der Wehr und kann nicht im Repository liegen. Das heißt: der Normalfall beim +ersten Start ist eine Karte ohne Hintergrund — und sie muss trotzdem etwas +taugen. Aus dieser Anforderung kam der leere Stil mit Hinweis, die Erkennung +eines nicht erreichbaren Servers und die Zusicherung im E2E-Test, dass genau +das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile. + ## Phase 7 — Benachrichtigungen und Termine **Vergleichsaufwand: 10–13 PT** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index d5cc142..aa02af8 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -17,7 +17,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 | | Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 | | Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 | -| **Summe** | **13:20 h** | **7** | | +| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 | +| **Summe** | **16:10 h** | **8** | | + +--- + +## 2026-09-04 · Phase 6 — Kartenmodule + +**22:10 – 01:00 · 2:50 h · Version 0.7.0** + +### Was entstanden ist + +Alles, was einen Ort hat: Löschwasser, Objekte mit Feuerwehrplan, die Lagekarte +eines Einsatzes und die geplante Förderstrecke. + +| Bereich | Dateien | +|---|---| +| Rechnen | `lib/karte/wasserfoerderung.ts`, `hydrant-import.ts`, `taktische-zeichen.ts` | +| Server | `server/routers/karte/hydrant.ts`, `objekt.ts`, `lagekarte.ts` | +| Karte | `components/karte/karte.tsx` (MapLibre), `api/lagekarte/strom` | +| Oberfläche | `app/(app)/karte/` mit Karte, Hydranten, Objekten, Wasserförderung; Lagekarte im Einsatz | +| Betrieb | tileserver-gl im Compose-Profil `karte`, `docker/karten/LIESMICH.md` | +| Tests | 48 Unit, 11 E2E je Browser | + +### Entscheidungen + +**Kein öffentlicher Kartendienst — auch nicht als Rückfall.** Wo eine Wehr +ausrückt und wo ihre Hydranten liegen, ist niemandes Geschäft. Die Kacheln +kommen aus dem eigenen tileserver-gl. Ohne ihn zeigt die Karte einen leeren +Grund **und sagt das**; Punkte, Entfernungen und Umkreissuche funktionieren +weiter. Ein stiller leerer Kasten wäre die schlechtere Lösung gewesen: er sieht +aus wie ein Fehler, ohne einer zu sein. + +**Die Umkreissuche gehört in die Datenbank.** `ST_DWithin` auf der Geografie +mit GiST-Index rechnet in Metern und nutzt den Index. In der Anwendung wäre es +ein vollständiger Tabellendurchlauf mit Grad-Arithmetik — und gefragt wird +während der Fahrt. + +**Der Import rät nicht.** Er erkennt viel („DN 100", „48,5031", „24.05.2026", +vertauschte Achsen), aber wo die Position fehlt, wird die Zeile übersprungen +statt geschätzt. Und ohne Statusangabe steht „ungeprüft" — nicht +„einsatzbereit". Eine Liste, die tausend Hydranten als geprüft ausweist, weil +die Spalte fehlte, wäre schlimmer als keine Liste. + +**Ein Ereignisstrom für beides.** Die Lagekarte benutzt denselben Hook wie die +Atemschutzüberwachung aus Phase 5, nur mit anderer Adresse. Zwei Ströme mit +unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler +verstecken kann. + +**Das Ergebnis der Wasserförderung wird nicht gespeichert.** Nur die Eingaben. +Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne falsch +auszusehen. + +### Wo es gehakt hat + +| Problem | Ursache | Lösung | +|---|---|---| +| Der Druckverlust lag um den Faktor zehn daneben | Die Beiwerte waren aus dem Kopf gegriffen statt aus der Formel abgeleitet: gebraucht wird `verlust = beiwert × (menge/100)²`, also 0,0234 für den B-Schlauch, nicht 0,23 | Beiwerte aus den Tabellenwerten zurückgerechnet. Aufgefallen ist es nur, weil die Tests gegen die Ausbildungstabelle prüfen und nicht gegen die eigene Implementierung | +| Ein nicht erreichbarer Kartenserver ließ die Karte kaputt aussehen | Der Hinweis „kein Hintergrund" hing an `stilUrl === null`, nicht daran, ob der Stil geladen wurde | MapLibre meldet den Fehlschlag; die Karte fällt jetzt in denselben ehrlichen Zustand wie ohne Server | +| Die Klicks in die Karte gingen im E2E-Test ins Leere | Die Lagekarte liegt unten auf der Einsatzseite, außerhalb des Fensters. `page.mouse` klickt in Fensterkoordinaten | Karte vor dem Klick ins Bild holen | +| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1 | DropIndex-Block aus der Migration entfernt, im Kopf vermerkt | + +### Abweichung von der Vorgabe + +**Keine Cluster-Darstellung.** Phase-6.md nennt sie. Bei 24 Löschwasserstellen +im Beispiel und einigen hundert in einer echten Wehr sind Cluster eine Lösung +für ein Problem, das erst bei Zehntausenden entsteht — und sie verstecken genau +das, was man sehen will: die Lücke im Netz. Nachrüstbar, wenn eine Wehr mit +großem Ausrückegebiet es vermisst. + +**Kein Höhenprofil aus einem Geländemodell.** Die Höhe je Punkt wird von Hand +eingetragen. Ein Höhenmodell (SRTM, DGM) wäre ein weiterer Datensatz von +hunderten Megabyte, den jede Wehr selbst beschaffen müsste. Die Rechnung ist +ohne Höhe zu günstig — und das steht auch so an der Eingabe. + +### Nachweise + +- `npx tsc --noEmit` — ohne Ausgabe +- `npm run test` — 354 Tests grün +- `npx playwright test tests/e2e/karte.spec.ts` — 22 grün (Desktop und Tablet) +- `npm run build` — 44 Routen +- `npm run db:seed` — 24 Löschwasserstellen, 3 Objekte, Standorte verortet --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 47ae83a..415b453 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,7 +15,7 @@ Phase 2 ████████████████████ fertig Phase 3 ████████████████████ fertig 0.4.0 Phase 4 ████████████████████ fertig 0.5.0 Phase 5 ████████████████████ fertig 0.6.0 -Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0 +Phase 6 ████████████████████ fertig 0.7.0 Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0 Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0 Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0 @@ -135,19 +135,20 @@ 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 · als Nächstes +## Phase 6 — Kartenmodule · fertig - MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet - Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus GeoJSON und Stadtwerke-CSV - Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar -- Lagekarte mit taktischen Zeichen nach FwDV 102, Echtzeit-Sync +- Lagekarte mit taktischen Zeichen nach FwDV 102, Abgleich über denselben + Ereignisstrom wie der Atemschutz - Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema **Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt und was ihn hergibt. -## Phase 7 — Benachrichtigungen und Termine +## Phase 7 — Benachrichtigungen und Termine · als Nächstes - Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht. - Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png index 56216c4..192e568 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 1beff21..e8115e0 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 74996d9..554c6df 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 cd8fa34..bcbf6a6 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 e71c74d..6fdf924 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 14d09e7..328f92a 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 9f124c3..b00ca8a 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 0af7ba3..c7089f1 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 7d22572..44ae50e 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 b3fb17e..e62db8e 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 e7bcd82..72b0491 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 cc2c1c6..3d6a96a 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 e463509..aa15299 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 665b343..ed7243e 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 6b47ca1..6dfe25f 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 924954b..85f5340 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 f11ff05..f0b7d67 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 7090652..c3a5bb9 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 52ce5f8..a32dfad 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 17cfd9c..d110d2f 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 6e161f2..95cbbdc 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 8e98133..e0e9ccb 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 da8aede..fd475d8 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 dcaf0ce..92d5c96 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 fee5a16..5b69c37 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 2054adf..45b050a 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 6385d48..d6bf9d2 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 1762094..d25f2bc 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 790dd54..76d221e 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 b3d8634..b8e8f9b 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 b19f65b..ca585c2 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 c80dabf..e5e44b5 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 331611b..03a21af 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 0f18b37..6fd9491 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 dea9748..76f19df 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 eee14bf..a24303a 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 26a6303..1201364 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 ae893eb..d0b7d55 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 084efe1..d2dfb80 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 8f41ef5..b7289f0 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 76c5a61..92a449b 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 7f44f12..22d391f 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 adb4eec..be1eef3 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 0195211..5cd6479 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 7da334b..98ece0c 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 e56f0b1..d51d243 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 dee352d..7f06b84 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 65bf0bb..0ac6bf8 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 f07d8a7..b7a4d97 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 ac6788f..a6827e7 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 edfb5a0..300e646 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 9fa0eae..7e1c739 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 7a7f409..d22c17e 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 fb01f28..e61072d 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 index 7f12222..4edec7c 100644 Binary files a/docs/screenshots/31-atemschutz-dark.png 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 index 3b1aa18..8d50d08 100644 Binary files a/docs/screenshots/31-atemschutz-light.png 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 index 29d8bbb..9d0f994 100644 Binary files a/docs/screenshots/32-atemschutz-nachweise-dark.png 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 index 3177b05..f517665 100644 Binary files a/docs/screenshots/32-atemschutz-nachweise-light.png and b/docs/screenshots/32-atemschutz-nachweise-light.png differ diff --git a/docs/screenshots/34-karte-dark.png b/docs/screenshots/34-karte-dark.png new file mode 100644 index 0000000..9060e58 Binary files /dev/null and b/docs/screenshots/34-karte-dark.png differ diff --git a/docs/screenshots/34-karte-light.png b/docs/screenshots/34-karte-light.png new file mode 100644 index 0000000..1ca0ec7 Binary files /dev/null and b/docs/screenshots/34-karte-light.png differ diff --git a/docs/screenshots/35-objekte-dark.png b/docs/screenshots/35-objekte-dark.png new file mode 100644 index 0000000..f0eefa6 Binary files /dev/null and b/docs/screenshots/35-objekte-dark.png differ diff --git a/docs/screenshots/35-objekte-light.png b/docs/screenshots/35-objekte-light.png new file mode 100644 index 0000000..95acd9f Binary files /dev/null and b/docs/screenshots/35-objekte-light.png differ diff --git a/docs/screenshots/36-wasserfoerderung-dark.png b/docs/screenshots/36-wasserfoerderung-dark.png new file mode 100644 index 0000000..f18a0ad Binary files /dev/null and b/docs/screenshots/36-wasserfoerderung-dark.png differ diff --git a/docs/screenshots/36-wasserfoerderung-light.png b/docs/screenshots/36-wasserfoerderung-light.png new file mode 100644 index 0000000..d39ca15 Binary files /dev/null and b/docs/screenshots/36-wasserfoerderung-light.png differ diff --git a/package-lock.json b/package-lock.json index eacbdcc..aa16ba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "florian", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "florian", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@prisma/client": "^6.16.2", "@radix-ui/react-avatar": "^1.1.10", @@ -37,6 +37,7 @@ "exceljs": "^4.4.0", "ioredis": "^5.8.1", "lucide-react": "^0.544.0", + "maplibre-gl": "^6.7.0", "minio": "^8.0.6", "next": "^15.5.4", "next-auth": "^5.0.0-beta.29", @@ -2009,6 +2010,92 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.1.tgz", + "integrity": "sha512-I/qcIKVFHFSg1Meu/eqHrkSTjIez0gCsSaK56TNPutM3TkE61aSq6F1cZ4Kg4KiNs3cpViLtubDjfyRcQCdEJQ==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.0.tgz", + "integrity": "sha512-g45M8gEI4sMO3X9ib4K7n3ZKFf0qQOL+NMkHCnEK51lOrYFHiEssOuZncx1+miIgi1IEE2NcbRMcq8RBkL+QHA==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -4328,6 +4415,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6732,6 +6825,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/effect": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", @@ -7984,6 +8083,12 @@ "giget": "dist/cli.mjs" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8959,6 +9064,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -9036,6 +9147,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9593,6 +9710,38 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.7.0.tgz", + "integrity": "sha512-Y1Q1+UP9WXou0DUrnaFdDsoMqiMCWy6aS968IBUaQ2eZi6H1/kPCfpfZOWXFJZbeKMkX9Wo6OFzY/k48qqPf3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.4.1", + "@maplibre/mlt": "^1.2.0", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "earcut": "^3.2.3", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9759,6 +9908,12 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -10339,6 +10494,18 @@ "node": ">= 14.16" } }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -10457,6 +10624,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -10567,6 +10740,12 @@ "dev": true, "license": "MIT" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10650,6 +10829,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -10975,6 +11160,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11926,6 +12120,12 @@ "node": "^18.0.0 || >=20.0.0" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", diff --git a/package.json b/package.json index 6bed8ab..abf33bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.6.0", + "version": "0.7.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { @@ -56,6 +56,7 @@ "exceljs": "^4.4.0", "ioredis": "^5.8.1", "lucide-react": "^0.544.0", + "maplibre-gl": "^6.7.0", "minio": "^8.0.6", "next": "^15.5.4", "next-auth": "^5.0.0-beta.29", diff --git a/prisma/migrations/20260903214555_kartenmodule/migration.sql b/prisma/migrations/20260903214555_kartenmodule/migration.sql new file mode 100644 index 0000000..1ac35fb --- /dev/null +++ b/prisma/migrations/20260903214555_kartenmodule/migration.sql @@ -0,0 +1,273 @@ +-- Phase 6 — Kartenmodule +-- +-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert: +-- die Volltext- und GiST-Indizes aus den fruehereren Phasen stehen nicht im +-- Schema, weil Prisma sie nicht abbilden kann. Rechte, Mandantentrennung, +-- Koordinaten-Trigger und Umkreisindizes stehen in der Migration daneben. + +-- CreateEnum +CREATE TYPE "HydrantTyp" AS ENUM ('UNTERFLUR', 'UEBERFLUR', 'SAUGSTELLE', 'ZISTERNE', 'OFFENES_GEWAESSER', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "HydrantStatus" AS ENUM ('EINSATZBEREIT', 'EINGESCHRAENKT', 'DEFEKT', 'UNBEKANNT'); + +-- CreateEnum +CREATE TYPE "ObjektKategorie" AS ENUM ('SCHULE', 'KINDERGARTEN', 'PFLEGEHEIM', 'KRANKENHAUS', 'VERSAMMLUNGSSTAETTE', 'HOCHHAUS', 'INDUSTRIE', 'LANDWIRTSCHAFT', 'GEFAHRSTOFFLAGER', 'SONDERBAU', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "PlanArt" AS ENUM ('UEBERSICHTSPLAN', 'GESCHOSSPLAN', 'UMGEBUNGSPLAN', 'SONDERPLAN', 'ANLAGE'); + +-- CreateEnum +CREATE TYPE "LageelementArt" AS ENUM ('ZEICHEN', 'LINIE', 'FLAECHE', 'TEXT'); + +-- CreateEnum +CREATE TYPE "FoerderpunktArt" AS ENUM ('ENTNAHME', 'VERSTAERKER', 'WEGPUNKT', 'ZIEL'); + +-- AlterTable +ALTER TABLE "standort" ADD COLUMN "breitengrad" DECIMAL(9,6), +ADD COLUMN "laengengrad" DECIMAL(9,6); + +-- CreateTable +CREATE TABLE "hydrant" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "bezeichnung" VARCHAR(120), + "typ" "HydrantTyp" NOT NULL DEFAULT 'UNTERFLUR', + "status" "HydrantStatus" NOT NULL DEFAULT 'UNBEKANNT', + "nennweite" INTEGER, + "durchfluss" INTEGER, + "strasse" VARCHAR(200), + "hausnummer" VARCHAR(20), + "ortsteil" VARCHAR(120), + "breitengrad" DECIMAL(9,6) NOT NULL, + "laengengrad" DECIMAL(9,6) NOT NULL, + "koordinaten" geography(Point, 4326), + "letztePruefung" DATE, + "naechstePruefung" DATE, + "betreiber" VARCHAR(200), + "hinweis" VARCHAR(1000), + "fotoDateiId" UUID, + "quelle" VARCHAR(40), + "externeNummer" VARCHAR(80), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "hydrant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "objekt" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "name" VARCHAR(200) NOT NULL, + "kategorie" "ObjektKategorie" NOT NULL DEFAULT 'SONSTIGES', + "strasse" VARCHAR(200), + "hausnummer" VARCHAR(20), + "plz" VARCHAR(10), + "ort" VARCHAR(120), + "breitengrad" DECIMAL(9,6), + "laengengrad" DECIMAL(9,6), + "koordinaten" geography(Point, 4326), + "zufahrt" VARCHAR(2000), + "gefahren" VARCHAR(2000), + "schluesseldepot" VARCHAR(500), + "loeschwasser" VARCHAR(1000), + "bemerkung" VARCHAR(2000), + "personenzahl" INTEGER, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "objekt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "objektkontakt" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "objektId" UUID NOT NULL, + "name" VARCHAR(200) NOT NULL, + "funktion" VARCHAR(120), + "telefon" VARCHAR(60), + "mobil" VARCHAR(60), + "jederzeit" BOOLEAN NOT NULL DEFAULT false, + "bemerkung" VARCHAR(500), + "reihenfolge" INTEGER NOT NULL DEFAULT 0, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "objektkontakt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "objektplan" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "objektId" UUID NOT NULL, + "dateiId" UUID NOT NULL, + "art" "PlanArt" NOT NULL DEFAULT 'UEBERSICHTSPLAN', + "bezeichnung" VARCHAR(200) NOT NULL, + "standAm" DATE, + "reihenfolge" INTEGER NOT NULL DEFAULT 0, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "objektplan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lagekarte" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatzId" UUID NOT NULL, + "breitengrad" DECIMAL(9,6), + "laengengrad" DECIMAL(9,6), + "zoomstufe" INTEGER, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "lagekarte_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lageelement" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "lagekarteId" UUID NOT NULL, + "art" "LageelementArt" NOT NULL DEFAULT 'ZEICHEN', + "zeichen" VARCHAR(60), + "text" VARCHAR(300), + "farbe" VARCHAR(20), + "geometrie" JSONB NOT NULL, + "drehung" INTEGER NOT NULL DEFAULT 0, + "erstelltVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + "geloeschtAm" TIMESTAMPTZ(3), + + CONSTRAINT "lageelement_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "wasserfoerderung" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "einsatzId" UUID, + "bezeichnung" VARCHAR(200) NOT NULL, + "durchmesser" INTEGER NOT NULL DEFAULT 75, + "foerdermenge" INTEGER NOT NULL DEFAULT 800, + "ausgangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 8, + "eingangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 1.5, + "bemerkung" VARCHAR(2000), + "geplantVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "wasserfoerderung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "foerderpunkt" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "wasserfoerderungId" UUID NOT NULL, + "reihenfolge" INTEGER NOT NULL, + "art" "FoerderpunktArt" NOT NULL DEFAULT 'WEGPUNKT', + "bezeichnung" VARCHAR(200), + "breitengrad" DECIMAL(9,6) NOT NULL, + "laengengrad" DECIMAL(9,6) NOT NULL, + "hoehe" DECIMAL(7,2), + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "foerderpunkt_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "hydrant_organisationId_status_idx" ON "hydrant"("organisationId", "status"); + +-- CreateIndex +CREATE INDEX "hydrant_organisationId_typ_idx" ON "hydrant"("organisationId", "typ"); + +-- CreateIndex +CREATE UNIQUE INDEX "hydrant_organisationId_externeNummer_key" ON "hydrant"("organisationId", "externeNummer"); + +-- CreateIndex +CREATE INDEX "objekt_organisationId_kategorie_idx" ON "objekt"("organisationId", "kategorie"); + +-- CreateIndex +CREATE INDEX "objekt_organisationId_name_idx" ON "objekt"("organisationId", "name"); + +-- CreateIndex +CREATE INDEX "objektkontakt_organisationId_objektId_idx" ON "objektkontakt"("organisationId", "objektId"); + +-- CreateIndex +CREATE INDEX "objektplan_organisationId_objektId_idx" ON "objektplan"("organisationId", "objektId"); + +-- CreateIndex +CREATE UNIQUE INDEX "lagekarte_einsatzId_key" ON "lagekarte"("einsatzId"); + +-- CreateIndex +CREATE INDEX "lageelement_organisationId_lagekarteId_geloeschtAm_idx" ON "lageelement"("organisationId", "lagekarteId", "geloeschtAm"); + +-- CreateIndex +CREATE INDEX "wasserfoerderung_organisationId_einsatzId_idx" ON "wasserfoerderung"("organisationId", "einsatzId"); + +-- CreateIndex +CREATE INDEX "foerderpunkt_organisationId_wasserfoerderungId_idx" ON "foerderpunkt"("organisationId", "wasserfoerderungId"); + +-- CreateIndex +CREATE UNIQUE INDEX "foerderpunkt_wasserfoerderungId_reihenfolge_key" ON "foerderpunkt"("wasserfoerderungId", "reihenfolge"); + +-- AddForeignKey +ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "objekt" ADD CONSTRAINT "objekt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_dateiId_fkey" FOREIGN KEY ("dateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_lagekarteId_fkey" FOREIGN KEY ("lagekarteId") REFERENCES "lagekarte"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_geplantVonId_fkey" FOREIGN KEY ("geplantVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_wasserfoerderungId_fkey" FOREIGN KEY ("wasserfoerderungId") REFERENCES "wasserfoerderung"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260903214700_karten_rls/migration.sql b/prisma/migrations/20260903214700_karten_rls/migration.sql new file mode 100644 index 0000000..254b6ca --- /dev/null +++ b/prisma/migrations/20260903214700_karten_rls/migration.sql @@ -0,0 +1,174 @@ +-- ============================================================================= +-- Phase 6: Mandantentrennung, Koordinaten und die Indizes für die Karte. +-- +-- Vier Dinge: +-- +-- 1. Row Level Security für die acht neuen Tabellen. +-- +-- 2. Koordinaten doppelt halten. Prisma kann den PostGIS-Typ nicht lesen, +-- also stehen Dezimalgrade daneben — und ein Trigger sorgt dafür, dass +-- beide nie auseinanderlaufen. Dasselbe Muster wie beim Einsatz aus +-- Phase 3, jetzt auch für Standort, Hydrant, Objekt und Foerderpunkt. +-- +-- 3. GiST-Indizes auf den Punkten. Ohne sie ist die Umkreissuche ein +-- vollstaendiger Durchlauf der Tabelle — bei tausend Hydranten im +-- Ausrueckegebiet auf der Anfahrt zu langsam. +-- +-- 4. Volltextsuche ueber Objekte: der Name allein genuegt nicht, gesucht +-- wird auch nach Strasse, Gefahren und Ansprechpartner. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Row Level Security +-- ----------------------------------------------------------------------------- +DO $$ +DECLARE + tabelle text; +BEGIN + FOREACH tabelle IN ARRAY ARRAY[ + 'hydrant', + 'objekt', + 'objektkontakt', + 'objektplan', + 'lagekarte', + 'lageelement', + 'wasserfoerderung', + 'foerderpunkt' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle); + EXECUTE format( + 'CREATE POLICY %I ON %I FOR ALL ' + 'USING ("organisationId" = aktuelle_organisation()) ' + 'WITH CHECK ("organisationId" = aktuelle_organisation())', + tabelle || '_mandant', tabelle + ); + END LOOP; +END; +$$; + +-- ----------------------------------------------------------------------------- +-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten +-- +-- Eine Funktion fuer alle Tabellen: sie erwartet die Spalten "breitengrad", +-- "laengengrad" und "koordinaten" und laesst sich damit an jede Tabelle +-- haengen, die einen Ort hat. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION koordinaten_setzen() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + breite double precision; + laenge double precision; +BEGIN + breite := (to_jsonb(NEW) ->> 'breitengrad')::double precision; + laenge := (to_jsonb(NEW) ->> 'laengengrad')::double precision; + + IF breite IS NULL OR laenge IS NULL THEN + NEW := jsonb_populate_record(NEW, jsonb_build_object('koordinaten', NULL)); + RETURN NEW; + END IF; + + -- Reihenfolge beachten: ST_MakePoint nimmt erst die Laenge, dann die Breite. + NEW := jsonb_populate_record( + NEW, + jsonb_build_object( + 'koordinaten', + ST_AsEWKT(ST_SetSRID(ST_MakePoint(laenge, breite), 4326)::geography) + ) + ); + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION koordinaten_setzen() IS + 'Haelt den PostGIS-Punkt einer Tabelle mit ihren Dezimalgraden im Gleichlauf.'; + +CREATE TRIGGER standort_koordinaten + BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "standort" + FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen(); + +CREATE TRIGGER hydrant_koordinaten + BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "hydrant" + FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen(); + +CREATE TRIGGER objekt_koordinaten + BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "objekt" + FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen(); + +-- ----------------------------------------------------------------------------- +-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler +-- ----------------------------------------------------------------------------- +ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_koordinaten_gueltig" CHECK ( + "breitengrad" >= -90 AND "breitengrad" <= 90 + AND "laengengrad" >= -180 AND "laengengrad" <= 180 +); + +ALTER TABLE "objekt" ADD CONSTRAINT "objekt_koordinaten_gueltig" CHECK ( + ("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90)) + AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180)) +); + +ALTER TABLE "standort" ADD CONSTRAINT "standort_koordinaten_gueltig" CHECK ( + ("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90)) + AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180)) +); + +ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_koordinaten_gueltig" CHECK ( + "breitengrad" >= -90 AND "breitengrad" <= 90 + AND "laengengrad" >= -180 AND "laengengrad" <= 180 +); + +-- Eine Foerderstrecke ohne Wasser oder mit einem Schlauch, den es nicht gibt. +ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_sinnvoll" CHECK ( + "durchmesser" IN (52, 75, 110) + AND "foerdermenge" > 0 AND "foerdermenge" <= 10000 + AND "ausgangsdruck" > 0 AND "ausgangsdruck" <= 16 + AND "eingangsdruck" >= 0 AND "eingangsdruck" < "ausgangsdruck" +); + +-- ----------------------------------------------------------------------------- +-- Indizes fuer die Umkreissuche +-- +-- GiST auf der Geografie: damit beantwortet Postgres "was liegt im Umkreis von +-- 300 Metern" ueber den Index statt ueber die ganze Tabelle. +-- ----------------------------------------------------------------------------- +CREATE INDEX "hydrant_umkreis" ON "hydrant" USING GIST ("koordinaten") + WHERE "geloeschtAm" IS NULL; + +CREATE INDEX "objekt_umkreis" ON "objekt" USING GIST ("koordinaten") + WHERE "geloeschtAm" IS NULL; + +-- ----------------------------------------------------------------------------- +-- Volltextsuche ueber Objekte +-- +-- Trigram wie bei den Geraeten aus Phase 1: gesucht wird mit Tippfehlern und +-- Wortfragmenten, nicht mit vollstaendigen Woertern. +-- ----------------------------------------------------------------------------- +CREATE INDEX "objekt_suche_name" ON "objekt" USING GIN ("name" gin_trgm_ops); +CREATE INDEX "objekt_suche_strasse" ON "objekt" USING GIN ("strasse" gin_trgm_ops); +CREATE INDEX "objekt_suche_gefahren" ON "objekt" USING GIN ("gefahren" gin_trgm_ops); +CREATE INDEX "hydrant_suche_bezeichnung" ON "hydrant" USING GIN ("bezeichnung" gin_trgm_ops); +CREATE INDEX "hydrant_suche_strasse" ON "hydrant" USING GIN ("strasse" gin_trgm_ops); + +-- ----------------------------------------------------------------------------- +-- 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'; + 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/schema.prisma b/prisma/schema.prisma index 1399274..0e6209b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,6 +208,16 @@ model Organisation { g26Untersuchungen G26Untersuchung[] belastungsuebungen Belastungsuebung[] + // Phase 6 — Kartenmodule + hydranten Hydrant[] + objekte Objekt[] + objektkontakte Objektkontakt[] + objektplaene Objektplan[] + lagekarten Lagekarte[] + lageelemente Lageelement[] + wasserfoerderungen Wasserfoerderung[] + foerderpunkte Foerderpunkt[] + @@index([aktiv]) @@map("organisation") } @@ -310,6 +320,10 @@ model Person { g26Untersuchungen G26Untersuchung[] @relation("G26Person") belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson") + // Phase 6 — Kartenmodule + gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner") + geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner") + /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@ -548,6 +562,10 @@ model Datei { alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll") alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis") + // Phase 6 + alsHydrantenfoto Hydrant[] @relation("HydrantFoto") + alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei") + @@unique([bucket, objektSchluessel]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, kategorie]) @@ -646,9 +664,12 @@ model Standort { plz String? @db.VarChar(10) ort String? @db.VarChar(120) - /// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht, - /// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage. - /// Ab Phase 6 zeigt die Karte diese Punkte an. + /// Koordinaten doppelt: als Dezimalgrad für die Anwendung und als + /// PostGIS-Punkt für die Karte. Prisma kann den PostGIS-Typ nicht lesen — + /// ohne die Grade daneben wäre jede Anzeige eine Rohabfrage. Ein Trigger + /// hält beides im Gleichlauf (Phase 6). + breitengrad Decimal? @db.Decimal(9, 6) + laengengrad Decimal? @db.Decimal(9, 6) koordinaten Unsupported("geography(Point, 4326)")? bemerkung String? @db.VarChar(1000) @@ -1680,6 +1701,10 @@ model Einsatz { // Phase 5 atemschutz Atemschutzeinsatz[] + // Phase 6 + lagekarte Lagekarte? + wasserfoerderungen Wasserfoerderung[] + @@unique([organisationId, einsatznummer]) @@index([organisationId, alarmiertUm(sort: Desc)]) @@index([organisationId, status]) @@ -2573,3 +2598,369 @@ model Belastungsuebung { @@index([organisationId, personId, datum(sort: Desc)]) @@map("belastungsuebung") } + +// ============================================================================= +// Phase 6 — Kartenmodule +// +// Alles, was einen Ort hat: Hydranten, Objekte mit Feuerwehrplänen, die +// Lagekarte eines Einsatzes und die geplante Wasserförderstrecke. +// +// Koordinaten liegen überall doppelt — als Dezimalgrad und als PostGIS-Punkt. +// Der Grund ist derselbe wie beim Einsatz aus Phase 3: Prisma kann den +// PostGIS-Typ nicht lesen, und ohne die Grade daneben wäre jede Anzeige eine +// Rohabfrage. Ein Trigger hält beides zusammen; die Umkreissuche läuft über +// den PostGIS-Punkt, weil nur der einen Index hat, der Entfernungen kann. +// ============================================================================= + +/// Woher das Löschwasser kommt. +enum HydrantTyp { + /// Der Regelfall im Ort: Deckel auf der Straße, Standrohr drauf. + UNTERFLUR + /// Sichtbare Säule. Im Winter schneller zu finden. + UEBERFLUR + /// Vorbereitete Entnahmestelle an einem Gewässer. + SAUGSTELLE + /// Löschwasserbehälter, meist unterirdisch. + ZISTERNE + /// Bach, Weiher, Fluss — mit allem, was daran hängt (Zufahrt, Jahreszeit). + OFFENES_GEWAESSER + SONSTIGES +} + +/// Ob man sich darauf verlassen kann. +enum HydrantStatus { + EINSATZBEREIT + /// Nutzbar, aber mit Einschränkung — geringe Leistung, schwer zugänglich. + EINGESCHRAENKT + DEFEKT + /// Noch nie geprüft oder aus einem Import ohne Angabe übernommen. + UNBEKANNT +} + +/// Grobe Einordnung eines Objekts. Bestimmt, was im Einsatz zuerst zählt. +enum ObjektKategorie { + SCHULE + KINDERGARTEN + PFLEGEHEIM + KRANKENHAUS + VERSAMMLUNGSSTAETTE + HOCHHAUS + INDUSTRIE + LANDWIRTSCHAFT + GEFAHRSTOFFLAGER + SONDERBAU + SONSTIGES +} + +/// Blattarten eines Feuerwehrplans nach DIN 14095. +enum PlanArt { + UEBERSICHTSPLAN + GESCHOSSPLAN + UMGEBUNGSPLAN + SONDERPLAN + /// Alles, was zusätzlich dazugehört: Betriebsanweisung, Stoffliste. + ANLAGE +} + +/// Was auf der Lagekarte liegt. +enum LageelementArt { + /// Taktisches Zeichen nach FwDV 102, siehe lib/karte/taktische-zeichen.ts. + ZEICHEN + /// Linienzug — Schlauchleitung, Absperrung, Anfahrtsweg. + LINIE + /// Geschlossene Fläche — Bereitstellungsraum, Gefahrenbereich. + FLAECHE + /// Freie Beschriftung. + TEXT +} + +/// Rolle eines Punktes in der Förderstrecke. +enum FoerderpunktArt { + /// Wo das Wasser herkommt. + ENTNAHME + /// Verstärkerpumpe in der Strecke. + VERSTAERKER + /// Zwischenpunkt ohne Pumpe — nur für den Streckenverlauf und die Höhe. + WEGPUNKT + /// Die Einsatzstelle. + ZIEL +} + +/// Ein Hydrant oder eine andere Löschwasserstelle. +/// +/// Die Frage, die dieses Modell beantworten muss, wird auf der Anfahrt +/// gestellt und lautet: **Was liegt in der Nähe, und was gibt es her?** +/// Deshalb stehen Typ, Nennweite und Durchfluss gleichrangig neben dem Ort — +/// ein Unterflurhydrant mit 80er Leitung ist etwas anderes als einer mit 150. +model Hydrant { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + /// Nummer oder Bezeichnung, wie sie die Wehr verwendet. + bezeichnung String? @db.VarChar(120) + typ HydrantTyp @default(UNTERFLUR) + status HydrantStatus @default(UNBEKANNT) + + /// Nennweite der Leitung in Millimetern (80, 100, 150 …). + nennweite Int? + /// Ergiebigkeit in Litern je Minute, aus der Prüfung oder vom Versorger. + durchfluss Int? + + strasse String? @db.VarChar(200) + hausnummer String? @db.VarChar(20) + ortsteil String? @db.VarChar(120) + + breitengrad Decimal @db.Decimal(9, 6) + laengengrad Decimal @db.Decimal(9, 6) + koordinaten Unsupported("geography(Point, 4326)")? + + letztePruefung DateTime? @db.Date + naechstePruefung DateTime? @db.Date + /// Wer die Leitung betreibt — im Zweifel der Ansprechpartner bei Defekt. + betreiber String? @db.VarChar(200) + /// Was man vor Ort wissen muss: „hinter der Hecke", „im Winter geräumt". + hinweis String? @db.VarChar(1000) + + fotoDateiId String? @db.Uuid + + /// Woher der Datensatz stammt: "hand", "geojson", "stadtwerke-csv". + quelle String? @db.VarChar(40) + /// Kennung beim Versorger, damit ein zweiter Import nichts doppelt anlegt. + externeNummer String? @db.VarChar(80) + + 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) + foto Datei? @relation("HydrantFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull) + + @@unique([organisationId, externeNummer]) + @@index([organisationId, status]) + @@index([organisationId, typ]) + @@map("hydrant") +} + +/// Ein Objekt mit Feuerwehrplan. +/// +/// Was hier steht, wird auf der Anfahrt gelesen — oft vom Beifahrersitz aus. +/// Deshalb sind Zufahrt, Gefahren und Schlüsseldepot eigene Felder und keine +/// Fließtextnotiz: im Einsatz sucht niemand in einem Absatz. +model Objekt { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + name String @db.VarChar(200) + kategorie ObjektKategorie @default(SONSTIGES) + + strasse String? @db.VarChar(200) + hausnummer String? @db.VarChar(20) + plz String? @db.VarChar(10) + ort String? @db.VarChar(120) + + breitengrad Decimal? @db.Decimal(9, 6) + laengengrad Decimal? @db.Decimal(9, 6) + koordinaten Unsupported("geography(Point, 4326)")? + + /// Wie man hinkommt: Tordurchfahrt, Höhenbegrenzung, Aufstellfläche. + zufahrt String? @db.VarChar(2000) + /// Was einen erwartet: Photovoltaik, Gasflaschen, Silo, Tiere. + gefahren String? @db.VarChar(2000) + /// Wo der Schlüssel liegt und wie man drankommt. + schluesseldepot String? @db.VarChar(500) + /// Löschwasser am Objekt: Wandhydranten, Sprinklerzentrale, Steigleitung. + loeschwasser String? @db.VarChar(1000) + bemerkung String? @db.VarChar(2000) + + /// Anzahl der Personen, mit denen zu rechnen ist (Schule, Heim, Betrieb). + personenzahl Int? + + 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) + kontakte Objektkontakt[] + plaene Objektplan[] + + @@index([organisationId, kategorie]) + @@index([organisationId, name]) + @@map("objekt") +} + +/// Wen man beim Objekt anruft. +model Objektkontakt { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + objektId String @db.Uuid + + name String @db.VarChar(200) + funktion String? @db.VarChar(120) + telefon String? @db.VarChar(60) + mobil String? @db.VarChar(60) + /// Erreichbar rund um die Uhr? Im Einsatz die einzige Frage, die zählt. + jederzeit Boolean @default(false) + bemerkung String? @db.VarChar(500) + + reihenfolge Int @default(0) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade) + + @@index([organisationId, objektId]) + @@map("objektkontakt") +} + +/// Ein Blatt des Feuerwehrplans nach DIN 14095. +model Objektplan { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + objektId String @db.Uuid + dateiId String @db.Uuid + + art PlanArt @default(UEBERSICHTSPLAN) + bezeichnung String @db.VarChar(200) + /// Stand des Plans — ein Feuerwehrplan von 2009 ist eine Vermutung. + standAm DateTime? @db.Date + reihenfolge Int @default(0) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade) + datei Datei @relation("ObjektplanDatei", fields: [dateiId], references: [id], onDelete: Restrict) + + @@index([organisationId, objektId]) + @@map("objektplan") +} + +/// Die Lagekarte eines Einsatzes. +/// +/// Eine je Einsatz. Sie ist der gemeinsame Zeichenblock: was einer einträgt, +/// sehen die anderen — verteilt über denselben Ereignisstrom wie die +/// Atemschutzüberwachung aus Phase 5. +model Lagekarte { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + einsatzId String @unique @db.Uuid + + /// Kartenausschnitt, damit alle denselben Blick haben. + breitengrad Decimal? @db.Decimal(9, 6) + laengengrad Decimal? @db.Decimal(9, 6) + zoomstufe Int? + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade) + elemente Lageelement[] + + @@map("lagekarte") +} + +/// Ein Element auf der Lagekarte. +/// +/// Die Geometrie steht als JSON: ein Punkt für ein taktisches Zeichen, eine +/// Liste von Punkten für Linie und Fläche. PostGIS wäre hier zu viel — +/// gerechnet wird damit nichts, es wird nur gezeichnet. +/// +/// Gelöscht wird weich: eine Lagekarte ist die Mitschrift einer Entscheidung, +/// und was jemand um 14:12 eingezeichnet hat, gehört auch dann noch dazu, wenn +/// es um 14:20 nicht mehr stimmte. +model Lageelement { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + lagekarteId String @db.Uuid + + art LageelementArt @default(ZEICHEN) + /// Schlüssel aus dem Zeichensatz, etwa "loeschfahrzeug" oder "brandstelle". + zeichen String? @db.VarChar(60) + text String? @db.VarChar(300) + farbe String? @db.VarChar(20) + + /// [[laenge, breite], …] in WGS 84. + geometrie Json @db.JsonB + /// Drehung eines Zeichens in Grad — ein Fahrzeug steht selten nach Norden. + drehung Int @default(0) + + erstelltVonId 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) + lagekarte Lagekarte @relation(fields: [lagekarteId], references: [id], onDelete: Cascade) + erstelltVon Person? @relation("LageelementZeichner", fields: [erstelltVonId], references: [id], onDelete: SetNull) + + @@index([organisationId, lagekarteId, geloeschtAm]) + @@map("lageelement") +} + +/// Eine geplante Wasserförderstrecke. +/// +/// Die Rechnung dahinter ist Physik mit klaren Formeln (siehe +/// lib/karte/wasserfoerderung.ts) — deshalb steht hier nur, woraus gerechnet +/// wurde. Das Ergebnis wird nicht gespeichert, sondern bei jeder Anzeige neu +/// gerechnet: eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, +/// ohne falsch auszusehen. +model Wasserfoerderung { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + einsatzId String? @db.Uuid + + bezeichnung String @db.VarChar(200) + + /// Schlauchdurchmesser in Millimetern: 75 (B) oder 110 (A). + durchmesser Int @default(75) + /// Geplante Fördermenge in Litern je Minute. + foerdermenge Int @default(800) + /// Ausgangsdruck der ersten Pumpe in bar. + ausgangsdruck Decimal @default(8) @db.Decimal(4, 1) + /// Mindestdruck am Eingang jeder weiteren Pumpe in bar. + eingangsdruck Decimal @default(1.5) @db.Decimal(4, 1) + + bemerkung String? @db.VarChar(2000) + + geplantVonId 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) + geplantVon Person? @relation("WasserfoerderungPlaner", fields: [geplantVonId], references: [id], onDelete: SetNull) + punkte Foerderpunkt[] + + @@index([organisationId, einsatzId]) + @@map("wasserfoerderung") +} + +/// Ein Punkt der Förderstrecke. +model Foerderpunkt { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + wasserfoerderungId String @db.Uuid + + reihenfolge Int + art FoerderpunktArt @default(WEGPUNKT) + bezeichnung String? @db.VarChar(200) + + breitengrad Decimal @db.Decimal(9, 6) + laengengrad Decimal @db.Decimal(9, 6) + /// Höhe über Normalnull in Metern. Der Höhenunterschied kostet Druck: + /// zehn Meter Steigung sind ein bar. + hoehe Decimal? @db.Decimal(7, 2) + + bemerkung String? @db.VarChar(500) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + wasserfoerderung Wasserfoerderung @relation(fields: [wasserfoerderungId], references: [id], onDelete: Cascade) + + @@unique([wasserfoerderungId, reihenfolge]) + @@index([organisationId, wasserfoerderungId]) + @@map("foerderpunkt") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index ed772a8..7d2f771 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -13,7 +13,9 @@ * Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht * 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. + * Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für + * die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit + * Feuerwehrplan. * * 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 @@ -52,6 +54,9 @@ import { QualifikationsArt, RolleSchluessel, SignaturRolle, + HydrantStatus, + HydrantTyp, + ObjektKategorie, StandortArt, TruppStatus, UebungsArt, @@ -2479,6 +2484,276 @@ async function atemschutzAnlegen(organisationId: string) { ); } +// ----------------------------------------------------------------------------- +// Phase 6 — Kartenmodule +// ----------------------------------------------------------------------------- + +/** Der Ortskern von Musterhausen — frei erfunden, aber in Niederbayern plausibel. */ +const ORTSMITTE = { breitengrad: 48.5, laengengrad: 12.15 }; + +/** + * Löschwasser und Objekte. + * + * Die Hydranten liegen in einem Raster um den Ortskern, mit den Zuständen, die + * eine Wehr tatsächlich vorfindet: die meisten in Ordnung, einige ungeprüft, + * zwei defekt. Dazu ein Weiher und eine Zisterne am Ortsrand — im Außenbereich + * gibt es keine Leitung. + * + * Die drei Objekte sind die, für die eine Wehr zuerst einen Plan schreibt: + * Schule, Pflegeheim, Aussiedlerhof. + */ +async function kartendatenAnlegen(organisationId: string) { + const vorhanden = await prisma.hydrant.count({ where: { organisationId } }); + if (vorhanden > 0) { + log(`Karte: bereits ${vorhanden} Löschwasserstellen vorhanden`); + return; + } + + // --- Standorte auf die Karte holen --------------------------------------- + // + // Ohne Koordinaten am Gerätehaus wüsste die Karte nicht, wohin sie schauen + // soll. Die Standorte gibt es seit Phase 1, die Dezimalgrade seit Phase 6. + const standorte = await prisma.standort.findMany({ + where: { organisationId, geloeschtAm: null }, + select: { id: true }, + orderBy: { erstelltAm: "asc" }, + }); + + for (const [index, standort] of standorte.entries()) { + await prisma.standort.update({ + where: { id: standort.id }, + data: { + breitengrad: ORTSMITTE.breitengrad + index * 0.004, + laengengrad: ORTSMITTE.laengengrad + index * 0.006, + }, + }); + } + + // --- Löschwasserstellen --------------------------------------------------- + const STRASSEN = [ + "Hauptstraße", + "Bahnhofstraße", + "Schulstraße", + "Kirchplatz", + "Am Anger", + "Industriestraße", + ]; + + const hydranten: Array<{ + bezeichnung: string; + typ: HydrantTyp; + status: HydrantStatus; + nennweite: number | null; + durchfluss: number | null; + strasse: string; + breitengrad: number; + laengengrad: number; + letztePruefung: Date | null; + hinweis: string | null; + }> = []; + + const heute = new Date(); + + function vorTagen(tage: number): Date { + const datum = new Date(heute); + datum.setUTCDate(datum.getUTCDate() - tage); + datum.setUTCHours(0, 0, 0, 0); + return datum; + } + + for (let nummer = 1; nummer <= 20; nummer += 1) { + const strasse = STRASSEN[nummer % STRASSEN.length]!; + // Ein Raster von rund 120 Metern Kantenlänge um die Ortsmitte. + const reihe = Math.floor((nummer - 1) / 5); + const spalte = (nummer - 1) % 5; + + const ungeprueft = nummer % 7 === 0; + const defekt = nummer === 9 || nummer === 17; + + hydranten.push({ + bezeichnung: `H-${String(nummer).padStart(4, "0")}`, + typ: nummer % 6 === 0 ? HydrantTyp.UEBERFLUR : HydrantTyp.UNTERFLUR, + status: defekt + ? HydrantStatus.DEFEKT + : ungeprueft + ? HydrantStatus.UNBEKANNT + : HydrantStatus.EINSATZBEREIT, + nennweite: nummer % 4 === 0 ? 150 : nummer % 3 === 0 ? 80 : 100, + durchfluss: nummer % 4 === 0 ? 1600 : nummer % 3 === 0 ? 400 : 800, + strasse, + breitengrad: ORTSMITTE.breitengrad + reihe * 0.0011 - 0.002, + laengengrad: ORTSMITTE.laengengrad + spalte * 0.0016 - 0.003, + letztePruefung: ungeprueft ? null : vorTagen(60 + nummer * 9), + hinweis: defekt + ? "Deckel verzogen, Standrohr passt nicht. Stadtwerke gemeldet." + : nummer === 3 + ? "Im Winter oft zugeschoben — Schneepflug fährt hier durch." + : null, + }); + } + + // Im Außenbereich gibt es keine Leitung. + hydranten.push( + { + bezeichnung: "Weiher Kiesgrube", + typ: HydrantTyp.OFFENES_GEWAESSER, + status: HydrantStatus.EINSATZBEREIT, + nennweite: null, + durchfluss: 2400, + strasse: "Kiesgrubenweg", + breitengrad: ORTSMITTE.breitengrad - 0.008, + laengengrad: ORTSMITTE.laengengrad + 0.012, + letztePruefung: vorTagen(200), + hinweis: "Zufahrt für LF geeignet, im Winter vereist. Saughöhe rund 3 m.", + }, + { + bezeichnung: "Zisterne Aussiedlerhof", + typ: HydrantTyp.ZISTERNE, + status: HydrantStatus.EINSATZBEREIT, + nennweite: null, + durchfluss: 800, + strasse: "Hofweg 2", + breitengrad: ORTSMITTE.breitengrad + 0.011, + laengengrad: ORTSMITTE.laengengrad + 0.018, + letztePruefung: vorTagen(120), + hinweis: "50 m³, Saugstutzen an der Nordseite der Scheune.", + }, + { + bezeichnung: "Saugstelle Mühlbach", + typ: HydrantTyp.SAUGSTELLE, + status: HydrantStatus.EINGESCHRAENKT, + nennweite: null, + durchfluss: 600, + strasse: "Mühlweg", + breitengrad: ORTSMITTE.breitengrad - 0.004, + laengengrad: ORTSMITTE.laengengrad - 0.009, + letztePruefung: vorTagen(400), + hinweis: "Im Sommer zu wenig Wasser. Nur nach Regen brauchbar.", + }, + { + bezeichnung: "H-0021", + typ: HydrantTyp.UEBERFLUR, + status: HydrantStatus.EINSATZBEREIT, + nennweite: 150, + durchfluss: 1600, + strasse: "Industriestraße", + breitengrad: ORTSMITTE.breitengrad + 0.006, + laengengrad: ORTSMITTE.laengengrad + 0.008, + letztePruefung: vorTagen(45), + hinweis: "Am Werkstor, ergiebigster Hydrant im Ort.", + }, + ); + + for (const hydrant of hydranten) { + await prisma.hydrant.create({ + data: { + organisationId, + ...hydrant, + betreiber: "Stadtwerke Musterhausen", + quelle: "seed", + externeNummer: `SW-${hydrant.bezeichnung}`, + }, + }); + } + + // --- Objekte mit Feuerwehrplan ------------------------------------------- + const OBJEKTE: Array<{ + name: string; + kategorie: ObjektKategorie; + strasse: string; + hausnummer: string; + breitengrad: number; + laengengrad: number; + personenzahl: number; + zufahrt: string; + gefahren: string; + schluesseldepot: string; + loeschwasser: string; + kontakte: Array<{ name: string; funktion: string; telefon: string; jederzeit: boolean }>; + }> = [ + { + name: "Grundschule Musterhausen", + kategorie: ObjektKategorie.SCHULE, + strasse: "Schulstraße", + hausnummer: "4", + breitengrad: ORTSMITTE.breitengrad + 0.0015, + laengengrad: ORTSMITTE.laengengrad + 0.0022, + personenzahl: 180, + zufahrt: + "Aufstellfläche auf dem Pausenhof, Zufahrt über die Schulstraße. Poller mit Feuerwehrschlüssel, Durchfahrt 3,10 m.", + gefahren: + "Photovoltaik auf dem Süddach (Gleichstrom bis zum Wechselrichter im Keller). Chemieraum im 1. OG Nordseite.", + schluesseldepot: "FSD rechts neben dem Haupteingang, Schlüssel für alle Türen.", + loeschwasser: "Wandhydranten in jedem Geschoss, Steigleitung trocken im Treppenhaus B.", + kontakte: [ + { name: "Ursula Berger", funktion: "Schulleitung", telefon: "08721 4711", jederzeit: false }, + { name: "Karl Steininger", funktion: "Hausmeister", telefon: "0170 1234567", jederzeit: true }, + ], + }, + { + name: "Seniorenheim St. Anna", + kategorie: ObjektKategorie.PFLEGEHEIM, + strasse: "Am Anger", + hausnummer: "11", + breitengrad: ORTSMITTE.breitengrad - 0.0018, + laengengrad: ORTSMITTE.laengengrad + 0.0009, + personenzahl: 84, + zufahrt: + "Anleiterbare Seite nur nach Norden. Aufstellfläche für DL im Innenhof, Einfahrt an der Ostseite.", + gefahren: + "62 Bewohner, davon 28 bettlägerig. Sauerstoffflaschen in den Zimmern 12, 14 und 31. Sammelplatz auf dem Parkplatz an der Ostseite.", + schluesseldepot: "FSD links vom Haupteingang, dazu Objektschlüssel in der Pforte.", + loeschwasser: "Wandhydranten je Geschoss, nächster Überflurhydrant 40 m westlich.", + kontakte: [ + { name: "Pflegedienstleitung", funktion: "Nachtdienst", telefon: "08721 5522", jederzeit: true }, + { name: "Hermann Falk", funktion: "Heimleitung", telefon: "0171 9876543", jederzeit: false }, + ], + }, + { + name: "Aussiedlerhof Huber", + kategorie: ObjektKategorie.LANDWIRTSCHAFT, + strasse: "Hofweg", + hausnummer: "2", + breitengrad: ORTSMITTE.breitengrad + 0.0108, + laengengrad: ORTSMITTE.laengengrad + 0.0178, + personenzahl: 6, + zufahrt: + "Feldweg ab der Kreisstraße, 600 m unbefestigt. Für TLF befahrbar, bei Nässe nur mit Allrad.", + gefahren: + "Silo mit Gärgasen, Biogasanlage 80 m nordöstlich. 60 Rinder im Stall, Notausgang Südseite. PV auf allen Dächern.", + schluesseldepot: "Kein FSD. Familie wohnt auf dem Hof, Schlüssel beim Betriebsleiter.", + loeschwasser: "Zisterne 50 m³ an der Scheune, sonst kein Löschwasser im Umkreis von 1,5 km.", + kontakte: [ + { name: "Josef Huber", funktion: "Betriebsleiter", telefon: "0175 3344556", jederzeit: true }, + ], + }, + ]; + + for (const vorlage of OBJEKTE) { + const { kontakte, ...felder } = vorlage; + + await prisma.objekt.create({ + data: { + organisationId, + ...felder, + plz: "84359", + ort: "Musterhausen", + kontakte: { + create: kontakte.map((kontakt, index) => ({ + organisationId, + ...kontakt, + reihenfolge: index, + })), + }, + }, + }); + } + + log( + `Karte: ${hydranten.length} Löschwasserstellen, ${OBJEKTE.length} Objekte mit Ansprechpartnern, ${standorte.length} Standorte verortet`, + ); +} + // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- @@ -2516,6 +2791,9 @@ async function main(): Promise { // Phase 5 — Atemschutz nach FwDV 7 await atemschutzAnlegen(organisation.id); + // Phase 6 — Kartenmodule + await kartendatenAnlegen(organisation.id); + log("Fertig."); log(""); log("Anmeldung für den Erststart:"); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index b41bde7..53637b2 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -184,6 +184,26 @@ const AUFNAHMEN: Aufnahme[] = [ beschreibung: "G26.3 und Belastungsübungen erfassen", angemeldet: true, }, + + // Phase 6 — Kartenmodule + { + name: "34-karte", + pfad: "/karte", + beschreibung: "Karte mit Löschwasser, Objekten und Umkreissuche", + angemeldet: true, + }, + { + name: "35-objekte", + pfad: "/karte/objekte", + beschreibung: "Objekte mit Feuerwehrplan", + angemeldet: true, + }, + { + name: "36-wasserfoerderung", + pfad: "/karte/wasserfoerderung", + beschreibung: "Wasserförderung über lange Wegstrecke", + angemeldet: true, + }, ]; /** diff --git a/src/app/(app)/einsaetze/[id]/lagekarte.tsx b/src/app/(app)/einsaetze/[id]/lagekarte.tsx new file mode 100644 index 0000000..60b0649 --- /dev/null +++ b/src/app/(app)/einsaetze/[id]/lagekarte.tsx @@ -0,0 +1,387 @@ +"use client"; + +import * as React from "react"; +import { Radio, Undo2, WifiOff } from "lucide-react"; +import { LageelementArt } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; +import { Karte, type KartenLinie, type KartenPunkt } from "@/components/karte/karte"; +import { useEreignisstrom } from "@/components/atemschutz/stand"; +import { GRUPPE_TEXT, ZEICHEN, alsSvg, type Zeichengruppe } from "@/lib/karte/taktische-zeichen"; + +/** + * Die Lagekarte eines Einsatzes. + * + * Der gemeinsame Zeichenblock: Zeichen setzen, Linien ziehen, beschriften — + * und alle sehen dasselbe. Verteilt über denselben Ereignisstrom wie die + * Atemschutzüberwachung aus Phase 5, mit demselben Hook. + * + * Drei Entscheidungen, die sich im Einsatz bewähren müssen: + * + * 1. **Ein Werkzeug, ein Klick.** Zeichen wählen, in die Karte tippen, + * fertig. Kein Dialog dazwischen — wer im Einsatz zeichnet, hat eine Hand + * am Funkgerät. + * 2. **Zurücknehmen statt löschen.** Was um 14:12 eingezeichnet wurde, + * gehört zur Mitschrift, auch wenn es um 14:20 nicht mehr stimmte. Es + * verschwindet von der Karte, nicht aus dem Datenbestand. + * 3. **Der Verbindungszustand steht sichtbar da.** Wer nicht sieht, dass der + * Draht weg ist, hält eine alte Lage für die aktuelle. + */ + +/** Wie oft ohne Ereignis nachgefragt wird. */ +const NACHFRAGE_MS = 5000; + +type Werkzeug = { art: "zeichen"; schluessel: string } | { art: "linie" } | { art: "text" }; + +export function Lagekarte({ + einsatzId, + stilUrl, + mittelpunkt, + zoom, + darfBearbeiten, +}: { + einsatzId: string; + stilUrl: string | null; + mittelpunkt: { breitengrad: number; laengengrad: number }; + zoom: number; + darfBearbeiten: boolean; +}) { + const [werkzeug, setWerkzeug] = React.useState(null); + const [linienpunkte, setLinienpunkte] = React.useState>([]); + const [beschriftung, setBeschriftung] = React.useState(""); + const [meldung, setMeldung] = React.useState(null); + + const werkzeuge = trpc.useUtils(); + + const karte = trpc.lagekarte.fuerEinsatz.useQuery( + { einsatzId }, + { refetchInterval: NACHFRAGE_MS }, + ); + + const neuLaden = React.useCallback(() => { + void werkzeuge.lagekarte.fuerEinsatz.invalidate({ einsatzId }); + }, [einsatzId, werkzeuge]); + + // Derselbe Hook wie beim Atemschutz — nur eine andere Adresse. + const { verbunden } = useEreignisstrom(einsatzId, neuLaden, "/api/lagekarte/strom?einsatz="); + + const eintragen = trpc.lagekarte.eintragen.useMutation({ + onSuccess: () => { + setLinienpunkte([]); + setBeschriftung(""); + neuLaden(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const zuruecknehmen = trpc.lagekarte.zuruecknehmen.useMutation({ + onSuccess: neuLaden, + onError: (fehler) => setMeldung(fehler.message), + }); + + const elemente = karte.data?.elemente ?? []; + + const punkte = React.useMemo(() => { + const liste: KartenPunkt[] = []; + + for (const element of elemente) { + const geometrie = element.geometrie as Array<[number, number]>; + if (!Array.isArray(geometrie) || geometrie.length === 0) continue; + + if (element.art === LageelementArt.ZEICHEN && element.zeichen) { + liste.push({ + id: element.id, + laengengrad: geometrie[0]![0], + breitengrad: geometrie[0]![1], + svg: alsSvg(element.zeichen, 36, element.farbe ?? undefined), + titel: element.text ?? undefined, + }); + } + + if (element.art === LageelementArt.TEXT) { + liste.push({ + id: element.id, + laengengrad: geometrie[0]![0], + breitengrad: geometrie[0]![1], + farbe: element.farbe ?? "#111827", + titel: element.text ?? undefined, + }); + } + } + + // Punkte einer Linie, die gerade gezogen wird. + linienpunkte.forEach((punkt, index) => { + liste.push({ + id: `entwurf-${index}`, + laengengrad: punkt[0], + breitengrad: punkt[1], + farbe: "#ca8a04", + titel: `Punkt ${index + 1}`, + }); + }); + + return liste; + }, [elemente, linienpunkte]); + + const linien = React.useMemo(() => { + const liste: KartenLinie[] = []; + + for (const element of elemente) { + if (element.art !== LageelementArt.LINIE && element.art !== LageelementArt.FLAECHE) continue; + + const geometrie = element.geometrie as Array<[number, number]>; + if (!Array.isArray(geometrie) || geometrie.length < 2) continue; + + liste.push({ + id: element.id, + punkte: geometrie, + farbe: element.farbe ?? "#b91c1c", + flaeche: element.art === LageelementArt.FLAECHE, + }); + } + + if (linienpunkte.length >= 2) { + liste.push({ id: "entwurf", punkte: linienpunkte, farbe: "#ca8a04", gestrichelt: true }); + } + + return liste; + }, [elemente, linienpunkte]); + + function kartenklick(ort: { breitengrad: number; laengengrad: number }) { + if (!darfBearbeiten || !werkzeug) return; + + if (werkzeug.art === "zeichen") { + eintragen.mutate({ + einsatzId, + art: LageelementArt.ZEICHEN, + zeichen: werkzeug.schluessel, + geometrie: [[ort.laengengrad, ort.breitengrad]], + }); + return; + } + + if (werkzeug.art === "text") { + if (!beschriftung.trim()) { + setMeldung("Erst den Text eintippen, dann in die Karte tippen."); + return; + } + eintragen.mutate({ + einsatzId, + art: LageelementArt.TEXT, + text: beschriftung.trim(), + geometrie: [[ort.laengengrad, ort.breitengrad]], + }); + return; + } + + // Linie: Punkte sammeln, bis jemand abschließt. + setLinienpunkte((bisher) => [...bisher, [ort.laengengrad, ort.breitengrad]]); + } + + const nachGruppe = React.useMemo(() => { + const karten = new Map(); + for (const zeichen of ZEICHEN) { + const liste = karten.get(zeichen.gruppe) ?? []; + liste.push(zeichen); + karten.set(zeichen.gruppe, liste); + } + return karten; + }, []); + + return ( +
+ {meldung && } + +
+

+ {elemente.length === 0 + ? "Noch nichts eingezeichnet." + : `${elemente.length} ${elemente.length === 1 ? "Eintrag" : "Einträge"} — alle Geräte sehen dasselbe.`} +

+ + + {verbunden ? : } + {verbunden ? "Abgleich läuft" : "Abgleich unterbrochen"} + +
+ + {darfBearbeiten && ( +
+ {/* Zeichenauswahl nach Gruppen. */} + {[...nachGruppe.entries()].map(([gruppe, zeichenliste]) => ( +
+

+ {GRUPPE_TEXT[gruppe]} +

+
+ {zeichenliste.map((zeichen) => { + const gewaehlt = + werkzeug?.art === "zeichen" && werkzeug.schluessel === zeichen.schluessel; + + return ( +
+
+ ))} + +
+ + + {werkzeug?.art === "linie" && linienpunkte.length >= 2 && ( + <> + + + + )} + +
+ setBeschriftung(ereignis.target.value)} + placeholder="Beschriftung" + aria-label="Beschriftung" + /> + +
+ + {werkzeug && ( +

+ {werkzeug.art === "linie" + ? "In die Karte tippen setzt Punkte." + : "In die Karte tippen setzt das Gewählte."} +

+ )} +
+
+ )} + + + + {/* Was eingetragen wurde — mit der Möglichkeit, es zurückzunehmen. */} + {elemente.length > 0 && ( +
    + {elemente.map((element) => ( +
  • + + {new Date(element.erstelltAm).toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + })} + + + {element.zeichen ?? element.art.toLowerCase()} + {element.text ? ` — ${element.text}` : ""} + + {element.erstelltVon && ( + + {element.erstelltVon.nachname} + + )} + {darfBearbeiten && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/einsaetze/[id]/page.tsx b/src/app/(app)/einsaetze/[id]/page.tsx index 2dd991c..44e4646 100644 --- a/src/app/(app)/einsaetze/[id]/page.tsx +++ b/src/app/(app)/einsaetze/[id]/page.tsx @@ -4,7 +4,11 @@ import { ArrowLeft } from "lucide-react"; import { aktuelleSitzung } from "@/lib/auth"; import { hatRecht } from "@/lib/authz"; import { Button } from "@/components/ui/button"; +import { env } from "@/lib/env"; +import { fuerOrganisation } from "@/lib/db"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail"; +import { Lagekarte } from "@/app/(app)/einsaetze/[id]/lagekarte"; export const metadata = { title: "Einsatz" }; @@ -21,6 +25,32 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s const { id } = await params; + // Mittelpunkt der Lagekarte: die Einsatzstelle, sonst das Gerätehaus, sonst + // die Vorgabe aus der Konfiguration. + const orte = await fuerOrganisation(sitzung.organisationId, async (db) => { + const [einsatz, standort] = await Promise.all([ + db.einsatz.findUnique({ + where: { id }, + select: { breitengrad: true, laengengrad: true }, + }), + db.standort.findFirst({ + where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } }, + select: { breitengrad: true, laengengrad: true }, + orderBy: { erstelltAm: "asc" }, + }), + ]); + return { einsatz, standort }; + }); + + const mittelpunkt = { + breitengrad: Number( + orte.einsatz?.breitengrad ?? orte.standort?.breitengrad ?? env.KARTE_BREITENGRAD, + ), + laengengrad: Number( + orte.einsatz?.laengengrad ?? orte.standort?.laengengrad ?? env.KARTE_LAENGENGRAD, + ), + }; + return (
@@ -37,6 +67,30 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")} darfFreigeben={hatRecht(sitzung, "einsatz.bericht")} /> + + {/* + Die Lagekarte (Phase 6). Sie steht unter dem Einsatz und nicht in einem + eigenen Reiter: wer sie braucht, braucht sie zusammen mit den Zeiten und + den Lagemeldungen, nicht statt ihnen. + */} + + + Lagekarte + + Taktische Zeichen nach FwDV 102. Was einer einträgt, sehen alle — auch auf einem + zweiten Gerät. + + + + + +
); } diff --git a/src/app/(app)/karte/hydranten/hydranten-pflege.tsx b/src/app/(app)/karte/hydranten/hydranten-pflege.tsx new file mode 100644 index 0000000..2bb263f --- /dev/null +++ b/src/app/(app)/karte/hydranten/hydranten-pflege.tsx @@ -0,0 +1,465 @@ +"use client"; + +import * as React from "react"; +import { Droplets, Plus, Trash2, Upload } from "lucide-react"; +import { HydrantStatus, HydrantTyp } from "@prisma/client"; +import Papa from "papaparse"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige"; +import { Karte } from "@/components/karte/karte"; +import { alsDatum } from "@/lib/utils"; + +/** + * Hydranten pflegen. + * + * Zwei Wege hinein: einzeln über die Karte (antippen, Angaben eintragen) und + * als Liste über den Import. Der Import ist der übliche Fall — die Wehr + * bekommt eine Datei von den Stadtwerken oder vom Landratsamt und will sie + * nicht abtippen. + * + * Wie beim Geräteimport aus Phase 1 in zwei Schritten: erst zeigen, was + * herauskäme, dann übernehmen. Niemand schreibt tausend Datensätze in eine + * Datenbank, ohne vorher gesehen zu haben, was drinsteht. + */ + +const TYP_TEXT: Record = { + UNTERFLUR: "Unterflur", + UEBERFLUR: "Überflur", + SAUGSTELLE: "Saugstelle", + ZISTERNE: "Zisterne", + OFFENES_GEWAESSER: "offenes Gewässer", + SONSTIGES: "sonstige", +}; + +const STATUS_TEXT: Record = { + EINSATZBEREIT: "einsatzbereit", + EINGESCHRAENKT: "eingeschränkt", + DEFEKT: "defekt", + UNBEKANNT: "ungeprüft", +}; + +export function HydrantenPflege({ + stilUrl, + mittelpunkt, + zoom, + darfBearbeiten, +}: { + stilUrl: string | null; + mittelpunkt: { breitengrad: number; laengengrad: number }; + zoom: number; + darfBearbeiten: boolean; +}) { + const [meldung, setMeldung] = React.useState(null); + const [erfolg, setErfolg] = React.useState(null); + const [neuerOrt, setNeuerOrt] = React.useState<{ + breitengrad: number; + laengengrad: number; + } | null>(null); + + const [vorschau, setVorschau] = React.useState<{ + format: "geojson" | "zeilen"; + inhalt?: string; + zeilen?: Array>; + gefunden: number; + uebersprungen: Array<{ zeile: number; grund: string }>; + hinweise: string[]; + beispiele: Array<{ bezeichnung: string | null; typ: string; strasse: string | null }>; + } | null>(null); + + const werkzeuge = trpc.useUtils(); + const hydranten = trpc.hydrant.liste.useQuery({}); + + const anlegen = trpc.hydrant.anlegen.useMutation({ + onSuccess: () => { + setNeuerOrt(null); + setErfolg("Hydrant angelegt."); + void werkzeuge.hydrant.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const entfernen = trpc.hydrant.entfernen.useMutation({ + onSuccess: () => void werkzeuge.hydrant.invalidate(), + onError: (fehler) => setMeldung(fehler.message), + }); + + const importieren = trpc.hydrant.importieren.useMutation({ + onError: (fehler) => setMeldung(fehler.message), + }); + + /** Datei einlesen und beim Server prüfen lassen — noch nichts schreiben. */ + async function dateiWaehlen(datei: File) { + setMeldung(null); + setErfolg(null); + + const text = await datei.text(); + const istGeoJson = datei.name.toLowerCase().endsWith(".geojson") || text.trimStart().startsWith("{"); + + if (istGeoJson) { + const ergebnis = await importieren.mutateAsync({ + format: "geojson", + inhalt: text, + nurPruefen: true, + }); + + setVorschau({ + format: "geojson", + inhalt: text, + gefunden: ergebnis.gefunden, + uebersprungen: ergebnis.uebersprungen, + hinweise: ergebnis.hinweise, + beispiele: ergebnis.vorschau.map((eintrag) => ({ + bezeichnung: eintrag.bezeichnung, + typ: eintrag.typ, + strasse: eintrag.strasse, + })), + }); + return; + } + + // CSV: mit demselben Werkzeug zerlegen wie der Geräteimport aus Phase 1. + const zerlegt = Papa.parse>(text, { + header: true, + skipEmptyLines: true, + delimiter: "", + }); + + const ergebnis = await importieren.mutateAsync({ + format: "zeilen", + zeilen: zerlegt.data, + nurPruefen: true, + }); + + setVorschau({ + format: "zeilen", + zeilen: zerlegt.data, + gefunden: ergebnis.gefunden, + uebersprungen: ergebnis.uebersprungen, + hinweise: ergebnis.hinweise, + beispiele: ergebnis.vorschau.map((eintrag) => ({ + bezeichnung: eintrag.bezeichnung, + typ: eintrag.typ, + strasse: eintrag.strasse, + })), + }); + } + + async function uebernehmen() { + if (!vorschau) return; + + const ergebnis = await importieren.mutateAsync({ + format: vorschau.format, + inhalt: vorschau.inhalt, + zeilen: vorschau.zeilen, + nurPruefen: false, + }); + + setVorschau(null); + setErfolg( + `${ergebnis.uebernommen} neu angelegt, ${ergebnis.aktualisiert} aktualisiert, ${ergebnis.uebersprungen.length} übersprungen.`, + ); + void werkzeuge.hydrant.invalidate(); + } + + return ( +
+ {meldung && } + {erfolg && ( +

+ {erfolg} +

+ )} + + {darfBearbeiten && ( + + + Import + + GeoJSON oder eine Tabelle der Stadtwerke. Spaltennamen werden erkannt, deutsche + Kommazahlen und Datumsangaben ebenso. Hydranten mit einer Nummer des Versorgers + werden beim zweiten Import aktualisiert statt doppelt angelegt. + + + +
+ + { + const datei = ereignis.target.files?.[0]; + if (datei) void dateiWaehlen(datei); + }} + /> +
+ + {vorschau && ( +
+

+ {vorschau.gefunden} {vorschau.gefunden === 1 ? "Hydrant" : "Hydranten"} gefunden + {vorschau.uebersprungen.length > 0 + ? `, ${vorschau.uebersprungen.length} übersprungen` + : ""} +

+ + {vorschau.hinweise.map((hinweis) => ( +

+ {hinweis} +

+ ))} + + {vorschau.beispiele.length > 0 && ( +
    + {vorschau.beispiele.slice(0, 5).map((beispiel, index) => ( +
  • + {beispiel.bezeichnung ?? "ohne Bezeichnung"} ·{" "} + {TYP_TEXT[beispiel.typ as HydrantTyp]} · {beispiel.strasse ?? "ohne Straße"} +
  • + ))} + {vorschau.gefunden > 5 &&
  • … und {vorschau.gefunden - 5} weitere
  • } +
+ )} + + {vorschau.uebersprungen.length > 0 && ( +
+ + Übersprungene Zeilen ansehen + +
    + {vorschau.uebersprungen.slice(0, 20).map((eintrag) => ( +
  • + Zeile {eintrag.zeile}: {eintrag.grund} +
  • + ))} +
+
+ )} + +
+ + +
+
+ )} +
+
+ )} + + {darfBearbeiten && ( + + + Einzeln erfassen + + In die Karte tippen setzt den Ort — danach die Angaben eintragen. + + + + ({ + id: hydrant.id, + breitengrad: Number(hydrant.breitengrad), + laengengrad: Number(hydrant.laengengrad), + titel: hydrant.bezeichnung ?? TYP_TEXT[hydrant.typ], + })) + } + beiKartenklick={setNeuerOrt} + hoehe="40vh" + /> + + {neuerOrt && ( +
{ + ereignis.preventDefault(); + const formular = new FormData(ereignis.currentTarget); + + anlegen.mutate({ + ...neuerOrt, + bezeichnung: String(formular.get("bezeichnung") ?? "").trim() || null, + typ: (formular.get("typ") as HydrantTyp) ?? HydrantTyp.UNTERFLUR, + status: (formular.get("status") as HydrantStatus) ?? HydrantStatus.UNBEKANNT, + nennweite: Number(formular.get("nennweite")) || null, + strasse: String(formular.get("strasse") ?? "").trim() || null, + }); + }} + > +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+ )} + + + + Bestand + + {hydranten.data?.length ?? 0} Löschwasserstellen + + + + {(hydranten.data?.length ?? 0) === 0 ? ( + + ) : ( + + + + Bezeichnung + Art + Straße + DN + l/min + Zustand + geprüft + {darfBearbeiten && } + + + + {hydranten.data?.slice(0, 200).map((hydrant) => ( + + + + {hydrant.bezeichnung ?? "—"} + + {TYP_TEXT[hydrant.typ]} + + {hydrant.strasse} {hydrant.hausnummer} + + + {hydrant.nennweite ?? "—"} + + + {hydrant.durchfluss ?? "—"} + + {STATUS_TEXT[hydrant.status]} + + {hydrant.letztePruefung ? alsDatum(hydrant.letztePruefung) : "nie"} + + {darfBearbeiten && ( + + + + )} + + ))} + +
+ )} + + {(hydranten.data?.length ?? 0) > 200 && ( +

+ Die ersten 200 von {hydranten.data?.length}. Die Karte zeigt alle. +

+ )} +
+
+
+ ); +} diff --git a/src/app/(app)/karte/hydranten/page.tsx b/src/app/(app)/karte/hydranten/page.tsx new file mode 100644 index 0000000..7915607 --- /dev/null +++ b/src/app/(app)/karte/hydranten/page.tsx @@ -0,0 +1,53 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; +import { env } from "@/lib/env"; +import { fuerOrganisation } from "@/lib/db"; +import { Button } from "@/components/ui/button"; +import { HydrantenPflege } from "@/app/(app)/karte/hydranten/hydranten-pflege"; + +export const metadata = { title: "Hydranten" }; + +/** Löschwasserstellen pflegen und importieren. */ +export default async function HydrantenSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/anmelden"); + if (!hatRecht(sitzung, "geraet.lesen")) redirect("/"); + + const standort = await fuerOrganisation(sitzung.organisationId, (db) => + db.standort.findFirst({ + where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } }, + select: { breitengrad: true, laengengrad: true }, + orderBy: { erstelltAm: "asc" }, + }), + ); + + return ( +
+
+ +

Hydranten

+

+ Löschwasserstellen erfassen, importieren und den Zustand pflegen. +

+
+ + +
+ ); +} diff --git a/src/app/(app)/karte/karten-ansicht.tsx b/src/app/(app)/karte/karten-ansicht.tsx new file mode 100644 index 0000000..196108e --- /dev/null +++ b/src/app/(app)/karte/karten-ansicht.tsx @@ -0,0 +1,367 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Droplets, MapPin, Plus, Search } from "lucide-react"; +import { HydrantStatus, HydrantTyp } 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; +import { Karte, type KartenPunkt } from "@/components/karte/karte"; +import { alsSvg } from "@/lib/karte/taktische-zeichen"; +import { alsDatum } from "@/lib/utils"; + +/** + * Die Karte des Ausrückegebiets. + * + * Zwei Ebenen: Hydranten und Objekte. Beide sind ein- und ausschaltbar, denn + * wer nach Löschwasser sucht, will keine Feuerwehrpläne sehen und umgekehrt. + * + * Die Umkreissuche ist der eigentliche Zweck: **was liegt in der Nähe?** Sie + * geht von einem angetippten Punkt aus — im Einsatz von der Einsatzstelle, am + * Schreibtisch von dort, wo man gerade hinschaut. + */ + +const TYP_TEXT: Record = { + UNTERFLUR: "Unterflur", + UEBERFLUR: "Überflur", + SAUGSTELLE: "Saugstelle", + ZISTERNE: "Zisterne", + OFFENES_GEWAESSER: "offenes Gewässer", + SONSTIGES: "sonstige", +}; + +const STATUS_TEXT: Record = { + EINSATZBEREIT: "einsatzbereit", + EINGESCHRAENKT: "eingeschränkt", + DEFEKT: "defekt", + UNBEKANNT: "ungeprüft", +}; + +const STATUS_FARBE: Record = { + EINSATZBEREIT: "#15803d", + EINGESCHRAENKT: "#ca8a04", + DEFEKT: "#b91c1c", + UNBEKANNT: "#6b7280", +}; + +export function KartenAnsicht({ + stilUrl, + mittelpunkt, + zoom, + darfBearbeiten, +}: { + stilUrl: string | null; + mittelpunkt: { breitengrad: number; laengengrad: number }; + zoom: number; + darfBearbeiten: boolean; +}) { + const [suche, setSuche] = React.useState(""); + const [typ, setTyp] = React.useState("alle"); + const [status, setStatus] = React.useState("alle"); + const [zeigeObjekte, setZeigeObjekte] = React.useState(true); + const [zeigeHydranten, setZeigeHydranten] = React.useState(true); + const [gewaehlt, setGewaehlt] = React.useState<{ art: "hydrant" | "objekt"; id: string } | null>( + null, + ); + const [umkreisPunkt, setUmkreisPunkt] = React.useState<{ + breitengrad: number; + laengengrad: number; + } | null>(null); + const [meldung, setMeldung] = React.useState(null); + + const hydranten = trpc.hydrant.liste.useQuery({ + suche: suche || undefined, + typ: typ === "alle" ? undefined : typ, + status: status === "alle" ? undefined : status, + }); + const objekte = trpc.objekt.liste.useQuery({ suche: suche || undefined }); + const kennzahlen = trpc.hydrant.kennzahlen.useQuery(); + + const umkreis = trpc.hydrant.umkreis.useQuery( + umkreisPunkt ? { ...umkreisPunkt, umkreisMeter: 500 } : { breitengrad: 0, laengengrad: 0 }, + { enabled: umkreisPunkt !== null }, + ); + + const hydrantSvg = React.useMemo(() => alsSvg("hydrant", 26), []); + + const punkte = React.useMemo(() => { + const liste: KartenPunkt[] = []; + + if (zeigeHydranten) { + for (const hydrant of hydranten.data ?? []) { + liste.push({ + id: `h-${hydrant.id}`, + breitengrad: Number(hydrant.breitengrad), + laengengrad: Number(hydrant.laengengrad), + svg: hydrantSvg.replace("color:#1d4ed8", `color:${STATUS_FARBE[hydrant.status]}`), + titel: `${hydrant.bezeichnung ?? TYP_TEXT[hydrant.typ]} — ${STATUS_TEXT[hydrant.status]}`, + beiKlick: () => setGewaehlt({ art: "hydrant", id: hydrant.id }), + }); + } + } + + if (zeigeObjekte) { + for (const objekt of objekte.data ?? []) { + if (objekt.breitengrad === null || objekt.laengengrad === null) continue; + liste.push({ + id: `o-${objekt.id}`, + breitengrad: Number(objekt.breitengrad), + laengengrad: Number(objekt.laengengrad), + farbe: "#1d4ed8", + titel: objekt.name, + beiKlick: () => setGewaehlt({ art: "objekt", id: objekt.id }), + }); + } + } + + return liste; + }, [hydranten.data, objekte.data, zeigeHydranten, zeigeObjekte, hydrantSvg]); + + const gewaehlterHydrant = + gewaehlt?.art === "hydrant" + ? hydranten.data?.find((eintrag) => eintrag.id === gewaehlt.id) + : undefined; + const gewaehltesObjekt = + gewaehlt?.art === "objekt" + ? objekte.data?.find((eintrag) => eintrag.id === gewaehlt.id) + : undefined; + + return ( +
+ {meldung && } + + {/* Filter */} +
+
+ +
+ + setSuche(ereignis.target.value)} + placeholder="Straße, Nummer, Objektname, Gefahr …" + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {darfBearbeiten && ( + + )} +
+ +
+ { + setUmkreisPunkt(ort); + setGewaehlt(null); + }} + hoehe="68vh" + /> + +
+ {/* Was gerade angetippt wurde. */} + {gewaehlterHydrant && ( + + + + {gewaehlterHydrant.bezeichnung ?? TYP_TEXT[gewaehlterHydrant.typ]} + + + {TYP_TEXT[gewaehlterHydrant.typ]} · {STATUS_TEXT[gewaehlterHydrant.status]} + + + + {gewaehlterHydrant.strasse && ( +

+ {gewaehlterHydrant.strasse} {gewaehlterHydrant.hausnummer ?? ""} +

+ )} +

+ {gewaehlterHydrant.nennweite ? `DN ${gewaehlterHydrant.nennweite}` : "Nennweite unbekannt"} + {gewaehlterHydrant.durchfluss + ? ` · ${gewaehlterHydrant.durchfluss} l/min` + : ""} +

+

+ {gewaehlterHydrant.letztePruefung + ? `zuletzt geprüft ${alsDatum(gewaehlterHydrant.letztePruefung)}` + : "noch nie geprüft"} +

+ {gewaehlterHydrant.hinweis &&

{gewaehlterHydrant.hinweis}

} +
+
+ )} + + {gewaehltesObjekt && ( + + + {gewaehltesObjekt.name} + + {gewaehltesObjekt.strasse} {gewaehltesObjekt.hausnummer} + + + + {gewaehltesObjekt.gefahren && ( +

+ Gefahren: {gewaehltesObjekt.gefahren} +

+ )} + +
+
+ )} + + {/* Umkreissuche */} + + + In der Nähe + + {umkreisPunkt + ? "Löschwasser im Umkreis von 500 Metern, nach Entfernung." + : "In die Karte tippen — dann steht hier, was in 500 Metern erreichbar ist."} + + + + {umkreisPunkt && (umkreis.data?.length ?? 0) === 0 && !umkreis.isPending && ( +

+ Kein Löschwasser im Umkreis von 500 Metern. Das ist eine Angabe, die man vor + dem Einsatz wissen will, nicht währenddessen. +

+ )} + +
    + {umkreis.data?.map((treffer) => ( +
  • + + {treffer.bezeichnung ?? TYP_TEXT[treffer.typ]} + + {treffer.strasse ?? "ohne Straße"} + {treffer.nennweite ? ` · DN ${treffer.nennweite}` : ""} + {treffer.durchfluss ? ` · ${treffer.durchfluss} l/min` : ""} + + + + {treffer.entfernung} m + +
  • + ))} +
+
+
+ + + + Bestand + + +
+

gesamt

+

+ {kennzahlen.data?.gesamt ?? 0} +

+
+
+

defekt

+

+ {kennzahlen.data?.defekt ?? 0} +

+
+
+

ungeprüft

+

+ {kennzahlen.data?.ungeprueft ?? 0} +

+
+
+
+
+
+
+ ); +} diff --git a/src/app/(app)/karte/objekte/[id]/objekt-detail.tsx b/src/app/(app)/karte/objekte/[id]/objekt-detail.tsx new file mode 100644 index 0000000..f557cff --- /dev/null +++ b/src/app/(app)/karte/objekte/[id]/objekt-detail.tsx @@ -0,0 +1,383 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { + AlertTriangle, + ArrowLeft, + DoorOpen, + Droplets, + FileText, + KeyRound, + Phone, + Plus, + Users, +} from "lucide-react"; +import { PlanArt } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; +import { KATEGORIE_TEXT } from "@/app/(app)/karte/objekte/objekt-liste"; +import { alsDatum } from "@/lib/utils"; + +/** + * Ein Objekt mit seinem Feuerwehrplan. + * + * Die Reihenfolge der Blöcke ist nicht willkürlich: **Zufahrt, Gefahren, + * Schlüsseldepot** stehen oben, weil sie auf der Anfahrt gelesen werden. + * Ansprechpartner und Pläne folgen. Stammdaten wie die Personenzahl stehen + * unten — die interessieren beim Erfassen, nicht im Einsatz. + */ + +const PLANART_TEXT: Record = { + UEBERSICHTSPLAN: "Übersichtsplan", + GESCHOSSPLAN: "Geschossplan", + UMGEBUNGSPLAN: "Umgebungsplan", + SONDERPLAN: "Sonderplan", + ANLAGE: "Anlage", +}; + +export function ObjektDetail({ id, darfBearbeiten }: { id: string; darfBearbeiten: boolean }) { + const [meldung, setMeldung] = React.useState(null); + const [bearbeiten, setBearbeiten] = React.useState(false); + const [kontaktOffen, setKontaktOffen] = React.useState(false); + + const werkzeuge = trpc.useUtils(); + const objekt = trpc.objekt.einzeln.useQuery({ id }); + + const speichern = trpc.objekt.bearbeiten.useMutation({ + onSuccess: () => { + setBearbeiten(false); + void werkzeuge.objekt.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const kontaktAnlegen = trpc.objekt.kontaktAnlegen.useMutation({ + onSuccess: () => { + setKontaktOffen(false); + void werkzeuge.objekt.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const kontaktEntfernen = trpc.objekt.kontaktEntfernen.useMutation({ + onSuccess: () => void werkzeuge.objekt.invalidate(), + }); + + // Formularzustand — erst wenn geladen ist. + const [felder, setFelder] = React.useState>({}); + React.useEffect(() => { + if (!objekt.data) return; + setFelder({ + zufahrt: objekt.data.zufahrt ?? "", + gefahren: objekt.data.gefahren ?? "", + schluesseldepot: objekt.data.schluesseldepot ?? "", + loeschwasser: objekt.data.loeschwasser ?? "", + bemerkung: objekt.data.bemerkung ?? "", + }); + }, [objekt.data]); + + if (objekt.isPending) { + return

Objekt wird geladen …

; + } + + if (objekt.isError || !objekt.data) { + return ( + + ); + } + + const daten = objekt.data; + + const block = ( + titel: string, + schluessel: string, + symbol: React.ReactNode, + platzhalter: string, + ) => ( +
+

+ {symbol} + {titel} +

+ + {bearbeiten ? ( +