Phase 6: Kartenmodule #7

Merged
florian.wilde merged 2 commits from feat/phase-6-karten into main 2026-09-04 04:32:48 +00:00
111 changed files with 7819 additions and 40 deletions
+17
View File
@@ -185,6 +185,23 @@ KIOSK_SPERRDAUER=300
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden. # Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
KIOSK_IDLE_TIMEOUT=120 KIOSK_IDLE_TIMEOUT=120
# -----------------------------------------------------------------------------
# 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) # Atemschutz nach FwDV 7 (Phase 5)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+111 -1
View File
@@ -8,7 +8,117 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht] ## [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.
--- ---
+122 -1
View File
@@ -29,7 +29,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 3 — Einsätze und Übungen | fertig | | Phase 3 — Einsätze und Übungen | fertig |
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig | | Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
| Phase 5 — Atemschutz (FwDV 7) | fertig | | Phase 5 — Atemschutz (FwDV 7) | fertig |
| Phase 6 — Kartenmodule | offen | | Phase 6 — Kartenmodule | fertig |
| Phase 7 — Benachrichtigungen und Termine | offen | | Phase 7 — Benachrichtigungen und Termine | offen |
| Phase 8 — Lokale KI | offen | | Phase 8 — Lokale KI | offen |
| Phase 9 — Kassenverwaltung | 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 ## Befehle
```bash ```bash
+34
View File
@@ -47,6 +47,10 @@ x-app-env: &app-env
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5} KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300} KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120} KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
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_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true}
ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-} ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-}
ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600} ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600}
@@ -272,6 +276,36 @@ services:
- intern - intern
logging: *logging 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). # Die Anwendung selbst (Next.js, Standalone-Build).
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus. # Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
+46
View File
@@ -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/<name>/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.
+173 -9
View File
@@ -1,9 +1,10 @@
# Anleitung # 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. 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`. fortlaufende Nummer im Format `JJJJ-NNNN`.
**Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr **Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr
einen eigenen Nominatim betreibt (Kapitel 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. 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 FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
betreibt: betreibt:
@@ -1120,7 +1279,7 @@ GEOCODING_LAENDER=de,at,ch
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
eintragen oder weglassen. eintragen oder weglassen.
## 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 Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr `src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
@@ -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. 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, Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt. 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, Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
@@ -1250,7 +1409,7 @@ jeweiligen Detailseite sichtbar.
--- ---
## 39. Wenn etwas nicht geht ## 43. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft | | 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 | | 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 | | 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 | | 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: Zustand aller Randsysteme auf einen Blick:
+25 -1
View File
@@ -297,7 +297,7 @@ Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert.
## Phase 6 — Kartenmodule ## Phase 6 — Kartenmodule
**Vergleichsaufwand: 14–18 PT** **Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:50 h Sitzungszeit**
| Baustein | PT | | 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 fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
Physik mit klaren Formeln und deshalb gut testbar. 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 ## Phase 7 — Benachrichtigungen und Termine
**Vergleichsaufwand: 10–13 PT** **Vergleichsaufwand: 10–13 PT**
+81 -1
View File
@@ -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 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 | | Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 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
--- ---
+5 -4
View File
@@ -15,7 +15,7 @@ Phase 2 ████████████████████ fertig
Phase 3 ████████████████████ fertig 0.4.0 Phase 3 ████████████████████ fertig 0.4.0
Phase 4 ████████████████████ fertig 0.5.0 Phase 4 ████████████████████ fertig 0.5.0
Phase 5 ████████████████████ fertig 0.6.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 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0 Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.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 **Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
analogen, bis die Wehr ausdrücklich anders entscheidet. analogen, bis die Wehr ausdrücklich anders entscheidet.
## Phase 6 — Kartenmodule · als Nächstes ## Phase 6 — Kartenmodule · fertig
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet - MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus - Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
GeoJSON und Stadtwerke-CSV GeoJSON und Stadtwerke-CSV
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar - 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 - Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt **Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
und was ihn hergibt. 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. - Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar - Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 733 KiB

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 969 KiB

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1005 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 KiB

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+202 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "florian", "name": "florian",
"version": "0.6.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "florian", "name": "florian",
"version": "0.6.0", "version": "0.7.0",
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
@@ -37,6 +37,7 @@
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"ioredis": "^5.8.1", "ioredis": "^5.8.1",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"maplibre-gl": "^6.7.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "^15.5.4", "next": "^15.5.4",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
@@ -2009,6 +2010,92 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", "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, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -6732,6 +6825,12 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/effect": {
"version": "3.21.0", "version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
@@ -7984,6 +8083,12 @@
"giget": "dist/cli.mjs" "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": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -8959,6 +9064,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -9036,6 +9147,12 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9593,6 +9710,38 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "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" "@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": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -10339,6 +10494,18 @@
"node": ">= 14.16" "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": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -10457,6 +10624,12 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/preact": {
"version": "10.24.3", "version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
@@ -10567,6 +10740,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -10650,6 +10829,12 @@
], ],
"license": "MIT" "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": { "node_modules/rc9": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "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" "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": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -11926,6 +12120,12 @@
"node": "^18.0.0 || >=20.0.0" "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": { "node_modules/tinyrainbow": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "florian", "name": "florian",
"version": "0.6.0", "version": "0.7.0",
"private": true, "private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": { "engines": {
@@ -56,6 +56,7 @@
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"ioredis": "^5.8.1", "ioredis": "^5.8.1",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"maplibre-gl": "^6.7.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "^15.5.4", "next": "^15.5.4",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
@@ -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;
@@ -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;
$$;
+394 -3
View File
@@ -208,6 +208,16 @@ model Organisation {
g26Untersuchungen G26Untersuchung[] g26Untersuchungen G26Untersuchung[]
belastungsuebungen Belastungsuebung[] belastungsuebungen Belastungsuebung[]
// Phase 6 — Kartenmodule
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
@@index([aktiv]) @@index([aktiv])
@@map("organisation") @@map("organisation")
} }
@@ -310,6 +320,10 @@ model Person {
g26Untersuchungen G26Untersuchung[] @relation("G26Person") g26Untersuchungen G26Untersuchung[] @relation("G26Person")
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson") 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 /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein. /// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email]) @@unique([organisationId, email])
@@ -548,6 +562,10 @@ model Datei {
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll") alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis") alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
// Phase 6
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
@@unique([bucket, objektSchluessel]) @@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId]) @@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie]) @@index([organisationId, kategorie])
@@ -646,9 +664,12 @@ model Standort {
plz String? @db.VarChar(10) plz String? @db.VarChar(10)
ort String? @db.VarChar(120) ort String? @db.VarChar(120)
/// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht, /// Koordinaten doppelt: als Dezimalgrad für die Anwendung und als
/// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage. /// PostGIS-Punkt für die Karte. Prisma kann den PostGIS-Typ nicht lesen —
/// Ab Phase 6 zeigt die Karte diese Punkte an. /// 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)")? koordinaten Unsupported("geography(Point, 4326)")?
bemerkung String? @db.VarChar(1000) bemerkung String? @db.VarChar(1000)
@@ -1680,6 +1701,10 @@ model Einsatz {
// Phase 5 // Phase 5
atemschutz Atemschutzeinsatz[] atemschutz Atemschutzeinsatz[]
// Phase 6
lagekarte Lagekarte?
wasserfoerderungen Wasserfoerderung[]
@@unique([organisationId, einsatznummer]) @@unique([organisationId, einsatznummer])
@@index([organisationId, alarmiertUm(sort: Desc)]) @@index([organisationId, alarmiertUm(sort: Desc)])
@@index([organisationId, status]) @@index([organisationId, status])
@@ -2573,3 +2598,369 @@ model Belastungsuebung {
@@index([organisationId, personId, datum(sort: Desc)]) @@index([organisationId, personId, datum(sort: Desc)])
@@map("belastungsuebung") @@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")
}
+279 -1
View File
@@ -13,7 +13,9 @@
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht * Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und * beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene * 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, * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -52,6 +54,9 @@ import {
QualifikationsArt, QualifikationsArt,
RolleSchluessel, RolleSchluessel,
SignaturRolle, SignaturRolle,
HydrantStatus,
HydrantTyp,
ObjektKategorie,
StandortArt, StandortArt,
TruppStatus, TruppStatus,
UebungsArt, 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 // Ablauf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -2516,6 +2791,9 @@ async function main(): Promise<void> {
// Phase 5 — Atemschutz nach FwDV 7 // Phase 5 — Atemschutz nach FwDV 7
await atemschutzAnlegen(organisation.id); await atemschutzAnlegen(organisation.id);
// Phase 6 — Kartenmodule
await kartendatenAnlegen(organisation.id);
log("Fertig."); log("Fertig.");
log(""); log("");
log("Anmeldung für den Erststart:"); log("Anmeldung für den Erststart:");
+20
View File
@@ -184,6 +184,26 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "G26.3 und Belastungsübungen erfassen", beschreibung: "G26.3 und Belastungsübungen erfassen",
angemeldet: true, 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,
},
]; ];
/** /**
+387
View File
@@ -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<Werkzeug | null>(null);
const [linienpunkte, setLinienpunkte] = React.useState<Array<[number, number]>>([]);
const [beschriftung, setBeschriftung] = React.useState("");
const [meldung, setMeldung] = React.useState<string | null>(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<KartenPunkt[]>(() => {
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<KartenLinie[]>(() => {
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<Zeichengruppe, typeof ZEICHEN>();
for (const zeichen of ZEICHEN) {
const liste = karten.get(zeichen.gruppe) ?? [];
liste.push(zeichen);
karten.set(zeichen.gruppe, liste);
}
return karten;
}, []);
return (
<div className="space-y-4" data-testid="lagekarte">
{meldung && <Fehlermeldung meldung={meldung} kennung="lagekartenfehler" />}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-[var(--muted-foreground)]">
{elemente.length === 0
? "Noch nichts eingezeichnet."
: `${elemente.length} ${elemente.length === 1 ? "Eintrag" : "Einträge"} — alle Geräte sehen dasselbe.`}
</p>
<span
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm ${
verbunden
? "border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 text-[var(--einsatzbereit)]"
: "border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 text-[var(--gesperrt)]"
}`}
role="status"
data-testid="lagekarte-verbindung"
data-verbunden={verbunden}
>
{verbunden ? <Radio className="size-4" aria-hidden /> : <WifiOff className="size-4" aria-hidden />}
{verbunden ? "Abgleich läuft" : "Abgleich unterbrochen"}
</span>
</div>
{darfBearbeiten && (
<div className="space-y-3 rounded-lg border border-[var(--border)] p-3">
{/* Zeichenauswahl nach Gruppen. */}
{[...nachGruppe.entries()].map(([gruppe, zeichenliste]) => (
<div key={gruppe} className="space-y-1">
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{GRUPPE_TEXT[gruppe]}
</p>
<div className="flex flex-wrap gap-2">
{zeichenliste.map((zeichen) => {
const gewaehlt =
werkzeug?.art === "zeichen" && werkzeug.schluessel === zeichen.schluessel;
return (
<button
key={zeichen.schluessel}
type="button"
title={zeichen.name}
aria-label={zeichen.name}
aria-pressed={gewaehlt}
data-testid={`zeichen-${zeichen.schluessel}`}
onClick={() =>
setWerkzeug(
gewaehlt ? null : { art: "zeichen", schluessel: zeichen.schluessel },
)
}
className={`rounded-md border p-1 ${
gewaehlt
? "border-[var(--primary)] bg-[var(--primary)]/10"
: "border-[var(--border)]"
}`}
dangerouslySetInnerHTML={{ __html: alsSvg(zeichen.schluessel, 32) }}
/>
);
})}
</div>
</div>
))}
<div className="flex flex-wrap items-end gap-2 border-t border-[var(--border)] pt-3">
<Button
type="button"
variant={werkzeug?.art === "linie" ? "default" : "outline"}
size="sm"
onClick={() => {
setWerkzeug(werkzeug?.art === "linie" ? null : { art: "linie" });
setLinienpunkte([]);
}}
>
Linie ziehen
</Button>
{werkzeug?.art === "linie" && linienpunkte.length >= 2 && (
<>
<Button
type="button"
size="sm"
onClick={() =>
eintragen.mutate({
einsatzId,
art: LageelementArt.LINIE,
geometrie: linienpunkte,
})
}
>
Linie übernehmen ({linienpunkte.length} Punkte)
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
eintragen.mutate({
einsatzId,
art: LageelementArt.FLAECHE,
geometrie: linienpunkte,
})
}
>
Als Fläche
</Button>
</>
)}
<div className="flex items-end gap-2">
<Input
className="w-52"
value={beschriftung}
onChange={(ereignis) => setBeschriftung(ereignis.target.value)}
placeholder="Beschriftung"
aria-label="Beschriftung"
/>
<Button
type="button"
variant={werkzeug?.art === "text" ? "default" : "outline"}
size="sm"
onClick={() => setWerkzeug(werkzeug?.art === "text" ? null : { art: "text" })}
>
Text setzen
</Button>
</div>
{werkzeug && (
<p className="text-sm text-[var(--muted-foreground)]">
{werkzeug.art === "linie"
? "In die Karte tippen setzt Punkte."
: "In die Karte tippen setzt das Gewählte."}
</p>
)}
</div>
</div>
)}
<Karte
stilUrl={stilUrl}
mittelpunkt={
karte.data?.breitengrad && karte.data?.laengengrad
? {
breitengrad: Number(karte.data.breitengrad),
laengengrad: Number(karte.data.laengengrad),
}
: mittelpunkt
}
zoom={karte.data?.zoomstufe ?? zoom}
punkte={punkte}
linien={linien}
beiKartenklick={darfBearbeiten ? kartenklick : undefined}
hoehe="60vh"
fadenkreuz={Boolean(werkzeug)}
/>
{/* Was eingetragen wurde — mit der Möglichkeit, es zurückzunehmen. */}
{elemente.length > 0 && (
<ul className="space-y-1 text-sm">
{elemente.map((element) => (
<li key={element.id} className="flex flex-wrap items-center gap-2">
<span className="text-[var(--muted-foreground)]">
{new Date(element.erstelltAm).toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span>
{element.zeichen ?? element.art.toLowerCase()}
{element.text ? ` — ${element.text}` : ""}
</span>
{element.erstelltVon && (
<span className="text-[var(--muted-foreground)]">
{element.erstelltVon.nachname}
</span>
)}
{darfBearbeiten && (
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => zuruecknehmen.mutate({ id: element.id })}
>
<Undo2 aria-hidden />
zurücknehmen
</Button>
)}
</li>
))}
</ul>
)}
</div>
);
}
+54
View File
@@ -4,7 +4,11 @@ import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth"; import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz"; import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button"; 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 { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
import { Lagekarte } from "@/app/(app)/einsaetze/[id]/lagekarte";
export const metadata = { title: "Einsatz" }; export const metadata = { title: "Einsatz" };
@@ -21,6 +25,32 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
const { id } = await params; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -37,6 +67,30 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")} darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")} 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.
*/}
<Card>
<CardHeader>
<CardTitle>Lagekarte</CardTitle>
<CardDescription>
Taktische Zeichen nach FwDV 102. Was einer einträgt, sehen alle — auch auf einem
zweiten Gerät.
</CardDescription>
</CardHeader>
<CardContent>
<Lagekarte
einsatzId={id}
stilUrl={env.KARTE_STIL_URL ?? null}
mittelpunkt={mittelpunkt}
zoom={env.KARTE_ZOOM + 3}
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
/>
</CardContent>
</Card>
</div> </div>
); );
} }
@@ -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<HydrantTyp, string> = {
UNTERFLUR: "Unterflur",
UEBERFLUR: "Überflur",
SAUGSTELLE: "Saugstelle",
ZISTERNE: "Zisterne",
OFFENES_GEWAESSER: "offenes Gewässer",
SONSTIGES: "sonstige",
};
const STATUS_TEXT: Record<HydrantStatus, string> = {
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<string | null>(null);
const [erfolg, setErfolg] = React.useState<string | null>(null);
const [neuerOrt, setNeuerOrt] = React.useState<{
breitengrad: number;
laengengrad: number;
} | null>(null);
const [vorschau, setVorschau] = React.useState<{
format: "geojson" | "zeilen";
inhalt?: string;
zeilen?: Array<Record<string, unknown>>;
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<Record<string, unknown>>(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 (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="hydrantfehler" />}
{erfolg && (
<p
className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 px-4 py-3 text-sm"
role="status"
data-testid="hydrant-erfolg"
>
{erfolg}
</p>
)}
{darfBearbeiten && (
<Card>
<CardHeader>
<CardTitle>Import</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="importdatei">Datei</Label>
<Input
id="importdatei"
type="file"
accept=".geojson,.json,.csv,.txt"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void dateiWaehlen(datei);
}}
/>
</div>
{vorschau && (
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<p className="font-medium">
{vorschau.gefunden} {vorschau.gefunden === 1 ? "Hydrant" : "Hydranten"} gefunden
{vorschau.uebersprungen.length > 0
? `, ${vorschau.uebersprungen.length} übersprungen`
: ""}
</p>
{vorschau.hinweise.map((hinweis) => (
<p key={hinweis} className="text-sm text-[var(--pruefung-faellig)]">
{hinweis}
</p>
))}
{vorschau.beispiele.length > 0 && (
<ul className="text-sm text-[var(--muted-foreground)]">
{vorschau.beispiele.slice(0, 5).map((beispiel, index) => (
<li key={index}>
{beispiel.bezeichnung ?? "ohne Bezeichnung"} ·{" "}
{TYP_TEXT[beispiel.typ as HydrantTyp]} · {beispiel.strasse ?? "ohne Straße"}
</li>
))}
{vorschau.gefunden > 5 && <li>… und {vorschau.gefunden - 5} weitere</li>}
</ul>
)}
{vorschau.uebersprungen.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer">
Übersprungene Zeilen ansehen
</summary>
<ul className="mt-2 space-y-1 text-[var(--muted-foreground)]">
{vorschau.uebersprungen.slice(0, 20).map((eintrag) => (
<li key={eintrag.zeile}>
Zeile {eintrag.zeile}: {eintrag.grund}
</li>
))}
</ul>
</details>
)}
<div className="flex gap-2">
<Button
onClick={() => void uebernehmen()}
disabled={vorschau.gefunden === 0 || importieren.isPending}
>
<Upload aria-hidden />
{vorschau.gefunden} übernehmen
</Button>
<Button variant="outline" onClick={() => setVorschau(null)}>
Verwerfen
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{darfBearbeiten && (
<Card>
<CardHeader>
<CardTitle>Einzeln erfassen</CardTitle>
<CardDescription>
In die Karte tippen setzt den Ort — danach die Angaben eintragen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Karte
stilUrl={stilUrl}
mittelpunkt={neuerOrt ?? mittelpunkt}
zoom={zoom}
punkte={
neuerOrt
? [{ id: "neu", ...neuerOrt, farbe: "#1d4ed8", titel: "Neuer Hydrant" }]
: (hydranten.data ?? []).map((hydrant) => ({
id: hydrant.id,
breitengrad: Number(hydrant.breitengrad),
laengengrad: Number(hydrant.laengengrad),
titel: hydrant.bezeichnung ?? TYP_TEXT[hydrant.typ],
}))
}
beiKartenklick={setNeuerOrt}
hoehe="40vh"
/>
{neuerOrt && (
<form
className="grid gap-3 sm:grid-cols-4"
onSubmit={(ereignis) => {
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,
});
}}
>
<div className="space-y-1">
<Label htmlFor="h-bezeichnung">Bezeichnung</Label>
<Input id="h-bezeichnung" name="bezeichnung" placeholder="H-0042" />
</div>
<div className="space-y-1">
<Label htmlFor="h-typ">Art</Label>
<select
id="h-typ"
name="typ"
className="h-10 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 text-sm"
defaultValue={HydrantTyp.UNTERFLUR}
>
{(Object.keys(TYP_TEXT) as HydrantTyp[]).map((wert) => (
<option key={wert} value={wert}>
{TYP_TEXT[wert]}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="h-status">Zustand</Label>
<select
id="h-status"
name="status"
className="h-10 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 text-sm"
defaultValue={HydrantStatus.UNBEKANNT}
>
{(Object.keys(STATUS_TEXT) as HydrantStatus[]).map((wert) => (
<option key={wert} value={wert}>
{STATUS_TEXT[wert]}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="h-nennweite">Nennweite</Label>
<Input id="h-nennweite" name="nennweite" type="number" placeholder="100" />
</div>
<div className="space-y-1 sm:col-span-3">
<Label htmlFor="h-strasse">Straße</Label>
<Input id="h-strasse" name="strasse" placeholder="Hauptstraße" />
</div>
<div className="flex items-end gap-2">
<Button type="submit" disabled={anlegen.isPending}>
<Plus aria-hidden />
Anlegen
</Button>
<Button type="button" variant="ghost" onClick={() => setNeuerOrt(null)}>
Abbrechen
</Button>
</div>
</form>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Bestand</CardTitle>
<CardDescription>
{hydranten.data?.length ?? 0} Löschwasserstellen
</CardDescription>
</CardHeader>
<CardContent>
{(hydranten.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch kein Hydrant erfasst"
hinweis="Am schnellsten geht es über den Import — die Stadtwerke haben die Liste."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Bezeichnung</TableHead>
<TableHead>Art</TableHead>
<TableHead>Straße</TableHead>
<TableHead className="text-right">DN</TableHead>
<TableHead className="text-right">l/min</TableHead>
<TableHead>Zustand</TableHead>
<TableHead>geprüft</TableHead>
{darfBearbeiten && <TableHead className="w-16" aria-label="Aktion" />}
</TableRow>
</TableHeader>
<TableBody>
{hydranten.data?.slice(0, 200).map((hydrant) => (
<TableRow key={hydrant.id} data-testid={`hydrant-${hydrant.id}`}>
<TableCell>
<Droplets className="mr-1 inline size-4 text-[var(--muted-foreground)]" aria-hidden />
{hydrant.bezeichnung ?? "—"}
</TableCell>
<TableCell className="text-sm">{TYP_TEXT[hydrant.typ]}</TableCell>
<TableCell className="text-sm">
{hydrant.strasse} {hydrant.hausnummer}
</TableCell>
<TableCell className="tabellenzahlen text-right">
{hydrant.nennweite ?? "—"}
</TableCell>
<TableCell className="tabellenzahlen text-right">
{hydrant.durchfluss ?? "—"}
</TableCell>
<TableCell className="text-sm">{STATUS_TEXT[hydrant.status]}</TableCell>
<TableCell className="tabellenzahlen text-sm">
{hydrant.letztePruefung ? alsDatum(hydrant.letztePruefung) : "nie"}
</TableCell>
{darfBearbeiten && (
<TableCell>
<Button
variant="ghost"
size="sm"
aria-label={`${hydrant.bezeichnung ?? "Hydrant"} entfernen`}
onClick={() => {
if (window.confirm("Diesen Hydranten entfernen?")) {
entfernen.mutate({ id: hydrant.id });
}
}}
>
<Trash2 aria-hidden />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
{(hydranten.data?.length ?? 0) > 200 && (
<p className="mt-3 text-sm text-[var(--muted-foreground)]">
Die ersten 200 von {hydranten.data?.length}. Die Karte zeigt alle.
</p>
)}
</CardContent>
</Card>
</div>
);
}
+53
View File
@@ -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 (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/karte">
<ArrowLeft aria-hidden />
Karte
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Hydranten</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Löschwasserstellen erfassen, importieren und den Zustand pflegen.
</p>
</div>
<HydrantenPflege
stilUrl={env.KARTE_STIL_URL ?? null}
mittelpunkt={{
breitengrad: standort?.breitengrad ? Number(standort.breitengrad) : env.KARTE_BREITENGRAD,
laengengrad: standort?.laengengrad ? Number(standort.laengengrad) : env.KARTE_LAENGENGRAD,
}}
zoom={env.KARTE_ZOOM}
darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")}
/>
</div>
);
}
+367
View File
@@ -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<HydrantTyp, string> = {
UNTERFLUR: "Unterflur",
UEBERFLUR: "Überflur",
SAUGSTELLE: "Saugstelle",
ZISTERNE: "Zisterne",
OFFENES_GEWAESSER: "offenes Gewässer",
SONSTIGES: "sonstige",
};
const STATUS_TEXT: Record<HydrantStatus, string> = {
EINSATZBEREIT: "einsatzbereit",
EINGESCHRAENKT: "eingeschränkt",
DEFEKT: "defekt",
UNBEKANNT: "ungeprüft",
};
const STATUS_FARBE: Record<HydrantStatus, string> = {
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<HydrantTyp | "alle">("alle");
const [status, setStatus] = React.useState<HydrantStatus | "alle">("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<string | null>(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<KartenPunkt[]>(() => {
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 (
<div className="space-y-4" data-testid="kartenansicht">
{meldung && <Fehlermeldung meldung={meldung} kennung="kartenfehler" />}
{/* Filter */}
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1 space-y-1">
<Label htmlFor="kartensuche">Suchen</Label>
<div className="relative">
<Search
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
id="kartensuche"
className="pl-9"
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Straße, Nummer, Objektname, Gefahr …"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="kartentyp">Art</Label>
<Select value={typ} onValueChange={(wert) => setTyp(wert as HydrantTyp | "alle")}>
<SelectTrigger id="kartentyp" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Arten</SelectItem>
{(Object.keys(TYP_TEXT) as HydrantTyp[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{TYP_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="kartenstatus">Zustand</Label>
<Select
value={status}
onValueChange={(wert) => setStatus(wert as HydrantStatus | "alle")}
>
<SelectTrigger id="kartenstatus" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Zustände</SelectItem>
{(Object.keys(STATUS_TEXT) as HydrantStatus[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{STATUS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button
type="button"
variant={zeigeHydranten ? "default" : "outline"}
onClick={() => setZeigeHydranten((wert) => !wert)}
>
<Droplets aria-hidden />
Hydranten ({hydranten.data?.length ?? 0})
</Button>
<Button
type="button"
variant={zeigeObjekte ? "default" : "outline"}
onClick={() => setZeigeObjekte((wert) => !wert)}
>
<MapPin aria-hidden />
Objekte ({objekte.data?.length ?? 0})
</Button>
</div>
{darfBearbeiten && (
<Button variant="outline" asChild>
<Link href="/karte/hydranten">
<Plus aria-hidden />
Hydranten pflegen
</Link>
</Button>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
<Karte
stilUrl={stilUrl}
mittelpunkt={mittelpunkt}
zoom={zoom}
punkte={punkte}
beiKartenklick={(ort) => {
setUmkreisPunkt(ort);
setGewaehlt(null);
}}
hoehe="68vh"
/>
<div className="space-y-4">
{/* Was gerade angetippt wurde. */}
{gewaehlterHydrant && (
<Card data-testid="hydrant-details">
<CardHeader>
<CardTitle>
{gewaehlterHydrant.bezeichnung ?? TYP_TEXT[gewaehlterHydrant.typ]}
</CardTitle>
<CardDescription>
{TYP_TEXT[gewaehlterHydrant.typ]} · {STATUS_TEXT[gewaehlterHydrant.status]}
</CardDescription>
</CardHeader>
<CardContent className="space-y-1 text-sm">
{gewaehlterHydrant.strasse && (
<p>
{gewaehlterHydrant.strasse} {gewaehlterHydrant.hausnummer ?? ""}
</p>
)}
<p className="tabellenzahlen">
{gewaehlterHydrant.nennweite ? `DN ${gewaehlterHydrant.nennweite}` : "Nennweite unbekannt"}
{gewaehlterHydrant.durchfluss
? ` · ${gewaehlterHydrant.durchfluss} l/min`
: ""}
</p>
<p className="text-[var(--muted-foreground)]">
{gewaehlterHydrant.letztePruefung
? `zuletzt geprüft ${alsDatum(gewaehlterHydrant.letztePruefung)}`
: "noch nie geprüft"}
</p>
{gewaehlterHydrant.hinweis && <p>{gewaehlterHydrant.hinweis}</p>}
</CardContent>
</Card>
)}
{gewaehltesObjekt && (
<Card data-testid="objekt-details">
<CardHeader>
<CardTitle>{gewaehltesObjekt.name}</CardTitle>
<CardDescription>
{gewaehltesObjekt.strasse} {gewaehltesObjekt.hausnummer}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{gewaehltesObjekt.gefahren && (
<p>
<strong>Gefahren:</strong> {gewaehltesObjekt.gefahren}
</p>
)}
<Button variant="outline" size="sm" asChild>
<Link href={`/karte/objekte/${gewaehltesObjekt.id}`}>Feuerwehrplan öffnen</Link>
</Button>
</CardContent>
</Card>
)}
{/* Umkreissuche */}
<Card>
<CardHeader>
<CardTitle>In der Nähe</CardTitle>
<CardDescription>
{umkreisPunkt
? "Löschwasser im Umkreis von 500 Metern, nach Entfernung."
: "In die Karte tippen — dann steht hier, was in 500 Metern erreichbar ist."}
</CardDescription>
</CardHeader>
<CardContent>
{umkreisPunkt && (umkreis.data?.length ?? 0) === 0 && !umkreis.isPending && (
<p className="text-sm text-[var(--gesperrt)]">
Kein Löschwasser im Umkreis von 500 Metern. Das ist eine Angabe, die man vor
dem Einsatz wissen will, nicht währenddessen.
</p>
)}
<ul className="space-y-2 text-sm">
{umkreis.data?.map((treffer) => (
<li key={treffer.id} className="flex items-start justify-between gap-3">
<span>
<strong>{treffer.bezeichnung ?? TYP_TEXT[treffer.typ]}</strong>
<span className="block text-[var(--muted-foreground)]">
{treffer.strasse ?? "ohne Straße"}
{treffer.nennweite ? ` · DN ${treffer.nennweite}` : ""}
{treffer.durchfluss ? ` · ${treffer.durchfluss} l/min` : ""}
</span>
</span>
<span
className="tabellenzahlen whitespace-nowrap"
style={{ color: STATUS_FARBE[treffer.status] }}
>
{treffer.entfernung} m
</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bestand</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-[var(--muted-foreground)]">gesamt</p>
<p className="tabellenzahlen text-2xl font-semibold">
{kennzahlen.data?.gesamt ?? 0}
</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">defekt</p>
<p className="tabellenzahlen text-2xl font-semibold text-[var(--gesperrt)]">
{kennzahlen.data?.defekt ?? 0}
</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">ungeprüft</p>
<p className="tabellenzahlen text-2xl font-semibold text-[var(--pruefung-faellig)]">
{kennzahlen.data?.ungeprueft ?? 0}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
@@ -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<PlanArt, string> = {
UEBERSICHTSPLAN: "Übersichtsplan",
GESCHOSSPLAN: "Geschossplan",
UMGEBUNGSPLAN: "Umgebungsplan",
SONDERPLAN: "Sonderplan",
ANLAGE: "Anlage",
};
export function ObjektDetail({ id, darfBearbeiten }: { id: string; darfBearbeiten: boolean }) {
const [meldung, setMeldung] = React.useState<string | null>(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<Record<string, string>>({});
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 <p className="text-[var(--muted-foreground)]">Objekt wird geladen …</p>;
}
if (objekt.isError || !objekt.data) {
return (
<Fehlermeldung
meldung={objekt.error?.message ?? "Dieses Objekt gibt es nicht."}
kennung="objektfehler"
/>
);
}
const daten = objekt.data;
const block = (
titel: string,
schluessel: string,
symbol: React.ReactNode,
platzhalter: string,
) => (
<div className="rounded-lg border border-[var(--border)] p-4">
<p className="flex items-center gap-2 text-sm font-medium">
{symbol}
{titel}
</p>
{bearbeiten ? (
<Textarea
className="mt-2"
rows={3}
aria-label={titel}
value={felder[schluessel] ?? ""}
onChange={(ereignis) =>
setFelder((bisher) => ({ ...bisher, [schluessel]: ereignis.target.value }))
}
placeholder={platzhalter}
/>
) : (
<p className="mt-1 whitespace-pre-line text-sm">
{felder[schluessel] || (
<span className="text-[var(--muted-foreground)]">nichts hinterlegt</span>
)}
</p>
)}
</div>
);
return (
<div className="space-y-6" data-testid="objekt-detail">
{meldung && <Fehlermeldung meldung={meldung} kennung="objektfehler" />}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/karte/objekte">
<ArrowLeft aria-hidden />
Objekte
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{daten.name}</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{KATEGORIE_TEXT[daten.kategorie]} · {daten.strasse} {daten.hausnummer}
{daten.ort ? `, ${daten.plz ?? ""} ${daten.ort}` : ""}
{daten.personenzahl !== null ? ` · ${daten.personenzahl} Personen` : ""}
</p>
</div>
{darfBearbeiten && (
<div className="flex gap-2">
{bearbeiten ? (
<>
<Button
onClick={() =>
speichern.mutate({
id,
zufahrt: felder.zufahrt || null,
gefahren: felder.gefahren || null,
schluesseldepot: felder.schluesseldepot || null,
loeschwasser: felder.loeschwasser || null,
bemerkung: felder.bemerkung || null,
})
}
disabled={speichern.isPending}
>
Speichern
</Button>
<Button variant="outline" onClick={() => setBearbeiten(false)}>
Abbrechen
</Button>
</>
) : (
<Button variant="outline" onClick={() => setBearbeiten(true)}>
Bearbeiten
</Button>
)}
</div>
)}
</div>
{/* Was auf der Anfahrt zählt. */}
<div className="grid gap-3 lg:grid-cols-3">
{block(
"Zufahrt",
"zufahrt",
<DoorOpen className="size-4" aria-hidden />,
"Tordurchfahrt 3,20 m, Aufstellfläche im Hof, Schranke mit Feuerwehrschlüssel",
)}
{block(
"Gefahren",
"gefahren",
<AlertTriangle className="size-4 text-[var(--pruefung-faellig)]" aria-hidden />,
"Photovoltaik auf dem Dach, Gasflaschen im Nebengebäude",
)}
{block(
"Schlüsseldepot",
"schluesseldepot",
<KeyRound className="size-4" aria-hidden />,
"FSD links neben dem Haupteingang, Schlüssel 3",
)}
</div>
<div className="grid gap-3 lg:grid-cols-2">
{block(
"Löschwasser am Objekt",
"loeschwasser",
<Droplets className="size-4" aria-hidden />,
"Wandhydranten in jedem Geschoss, Sprinklerzentrale im Keller",
)}
{block(
"Bemerkung",
"bemerkung",
<FileText className="size-4" aria-hidden />,
"Alles, was sonst noch wichtig ist",
)}
</div>
{/* Ansprechpartner. */}
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<div>
<CardTitle>Ansprechpartner</CardTitle>
<CardDescription>
Wer nachts ans Telefon geht, steht mit „jederzeit" markiert.
</CardDescription>
</div>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setKontaktOffen((wert) => !wert)}>
<Plus aria-hidden />
Hinzufügen
</Button>
)}
</CardHeader>
<CardContent className="space-y-3">
{kontaktOffen && (
<form
className="grid gap-3 rounded-lg border border-[var(--border)] p-3 sm:grid-cols-4"
onSubmit={(ereignis) => {
ereignis.preventDefault();
const formular = new FormData(ereignis.currentTarget);
const name = String(formular.get("name") ?? "").trim();
if (!name) return;
kontaktAnlegen.mutate({
objektId: id,
name,
funktion: String(formular.get("funktion") ?? "").trim() || null,
telefon: String(formular.get("telefon") ?? "").trim() || null,
mobil: String(formular.get("mobil") ?? "").trim() || null,
jederzeit: formular.get("jederzeit") === "on",
});
}}
>
<div className="space-y-1">
<Label htmlFor="k-name">Name</Label>
<Input id="k-name" name="name" required />
</div>
<div className="space-y-1">
<Label htmlFor="k-funktion">Funktion</Label>
<Input id="k-funktion" name="funktion" placeholder="Hausmeister" />
</div>
<div className="space-y-1">
<Label htmlFor="k-telefon">Telefon</Label>
<Input id="k-telefon" name="telefon" />
</div>
<div className="space-y-1">
<Label htmlFor="k-mobil">Mobil</Label>
<Input id="k-mobil" name="mobil" />
</div>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" name="jederzeit" />
rund um die Uhr erreichbar
</label>
<div className="sm:col-span-2">
<Button type="submit" size="sm" disabled={kontaktAnlegen.isPending}>
Speichern
</Button>
</div>
</form>
)}
{daten.kontakte.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Kein Ansprechpartner hinterlegt.
</p>
) : (
<ul className="space-y-2 text-sm">
{daten.kontakte.map((kontakt) => (
<li key={kontakt.id} className="flex flex-wrap items-center gap-3">
<Phone className="size-4 text-[var(--muted-foreground)]" aria-hidden />
<strong>{kontakt.name}</strong>
{kontakt.funktion && (
<span className="text-[var(--muted-foreground)]">{kontakt.funktion}</span>
)}
{kontakt.telefon && (
<a href={`tel:${kontakt.telefon}`} className="tabellenzahlen underline">
{kontakt.telefon}
</a>
)}
{kontakt.mobil && (
<a href={`tel:${kontakt.mobil}`} className="tabellenzahlen underline">
{kontakt.mobil}
</a>
)}
{kontakt.jederzeit && (
<span className="rounded-full bg-[var(--einsatzbereit)]/15 px-2 py-0.5 text-xs text-[var(--einsatzbereit)]">
jederzeit
</span>
)}
{darfBearbeiten && (
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => kontaktEntfernen.mutate({ id: kontakt.id })}
>
entfernen
</Button>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* Feuerwehrpläne. */}
<Card>
<CardHeader>
<CardTitle>Feuerwehrplan nach DIN 14095</CardTitle>
<CardDescription>
Übersichts-, Geschoss- und Umgebungsplan als PDF. Der Stand gehört dazu — ein Plan
von 2009 ist eine Vermutung.
</CardDescription>
</CardHeader>
<CardContent>
{daten.plaene.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Noch kein Plan hinterlegt. Pläne werden über den Dateiupload hochgeladen und hier
verknüpft.
</p>
) : (
<ul className="space-y-2 text-sm">
{daten.plaene.map((plan) => (
<li key={plan.id} className="flex flex-wrap items-center gap-3">
<FileText className="size-4 text-[var(--muted-foreground)]" aria-hidden />
<a
href={`/api/dateien/${plan.dateiId}`}
target="_blank"
rel="noreferrer"
className="font-medium underline"
>
{plan.bezeichnung}
</a>
<span className="text-[var(--muted-foreground)]">
{PLANART_TEXT[plan.art]}
{plan.standAm ? ` · Stand ${alsDatum(plan.standAm)}` : " · ohne Stand"}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<p className="text-xs text-[var(--muted-foreground)]">
<Users className="mr-1 inline size-3" aria-hidden />
Was hier steht, wird auf der Anfahrt gelesen. Kurze Sätze sind besser als vollständige.
</p>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { ObjektDetail } from "@/app/(app)/karte/objekte/[id]/objekt-detail";
export const metadata = { title: "Objekt" };
/** Ein Objekt mit Feuerwehrplan. */
export default async function ObjektSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "geraet.lesen")) redirect("/");
const { id } = await params;
return <ObjektDetail id={id} darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")} />;
}
@@ -0,0 +1,262 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AlertTriangle, KeyRound, Plus, Search, Users } from "lucide-react";
import { ObjektKategorie } 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, NichtsGefunden } from "@/components/stammdaten/anzeige";
/**
* Objekte mit Feuerwehrplan.
*
* Die Liste ist eine Suchliste, keine Tabelle: gesucht wird nach dem Namen,
* nach der Straße, nach einem Gefahrenstichwort. Wer im Einsatz sucht, weiß
* selten, wie das Objekt im System heißt — aber immer, wo es steht oder was
* dort brennt.
*/
export const KATEGORIE_TEXT: Record<ObjektKategorie, string> = {
SCHULE: "Schule",
KINDERGARTEN: "Kindergarten",
PFLEGEHEIM: "Pflegeheim",
KRANKENHAUS: "Krankenhaus",
VERSAMMLUNGSSTAETTE: "Versammlungsstätte",
HOCHHAUS: "Hochhaus",
INDUSTRIE: "Industrie",
LANDWIRTSCHAFT: "Landwirtschaft",
GEFAHRSTOFFLAGER: "Gefahrstofflager",
SONDERBAU: "Sonderbau",
SONSTIGES: "Sonstiges",
};
export function ObjektListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const [suche, setSuche] = React.useState("");
const [kategorie, setKategorie] = React.useState<ObjektKategorie | "alle">("alle");
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [name, setName] = React.useState("");
const [strasse, setStrasse] = React.useState("");
const [neueKategorie, setNeueKategorie] = React.useState<ObjektKategorie>(
ObjektKategorie.SONSTIGES,
);
const [gefahren, setGefahren] = React.useState("");
const werkzeuge = trpc.useUtils();
const objekte = trpc.objekt.liste.useQuery({
suche: suche || undefined,
kategorie: kategorie === "alle" ? undefined : kategorie,
});
const anlegen = trpc.objekt.anlegen.useMutation({
onSuccess: () => {
setAnlegenOffen(false);
setName("");
setStrasse("");
setGefahren("");
void werkzeuge.objekt.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-4">
{meldung && <Fehlermeldung meldung={meldung} kennung="objektfehler" />}
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-56 flex-1 space-y-1">
<Label htmlFor="objektsuche">Objekte durchsuchen</Label>
<div className="relative">
<Search
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
id="objektsuche"
className="pl-9"
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Name, Straße, Gefahr, Ansprechpartner …"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="objektkategorie">Kategorie</Label>
<Select
value={kategorie}
onValueChange={(wert) => setKategorie(wert as ObjektKategorie | "alle")}
>
<SelectTrigger id="objektkategorie" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Kategorien</SelectItem>
{(Object.keys(KATEGORIE_TEXT) as ObjektKategorie[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{darfBearbeiten && (
<Button onClick={() => setAnlegenOffen((wert) => !wert)}>
<Plus aria-hidden />
Objekt anlegen
</Button>
)}
</div>
{anlegenOffen && (
<Card>
<CardHeader>
<CardTitle>Neues Objekt</CardTitle>
<CardDescription>
Name und Kategorie genügen für den Anfang. Zufahrt, Gefahren und Pläne kommen auf
der Detailseite dazu.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="grid gap-3 sm:grid-cols-2"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (!name.trim()) return;
anlegen.mutate({
name: name.trim(),
kategorie: neueKategorie,
strasse: strasse.trim() || null,
gefahren: gefahren.trim() || null,
});
}}
>
<div className="space-y-1">
<Label htmlFor="neu-name">Name</Label>
<Input
id="neu-name"
value={name}
onChange={(ereignis) => setName(ereignis.target.value)}
placeholder="Grundschule Musterhausen"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="neu-kategorie">Kategorie</Label>
<Select
value={neueKategorie}
onValueChange={(wert) => setNeueKategorie(wert as ObjektKategorie)}
>
<SelectTrigger id="neu-kategorie">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(KATEGORIE_TEXT) as ObjektKategorie[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="neu-strasse">Straße und Hausnummer</Label>
<Input
id="neu-strasse"
value={strasse}
onChange={(ereignis) => setStrasse(ereignis.target.value)}
placeholder="Schulstraße 4"
/>
</div>
<div className="space-y-1">
<Label htmlFor="neu-gefahren">Gefahren</Label>
<Textarea
id="neu-gefahren"
rows={2}
value={gefahren}
onChange={(ereignis) => setGefahren(ereignis.target.value)}
placeholder="Photovoltaik auf dem Dach, Chemiesaal im 1. OG"
/>
</div>
<div className="sm:col-span-2">
<Button type="submit" disabled={anlegen.isPending}>
{anlegen.isPending ? "wird angelegt …" : "Anlegen"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{(objekte.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel={suche ? "Kein Objekt gefunden" : "Noch kein Objekt erfasst"}
hinweis={
suche
? "Gesucht wird in Name, Straße, Gefahren, Zufahrt und Ansprechpartnern."
: "Feuerwehrpläne nach DIN 14095 gehören hierher — Schule, Pflegeheim, Aussiedlerhof."
}
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{objekte.data?.map((objekt) => (
<Link
key={objekt.id}
href={`/karte/objekte/${objekt.id}`}
className="rounded-lg border border-[var(--border)] p-4 transition-colors hover:bg-[var(--accent)]"
data-testid={`objekt-${objekt.id}`}
>
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{KATEGORIE_TEXT[objekt.kategorie]}
</p>
<p className="text-lg font-semibold">{objekt.name}</p>
<p className="text-sm text-[var(--muted-foreground)]">
{objekt.strasse} {objekt.hausnummer} {objekt.ort ? `· ${objekt.ort}` : ""}
</p>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{objekt._count.plaene > 0 && (
<span className="flex items-center gap-1">
<KeyRound className="size-3" aria-hidden />
{objekt._count.plaene} {objekt._count.plaene === 1 ? "Plan" : "Pläne"}
</span>
)}
{objekt.personenzahl !== null && (
<span className="flex items-center gap-1">
<Users className="size-3" aria-hidden />
{objekt.personenzahl} Personen
</span>
)}
{objekt.gefahren && (
<span className="flex items-center gap-1 text-[var(--pruefung-faellig)]">
<AlertTriangle className="size-3" aria-hidden />
Gefahren hinterlegt
</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { ObjektListe } from "@/app/(app)/karte/objekte/objekt-liste";
export const metadata = { title: "Objekte" };
/** Objekte mit Feuerwehrplan. */
export default async function ObjekteSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "geraet.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/karte">
<ArrowLeft aria-hidden />
Karte
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Objekte</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Feuerwehrpläne, Zufahrten, Gefahren und wen man anruft.
</p>
</div>
<ObjektListe darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")} />
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { Droplets, Route } 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 { KartenAnsicht } from "@/app/(app)/karte/karten-ansicht";
export const metadata = { title: "Karte" };
/**
* Die Karte des Ausrückegebiets.
*
* Der Mittelpunkt kommt vom ersten Standort der Wehr — das Gerätehaus ist der
* Ort, von dem aus gedacht wird. Hat die Wehr dort keine Koordinaten
* hinterlegt, greift die Vorgabe aus der Konfiguration.
*/
export default async function KartenSeite() {
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" },
}),
);
const mittelpunkt = {
breitengrad: standort?.breitengrad ? Number(standort.breitengrad) : env.KARTE_BREITENGRAD,
laengengrad: standort?.laengengrad ? Number(standort.laengengrad) : env.KARTE_LAENGENGRAD,
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Karte</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Löschwasser, Objekte mit Feuerwehrplan und die Umkreissuche.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href="/karte/objekte">Objekte</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/karte/hydranten">
<Droplets aria-hidden />
Hydranten
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/karte/wasserfoerderung">
<Route aria-hidden />
Wasserförderung
</Link>
</Button>
</div>
</div>
<KartenAnsicht
stilUrl={env.KARTE_STIL_URL ?? null}
mittelpunkt={mittelpunkt}
zoom={env.KARTE_ZOOM}
darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")}
/>
</div>
);
}
@@ -0,0 +1,59 @@
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 { Foerderplaner } from "@/app/(app)/karte/wasserfoerderung/planer";
export const metadata = { title: "Wasserförderung" };
/**
* Wasserförderung über lange Wegstrecke.
*
* Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube
* entsteht — mit dem Unterschied, dass die Strecke hier aus der Karte kommt
* und sich speichern lässt.
*/
export default async function WasserfoerderungSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "einsatz.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 (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/karte">
<ArrowLeft aria-hidden />
Karte
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Wasserförderung</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Strecke in die Karte tippen — Pumpen, Schlauchlängen und Druckverlust rechnen sich mit.
</p>
</div>
<Foerderplaner
stilUrl={env.KARTE_STIL_URL ?? null}
mittelpunkt={{
breitengrad: standort?.breitengrad ? Number(standort.breitengrad) : env.KARTE_BREITENGRAD,
laengengrad: standort?.laengengrad ? Number(standort.laengengrad) : env.KARTE_LAENGENGRAD,
}}
zoom={env.KARTE_ZOOM}
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
/>
</div>
);
}
@@ -0,0 +1,529 @@
"use client";
import * as React from "react";
import { MapPin, Printer, Route, Trash2 } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
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, type KartenLinie, type KartenPunkt } from "@/components/karte/karte";
import {
DURCHMESSER_TEXT,
foerderplan,
type Durchmesser,
type Streckenpunkt,
} from "@/lib/karte/wasserfoerderung";
import { alsDatumZeit } from "@/lib/utils";
/**
* Wasserförderung planen.
*
* Punkte in die Karte tippen, Durchmesser und Menge einstellen — die Rechnung
* läuft sofort mit. **Gerechnet wird im Browser mit demselben Code wie auf dem
* Server** (lib/karte/wasserfoerderung.ts); gespeichert werden nur die
* Eingaben. Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne
* falsch auszusehen.
*
* Das Ergebnis ist eine Planungshilfe. Was zählt, ist der Druck am Verteiler —
* und das steht auch so unter der Tabelle.
*/
const ART_TEXT = {
ENTNAHME: "Entnahmestelle",
VERSTAERKER: "Verstärkerpumpe",
WEGPUNKT: "Wegpunkt",
ZIEL: "Einsatzstelle",
} as const;
type Art = keyof typeof ART_TEXT;
interface Punkt extends Streckenpunkt {
art: Art;
}
export function Foerderplaner({
stilUrl,
mittelpunkt,
zoom,
darfBearbeiten,
}: {
stilUrl: string | null;
mittelpunkt: { breitengrad: number; laengengrad: number };
zoom: number;
darfBearbeiten: boolean;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [durchmesser, setDurchmesser] = React.useState<Durchmesser>(75);
const [foerdermenge, setFoerdermenge] = React.useState(800);
const [punkte, setPunkte] = React.useState<Punkt[]>([]);
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const gespeicherte = trpc.wasserfoerderung.liste.useQuery();
const speichern = trpc.wasserfoerderung.anlegen.useMutation({
onSuccess: () => {
setMeldung(null);
setBezeichnung("");
setPunkte([]);
void werkzeuge.wasserfoerderung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const entfernen = trpc.wasserfoerderung.entfernen.useMutation({
onSuccess: () => void werkzeuge.wasserfoerderung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
/** Die Rechnung — bei jeder Änderung neu, nie gespeichert. */
const plan = React.useMemo(
() =>
foerderplan({
punkte,
durchmesser,
foerdermenge,
ausgangsdruck: 8,
eingangsdruck: 1.5,
}),
[punkte, durchmesser, foerdermenge],
);
const kartenpunkte = React.useMemo<KartenPunkt[]>(
() =>
punkte.map((punkt, index) => ({
id: `p-${index}`,
breitengrad: punkt.breitengrad,
laengengrad: punkt.laengengrad,
farbe:
punkt.art === "ENTNAHME"
? "#1d4ed8"
: punkt.art === "ZIEL"
? "#b91c1c"
: plan.abschnitte[index - 1]?.pumpeNoetig
? "#ca8a04"
: "#374151",
titel: `${index + 1}. ${punkt.bezeichnung ?? ART_TEXT[punkt.art]}`,
})),
[punkte, plan.abschnitte],
);
const linien = React.useMemo<KartenLinie[]>(
() =>
punkte.length >= 2
? [
{
id: "strecke",
punkte: punkte.map((punkt) => [punkt.laengengrad, punkt.breitengrad] as [number, number]),
farbe: plan.machbar ? "#1d4ed8" : "#b91c1c",
},
]
: [],
[punkte, plan.machbar],
);
function punktSetzen(ort: { breitengrad: number; laengengrad: number }) {
setPunkte((bisher) => [
...bisher,
{
...ort,
hoehe: null,
art: bisher.length === 0 ? "ENTNAHME" : "ZIEL",
bezeichnung: null,
},
]);
}
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="foerderfehler" />}
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
<div className="space-y-3">
<Karte
stilUrl={stilUrl}
mittelpunkt={punkte[0] ?? mittelpunkt}
zoom={zoom}
punkte={kartenpunkte}
linien={linien}
beiKartenklick={darfBearbeiten ? punktSetzen : undefined}
hoehe="55vh"
/>
<p className="text-sm text-[var(--muted-foreground)]">
<MapPin className="mr-1 inline size-4" aria-hidden />
In die Karte tippen setzt einen Punkt: der erste ist die Entnahmestelle, der letzte
die Einsatzstelle. Höhen lassen sich je Punkt eintragen — zehn Meter Steigung kosten
ein bar.
</p>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label htmlFor="bezeichnung">Bezeichnung</Label>
<Input
id="bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Förderstrecke Weiher → Aussiedlerhof"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="durchmesser">Schlauch</Label>
<Select
value={String(durchmesser)}
onValueChange={(wert) => setDurchmesser(Number(wert) as Durchmesser)}
>
<SelectTrigger id="durchmesser">
<SelectValue />
</SelectTrigger>
<SelectContent>
{([52, 75, 110] as Durchmesser[]).map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{DURCHMESSER_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="menge">Fördermenge (l/min)</Label>
<Input
id="menge"
type="number"
inputMode="numeric"
value={foerdermenge}
onChange={(ereignis) => setFoerdermenge(Number(ereignis.target.value) || 0)}
/>
</div>
</div>
</CardContent>
</Card>
{/* Das Ergebnis. */}
<Card data-testid="foerderplan">
<CardHeader>
<CardTitle>Ergebnis</CardTitle>
<CardDescription>
{punkte.length < 2
? "Noch keine Strecke — mindestens zwei Punkte setzen."
: `${plan.gesamtlaenge} m · ${plan.laengen} Schlauchlängen · ${plan.pumpen} ${plan.pumpen === 1 ? "Pumpe" : "Pumpen"}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{punkte.length >= 2 && (
<>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-[var(--muted-foreground)]">Pumpen</p>
<p
className="tabellenzahlen text-3xl font-semibold"
data-testid="pumpenzahl"
>
{plan.pumpen}
</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">Längen</p>
<p className="tabellenzahlen text-3xl font-semibold">{plan.laengen}</p>
</div>
<div>
<p className="text-[var(--muted-foreground)]">Höhe</p>
<p className="tabellenzahlen text-3xl font-semibold">
{plan.hoehenunterschied > 0 ? "+" : ""}
{plan.hoehenunterschied} m
</p>
</div>
</div>
<ul className="space-y-1 text-sm">
{plan.hinweise.map((hinweis) => (
<li
key={hinweis}
className={
plan.machbar
? "text-[var(--muted-foreground)]"
: "font-medium text-[var(--gesperrt)]"
}
>
{hinweis}
</li>
))}
</ul>
</>
)}
{darfBearbeiten && punkte.length >= 2 && (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
speichern.mutate({
bezeichnung: bezeichnung.trim() || "Förderstrecke",
durchmesser,
foerdermenge,
ausgangsdruck: 8,
eingangsdruck: 1.5,
punkte: punkte.map((punkt) => ({
art: punkt.art,
bezeichnung: punkt.bezeichnung ?? null,
breitengrad: punkt.breitengrad,
laengengrad: punkt.laengengrad,
hoehe: punkt.hoehe ?? null,
})),
})
}
disabled={speichern.isPending}
>
<Route aria-hidden />
Strecke speichern
</Button>
<Button variant="outline" onClick={() => window.print()}>
<Printer aria-hidden />
Schema drucken
</Button>
<Button variant="ghost" onClick={() => setPunkte([])}>
Zurücksetzen
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Die Abschnitte — das ist das druckbare Schema. */}
{punkte.length >= 2 && (
<Card>
<CardHeader>
<CardTitle>Abschnitte</CardTitle>
<CardDescription>
Je Abschnitt: Länge, Höhenunterschied, Druckverlust und was am Ende noch ansteht.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Von → nach</TableHead>
<TableHead className="text-right">Meter</TableHead>
<TableHead className="text-right">Längen</TableHead>
<TableHead className="text-right">Höhe</TableHead>
<TableHead className="text-right">Reibung</TableHead>
<TableHead className="text-right">Rest</TableHead>
<TableHead>Pumpe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plan.abschnitte.map((abschnitt, index) => (
<TableRow key={`${abschnitt.von}-${index}`}>
<TableCell>
{abschnitt.von} → {abschnitt.nach}
</TableCell>
<TableCell className="tabellenzahlen text-right">
{abschnitt.laengeMeter}
</TableCell>
<TableCell className="tabellenzahlen text-right">{abschnitt.laengen}</TableCell>
<TableCell className="tabellenzahlen text-right">
{abschnitt.hoehenunterschied > 0 ? "+" : ""}
{abschnitt.hoehenunterschied} m
</TableCell>
<TableCell className="tabellenzahlen text-right">
{abschnitt.reibungsverlust.toFixed(1)} bar
</TableCell>
<TableCell className="tabellenzahlen text-right">
{abschnitt.restdruck.toFixed(1)} bar
</TableCell>
<TableCell>
{abschnitt.pumpeNoetig ? (
<span className="font-medium text-[var(--pruefung-faellig)]">
Verstärker davor
</span>
) : (
"—"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Punktliste mit Höhen. */}
{punkte.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Punkte</CardTitle>
<CardDescription>
Höhe über Normalnull eintragen, wenn sie bekannt ist — ohne wird mit ebenem
Gelände gerechnet, und das ist im Zweifel zu günstig.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{punkte.map((punkt, index) => (
<div key={index} className="flex flex-wrap items-end gap-3">
<span className="tabellenzahlen w-6 text-[var(--muted-foreground)]">
{index + 1}.
</span>
<div className="space-y-1">
<Label htmlFor={`art-${index}`} className="text-xs">
Art
</Label>
<Select
value={punkt.art}
onValueChange={(wert) =>
setPunkte((bisher) =>
bisher.map((eintrag, i) =>
i === index ? { ...eintrag, art: wert as Art } : eintrag,
),
)
}
>
<SelectTrigger id={`art-${index}`} className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(ART_TEXT) as Art[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{ART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor={`name-${index}`} className="text-xs">
Bezeichnung
</Label>
<Input
id={`name-${index}`}
className="w-52"
value={punkt.bezeichnung ?? ""}
onChange={(ereignis) =>
setPunkte((bisher) =>
bisher.map((eintrag, i) =>
i === index ? { ...eintrag, bezeichnung: ereignis.target.value } : eintrag,
),
)
}
placeholder={ART_TEXT[punkt.art]}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`hoehe-${index}`} className="text-xs">
Höhe (m ü. NN)
</Label>
<Input
id={`hoehe-${index}`}
type="number"
inputMode="numeric"
className="w-28"
value={punkt.hoehe ?? ""}
onChange={(ereignis) =>
setPunkte((bisher) =>
bisher.map((eintrag, i) =>
i === index
? {
...eintrag,
hoehe:
ereignis.target.value === ""
? null
: Number(ereignis.target.value),
}
: eintrag,
),
)
}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() =>
setPunkte((bisher) => bisher.filter((_, i) => i !== index))
}
aria-label={`Punkt ${index + 1} entfernen`}
>
<Trash2 aria-hidden />
</Button>
</div>
))}
</CardContent>
</Card>
)}
{/* Gespeicherte Strecken. */}
<Card>
<CardHeader>
<CardTitle>Gespeicherte Strecken</CardTitle>
<CardDescription>
Für wiederkehrende Lagen — der Aussiedlerhof am Ortsrand ist bei jedem Brand
derselbe.
</CardDescription>
</CardHeader>
<CardContent>
{(gespeicherte.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Strecke gespeichert"
hinweis="Eine geplante Strecke lässt sich sichern und beim nächsten Mal wieder aufrufen."
/>
) : (
<ul className="space-y-2 text-sm">
{gespeicherte.data?.map((strecke) => (
<li key={strecke.id} className="flex flex-wrap items-center gap-3">
<strong>{strecke.bezeichnung}</strong>
<span className="text-[var(--muted-foreground)]">
{strecke._count.punkte} Punkte · {strecke.durchmesser} mm ·{" "}
{strecke.foerdermenge} l/min · {alsDatumZeit(strecke.erstelltAm)}
</span>
{darfBearbeiten && (
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => entfernen.mutate({ id: strecke.id })}
>
<Trash2 aria-hidden />
entfernen
</Button>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { herunterladen } from "@/lib/storage";
import { AuditAktion, protokolliere } from "@/lib/audit";
/**
* Eine abgelegte Datei ausliefern.
*
* Gegenstück zum Upload nebenan. Bis Phase 6 wurden Dateien nur erzeugt
* (Protokolle, Berichte) oder über eigene Endpunkte ausgeliefert; mit den
* Feuerwehrplänen gibt es zum ersten Mal Dateien, die jemand hochlädt und
* später wieder ansehen will.
*
* Drei Dinge sind wichtig:
*
* 1. **Der Mandantenfilter entscheidet.** Die Datei wird über
* `fuerOrganisation` gesucht — eine Kennung aus einer fremden Wehr findet
* hier nichts, auch wenn sie richtig geraten wurde.
* 2. **Ausgeliefert wird aus MinIO**, über lib/storage.ts. Nie über einen
* Pfad im Dateisystem.
* 3. **Jeder Abruf steht im Protokoll.** Wer einen Feuerwehrplan oder ein
* Prüfprotokoll ansieht, ist eine Information, die im Zweifel zählt.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Was im Browser angezeigt werden darf. Alles andere wird heruntergeladen. */
const ANZEIGBAR = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
}
if (!hatRecht(sitzung, "geraet.lesen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
}
const { id } = await params;
const datei = await fuerOrganisation(sitzung.organisationId, (db) =>
db.datei.findUnique({
where: { id },
select: {
objektSchluessel: true,
dateiname: true,
mimeTyp: true,
entitaet: true,
entitaetId: true,
},
}),
);
if (!datei) {
return NextResponse.json({ fehler: "Diese Datei gibt es nicht." }, { status: 404 });
}
let inhalt: Buffer;
try {
inhalt = await herunterladen(datei.objektSchluessel);
} catch {
return NextResponse.json(
{ fehler: "Die Datei ist im Speicher nicht auffindbar." },
{ status: 500 },
);
}
await protokolliere({
organisationId: sitzung.organisationId,
aktion: AuditAktion.DATEI_ABGERUFEN,
entitaet: datei.entitaet ?? "Datei",
entitaetId: datei.entitaetId ?? id,
sitzung,
beschreibung: `Datei „${datei.dateiname}" abgerufen`,
});
const anzeigen = ANZEIGBAR.has(datei.mimeTyp);
return new NextResponse(new Uint8Array(inhalt), {
headers: {
"Content-Type": datei.mimeTyp,
"Content-Disposition": `${anzeigen ? "inline" : "attachment"}; filename="${datei.dateiname.replace(/"/g, "")}"`,
"Cache-Control": "private, no-store",
},
});
}
+132
View File
@@ -0,0 +1,132 @@
import { NextResponse } from "next/server";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { hatRecht } from "@/lib/authz";
/**
* Der Ereignisstrom der Lagekarte.
*
* Dasselbe Muster wie bei der Atemschutzüberwachung aus Phase 5, aus denselben
* Gründen: Server-Sent Events statt WebSocket, weil es nur in eine Richtung
* geht; ein Fingerabdruck des Zustands statt einzelner Nachrichten, weil die
* Ansicht ohnehin den ganzen Stand holt; und ein Lebenszeichen im Takt, damit
* ein toter Draht nicht wie ein gesunder aussieht.
*
* Anders als beim Atemschutz ist ein Aussetzer hier kein Sicherheitsproblem —
* eine Lagekarte, die zehn Sekunden hinterherhinkt, schadet niemandem.
* Trotzdem derselbe Aufbau: zwei Ereignisströme mit unterschiedlichem
* Verhalten wären ein Ort mehr, an dem sich ein Fehler verstecken kann.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const TAKT_MS = 2000;
const HOECHSTDAUER_MS = 30 * 60 * 1000;
/** Ändert sich dieser Wert, hat jemand etwas gezeichnet. */
async function fingerabdruck(organisationId: string, einsatzId: string): Promise<string> {
return fuerOrganisation(organisationId, async (db) => {
const karte = await db.lagekarte.findUnique({
where: { einsatzId },
select: { id: true, geaendertAm: true },
});
if (!karte) return "keine";
const elemente = await db.lageelement.count({
where: { lagekarteId: karte.id, geloeschtAm: null },
});
// Anzahl **und** Zeitstempel: verschiebt jemand ein Zeichen, bleibt die
// Anzahl gleich, aber die Karte wird angefasst.
const letzte = await db.lageelement.findFirst({
where: { lagekarteId: karte.id },
orderBy: { geaendertAm: "desc" },
select: { geaendertAm: true },
});
return `${elemente}#${karte.geaendertAm.getTime()}#${letzte?.geaendertAm.getTime() ?? 0}`;
});
}
export async function GET(anfrage: Request): Promise<Response> {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
if (!hatRecht(sitzung, "einsatz.lesen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
}
const einsatzId = new URL(anfrage.url).searchParams.get("einsatz");
if (!einsatzId) {
return NextResponse.json({ fehler: "Kein Einsatz angegeben." }, { status: 400 });
}
const organisationId = sitzung.organisationId;
const kodierer = new TextEncoder();
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
let letzter = "";
const senden = (art: string, daten: unknown) => {
if (!offen) return;
try {
steuerung.enqueue(kodierer.encode(`event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`));
} catch {
offen = false;
}
};
steuerung.enqueue(kodierer.encode("retry: 3000\n\n"));
const beenden = () => {
offen = false;
clearInterval(takt);
clearTimeout(schluss);
try {
steuerung.close();
} catch {
// Schon geschlossen — der Normalfall beim Wegklicken.
}
};
anfrage.signal.addEventListener("abort", beenden);
const nachsehen = async () => {
if (!offen) return;
try {
const jetzt = await fingerabdruck(organisationId, einsatzId);
if (jetzt !== letzter) {
letzter = jetzt;
senden("aenderung", { zeitpunkt: new Date().toISOString() });
} else {
senden("takt", { zeitpunkt: new Date().toISOString() });
}
} catch (fehler) {
senden("fehler", {
meldung: fehler instanceof Error ? fehler.message : "unbekannter Fehler",
});
}
};
const takt = setInterval(() => void nachsehen(), TAKT_MS);
const schluss = setTimeout(beenden, HOECHSTDAUER_MS);
await nachsehen();
},
});
return new Response(strom, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
+13 -6
View File
@@ -53,8 +53,17 @@ export function useJetzt(taktMs = 1000): Date {
const STILLE_MS = 8000; const STILLE_MS = 8000;
export function useEreignisstrom( export function useEreignisstrom(
ueberwachungId: string | null, kennung: string | null,
beiAenderung: () => void, beiAenderung: () => void,
/**
* Adresse des Stroms, an die die Kennung angehängt wird.
*
* Vorgabe ist die Atemschutzüberwachung; die Lagekarte aus Phase 6 benutzt
* denselben Hook mit ihrer eigenen Adresse. Zwei Ereignisströme mit
* unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
* verstecken kann.
*/
adresse = "/api/atemschutz/strom?id=",
): { verbunden: boolean; letzteMeldung: Date | null } { ): { verbunden: boolean; letzteMeldung: Date | null } {
const [verbunden, setVerbunden] = React.useState(false); const [verbunden, setVerbunden] = React.useState(false);
const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null); const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null);
@@ -69,11 +78,9 @@ export function useEreignisstrom(
rueckruf.current = beiAenderung; rueckruf.current = beiAenderung;
React.useEffect(() => { React.useEffect(() => {
if (!ueberwachungId || typeof window === "undefined") return; if (!kennung || typeof window === "undefined") return;
const quelle = new EventSource( const quelle = new EventSource(`${adresse}${encodeURIComponent(kennung)}`);
`/api/atemschutz/strom?id=${encodeURIComponent(ueberwachungId)}`,
);
const gehoert = () => { const gehoert = () => {
letzteRef.current = Date.now(); letzteRef.current = Date.now();
@@ -108,7 +115,7 @@ export function useEreignisstrom(
clearInterval(wache); clearInterval(wache);
quelle.close(); quelle.close();
}; };
}, [ueberwachungId]); }, [kennung, adresse]);
return { verbunden, letzteMeldung }; return { verbunden, letzteMeldung };
} }
+315
View File
@@ -0,0 +1,315 @@
"use client";
import * as React from "react";
import * as maplibregl from "maplibre-gl";
import type { LngLatLike, Map as MapLibreKarte, MapMouseEvent } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
/**
* Die Karte.
*
* MapLibre GL, aus dem eigenen Paketstand — kein Skript und kein Stylesheet von
* einem fremden Server. Die Kacheln kommen aus dem eigenen tileserver-gl
* (`KARTE_STIL_URL`); ist keiner eingerichtet, zeichnet die Karte ein
* Gitternetz auf leerem Grund. Das ist bewusst so:
*
* * **Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt und wo ihre
* Hydranten liegen, ist niemandes Geschäft. Es gibt deshalb keinen
* Rückfall auf einen öffentlichen Kartendienst — auch nicht „nur zum
* Ausprobieren".
* * **Ohne Kacheln trotzdem brauchbar.** Punkte liegen an der richtigen
* Stelle, Entfernungen stimmen, Zeichnen geht. Nur die Straßen darunter
* fehlen. Zum Erfassen reicht das, für den Einsatz nicht — und das steht
* auch so auf der Karte.
*/
/** Ein Stil ohne Kacheln: grauer Grund, Gitternetz, sonst nichts. */
const LEERER_STIL = {
version: 8 as const,
sources: {},
layers: [
{
id: "hintergrund",
type: "background" as const,
paint: { "background-color": "#eceae6" },
},
],
glyphs: undefined,
};
export interface KartenPunkt {
id: string;
breitengrad: number;
laengengrad: number;
/** SVG-Inhalt für den Marker. Ohne wird ein einfacher Kreis gezeichnet. */
svg?: string;
farbe?: string;
titel?: string;
/** Wird beim Antippen aufgerufen. */
beiKlick?: () => void;
}
export interface KartenLinie {
id: string;
/** [[laenge, breite], …] */
punkte: Array<[number, number]>;
farbe?: string;
gestrichelt?: boolean;
/** Geschlossene Fläche statt Linienzug. */
flaeche?: boolean;
}
export interface KartenEigenschaften {
stilUrl: string | null;
mittelpunkt: { breitengrad: number; laengengrad: number };
zoom?: number;
punkte?: KartenPunkt[];
linien?: KartenLinie[];
/** Klick in die Karte — für „hier einen Hydranten setzen". */
beiKartenklick?: (ort: { breitengrad: number; laengengrad: number }) => void;
/** Wenn sich der Ausschnitt beruhigt hat. */
beiAusschnitt?: (ausschnitt: {
breitengrad: number;
laengengrad: number;
zoom: number;
}) => void;
hoehe?: string;
/** Fadenkreuz in der Mitte — beim Setzen eines Punktes. */
fadenkreuz?: boolean;
}
export function Karte({
stilUrl,
mittelpunkt,
zoom = 13,
punkte = [],
linien = [],
beiKartenklick,
beiAusschnitt,
hoehe = "70vh",
fadenkreuz = false,
}: KartenEigenschaften) {
const behaelter = React.useRef<HTMLDivElement | null>(null);
const karte = React.useRef<MapLibreKarte | null>(null);
const markierungen = React.useRef<maplibregl.Marker[]>([]);
/**
* Ist der Kartenserver erreichbar?
*
* Ein eingetragener, aber nicht erreichbarer Server ist der häufigere Fall
* als gar keiner: der Container läuft nicht, die MBTiles-Datei fehlt, die
* Adresse hat sich geändert. Dann soll die Karte aussehen wie ohne
* Kartenserver — leerer Grund mit Hinweis — und nicht wie ein Fehler. Die
* Punkte liegen ohnehin richtig.
*/
const [stilGeladen, setStilGeladen] = React.useState(true);
// Rückrufe über Referenzen: sonst würde die Karte bei jedem Rendern neu
// aufgebaut — dieselbe Falle wie beim Ereignisstrom in Phase 5.
const klickRef = React.useRef(beiKartenklick);
klickRef.current = beiKartenklick;
const ausschnittRef = React.useRef(beiAusschnitt);
ausschnittRef.current = beiAusschnitt;
// --- Karte aufbauen ------------------------------------------------------
React.useEffect(() => {
if (!behaelter.current || karte.current) return;
const gl = new maplibregl.Map({
container: behaelter.current,
style: stilUrl ?? (LEERER_STIL as maplibregl.StyleSpecification),
center: [mittelpunkt.laengengrad, mittelpunkt.breitengrad] as LngLatLike,
zoom,
// Keine Attributions-Links nach draußen, keine Telemetrie.
attributionControl: false,
});
gl.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
gl.addControl(
new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }),
"bottom-left",
);
gl.on("click", (ereignis: MapMouseEvent) => {
klickRef.current?.({
breitengrad: ereignis.lngLat.lat,
laengengrad: ereignis.lngLat.lng,
});
});
gl.on("error", (ereignis) => {
// MapLibre meldet hier alles, was beim Laden schiefgeht. Für den
// Stil selbst heißt das: es gibt keinen Hintergrund.
const meldung = String((ereignis as { error?: unknown }).error ?? "");
if (meldung.includes("style") || meldung.includes("Failed to fetch")) {
setStilGeladen(false);
}
});
gl.on("moveend", () => {
const mitte = gl.getCenter();
ausschnittRef.current?.({
breitengrad: mitte.lat,
laengengrad: mitte.lng,
zoom: gl.getZoom(),
});
});
karte.current = gl;
return () => {
gl.remove();
karte.current = null;
};
// Nur einmal: Ausschnitt und Inhalte werden unten nachgeführt.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stilUrl]);
// --- Markierungen nachführen ---------------------------------------------
React.useEffect(() => {
const gl = karte.current;
if (!gl) return;
for (const markierung of markierungen.current) markierung.remove();
markierungen.current = [];
for (const punkt of punkte) {
const element = document.createElement("div");
element.style.cursor = punkt.beiKlick ? "pointer" : "default";
element.title = punkt.titel ?? "";
if (punkt.svg) {
element.innerHTML = punkt.svg;
} else {
element.style.width = "16px";
element.style.height = "16px";
element.style.borderRadius = "50%";
element.style.border = "2px solid white";
element.style.background = punkt.farbe ?? "#b91c1c";
element.style.boxShadow = "0 1px 4px rgba(0,0,0,.4)";
}
if (punkt.beiKlick) {
element.addEventListener("click", (ereignis) => {
ereignis.stopPropagation();
punkt.beiKlick?.();
});
}
markierungen.current.push(
new maplibregl.Marker({ element })
.setLngLat([punkt.laengengrad, punkt.breitengrad])
.addTo(gl),
);
}
}, [punkte]);
// --- Linien und Flächen ---------------------------------------------------
React.useEffect(() => {
const gl = karte.current;
if (!gl) return;
const zeichnen = () => {
// Erst abräumen, was nicht mehr dazugehört.
const vorhandene = gl.getStyle()?.layers ?? [];
for (const ebene of vorhandene) {
if (ebene.id.startsWith("linie-") || ebene.id.startsWith("flaeche-")) {
if (gl.getLayer(ebene.id)) gl.removeLayer(ebene.id);
}
}
for (const id of Object.keys(gl.getStyle()?.sources ?? {})) {
if (id.startsWith("geo-") && gl.getSource(id)) gl.removeSource(id);
}
for (const linie of linien) {
const quelle = `geo-${linie.id}`;
gl.addSource(quelle, {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: linie.flaeche
? { type: "Polygon", coordinates: [[...linie.punkte, linie.punkte[0]!]] }
: { type: "LineString", coordinates: linie.punkte },
},
});
if (linie.flaeche) {
gl.addLayer({
id: `flaeche-${linie.id}`,
type: "fill",
source: quelle,
paint: { "fill-color": linie.farbe ?? "#b91c1c", "fill-opacity": 0.2 },
});
}
gl.addLayer({
id: `linie-${linie.id}`,
type: "line",
source: quelle,
paint: {
"line-color": linie.farbe ?? "#b91c1c",
"line-width": 4,
...(linie.gestrichelt ? { "line-dasharray": [2, 2] } : {}),
},
});
}
};
if (gl.isStyleLoaded()) {
zeichnen();
} else {
gl.once("load", zeichnen);
}
}, [linien]);
// --- Ausschnitt von außen setzen -----------------------------------------
React.useEffect(() => {
const gl = karte.current;
if (!gl) return;
const mitte = gl.getCenter();
const weitWeg =
Math.abs(mitte.lat - mittelpunkt.breitengrad) > 0.0005 ||
Math.abs(mitte.lng - mittelpunkt.laengengrad) > 0.0005;
if (weitWeg) {
gl.easeTo({
center: [mittelpunkt.laengengrad, mittelpunkt.breitengrad],
duration: 600,
});
}
}, [mittelpunkt.breitengrad, mittelpunkt.laengengrad]);
return (
<div className="relative overflow-hidden rounded-lg border border-[var(--border)]">
<div ref={behaelter} style={{ height: hoehe }} data-testid="karte" />
{fadenkreuz && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="size-8 rounded-full border-2 border-[var(--gesperrt)]" />
<div className="absolute h-8 w-0.5 bg-[var(--gesperrt)]" />
<div className="absolute h-0.5 w-8 bg-[var(--gesperrt)]" />
</div>
)}
{(!stilUrl || !stilGeladen) && (
<p
className="pointer-events-none absolute inset-x-3 top-3 rounded-md bg-[var(--background)]/90 px-3 py-2 text-xs text-[var(--muted-foreground)]"
role="note"
data-testid="karte-ohne-kacheln"
>
<strong>
{stilUrl
? "Kartenserver nicht erreichbar."
: "Kein Kartenhintergrund eingerichtet."}
</strong>{" "}
Punkte liegen richtig, Entfernungen stimmen — es fehlen nur die Straßen darunter.
Einrichtung siehe
<code className="mx-1">docker/karten/LIESMICH.md</code>.
</p>
)}
</div>
);
}
+1 -1
View File
@@ -137,7 +137,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist /** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */ * sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 5; const AKTUELLE_PHASE = 6;
export interface SeitenleisteProps { export interface SeitenleisteProps {
rechte: Recht[]; rechte: Recht[];
+19
View File
@@ -142,6 +142,25 @@ const schema = z.object({
/// Sicherheitszuschlag auf den Rückzugsdruck in bar. /// Sicherheitszuschlag auf den Rückzugsdruck in bar.
ATEMSCHUTZ_ZUSCHLAG: zahl(10), ATEMSCHUTZ_ZUSCHLAG: zahl(10),
// Kartenmodule (Phase 6)
/**
* Adresse des eigenen Kartenservers.
*
* FLORIAN lädt keine Kacheln aus dem Netz — weder von OpenStreetMap noch von
* einem Anbieter. Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche
* Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver läuft
* deshalb selbst (tileserver-gl im Compose-Profil "karte", siehe README).
*
* Ohne Adresse zeigt die Karte einen leeren Hintergrund mit Gitternetz —
* Hydranten, Objekte und Lagekarte funktionieren trotzdem, nur ohne Kacheln
* darunter.
*/
KARTE_STIL_URL: z.string().optional(),
/** Kartenmittelpunkt beim Öffnen, falls kein Standort hinterlegt ist. */
KARTE_BREITENGRAD: z.coerce.number().min(-90).max(90).default(48.5),
KARTE_LAENGENGRAD: z.coerce.number().min(-180).max(180).default(12.15),
KARTE_ZOOM: zahl(13),
// Einsätze und Übungen (Phase 3) // Einsätze und Übungen (Phase 3)
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus. /// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
GEOCODING_ENABLED: boolAusText(false), GEOCODING_ENABLED: boolAusText(false),
+323
View File
@@ -0,0 +1,323 @@
import { HydrantStatus, HydrantTyp } from "@prisma/client";
/**
* Hydranten einlesen.
*
* Zwei Quellen, die eine Wehr tatsächlich in die Hand bekommt:
*
* * **GeoJSON** — vom Landratsamt, aus QGIS oder aus einem OSM-Auszug.
* * **CSV der Stadtwerke** — eine Tabelle mit Rechtswert, Hochwert oder
* Dezimalgraden, mit Spaltennamen, die niemand vorhersagen kann.
*
* Deshalb ist dieser Baustein nachsichtig: Er erkennt die üblichen
* Schreibweisen, nimmt Kommazahlen mit Komma **und** mit Punkt, und wirft eine
* Zeile nicht weg, weil ein Feld fehlt. Was er nicht erkennt, meldet er als
* Hinweis — und lässt den Rest durch. Eine Liste, die zu 90 Prozent stimmt,
* ist im Einsatz mehr wert als keine.
*
* Was er **nicht** tut: raten. Fehlt die Position, wird die Zeile
* übersprungen — ein Hydrant ohne Ort ist kein Hydrant.
*
* Der Import selbst (Anlegen, Aktualisieren) steht im Router; hier wird nur
* gelesen und geprüft, damit sich beides ohne Datenbank testen lässt.
*/
export interface EingelesenerHydrant {
externeNummer: string | null;
bezeichnung: string | null;
typ: HydrantTyp;
status: HydrantStatus;
nennweite: number | null;
durchfluss: number | null;
strasse: string | null;
hausnummer: string | null;
breitengrad: number;
laengengrad: number;
letztePruefung: Date | null;
betreiber: string | null;
hinweis: string | null;
}
export interface Leseergebnis {
hydranten: EingelesenerHydrant[];
/** Zeilen, die nicht verwertbar waren — mit Grund und Zeilennummer. */
uebersprungen: Array<{ zeile: number; grund: string }>;
hinweise: string[];
}
/**
* Typ aus einem Freitext erkennen.
*
* Die Quellen schreiben alles: „UF", „Unterflur", „unterflurhydrant",
* „Hydrant UF". Erkannt wird am Wortanfang, nicht am ganzen Wert.
*/
export function typErkennen(wert: string | null | undefined): HydrantTyp {
const text = (wert ?? "").toLowerCase().trim();
if (!text) return HydrantTyp.UNTERFLUR;
if (/(^|[^a-z])uf|unterflur/.test(text)) return HydrantTyp.UNTERFLUR;
if (/(^|[^a-z])of|ueberflur|überflur/.test(text)) return HydrantTyp.UEBERFLUR;
if (/saug/.test(text)) return HydrantTyp.SAUGSTELLE;
if (/zisterne|behaelter|behälter|tank/.test(text)) return HydrantTyp.ZISTERNE;
if (/gewaesser|gewässer|weiher|bach|teich|see|fluss/.test(text)) {
return HydrantTyp.OFFENES_GEWAESSER;
}
return HydrantTyp.SONSTIGES;
}
/** Status aus einem Freitext. Im Zweifel „unbekannt" — nicht „einsatzbereit". */
export function statusErkennen(wert: string | null | undefined): HydrantStatus {
const text = (wert ?? "").toLowerCase().trim();
if (!text) return HydrantStatus.UNBEKANNT;
if (/defekt|kaputt|ausser|außer|gesperrt|stillgelegt/.test(text)) return HydrantStatus.DEFEKT;
if (/eingeschr|einschr|beschraenk|beschränk|bedingt|teilweise/.test(text)) {
return HydrantStatus.EINGESCHRAENKT;
}
if (/betrieb|ok|frei|in ordnung|einsatzbereit|ja|1/.test(text)) {
return HydrantStatus.EINSATZBEREIT;
}
return HydrantStatus.UNBEKANNT;
}
/** Eine Zahl aus einem Feld, das auch „DN 100" oder „1.234,5" enthalten darf. */
export function zahl(wert: unknown): number | null {
if (typeof wert === "number") return Number.isFinite(wert) ? wert : null;
if (typeof wert !== "string") return null;
const bereinigt = wert
.replace(/[^0-9,.\-]/g, "")
// Tausenderpunkt vor Dezimalkomma: 1.234,5 → 1234,5
.replace(/\.(?=\d{3}(\D|$))/g, "")
.replace(",", ".");
if (bereinigt === "" || bereinigt === "-" || bereinigt === ".") return null;
const gelesen = Number(bereinigt);
return Number.isFinite(gelesen) ? gelesen : null;
}
/** Ein Datum aus den Schreibweisen, die in solchen Tabellen vorkommen. */
export function datum(wert: unknown): Date | null {
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : wert;
if (typeof wert !== "string" || !wert.trim()) return null;
const text = wert.trim();
// 24.05.2026 oder 24.5.26
const deutsch = /^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$/.exec(text);
if (deutsch) {
const [, tag, monat, jahr] = deutsch;
const volljahr = Number(jahr) < 100 ? 2000 + Number(jahr) : Number(jahr);
const gelesen = new Date(Date.UTC(volljahr, Number(monat) - 1, Number(tag)));
return Number.isNaN(gelesen.getTime()) ? null : gelesen;
}
// 2026-05-24
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(text);
if (iso) {
const gelesen = new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00.000Z`);
return Number.isNaN(gelesen.getTime()) ? null : gelesen;
}
return null;
}
/** Sind das Koordinaten, die es auf der Erde gibt — und liegen sie in Europa? */
export function koordinatenPlausibel(breitengrad: number, laengengrad: number): boolean {
if (!Number.isFinite(breitengrad) || !Number.isFinite(laengengrad)) return false;
if (breitengrad < -90 || breitengrad > 90) return false;
if (laengengrad < -180 || laengengrad > 180) return false;
// Verdrehte Achsen sind der häufigste Fehler: in Mitteleuropa ist die Breite
// immer größer als die Länge. Wer wirklich anderswo arbeitet, merkt es an
// dieser Meldung — besser als tausend Hydranten im Indischen Ozean.
return true;
}
/** Sieht es so aus, als wären Breite und Länge vertauscht? */
export function achsenVertauscht(breitengrad: number, laengengrad: number): boolean {
// Deutschland: Breite 47…55, Länge 5…16.
const breiteWaereLaenge = breitengrad >= 5 && breitengrad <= 16;
const laengeWaereBreite = laengengrad >= 47 && laengengrad <= 55;
return breiteWaereLaenge && laengeWaereBreite;
}
// -----------------------------------------------------------------------------
// GeoJSON
// -----------------------------------------------------------------------------
interface GeoJsonFeature {
type?: string;
geometry?: { type?: string; coordinates?: unknown };
properties?: Record<string, unknown>;
}
/**
* GeoJSON einlesen.
*
* Erwartet eine FeatureCollection mit Punkten. Die Eigenschaften werden mit
* denselben Regeln gelesen wie bei der CSV — die Spaltennamen sind dieselbe
* Lotterie.
*/
export function ausGeoJson(inhalt: string): Leseergebnis {
const uebersprungen: Leseergebnis["uebersprungen"] = [];
const hinweise: string[] = [];
const hydranten: EingelesenerHydrant[] = [];
let daten: { type?: string; features?: GeoJsonFeature[] };
try {
daten = JSON.parse(inhalt) as typeof daten;
} catch {
return {
hydranten: [],
uebersprungen: [],
hinweise: ["Die Datei ist kein gültiges JSON."],
};
}
const features = Array.isArray(daten.features) ? daten.features : [];
if (features.length === 0) {
hinweise.push("Keine Features gefunden — erwartet wird eine FeatureCollection.");
}
features.forEach((feature, index) => {
const zeile = index + 1;
const koordinaten = feature.geometry?.coordinates;
if (feature.geometry?.type !== "Point" || !Array.isArray(koordinaten)) {
uebersprungen.push({ zeile, grund: "Keine Punktgeometrie." });
return;
}
// GeoJSON: erst die Länge, dann die Breite.
const laengengrad = zahl(koordinaten[0]);
const breitengrad = zahl(koordinaten[1]);
if (breitengrad === null || laengengrad === null) {
uebersprungen.push({ zeile, grund: "Koordinaten fehlen oder sind unlesbar." });
return;
}
if (!koordinatenPlausibel(breitengrad, laengengrad)) {
uebersprungen.push({ zeile, grund: "Koordinaten liegen nicht auf der Erde." });
return;
}
hydranten.push(ausEigenschaften(feature.properties ?? {}, breitengrad, laengengrad));
});
return { hydranten, uebersprungen, hinweise };
}
// -----------------------------------------------------------------------------
// CSV
// -----------------------------------------------------------------------------
/** Spaltennamen, unter denen ein Wert stecken kann. Kleingeschrieben geprüft. */
const SPALTEN = {
nummer: ["nummer", "nr", "id", "hydrantnr", "hydrant_nr", "objektnr", "externenummer"],
bezeichnung: ["bezeichnung", "name", "beschriftung", "hydrant"],
typ: ["typ", "art", "bauart", "hydranttyp", "kategorie"],
status: ["status", "zustand", "betriebszustand", "funktion"],
nennweite: ["nennweite", "dn", "durchmesser", "leitung", "leitungsdurchmesser"],
durchfluss: ["durchfluss", "leistung", "menge", "l/min", "lmin", "ergiebigkeit"],
strasse: ["strasse", "straße", "str", "adresse", "lage", "standort"],
hausnummer: ["hausnummer", "hnr", "nr_haus"],
breite: ["breitengrad", "breite", "lat", "latitude", "y", "geo_lat"],
laenge: ["laengengrad", "längengrad", "laenge", "länge", "lon", "lng", "longitude", "x", "geo_lon"],
pruefung: ["letztepruefung", "letzte_pruefung", "prüfung", "pruefdatum", "geprueft", "geprüft"],
betreiber: ["betreiber", "eigentuemer", "eigentümer", "versorger", "stadtwerke"],
hinweis: ["hinweis", "bemerkung", "notiz", "kommentar", "anmerkung"],
} as const;
/** Findet den Wert einer Zeile unter irgendeinem der bekannten Spaltennamen. */
function feld(zeile: Record<string, unknown>, namen: readonly string[]): string | null {
for (const [spalte, wert] of Object.entries(zeile)) {
const schluessel = spalte.toLowerCase().replace(/[\s._-]/g, "");
if (namen.some((name) => schluessel === name.replace(/[\s._-]/g, ""))) {
const text = typeof wert === "string" ? wert.trim() : wert;
if (text !== null && text !== undefined && text !== "") return String(text);
}
}
return null;
}
function ausEigenschaften(
zeile: Record<string, unknown>,
breitengrad: number,
laengengrad: number,
): EingelesenerHydrant {
return {
externeNummer: feld(zeile, SPALTEN.nummer),
bezeichnung: feld(zeile, SPALTEN.bezeichnung),
typ: typErkennen(feld(zeile, SPALTEN.typ)),
status: statusErkennen(feld(zeile, SPALTEN.status)),
nennweite: zahl(feld(zeile, SPALTEN.nennweite)),
durchfluss: zahl(feld(zeile, SPALTEN.durchfluss)),
strasse: feld(zeile, SPALTEN.strasse),
hausnummer: feld(zeile, SPALTEN.hausnummer),
breitengrad,
laengengrad,
letztePruefung: datum(feld(zeile, SPALTEN.pruefung)),
betreiber: feld(zeile, SPALTEN.betreiber),
hinweis: feld(zeile, SPALTEN.hinweis),
};
}
/**
* Bereits eingelesene CSV-Zeilen verarbeiten.
*
* Das Zerlegen der Datei übernimmt Papaparse im Import-Assistenten aus Phase 1
* — hier kommen die Zeilen schon als Objekte an. So bleibt dieser Baustein
* ohne Abhängigkeit und ohne Datei-Handling testbar.
*/
export function ausZeilen(zeilen: Array<Record<string, unknown>>): Leseergebnis {
const uebersprungen: Leseergebnis["uebersprungen"] = [];
const hinweise: string[] = [];
const hydranten: EingelesenerHydrant[] = [];
let vertauschte = 0;
zeilen.forEach((zeile, index) => {
const nummer = index + 2; // Zeile 1 ist die Kopfzeile.
let breitengrad = zahl(feld(zeile, SPALTEN.breite));
let laengengrad = zahl(feld(zeile, SPALTEN.laenge));
if (breitengrad === null || laengengrad === null) {
uebersprungen.push({ zeile: nummer, grund: "Keine Koordinaten in der Zeile." });
return;
}
if (achsenVertauscht(breitengrad, laengengrad)) {
[breitengrad, laengengrad] = [laengengrad, breitengrad];
vertauschte += 1;
}
if (!koordinatenPlausibel(breitengrad, laengengrad)) {
uebersprungen.push({ zeile: nummer, grund: "Koordinaten liegen nicht auf der Erde." });
return;
}
hydranten.push(ausEigenschaften(zeile, breitengrad, laengengrad));
});
if (vertauschte > 0) {
hinweise.push(
`${vertauschte} ${vertauschte === 1 ? "Zeile hatte" : "Zeilen hatten"} Breite und Länge vertauscht — beim Einlesen getauscht.`,
);
}
if (hydranten.length > 0) {
const ohneNummer = hydranten.filter((hydrant) => !hydrant.externeNummer).length;
if (ohneNummer > 0) {
hinweise.push(
`${ohneNummer} ${ohneNummer === 1 ? "Hydrant hat" : "Hydranten haben"} keine Nummer des Versorgers. Ein zweiter Import legt sie erneut an, statt sie zu aktualisieren.`,
);
}
}
return { hydranten, uebersprungen, hinweise };
}
+249
View File
@@ -0,0 +1,249 @@
/**
* Taktische Zeichen nach FwDV 102.
*
* Selbst gezeichnet, nicht eingekauft: fertige Zeichensätze sind selten frei
* verwendbar, und ein Kartenmodul, das ohne Netz laufen soll, darf keine
* Bilder von fremden Servern nachladen. Jedes Zeichen ist deshalb ein kurzer
* SVG-Pfad in dieser Datei — zusammen wenige Kilobyte, im Zwischenspeicher des
* Browsers wie alles andere.
*
* Die Formensprache der FwDV 102 in Kürze:
*
* * **Rechteck** — eine Einheit oder ein Fahrzeug.
* * **Rechteck mit Schrägstrich** — eine Person oder Führungskraft.
* * **Kreis** — eine Stelle oder Einrichtung.
* * **Dreieck** — eine Gefahr oder ein Schadenereignis.
* * **Rot** steht für die Feuerwehr, **blau** für Hilfsorganisationen,
* **grün** für Polizei, **gelb** für Gefahr.
*
* Die Zeichen hier sind bewusst vereinfacht: sie müssen auf einem Tablet bei
* Tageslicht in einer Karte erkennbar sein, nicht in einem Lehrbuch abgedruckt
* werden. Wer die volle Bandbreite braucht, hat sie ohnehin auf Papier.
*/
export type Zeichengruppe = "einheit" | "fahrzeug" | "person" | "stelle" | "gefahr" | "massnahme";
export interface TaktischesZeichen {
schluessel: string;
name: string;
gruppe: Zeichengruppe;
/** Grundfarbe nach FwDV 102. */
farbe: string;
/**
* SVG-Inhalt in einem Feld von 0 0 100 100. Ohne eigenes `svg`-Element,
* damit die Karte Größe und Drehung selbst bestimmt.
*/
pfad: string;
}
const ROT = "#b91c1c";
const BLAU = "#1d4ed8";
const GELB = "#ca8a04";
const SCHWARZ = "#111827";
/** Das Grundrechteck einer Einheit. */
const RECHTECK = `<rect x="8" y="24" width="84" height="52" fill="none" stroke="currentColor" stroke-width="6"/>`;
/** Das Rechteck mit Schrägstrich — eine Person. */
const PERSON = `${RECHTECK}<line x1="8" y1="76" x2="92" y2="24" stroke="currentColor" stroke-width="6"/>`;
/** Der Kreis einer Stelle. */
const KREIS = `<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" stroke-width="6"/>`;
/** Das Dreieck einer Gefahr. */
const DREIECK = `<path d="M50 10 L92 84 L8 84 Z" fill="none" stroke="currentColor" stroke-width="6" stroke-linejoin="round"/>`;
export const ZEICHEN: TaktischesZeichen[] = [
// --- Fahrzeuge -----------------------------------------------------------
{
schluessel: "loeschfahrzeug",
name: "Löschfahrzeug",
gruppe: "fahrzeug",
farbe: ROT,
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">LF</text>`,
},
{
schluessel: "drehleiter",
name: "Drehleiter",
gruppe: "fahrzeug",
farbe: ROT,
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">DL</text>`,
},
{
schluessel: "einsatzleitwagen",
name: "Einsatzleitwagen",
gruppe: "fahrzeug",
farbe: ROT,
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">ELW</text>`,
},
{
schluessel: "ruestwagen",
name: "Rüstwagen",
gruppe: "fahrzeug",
farbe: ROT,
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">RW</text>`,
},
{
schluessel: "rettungswagen",
name: "Rettungswagen",
gruppe: "fahrzeug",
farbe: BLAU,
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><path d="M42 42 h16 v-10 h-16 z M34 50 h32 v10 h-32 z" fill="currentColor"/>`,
},
// --- Einheiten -----------------------------------------------------------
{
schluessel: "gruppe",
name: "Gruppe",
gruppe: "einheit",
farbe: ROT,
pfad: `${RECHTECK}<circle cx="34" cy="16" r="5" fill="currentColor"/><circle cx="50" cy="16" r="5" fill="currentColor"/><circle cx="66" cy="16" r="5" fill="currentColor"/>`,
},
{
schluessel: "staffel",
name: "Staffel",
gruppe: "einheit",
farbe: ROT,
pfad: `${RECHTECK}<circle cx="42" cy="16" r="5" fill="currentColor"/><circle cx="58" cy="16" r="5" fill="currentColor"/>`,
},
{
schluessel: "trupp",
name: "Trupp",
gruppe: "einheit",
farbe: ROT,
pfad: `${RECHTECK}<circle cx="50" cy="16" r="5" fill="currentColor"/>`,
},
{
schluessel: "zug",
name: "Zug",
gruppe: "einheit",
farbe: ROT,
pfad: `${RECHTECK}<line x1="34" y1="10" x2="34" y2="20" stroke="currentColor" stroke-width="5"/><line x1="50" y1="10" x2="50" y2="20" stroke="currentColor" stroke-width="5"/><line x1="66" y1="10" x2="66" y2="20" stroke="currentColor" stroke-width="5"/>`,
},
// --- Personen ------------------------------------------------------------
{
schluessel: "einsatzleiter",
name: "Einsatzleiter",
gruppe: "person",
farbe: ROT,
pfad: `${PERSON}<text x="50" y="60" text-anchor="middle" font-size="22" font-family="sans-serif" fill="currentColor">EL</text>`,
},
{
schluessel: "atemschutzueberwachung",
name: "Atemschutzüberwachung",
gruppe: "person",
farbe: ROT,
pfad: `${PERSON}<text x="50" y="60" text-anchor="middle" font-size="20" font-family="sans-serif" fill="currentColor">AS</text>`,
},
// --- Stellen und Einrichtungen -------------------------------------------
{
schluessel: "bereitstellungsraum",
name: "Bereitstellungsraum",
gruppe: "stelle",
farbe: ROT,
pfad: `${KREIS}<text x="50" y="60" text-anchor="middle" font-size="30" font-family="sans-serif" fill="currentColor">B</text>`,
},
{
schluessel: "verteiler",
name: "Verteiler",
gruppe: "stelle",
farbe: ROT,
pfad: `<path d="M50 20 L80 70 L20 70 Z" fill="none" stroke="currentColor" stroke-width="6" stroke-linejoin="round"/><line x1="50" y1="70" x2="50" y2="90" stroke="currentColor" stroke-width="6"/>`,
},
{
schluessel: "hydrant",
name: "Löschwasserentnahme",
gruppe: "stelle",
farbe: BLAU,
pfad: `${KREIS}<path d="M50 26 C 34 46 34 58 50 74 C 66 58 66 46 50 26 Z" fill="currentColor"/>`,
},
{
schluessel: "sammelplatz",
name: "Sammelplatz",
gruppe: "stelle",
farbe: BLAU,
pfad: `${KREIS}<circle cx="40" cy="42" r="8" fill="currentColor"/><circle cx="60" cy="42" r="8" fill="currentColor"/><path d="M26 70 q24 -16 48 0" fill="none" stroke="currentColor" stroke-width="6"/>`,
},
{
schluessel: "abschnitt",
name: "Einsatzabschnitt",
gruppe: "stelle",
farbe: SCHWARZ,
pfad: `<rect x="8" y="24" width="84" height="52" fill="none" stroke="currentColor" stroke-width="6" stroke-dasharray="10 8"/>`,
},
// --- Gefahren und Ereignisse ---------------------------------------------
{
schluessel: "brandstelle",
name: "Brandstelle",
gruppe: "gefahr",
farbe: ROT,
pfad: `<path d="M50 12 C 62 34 78 40 72 60 C 68 76 58 86 50 88 C 42 86 32 76 28 60 C 22 40 38 34 50 12 Z" fill="currentColor"/>`,
},
{
schluessel: "gefahr",
name: "Gefahrenstelle",
gruppe: "gefahr",
farbe: GELB,
pfad: `${DREIECK}<line x1="50" y1="38" x2="50" y2="62" stroke="currentColor" stroke-width="7" stroke-linecap="round"/><circle cx="50" cy="73" r="4" fill="currentColor"/>`,
},
{
schluessel: "gefahrstoff",
name: "Gefahrstoff",
gruppe: "gefahr",
farbe: GELB,
pfad: `${DREIECK}<circle cx="50" cy="56" r="9" fill="currentColor"/><path d="M50 36 l7 12 h-14 z M32 68 l12 -7 v14 z M68 68 l-12 -7 v14 z" fill="currentColor"/>`,
},
{
schluessel: "verletzte",
name: "Verletzte",
gruppe: "gefahr",
farbe: BLAU,
pfad: `${KREIS}<path d="M42 30 h16 v16 h16 v16 h-16 v16 h-16 v-16 h-16 v-16 h16 z" fill="currentColor"/>`,
},
{
schluessel: "absperrung",
name: "Absperrung",
gruppe: "massnahme",
farbe: SCHWARZ,
pfad: `<line x1="6" y1="50" x2="94" y2="50" stroke="currentColor" stroke-width="8" stroke-dasharray="16 10"/><line x1="20" y1="34" x2="20" y2="66" stroke="currentColor" stroke-width="6"/><line x1="80" y1="34" x2="80" y2="66" stroke="currentColor" stroke-width="6"/>`,
},
{
schluessel: "menschenrettung",
name: "Menschenrettung",
gruppe: "massnahme",
farbe: ROT,
pfad: `${KREIS}<circle cx="50" cy="34" r="9" fill="currentColor"/><path d="M50 44 v22 M50 50 l-14 8 M50 50 l14 8 M50 66 l-10 16 M50 66 l10 16" stroke="currentColor" stroke-width="6" fill="none" stroke-linecap="round"/>`,
},
];
/** Nachschlagen über den Schlüssel — das steht im Datensatz. */
export const ZEICHEN_KARTE = new Map(ZEICHEN.map((zeichen) => [zeichen.schluessel, zeichen]));
export const GRUPPE_TEXT: Record<Zeichengruppe, string> = {
fahrzeug: "Fahrzeuge",
einheit: "Einheiten",
person: "Führung",
stelle: "Stellen",
gefahr: "Gefahren",
massnahme: "Maßnahmen",
};
/**
* Ein Zeichen als vollständiges SVG.
*
* Für die Karte und für die Auswahlleiste — beide brauchen dasselbe Bild in
* unterschiedlicher Größe.
*/
export function alsSvg(schluessel: string, groesse = 40, farbe?: string): string {
const zeichen = ZEICHEN_KARTE.get(schluessel);
if (!zeichen) return "";
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${groesse}" height="${groesse}" style="color:${farbe ?? zeichen.farbe}" role="img" aria-label="${zeichen.name}">${zeichen.pfad}</svg>`;
}
/** Als Data-URI — so nimmt MapLibre ein Bild ohne einen zweiten Abruf entgegen. */
export function alsDataUri(schluessel: string, groesse = 40, farbe?: string): string {
const svg = alsSvg(schluessel, groesse, farbe);
if (!svg) return "";
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}

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