@@ -185,6 +185,23 @@ KIOSK_SPERRDAUER=300
|
||||
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
|
||||
KIOSK_IDLE_TIMEOUT=120
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Kartenmodule (Phase 6)
|
||||
# -----------------------------------------------------------------------------
|
||||
# FLORIAN laedt keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre
|
||||
# Hydranten liegen, ist niemandes Geschaeft — der Kartenserver laeuft selbst:
|
||||
#
|
||||
# docker compose --profile karte up -d
|
||||
#
|
||||
# Dafuer wird eine MBTiles-Datei des Ausrueckegebiets gebraucht (siehe README).
|
||||
# Ohne KARTE_STIL_URL zeigt die Karte einen leeren Hintergrund mit Gitternetz;
|
||||
# Hydranten, Objekte und Lagekarte funktionieren trotzdem.
|
||||
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
|
||||
# Kartenmittelpunkt beim Oeffnen (Musterhausen).
|
||||
KARTE_BREITENGRAD=48.5
|
||||
KARTE_LAENGENGRAD=12.15
|
||||
KARTE_ZOOM=13
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Atemschutz nach FwDV 7 (Phase 5)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,117 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
Nächster Schritt ist Phase 6 (Kartenmodule).
|
||||
Nächster Schritt ist Phase 7 (Benachrichtigungen und Termine).
|
||||
|
||||
---
|
||||
|
||||
## [0.7.0] — 2026-09-04
|
||||
|
||||
Phase 6: Kartenmodule. Ab hier weiß die Wehr auf der Anfahrt, wo der nächste
|
||||
Hydrant liegt und was er hergibt.
|
||||
|
||||
**Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt, wo ihre Hydranten liegen
|
||||
und welche Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver
|
||||
läuft selbst (tileserver-gl im Compose-Profil `karte`); ohne ihn zeigt die
|
||||
Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und
|
||||
Umkreissuche funktionieren trotzdem.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Hydrant` mit Typ (Unterflur, Überflur, Saugstelle, Zisterne, offenes
|
||||
Gewässer), Nennweite, Durchfluss, Zustand, letzter Prüfung und Hinweis für
|
||||
das, was man vor Ort wissen muss.
|
||||
- `Objekt` mit Kategorie, **Zufahrt, Gefahren und Schlüsseldepot als eigene
|
||||
Felder** — im Einsatz sucht niemand in einem Absatz. Dazu `Objektkontakt`
|
||||
(mit „jederzeit erreichbar") und `Objektplan` (DIN 14095).
|
||||
- `Lagekarte` und `Lageelement` — eine Karte je Einsatz, jedes Element mit
|
||||
Geometrie, Zeichen und Urheber.
|
||||
- `Wasserfoerderung` und `Foerderpunkt` — die Eingaben einer Förderstrecke.
|
||||
Das Ergebnis wird **nicht** gespeichert, sondern bei jeder Anzeige neu
|
||||
gerechnet.
|
||||
- Standorte haben jetzt Dezimalgrade neben dem PostGIS-Punkt und liegen damit
|
||||
auf der Karte.
|
||||
|
||||
**Karte**
|
||||
|
||||
- MapLibre GL aus dem eigenen Paketstand, Kacheln aus dem eigenen
|
||||
tileserver-gl. Kein Skript, kein Stylesheet, kein Bild von einem fremden
|
||||
Server.
|
||||
- Hydranten und Objekte als getrennt schaltbare Ebenen, Filter nach Art und
|
||||
Zustand, Suche über Straße, Nummer, Objektname und Gefahrenstichwort.
|
||||
- **Umkreissuche über PostGIS**: `ST_DWithin` auf der Geografie mit
|
||||
GiST-Index — in die Karte tippen, und es steht da, was in 500 Metern
|
||||
erreichbar ist, nach Entfernung sortiert.
|
||||
|
||||
**Import**
|
||||
|
||||
- GeoJSON und Tabellen der Stadtwerke. Spaltennamen werden erkannt („DN",
|
||||
„Lage", „Latitude", „Pruefdatum"), deutsche Kommazahlen und Datumsangaben
|
||||
ebenso, vertauschte Achsen werden erkannt und zurückgedreht.
|
||||
- Zwei Schritte wie beim Geräteimport aus Phase 1: erst zeigen, was
|
||||
herauskäme, dann übernehmen. Übersprungene Zeilen stehen mit Zeilennummer
|
||||
und Grund da.
|
||||
- Wiederholbar: Hydranten mit einer Nummer des Versorgers werden aktualisiert
|
||||
statt doppelt angelegt.
|
||||
|
||||
**Objekte und Feuerwehrpläne**
|
||||
|
||||
- Zufahrt, Gefahren, Schlüsseldepot und Löschwasser am Objekt stehen ganz
|
||||
oben — in dieser Reihenfolge werden sie auf der Anfahrt gelesen.
|
||||
- Ansprechpartner mit Telefonnummern zum Antippen und der Angabe, wer nachts
|
||||
ans Telefon geht.
|
||||
- Planblätter nach DIN 14095 mit Art und Stand. Ein Plan von 2009 ist eine
|
||||
Vermutung, und das steht dran.
|
||||
|
||||
**Lagekarte**
|
||||
|
||||
- 22 taktische Zeichen nach FwDV 102, **selbst gezeichnet** als SVG in
|
||||
`lib/karte/taktische-zeichen.ts`: Fahrzeuge, Einheiten, Führung, Stellen,
|
||||
Gefahren, Maßnahmen.
|
||||
- Zeichen setzen, Linien ziehen, Flächen aufziehen, beschriften — ein
|
||||
Werkzeug, ein Klick.
|
||||
- Abgleich zwischen Geräten über denselben Ereignisstrom wie die
|
||||
Atemschutzüberwachung aus Phase 5; der Zustand der Verbindung steht sichtbar
|
||||
da.
|
||||
- Zurücknehmen statt löschen: was um 14:12 eingezeichnet wurde, gehört zur
|
||||
Mitschrift.
|
||||
|
||||
**Wasserförderung**
|
||||
|
||||
- Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen — die
|
||||
Rechnung läuft mit: Reibungsverlust, Höhenunterschied, Zahl der
|
||||
Verstärkerpumpen, Schlauchlängen je Abschnitt.
|
||||
- Gerechnet wird im Browser **und** auf dem Server mit demselben Code.
|
||||
- Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
|
||||
statt eine Zahl auszuwerfen, die niemand halten kann.
|
||||
- Unter jeder Rechnung steht, dass sie eine Planungshilfe ist — was zählt, ist
|
||||
der Druck am Verteiler.
|
||||
|
||||
**Dateien abrufen**
|
||||
|
||||
- `/api/dateien/[id]` liefert eine abgelegte Datei aus, mandantengebunden und
|
||||
mit Eintrag im Protokoll. Bis Phase 6 gab es nur den Upload und eigene
|
||||
Endpunkte je Dokumentart.
|
||||
|
||||
**Sonstiges**
|
||||
|
||||
- Der Seed legt 24 Löschwasserstellen an — mit Weiher, Zisterne und
|
||||
Saugstelle im Außenbereich, zwei defekten und zwei ungeprüften — sowie drei
|
||||
Objekte mit Ansprechpartnern: Schule, Pflegeheim, Aussiedlerhof.
|
||||
- 59 neue Tests: 48 Unit-Tests zu Druckverlust, Förderplan und Import,
|
||||
11 Playwright-Durchläufe je Browser.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Der Koordinaten-Trigger aus Phase 3 ist jetzt allgemein: eine Funktion für
|
||||
jede Tabelle mit Breiten- und Längengrad. Sie hängt an Standort, Hydrant und
|
||||
Objekt.
|
||||
- Der Ereignisstrom-Hook aus Phase 5 nimmt jetzt eine Adresse entgegen und
|
||||
wird von Lagekarte und Atemschutzüberwachung gemeinsam benutzt. Zwei Ströme
|
||||
mit unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||||
verstecken kann.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
| Phase 3 — Einsätze und Übungen | fertig |
|
||||
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
|
||||
| Phase 5 — Atemschutz (FwDV 7) | fertig |
|
||||
| Phase 6 — Kartenmodule | offen |
|
||||
| Phase 6 — Kartenmodule | fertig |
|
||||
| Phase 7 — Benachrichtigungen und Termine | offen |
|
||||
| Phase 8 — Lokale KI | offen |
|
||||
| Phase 9 — Kassenverwaltung | offen |
|
||||
@@ -664,6 +664,127 @@ Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Kartenmodule
|
||||
|
||||
Die Frage, wegen der es das Modul gibt, wird auf der Anfahrt gestellt: **Was
|
||||
liegt in der Nähe, und was gibt es her?**
|
||||
|
||||
### Keine Kacheln aus dem Netz
|
||||
|
||||
Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche Objekte sie im Blick
|
||||
hat, ist niemandes Geschäft. FLORIAN lädt deshalb **keine** Kartenkacheln von
|
||||
einem öffentlichen Dienst — auch nicht „nur zum Ausprobieren". Der Kartenserver
|
||||
läuft selbst:
|
||||
|
||||
```bash
|
||||
docker compose --profile karte up -d
|
||||
```
|
||||
|
||||
Dafür wird eine MBTiles-Datei des eigenen Ausrückegebiets gebraucht; woher man
|
||||
sie bekommt, steht in [docker/karten/LIESMICH.md](docker/karten/LIESMICH.md).
|
||||
Danach in der `.env`:
|
||||
|
||||
```
|
||||
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
|
||||
```
|
||||
|
||||
**Ohne Kartenserver läuft alles trotzdem** — die Karte zeigt einen leeren Grund
|
||||
mit einem Hinweis darauf. Punkte liegen an der richtigen Stelle, Entfernungen
|
||||
stimmen, die Umkreissuche rechnet; es fehlen nur die Straßen darunter. Ist ein
|
||||
Server eingetragen, aber nicht erreichbar, sieht es genauso aus — mit
|
||||
entsprechendem Hinweis.
|
||||
|
||||
### Löschwasser
|
||||
|
||||
Hydranten, Saugstellen, Zisternen und offene Gewässer mit Typ, Nennweite,
|
||||
Durchfluss, Zustand und dem, was man vor Ort wissen muss („im Winter oft
|
||||
zugeschoben").
|
||||
|
||||
Die **Umkreissuche** ist der eigentliche Zweck: in die Karte tippen, und es
|
||||
steht da, was in 500 Metern erreichbar ist — nach Entfernung sortiert, mit
|
||||
Nennweite und Durchfluss. Sie läuft über PostGIS (`ST_DWithin` auf der
|
||||
Geografie, GiST-Index), nicht über die Dezimalgrade: bei tausend Hydranten wäre
|
||||
alles andere ein vollständiger Tabellendurchlauf, und gefragt wird während der
|
||||
Fahrt.
|
||||
|
||||
### Import
|
||||
|
||||
Zwei Quellen, die eine Wehr tatsächlich bekommt:
|
||||
|
||||
| Quelle | Was erkannt wird |
|
||||
|---|---|
|
||||
| **GeoJSON** | FeatureCollection mit Punkten, Eigenschaften wie in der CSV |
|
||||
| **CSV der Stadtwerke** | Spaltennamen wie `DN`, `Lage`, `Latitude`, `Pruefdatum`, `Bauart` |
|
||||
|
||||
Der Import ist nachsichtig, aber ehrlich: „DN 100" wird zu 100, „48,5031" zu
|
||||
48.5031, „24.05.2026" zu einem Datum, und vertauschte Achsen werden erkannt und
|
||||
zurückgedreht. Was nicht lesbar ist, wird mit Zeilennummer und Grund gemeldet —
|
||||
und der Rest läuft durch. Eine Liste, die zu 90 Prozent stimmt, ist im Einsatz
|
||||
mehr wert als keine.
|
||||
|
||||
**Geraten wird nichts.** Fehlt die Position, wird die Zeile übersprungen: ein
|
||||
Hydrant ohne Ort ist kein Hydrant. Und ohne Statusangabe steht „ungeprüft",
|
||||
nicht „einsatzbereit".
|
||||
|
||||
### Objekte und Feuerwehrpläne
|
||||
|
||||
Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom Beifahrersitz,
|
||||
oft in zwanzig Sekunden. Deshalb sind **Zufahrt**, **Gefahren** und
|
||||
**Schlüsseldepot** eigene Felder und stehen ganz oben, nicht in einer
|
||||
Fließtextnotiz.
|
||||
|
||||
Dazu Ansprechpartner mit der einzigen Angabe, die im Einsatz zählt — wer nachts
|
||||
ans Telefon geht — und die Planblätter nach DIN 14095 mit ihrem Stand.
|
||||
|
||||
Gesucht wird über alles: Name, Straße, Gefahrenstichwort, Ansprechpartner. Wer
|
||||
im Einsatz sucht, weiß selten, wie das Objekt im System heißt.
|
||||
|
||||
### Lagekarte
|
||||
|
||||
Eine je Einsatz, unterhalb der Einsatzdaten. 22 taktische Zeichen nach
|
||||
FwDV 102, selbst gezeichnet als SVG — fertige Sätze sind selten frei
|
||||
verwendbar, und nachgeladen wird ohnehin nichts.
|
||||
|
||||
Ein Werkzeug, ein Klick: Zeichen wählen, in die Karte tippen. Dazu Linienzüge
|
||||
(Schlauchleitung, Absperrung), Flächen (Bereitstellungsraum) und
|
||||
Beschriftungen.
|
||||
|
||||
Der Abgleich zwischen Geräten läuft über denselben Ereignisstrom wie die
|
||||
Atemschutzüberwachung, und wie dort steht der Zustand der Verbindung sichtbar
|
||||
da. Zurückgenommene Elemente verschwinden von der Karte, nicht aus dem
|
||||
Datenbestand: eine Lagekarte ist die Mitschrift einer Entscheidung.
|
||||
|
||||
### Wasserförderung
|
||||
|
||||
Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht.
|
||||
Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen:
|
||||
|
||||
```
|
||||
Reibungsverlust je 100 m = Beiwert × (Fördermenge / 100)²
|
||||
Höhenverlust = Höhenmeter × 0,1 bar
|
||||
```
|
||||
|
||||
Daraus: Zahl der Verstärkerpumpen, Schlauchlängen je Abschnitt, Restdruck und
|
||||
der Druck, der an der Einsatzstelle ankommt. Die Beiwerte treffen die
|
||||
Tabellenwerte der Ausbildung — 1,5 bar je 100 m im B-Schlauch bei 800 l/min.
|
||||
|
||||
Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
|
||||
statt eine Zahl auszuwerfen, die niemand halten kann. Und unter jeder Rechnung
|
||||
steht, was sie ist: **eine Planungshilfe. Was zählt, ist der Druck am
|
||||
Verteiler.**
|
||||
|
||||
### Zusätzliche Umgebungsvariablen
|
||||
|
||||
| Variable | Vorgabe | Bedeutung |
|
||||
|---|---|---|
|
||||
| `KARTE_STIL_URL` | — | Stil des eigenen Kartenservers; leer = leerer Grund |
|
||||
| `KARTE_BREITENGRAD` | `48.5` | Mittelpunkt, falls kein Standort verortet ist |
|
||||
| `KARTE_LAENGENGRAD` | `12.15` | dito |
|
||||
| `KARTE_ZOOM` | `13` | Zoomstufe beim Öffnen |
|
||||
| `KARTE_PORT` | `8085` | Port des tileserver-gl (Profil `karte`) |
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
```bash
|
||||
|
||||
@@ -47,6 +47,10 @@ x-app-env: &app-env
|
||||
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
||||
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
||||
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
|
||||
KARTE_STIL_URL: ${KARTE_STIL_URL:-}
|
||||
KARTE_BREITENGRAD: ${KARTE_BREITENGRAD:-48.5}
|
||||
KARTE_LAENGENGRAD: ${KARTE_LAENGENGRAD:-12.15}
|
||||
KARTE_ZOOM: ${KARTE_ZOOM:-13}
|
||||
ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true}
|
||||
ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-}
|
||||
ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600}
|
||||
@@ -272,6 +276,36 @@ services:
|
||||
- intern
|
||||
logging: *logging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kartenserver (Phase 6).
|
||||
#
|
||||
# Nur mit Profil: "docker compose --profile karte up -d". FLORIAN läuft ohne
|
||||
# ihn — die Karte zeigt dann einen leeren Hintergrund, Hydranten und
|
||||
# Lagekarte funktionieren trotzdem.
|
||||
#
|
||||
# Gebraucht wird eine MBTiles-Datei des Ausrückegebiets unter
|
||||
# docker/karten/. Woher man sie bekommt, steht in der README; sie liegt
|
||||
# bewusst nicht im Repository — das sind je nach Zuschnitt hunderte Megabyte
|
||||
# bis Gigabyte, und jede Wehr braucht einen anderen Ausschnitt.
|
||||
#
|
||||
# Der Dienst hängt im internen Netz und im Edge-Netz: der Browser des
|
||||
# Anwenders holt die Kacheln direkt, nicht über die Anwendung.
|
||||
# ---------------------------------------------------------------------------
|
||||
tileserver:
|
||||
image: maptiler/tileserver-gl:v4.11.1
|
||||
container_name: florian-tileserver
|
||||
restart: unless-stopped
|
||||
profiles: [karte]
|
||||
command: ["--port", "8080", "--public_url", "${KARTE_OEFFENTLICH:-http://localhost:8085/}"]
|
||||
volumes:
|
||||
- ./docker/karten:/data:ro
|
||||
ports:
|
||||
- "${KARTE_PORT:-8085}:8080"
|
||||
networks:
|
||||
- edge
|
||||
- intern
|
||||
logging: *logging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Anwendung selbst (Next.js, Standalone-Build).
|
||||
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,9 +1,10 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 5 (Version 0.6.0). Die Bilder zeigen die
|
||||
Bedienung von FLORIAN, Stand Phase 6 (Version 0.7.0). Die Bilder zeigen die
|
||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||
|
||||
> Die Kartenmodule kommen in Phase 6 dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
> Benachrichtigungen, lokale KI und die Kassenverwaltung kommen in den
|
||||
> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -602,7 +603,7 @@ Bericht — wird später nachgetragen. Der Einsatz bekommt sofort eine
|
||||
fortlaufende Nummer im Format `JJJJ-NNNN`.
|
||||
|
||||
**Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr
|
||||
einen eigenen Nominatim betreibt (Kapitel 34). Ohne den bleibt das Feld leer —
|
||||
einen eigenen Nominatim betreibt (Kapitel 38). Ohne den bleibt das Feld leer —
|
||||
das ist in Ordnung, die Karte kommt in Phase 6.
|
||||
|
||||
---
|
||||
@@ -1100,7 +1101,165 @@ weiter einsetzbar, aber es wird Zeit für einen Termin.
|
||||
|
||||
---
|
||||
|
||||
## 34. Adressen zu Koordinaten (Nominatim)
|
||||
## 34. Die Karte
|
||||
|
||||

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

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

|
||||
|
||||
Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht.
|
||||
Punkte in die Karte tippen — der erste ist die Entnahmestelle, der letzte die
|
||||
Einsatzstelle —, dann Schlauchdurchmesser und Fördermenge einstellen.
|
||||
|
||||
Sofort steht da:
|
||||
|
||||
- **wie viele Pumpen** gebraucht werden und wo ein Verstärker stehen muss,
|
||||
- **wie viele Schlauchlängen** je Abschnitt,
|
||||
- **welcher Druck** am Ende ankommt.
|
||||
|
||||
Zwei Dinge kosten Druck: die Reibung im Schlauch (steigt mit dem **Quadrat** der
|
||||
Fördermenge — doppelte Menge, vierfacher Verlust) und die Steigung (zehn Meter
|
||||
sind ein bar). Wo die Höhe bekannt ist, sollte sie eingetragen werden; ohne
|
||||
rechnet FLORIAN mit ebenem Gelände, und das ist im Zweifel zu günstig.
|
||||
|
||||
Geht eine Strecke nicht auf, sagt die Ausgabe das — statt eine Zahl
|
||||
auszuwerfen, die niemand halten kann.
|
||||
|
||||
> **Es bleibt eine Planungshilfe.** Was zählt, ist der Druck am Verteiler — und
|
||||
> den liest jemand ab.
|
||||
|
||||
---
|
||||
|
||||
## 38. Adressen zu Koordinaten (Nominatim)
|
||||
|
||||
FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
|
||||
betreibt:
|
||||
@@ -1120,7 +1279,7 @@ GEOCODING_LAENDER=de,at,ch
|
||||
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
||||
eintragen oder weglassen.
|
||||
|
||||
## 35. Rollen und was sie dürfen
|
||||
## 39. Rollen und was sie dürfen
|
||||
|
||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||
@@ -1149,7 +1308,7 @@ Das Terminal selbst ist nur die Tür.
|
||||
|
||||
---
|
||||
|
||||
## 36. Anmeldung über authentik einrichten
|
||||
## 40. Anmeldung über authentik einrichten
|
||||
|
||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||
|
||||
@@ -1202,7 +1361,7 @@ ganze Wehr aus.
|
||||
|
||||
---
|
||||
|
||||
## 37. PIN und Ausweiskarte verwalten
|
||||
## 41. PIN und Ausweiskarte verwalten
|
||||
|
||||
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||
was dahinter liegt.
|
||||
@@ -1225,7 +1384,7 @@ Huber), PIN `471203`.
|
||||
|
||||
---
|
||||
|
||||
## 38. Was protokolliert wird
|
||||
## 42. Was protokolliert wird
|
||||
|
||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||
@@ -1250,7 +1409,7 @@ jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 39. Wenn etwas nicht geht
|
||||
## 43. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
@@ -1274,6 +1433,11 @@ jeweiligen Detailseite sichtbar.
|
||||
| Die Überwachung lässt sich nicht beenden | Ein Trupp ist noch nicht als draußen gemeldet | Trupp abmelden, dann beenden. Die Meldung nennt die Anzahl |
|
||||
| Oben steht „Verbindung unterbrochen", obwohl das Netz da ist | Der Ereignisstrom wurde von einem Proxy gepuffert oder gekappt | Die Ansicht arbeitet weiter; bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
||||
| Das Atemschutzprotokoll fehlt | Die Überwachung ist noch nicht beendet | Ein Protokoll entsteht erst mit dem Ende — vorher wäre es unvollständig und sähe vollständig aus |
|
||||
| Die Karte zeigt keinen Hintergrund | Kein Kartenserver eingerichtet oder nicht erreichbar | `docker compose --profile karte up -d`, MBTiles nach `docker/karten/`, `KARTE_STIL_URL` setzen. Ohne läuft alles außer den Straßen |
|
||||
| Der Import überspringt alle Zeilen | Keine Spalte wurde als Breite und Länge erkannt | Spalten umbenennen (`lat`/`lon`, `Breitengrad`/`Längengrad`) — die erkannten Namen stehen in `lib/karte/hydrant-import.ts` |
|
||||
| Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen |
|
||||
| Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
||||
| Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern |
|
||||
|
||||
Zustand aller Randsysteme auf einen Blick:
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert.
|
||||
|
||||
## Phase 6 — Kartenmodule
|
||||
|
||||
**Vergleichsaufwand: 14–18 PT**
|
||||
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:50 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -313,6 +313,30 @@ Das SVG-Set für die taktischen Zeichen ist Fleißarbeit, aber unvermeidbar —
|
||||
fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
|
||||
Physik mit klaren Formeln und deshalb gut testbar.
|
||||
|
||||
**Rückblick.** Beide Sätze stimmen. Das Zeichenset war Fleißarbeit — 22 Zeichen
|
||||
als SVG-Pfade, keine davon schwierig, alle zusammen eine Stunde. Und die
|
||||
Wasserförderung war tatsächlich der am besten testbare Teil des ganzen
|
||||
Projekts: 21 Tests, die alle etwas über die Physik aussagen statt über den Code.
|
||||
|
||||
Dabei fiel auf, dass die Beiwerte im ersten Wurf um den Faktor zehn danebenlagen
|
||||
— aufgefallen ist es nur, weil die Tests gegen die **Tabellenwerte der
|
||||
Ausbildung** prüfen und nicht gegen das, was die Funktion gerade ausrechnet.
|
||||
Ein Test, der die eigene Implementierung bestätigt, hätte hier fröhlich grün
|
||||
gemeldet.
|
||||
|
||||
Billiger als angesetzt war die Umkreissuche. 3,0 PT für „Hydranten mit PostGIS,
|
||||
Cluster, Filter, Umkreissuche" — davon entfiel der Löwenanteil auf eine einzige
|
||||
SQL-Abfrage mit `ST_DWithin` und einen GiST-Index. PostGIS war seit Phase 0 im
|
||||
Container; genutzt wurde es erst jetzt.
|
||||
|
||||
Teurer war die Frage, was passiert, wenn es **keine Karte** gibt. Eine MBTiles-
|
||||
Datei des Ausrückegebiets ist hunderte Megabyte groß, gehört niemandem außer
|
||||
der Wehr und kann nicht im Repository liegen. Das heißt: der Normalfall beim
|
||||
ersten Start ist eine Karte ohne Hintergrund — und sie muss trotzdem etwas
|
||||
taugen. Aus dieser Anforderung kam der leere Stil mit Hinweis, die Erkennung
|
||||
eines nicht erreichbaren Servers und die Zusicherung im E2E-Test, dass genau
|
||||
das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
|
||||
|
||||
## Phase 7 — Benachrichtigungen und Termine
|
||||
|
||||
**Vergleichsaufwand: 10–13 PT**
|
||||
|
||||
@@ -17,7 +17,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||||
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||||
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **13:20 h** | **7** | |
|
||||
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
||||
| **Summe** | **16:10 h** | **8** | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-04 · Phase 6 — Kartenmodule
|
||||
|
||||
**22:10 – 01:00 · 2:50 h · Version 0.7.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Alles, was einen Ort hat: Löschwasser, Objekte mit Feuerwehrplan, die Lagekarte
|
||||
eines Einsatzes und die geplante Förderstrecke.
|
||||
|
||||
| Bereich | Dateien |
|
||||
|---|---|
|
||||
| Rechnen | `lib/karte/wasserfoerderung.ts`, `hydrant-import.ts`, `taktische-zeichen.ts` |
|
||||
| Server | `server/routers/karte/hydrant.ts`, `objekt.ts`, `lagekarte.ts` |
|
||||
| Karte | `components/karte/karte.tsx` (MapLibre), `api/lagekarte/strom` |
|
||||
| Oberfläche | `app/(app)/karte/` mit Karte, Hydranten, Objekten, Wasserförderung; Lagekarte im Einsatz |
|
||||
| Betrieb | tileserver-gl im Compose-Profil `karte`, `docker/karten/LIESMICH.md` |
|
||||
| Tests | 48 Unit, 11 E2E je Browser |
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Kein öffentlicher Kartendienst — auch nicht als Rückfall.** Wo eine Wehr
|
||||
ausrückt und wo ihre Hydranten liegen, ist niemandes Geschäft. Die Kacheln
|
||||
kommen aus dem eigenen tileserver-gl. Ohne ihn zeigt die Karte einen leeren
|
||||
Grund **und sagt das**; Punkte, Entfernungen und Umkreissuche funktionieren
|
||||
weiter. Ein stiller leerer Kasten wäre die schlechtere Lösung gewesen: er sieht
|
||||
aus wie ein Fehler, ohne einer zu sein.
|
||||
|
||||
**Die Umkreissuche gehört in die Datenbank.** `ST_DWithin` auf der Geografie
|
||||
mit GiST-Index rechnet in Metern und nutzt den Index. In der Anwendung wäre es
|
||||
ein vollständiger Tabellendurchlauf mit Grad-Arithmetik — und gefragt wird
|
||||
während der Fahrt.
|
||||
|
||||
**Der Import rät nicht.** Er erkennt viel („DN 100", „48,5031", „24.05.2026",
|
||||
vertauschte Achsen), aber wo die Position fehlt, wird die Zeile übersprungen
|
||||
statt geschätzt. Und ohne Statusangabe steht „ungeprüft" — nicht
|
||||
„einsatzbereit". Eine Liste, die tausend Hydranten als geprüft ausweist, weil
|
||||
die Spalte fehlte, wäre schlimmer als keine Liste.
|
||||
|
||||
**Ein Ereignisstrom für beides.** Die Lagekarte benutzt denselben Hook wie die
|
||||
Atemschutzüberwachung aus Phase 5, nur mit anderer Adresse. Zwei Ströme mit
|
||||
unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||||
verstecken kann.
|
||||
|
||||
**Das Ergebnis der Wasserförderung wird nicht gespeichert.** Nur die Eingaben.
|
||||
Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne falsch
|
||||
auszusehen.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Der Druckverlust lag um den Faktor zehn daneben | Die Beiwerte waren aus dem Kopf gegriffen statt aus der Formel abgeleitet: gebraucht wird `verlust = beiwert × (menge/100)²`, also 0,0234 für den B-Schlauch, nicht 0,23 | Beiwerte aus den Tabellenwerten zurückgerechnet. Aufgefallen ist es nur, weil die Tests gegen die Ausbildungstabelle prüfen und nicht gegen die eigene Implementierung |
|
||||
| Ein nicht erreichbarer Kartenserver ließ die Karte kaputt aussehen | Der Hinweis „kein Hintergrund" hing an `stilUrl === null`, nicht daran, ob der Stil geladen wurde | MapLibre meldet den Fehlschlag; die Karte fällt jetzt in denselben ehrlichen Zustand wie ohne Server |
|
||||
| Die Klicks in die Karte gingen im E2E-Test ins Leere | Die Lagekarte liegt unten auf der Einsatzseite, außerhalb des Fensters. `page.mouse` klickt in Fensterkoordinaten | Karte vor dem Klick ins Bild holen |
|
||||
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1 | DropIndex-Block aus der Migration entfernt, im Kopf vermerkt |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**Keine Cluster-Darstellung.** Phase-6.md nennt sie. Bei 24 Löschwasserstellen
|
||||
im Beispiel und einigen hundert in einer echten Wehr sind Cluster eine Lösung
|
||||
für ein Problem, das erst bei Zehntausenden entsteht — und sie verstecken genau
|
||||
das, was man sehen will: die Lücke im Netz. Nachrüstbar, wenn eine Wehr mit
|
||||
großem Ausrückegebiet es vermisst.
|
||||
|
||||
**Kein Höhenprofil aus einem Geländemodell.** Die Höhe je Punkt wird von Hand
|
||||
eingetragen. Ein Höhenmodell (SRTM, DGM) wäre ein weiterer Datensatz von
|
||||
hunderten Megabyte, den jede Wehr selbst beschaffen müsste. Die Rechnung ist
|
||||
ohne Höhe zu günstig — und das steht auch so an der Eingabe.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npx tsc --noEmit` — ohne Ausgabe
|
||||
- `npm run test` — 354 Tests grün
|
||||
- `npx playwright test tests/e2e/karte.spec.ts` — 22 grün (Desktop und Tablet)
|
||||
- `npm run build` — 44 Routen
|
||||
- `npm run db:seed` — 24 Löschwasserstellen, 3 Objekte, Standorte verortet
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Phase 2 ████████████████████ fertig
|
||||
Phase 3 ████████████████████ fertig 0.4.0
|
||||
Phase 4 ████████████████████ fertig 0.5.0
|
||||
Phase 5 ████████████████████ fertig 0.6.0
|
||||
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
||||
Phase 6 ████████████████████ fertig 0.7.0
|
||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
||||
@@ -135,19 +135,20 @@ eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
||||
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
|
||||
analogen, bis die Wehr ausdrücklich anders entscheidet.
|
||||
|
||||
## Phase 6 — Kartenmodule · als Nächstes
|
||||
## Phase 6 — Kartenmodule · fertig
|
||||
|
||||
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
|
||||
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
||||
GeoJSON und Stadtwerke-CSV
|
||||
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar
|
||||
- Lagekarte mit taktischen Zeichen nach FwDV 102, Echtzeit-Sync
|
||||
- Lagekarte mit taktischen Zeichen nach FwDV 102, Abgleich über denselben
|
||||
Ereignisstrom wie der Atemschutz
|
||||
- Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
|
||||
|
||||
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
||||
und was ihn hergibt.
|
||||
|
||||
## Phase 7 — Benachrichtigungen und Termine
|
||||
## Phase 7 — Benachrichtigungen und Termine · als Nächstes
|
||||
|
||||
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
||||
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
||||
|
||||
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 415 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 733 KiB After Width: | Height: | Size: 792 KiB |
|
Before Width: | Height: | Size: 756 KiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 969 KiB After Width: | Height: | Size: 1012 KiB |
|
Before Width: | Height: | Size: 1014 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 1005 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 830 KiB |
|
Before Width: | Height: | Size: 565 KiB After Width: | Height: | Size: 846 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 663 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 670 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "florian",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -37,6 +37,7 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.8.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"maplibre-gl": "^6.7.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^15.5.4",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
@@ -2009,6 +2010,92 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.1.tgz",
|
||||
"integrity": "sha512-I/qcIKVFHFSg1Meu/eqHrkSTjIez0gCsSaK56TNPutM3TkE61aSq6F1cZ4Kg4KiNs3cpViLtubDjfyRcQCdEJQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.0.tgz",
|
||||
"integrity": "sha512-g45M8gEI4sMO3X9ib4K7n3ZKFf0qQOL+NMkHCnEK51lOrYFHiEssOuZncx1+miIgi1IEE2NcbRMcq8RBkL+QHA==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
@@ -4328,6 +4415,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -6732,6 +6825,12 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.21.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||
@@ -7984,6 +8083,12 @@
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -8959,6 +9064,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -9036,6 +9147,12 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -9593,6 +9710,38 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.7.0.tgz",
|
||||
"integrity": "sha512-Y1Q1+UP9WXou0DUrnaFdDsoMqiMCWy6aS968IBUaQ2eZi6H1/kPCfpfZOWXFJZbeKMkX9Wo6OFzY/k48qqPf3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.1",
|
||||
"@maplibre/mlt": "^1.2.0",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -9759,6 +9908,12 @@
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
@@ -10339,6 +10494,18 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
@@ -10457,6 +10624,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.24.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||
@@ -10567,6 +10740,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -10650,6 +10829,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
@@ -10975,6 +11160,15 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -11926,6 +12120,12 @@
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"engines": {
|
||||
@@ -56,6 +56,7 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.8.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"maplibre-gl": "^6.7.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^15.5.4",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -208,6 +208,16 @@ model Organisation {
|
||||
g26Untersuchungen G26Untersuchung[]
|
||||
belastungsuebungen Belastungsuebung[]
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
hydranten Hydrant[]
|
||||
objekte Objekt[]
|
||||
objektkontakte Objektkontakt[]
|
||||
objektplaene Objektplan[]
|
||||
lagekarten Lagekarte[]
|
||||
lageelemente Lageelement[]
|
||||
wasserfoerderungen Wasserfoerderung[]
|
||||
foerderpunkte Foerderpunkt[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("organisation")
|
||||
}
|
||||
@@ -310,6 +320,10 @@ model Person {
|
||||
g26Untersuchungen G26Untersuchung[] @relation("G26Person")
|
||||
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson")
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
|
||||
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
@@unique([organisationId, email])
|
||||
@@ -548,6 +562,10 @@ model Datei {
|
||||
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
|
||||
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
|
||||
|
||||
// Phase 6
|
||||
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
|
||||
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
|
||||
|
||||
@@unique([bucket, objektSchluessel])
|
||||
@@index([organisationId, entitaet, entitaetId])
|
||||
@@index([organisationId, kategorie])
|
||||
@@ -646,9 +664,12 @@ model Standort {
|
||||
plz String? @db.VarChar(10)
|
||||
ort String? @db.VarChar(120)
|
||||
|
||||
/// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht,
|
||||
/// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage.
|
||||
/// Ab Phase 6 zeigt die Karte diese Punkte an.
|
||||
/// Koordinaten doppelt: als Dezimalgrad für die Anwendung und als
|
||||
/// PostGIS-Punkt für die Karte. Prisma kann den PostGIS-Typ nicht lesen —
|
||||
/// ohne die Grade daneben wäre jede Anzeige eine Rohabfrage. Ein Trigger
|
||||
/// hält beides im Gleichlauf (Phase 6).
|
||||
breitengrad Decimal? @db.Decimal(9, 6)
|
||||
laengengrad Decimal? @db.Decimal(9, 6)
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
@@ -1680,6 +1701,10 @@ model Einsatz {
|
||||
// Phase 5
|
||||
atemschutz Atemschutzeinsatz[]
|
||||
|
||||
// Phase 6
|
||||
lagekarte Lagekarte?
|
||||
wasserfoerderungen Wasserfoerderung[]
|
||||
|
||||
@@unique([organisationId, einsatznummer])
|
||||
@@index([organisationId, alarmiertUm(sort: Desc)])
|
||||
@@index([organisationId, status])
|
||||
@@ -2573,3 +2598,369 @@ model Belastungsuebung {
|
||||
@@index([organisationId, personId, datum(sort: Desc)])
|
||||
@@map("belastungsuebung")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 6 — Kartenmodule
|
||||
//
|
||||
// Alles, was einen Ort hat: Hydranten, Objekte mit Feuerwehrplänen, die
|
||||
// Lagekarte eines Einsatzes und die geplante Wasserförderstrecke.
|
||||
//
|
||||
// Koordinaten liegen überall doppelt — als Dezimalgrad und als PostGIS-Punkt.
|
||||
// Der Grund ist derselbe wie beim Einsatz aus Phase 3: Prisma kann den
|
||||
// PostGIS-Typ nicht lesen, und ohne die Grade daneben wäre jede Anzeige eine
|
||||
// Rohabfrage. Ein Trigger hält beides zusammen; die Umkreissuche läuft über
|
||||
// den PostGIS-Punkt, weil nur der einen Index hat, der Entfernungen kann.
|
||||
// =============================================================================
|
||||
|
||||
/// Woher das Löschwasser kommt.
|
||||
enum HydrantTyp {
|
||||
/// Der Regelfall im Ort: Deckel auf der Straße, Standrohr drauf.
|
||||
UNTERFLUR
|
||||
/// Sichtbare Säule. Im Winter schneller zu finden.
|
||||
UEBERFLUR
|
||||
/// Vorbereitete Entnahmestelle an einem Gewässer.
|
||||
SAUGSTELLE
|
||||
/// Löschwasserbehälter, meist unterirdisch.
|
||||
ZISTERNE
|
||||
/// Bach, Weiher, Fluss — mit allem, was daran hängt (Zufahrt, Jahreszeit).
|
||||
OFFENES_GEWAESSER
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Ob man sich darauf verlassen kann.
|
||||
enum HydrantStatus {
|
||||
EINSATZBEREIT
|
||||
/// Nutzbar, aber mit Einschränkung — geringe Leistung, schwer zugänglich.
|
||||
EINGESCHRAENKT
|
||||
DEFEKT
|
||||
/// Noch nie geprüft oder aus einem Import ohne Angabe übernommen.
|
||||
UNBEKANNT
|
||||
}
|
||||
|
||||
/// Grobe Einordnung eines Objekts. Bestimmt, was im Einsatz zuerst zählt.
|
||||
enum ObjektKategorie {
|
||||
SCHULE
|
||||
KINDERGARTEN
|
||||
PFLEGEHEIM
|
||||
KRANKENHAUS
|
||||
VERSAMMLUNGSSTAETTE
|
||||
HOCHHAUS
|
||||
INDUSTRIE
|
||||
LANDWIRTSCHAFT
|
||||
GEFAHRSTOFFLAGER
|
||||
SONDERBAU
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Blattarten eines Feuerwehrplans nach DIN 14095.
|
||||
enum PlanArt {
|
||||
UEBERSICHTSPLAN
|
||||
GESCHOSSPLAN
|
||||
UMGEBUNGSPLAN
|
||||
SONDERPLAN
|
||||
/// Alles, was zusätzlich dazugehört: Betriebsanweisung, Stoffliste.
|
||||
ANLAGE
|
||||
}
|
||||
|
||||
/// Was auf der Lagekarte liegt.
|
||||
enum LageelementArt {
|
||||
/// Taktisches Zeichen nach FwDV 102, siehe lib/karte/taktische-zeichen.ts.
|
||||
ZEICHEN
|
||||
/// Linienzug — Schlauchleitung, Absperrung, Anfahrtsweg.
|
||||
LINIE
|
||||
/// Geschlossene Fläche — Bereitstellungsraum, Gefahrenbereich.
|
||||
FLAECHE
|
||||
/// Freie Beschriftung.
|
||||
TEXT
|
||||
}
|
||||
|
||||
/// Rolle eines Punktes in der Förderstrecke.
|
||||
enum FoerderpunktArt {
|
||||
/// Wo das Wasser herkommt.
|
||||
ENTNAHME
|
||||
/// Verstärkerpumpe in der Strecke.
|
||||
VERSTAERKER
|
||||
/// Zwischenpunkt ohne Pumpe — nur für den Streckenverlauf und die Höhe.
|
||||
WEGPUNKT
|
||||
/// Die Einsatzstelle.
|
||||
ZIEL
|
||||
}
|
||||
|
||||
/// Ein Hydrant oder eine andere Löschwasserstelle.
|
||||
///
|
||||
/// Die Frage, die dieses Modell beantworten muss, wird auf der Anfahrt
|
||||
/// gestellt und lautet: **Was liegt in der Nähe, und was gibt es her?**
|
||||
/// Deshalb stehen Typ, Nennweite und Durchfluss gleichrangig neben dem Ort —
|
||||
/// ein Unterflurhydrant mit 80er Leitung ist etwas anderes als einer mit 150.
|
||||
model Hydrant {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
/// Nummer oder Bezeichnung, wie sie die Wehr verwendet.
|
||||
bezeichnung String? @db.VarChar(120)
|
||||
typ HydrantTyp @default(UNTERFLUR)
|
||||
status HydrantStatus @default(UNBEKANNT)
|
||||
|
||||
/// Nennweite der Leitung in Millimetern (80, 100, 150 …).
|
||||
nennweite Int?
|
||||
/// Ergiebigkeit in Litern je Minute, aus der Prüfung oder vom Versorger.
|
||||
durchfluss Int?
|
||||
|
||||
strasse String? @db.VarChar(200)
|
||||
hausnummer String? @db.VarChar(20)
|
||||
ortsteil String? @db.VarChar(120)
|
||||
|
||||
breitengrad Decimal @db.Decimal(9, 6)
|
||||
laengengrad Decimal @db.Decimal(9, 6)
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
|
||||
letztePruefung DateTime? @db.Date
|
||||
naechstePruefung DateTime? @db.Date
|
||||
/// Wer die Leitung betreibt — im Zweifel der Ansprechpartner bei Defekt.
|
||||
betreiber String? @db.VarChar(200)
|
||||
/// Was man vor Ort wissen muss: „hinter der Hecke", „im Winter geräumt".
|
||||
hinweis String? @db.VarChar(1000)
|
||||
|
||||
fotoDateiId String? @db.Uuid
|
||||
|
||||
/// Woher der Datensatz stammt: "hand", "geojson", "stadtwerke-csv".
|
||||
quelle String? @db.VarChar(40)
|
||||
/// Kennung beim Versorger, damit ein zweiter Import nichts doppelt anlegt.
|
||||
externeNummer String? @db.VarChar(80)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
foto Datei? @relation("HydrantFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([organisationId, externeNummer])
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, typ])
|
||||
@@map("hydrant")
|
||||
}
|
||||
|
||||
/// Ein Objekt mit Feuerwehrplan.
|
||||
///
|
||||
/// Was hier steht, wird auf der Anfahrt gelesen — oft vom Beifahrersitz aus.
|
||||
/// Deshalb sind Zufahrt, Gefahren und Schlüsseldepot eigene Felder und keine
|
||||
/// Fließtextnotiz: im Einsatz sucht niemand in einem Absatz.
|
||||
model Objekt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(200)
|
||||
kategorie ObjektKategorie @default(SONSTIGES)
|
||||
|
||||
strasse String? @db.VarChar(200)
|
||||
hausnummer String? @db.VarChar(20)
|
||||
plz String? @db.VarChar(10)
|
||||
ort String? @db.VarChar(120)
|
||||
|
||||
breitengrad Decimal? @db.Decimal(9, 6)
|
||||
laengengrad Decimal? @db.Decimal(9, 6)
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
|
||||
/// Wie man hinkommt: Tordurchfahrt, Höhenbegrenzung, Aufstellfläche.
|
||||
zufahrt String? @db.VarChar(2000)
|
||||
/// Was einen erwartet: Photovoltaik, Gasflaschen, Silo, Tiere.
|
||||
gefahren String? @db.VarChar(2000)
|
||||
/// Wo der Schlüssel liegt und wie man drankommt.
|
||||
schluesseldepot String? @db.VarChar(500)
|
||||
/// Löschwasser am Objekt: Wandhydranten, Sprinklerzentrale, Steigleitung.
|
||||
loeschwasser String? @db.VarChar(1000)
|
||||
bemerkung String? @db.VarChar(2000)
|
||||
|
||||
/// Anzahl der Personen, mit denen zu rechnen ist (Schule, Heim, Betrieb).
|
||||
personenzahl Int?
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
kontakte Objektkontakt[]
|
||||
plaene Objektplan[]
|
||||
|
||||
@@index([organisationId, kategorie])
|
||||
@@index([organisationId, name])
|
||||
@@map("objekt")
|
||||
}
|
||||
|
||||
/// Wen man beim Objekt anruft.
|
||||
model Objektkontakt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
objektId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(200)
|
||||
funktion String? @db.VarChar(120)
|
||||
telefon String? @db.VarChar(60)
|
||||
mobil String? @db.VarChar(60)
|
||||
/// Erreichbar rund um die Uhr? Im Einsatz die einzige Frage, die zählt.
|
||||
jederzeit Boolean @default(false)
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
reihenfolge Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organisationId, objektId])
|
||||
@@map("objektkontakt")
|
||||
}
|
||||
|
||||
/// Ein Blatt des Feuerwehrplans nach DIN 14095.
|
||||
model Objektplan {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
objektId String @db.Uuid
|
||||
dateiId String @db.Uuid
|
||||
|
||||
art PlanArt @default(UEBERSICHTSPLAN)
|
||||
bezeichnung String @db.VarChar(200)
|
||||
/// Stand des Plans — ein Feuerwehrplan von 2009 ist eine Vermutung.
|
||||
standAm DateTime? @db.Date
|
||||
reihenfolge Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
|
||||
datei Datei @relation("ObjektplanDatei", fields: [dateiId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([organisationId, objektId])
|
||||
@@map("objektplan")
|
||||
}
|
||||
|
||||
/// Die Lagekarte eines Einsatzes.
|
||||
///
|
||||
/// Eine je Einsatz. Sie ist der gemeinsame Zeichenblock: was einer einträgt,
|
||||
/// sehen die anderen — verteilt über denselben Ereignisstrom wie die
|
||||
/// Atemschutzüberwachung aus Phase 5.
|
||||
model Lagekarte {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String @unique @db.Uuid
|
||||
|
||||
/// Kartenausschnitt, damit alle denselben Blick haben.
|
||||
breitengrad Decimal? @db.Decimal(9, 6)
|
||||
laengengrad Decimal? @db.Decimal(9, 6)
|
||||
zoomstufe Int?
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||
elemente Lageelement[]
|
||||
|
||||
@@map("lagekarte")
|
||||
}
|
||||
|
||||
/// Ein Element auf der Lagekarte.
|
||||
///
|
||||
/// Die Geometrie steht als JSON: ein Punkt für ein taktisches Zeichen, eine
|
||||
/// Liste von Punkten für Linie und Fläche. PostGIS wäre hier zu viel —
|
||||
/// gerechnet wird damit nichts, es wird nur gezeichnet.
|
||||
///
|
||||
/// Gelöscht wird weich: eine Lagekarte ist die Mitschrift einer Entscheidung,
|
||||
/// und was jemand um 14:12 eingezeichnet hat, gehört auch dann noch dazu, wenn
|
||||
/// es um 14:20 nicht mehr stimmte.
|
||||
model Lageelement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
lagekarteId String @db.Uuid
|
||||
|
||||
art LageelementArt @default(ZEICHEN)
|
||||
/// Schlüssel aus dem Zeichensatz, etwa "loeschfahrzeug" oder "brandstelle".
|
||||
zeichen String? @db.VarChar(60)
|
||||
text String? @db.VarChar(300)
|
||||
farbe String? @db.VarChar(20)
|
||||
|
||||
/// [[laenge, breite], …] in WGS 84.
|
||||
geometrie Json @db.JsonB
|
||||
/// Drehung eines Zeichens in Grad — ein Fahrzeug steht selten nach Norden.
|
||||
drehung Int @default(0)
|
||||
|
||||
erstelltVonId String? @db.Uuid
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
lagekarte Lagekarte @relation(fields: [lagekarteId], references: [id], onDelete: Cascade)
|
||||
erstelltVon Person? @relation("LageelementZeichner", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, lagekarteId, geloeschtAm])
|
||||
@@map("lageelement")
|
||||
}
|
||||
|
||||
/// Eine geplante Wasserförderstrecke.
|
||||
///
|
||||
/// Die Rechnung dahinter ist Physik mit klaren Formeln (siehe
|
||||
/// lib/karte/wasserfoerderung.ts) — deshalb steht hier nur, woraus gerechnet
|
||||
/// wurde. Das Ergebnis wird nicht gespeichert, sondern bei jeder Anzeige neu
|
||||
/// gerechnet: eine gespeicherte Zahl neben geänderten Eingaben wäre falsch,
|
||||
/// ohne falsch auszusehen.
|
||||
model Wasserfoerderung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String? @db.Uuid
|
||||
|
||||
bezeichnung String @db.VarChar(200)
|
||||
|
||||
/// Schlauchdurchmesser in Millimetern: 75 (B) oder 110 (A).
|
||||
durchmesser Int @default(75)
|
||||
/// Geplante Fördermenge in Litern je Minute.
|
||||
foerdermenge Int @default(800)
|
||||
/// Ausgangsdruck der ersten Pumpe in bar.
|
||||
ausgangsdruck Decimal @default(8) @db.Decimal(4, 1)
|
||||
/// Mindestdruck am Eingang jeder weiteren Pumpe in bar.
|
||||
eingangsdruck Decimal @default(1.5) @db.Decimal(4, 1)
|
||||
|
||||
bemerkung String? @db.VarChar(2000)
|
||||
|
||||
geplantVonId String? @db.Uuid
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
|
||||
geplantVon Person? @relation("WasserfoerderungPlaner", fields: [geplantVonId], references: [id], onDelete: SetNull)
|
||||
punkte Foerderpunkt[]
|
||||
|
||||
@@index([organisationId, einsatzId])
|
||||
@@map("wasserfoerderung")
|
||||
}
|
||||
|
||||
/// Ein Punkt der Förderstrecke.
|
||||
model Foerderpunkt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
wasserfoerderungId String @db.Uuid
|
||||
|
||||
reihenfolge Int
|
||||
art FoerderpunktArt @default(WEGPUNKT)
|
||||
bezeichnung String? @db.VarChar(200)
|
||||
|
||||
breitengrad Decimal @db.Decimal(9, 6)
|
||||
laengengrad Decimal @db.Decimal(9, 6)
|
||||
/// Höhe über Normalnull in Metern. Der Höhenunterschied kostet Druck:
|
||||
/// zehn Meter Steigung sind ein bar.
|
||||
hoehe Decimal? @db.Decimal(7, 2)
|
||||
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
wasserfoerderung Wasserfoerderung @relation(fields: [wasserfoerderungId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([wasserfoerderungId, reihenfolge])
|
||||
@@index([organisationId, wasserfoerderungId])
|
||||
@@map("foerderpunkt")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
|
||||
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
|
||||
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
||||
* Überwachung mit vollständigem Protokoll.
|
||||
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
||||
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
||||
* Feuerwehrplan.
|
||||
*
|
||||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||
@@ -52,6 +54,9 @@ import {
|
||||
QualifikationsArt,
|
||||
RolleSchluessel,
|
||||
SignaturRolle,
|
||||
HydrantStatus,
|
||||
HydrantTyp,
|
||||
ObjektKategorie,
|
||||
StandortArt,
|
||||
TruppStatus,
|
||||
UebungsArt,
|
||||
@@ -2479,6 +2484,276 @@ async function atemschutzAnlegen(organisationId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Phase 6 — Kartenmodule
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Der Ortskern von Musterhausen — frei erfunden, aber in Niederbayern plausibel. */
|
||||
const ORTSMITTE = { breitengrad: 48.5, laengengrad: 12.15 };
|
||||
|
||||
/**
|
||||
* Löschwasser und Objekte.
|
||||
*
|
||||
* Die Hydranten liegen in einem Raster um den Ortskern, mit den Zuständen, die
|
||||
* eine Wehr tatsächlich vorfindet: die meisten in Ordnung, einige ungeprüft,
|
||||
* zwei defekt. Dazu ein Weiher und eine Zisterne am Ortsrand — im Außenbereich
|
||||
* gibt es keine Leitung.
|
||||
*
|
||||
* Die drei Objekte sind die, für die eine Wehr zuerst einen Plan schreibt:
|
||||
* Schule, Pflegeheim, Aussiedlerhof.
|
||||
*/
|
||||
async function kartendatenAnlegen(organisationId: string) {
|
||||
const vorhanden = await prisma.hydrant.count({ where: { organisationId } });
|
||||
if (vorhanden > 0) {
|
||||
log(`Karte: bereits ${vorhanden} Löschwasserstellen vorhanden`);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Standorte auf die Karte holen ---------------------------------------
|
||||
//
|
||||
// Ohne Koordinaten am Gerätehaus wüsste die Karte nicht, wohin sie schauen
|
||||
// soll. Die Standorte gibt es seit Phase 1, die Dezimalgrade seit Phase 6.
|
||||
const standorte = await prisma.standort.findMany({
|
||||
where: { organisationId, geloeschtAm: null },
|
||||
select: { id: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
});
|
||||
|
||||
for (const [index, standort] of standorte.entries()) {
|
||||
await prisma.standort.update({
|
||||
where: { id: standort.id },
|
||||
data: {
|
||||
breitengrad: ORTSMITTE.breitengrad + index * 0.004,
|
||||
laengengrad: ORTSMITTE.laengengrad + index * 0.006,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Löschwasserstellen ---------------------------------------------------
|
||||
const STRASSEN = [
|
||||
"Hauptstraße",
|
||||
"Bahnhofstraße",
|
||||
"Schulstraße",
|
||||
"Kirchplatz",
|
||||
"Am Anger",
|
||||
"Industriestraße",
|
||||
];
|
||||
|
||||
const hydranten: Array<{
|
||||
bezeichnung: string;
|
||||
typ: HydrantTyp;
|
||||
status: HydrantStatus;
|
||||
nennweite: number | null;
|
||||
durchfluss: number | null;
|
||||
strasse: string;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
letztePruefung: Date | null;
|
||||
hinweis: string | null;
|
||||
}> = [];
|
||||
|
||||
const heute = new Date();
|
||||
|
||||
function vorTagen(tage: number): Date {
|
||||
const datum = new Date(heute);
|
||||
datum.setUTCDate(datum.getUTCDate() - tage);
|
||||
datum.setUTCHours(0, 0, 0, 0);
|
||||
return datum;
|
||||
}
|
||||
|
||||
for (let nummer = 1; nummer <= 20; nummer += 1) {
|
||||
const strasse = STRASSEN[nummer % STRASSEN.length]!;
|
||||
// Ein Raster von rund 120 Metern Kantenlänge um die Ortsmitte.
|
||||
const reihe = Math.floor((nummer - 1) / 5);
|
||||
const spalte = (nummer - 1) % 5;
|
||||
|
||||
const ungeprueft = nummer % 7 === 0;
|
||||
const defekt = nummer === 9 || nummer === 17;
|
||||
|
||||
hydranten.push({
|
||||
bezeichnung: `H-${String(nummer).padStart(4, "0")}`,
|
||||
typ: nummer % 6 === 0 ? HydrantTyp.UEBERFLUR : HydrantTyp.UNTERFLUR,
|
||||
status: defekt
|
||||
? HydrantStatus.DEFEKT
|
||||
: ungeprueft
|
||||
? HydrantStatus.UNBEKANNT
|
||||
: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: nummer % 4 === 0 ? 150 : nummer % 3 === 0 ? 80 : 100,
|
||||
durchfluss: nummer % 4 === 0 ? 1600 : nummer % 3 === 0 ? 400 : 800,
|
||||
strasse,
|
||||
breitengrad: ORTSMITTE.breitengrad + reihe * 0.0011 - 0.002,
|
||||
laengengrad: ORTSMITTE.laengengrad + spalte * 0.0016 - 0.003,
|
||||
letztePruefung: ungeprueft ? null : vorTagen(60 + nummer * 9),
|
||||
hinweis: defekt
|
||||
? "Deckel verzogen, Standrohr passt nicht. Stadtwerke gemeldet."
|
||||
: nummer === 3
|
||||
? "Im Winter oft zugeschoben — Schneepflug fährt hier durch."
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Im Außenbereich gibt es keine Leitung.
|
||||
hydranten.push(
|
||||
{
|
||||
bezeichnung: "Weiher Kiesgrube",
|
||||
typ: HydrantTyp.OFFENES_GEWAESSER,
|
||||
status: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: null,
|
||||
durchfluss: 2400,
|
||||
strasse: "Kiesgrubenweg",
|
||||
breitengrad: ORTSMITTE.breitengrad - 0.008,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.012,
|
||||
letztePruefung: vorTagen(200),
|
||||
hinweis: "Zufahrt für LF geeignet, im Winter vereist. Saughöhe rund 3 m.",
|
||||
},
|
||||
{
|
||||
bezeichnung: "Zisterne Aussiedlerhof",
|
||||
typ: HydrantTyp.ZISTERNE,
|
||||
status: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: null,
|
||||
durchfluss: 800,
|
||||
strasse: "Hofweg 2",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.011,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.018,
|
||||
letztePruefung: vorTagen(120),
|
||||
hinweis: "50 m³, Saugstutzen an der Nordseite der Scheune.",
|
||||
},
|
||||
{
|
||||
bezeichnung: "Saugstelle Mühlbach",
|
||||
typ: HydrantTyp.SAUGSTELLE,
|
||||
status: HydrantStatus.EINGESCHRAENKT,
|
||||
nennweite: null,
|
||||
durchfluss: 600,
|
||||
strasse: "Mühlweg",
|
||||
breitengrad: ORTSMITTE.breitengrad - 0.004,
|
||||
laengengrad: ORTSMITTE.laengengrad - 0.009,
|
||||
letztePruefung: vorTagen(400),
|
||||
hinweis: "Im Sommer zu wenig Wasser. Nur nach Regen brauchbar.",
|
||||
},
|
||||
{
|
||||
bezeichnung: "H-0021",
|
||||
typ: HydrantTyp.UEBERFLUR,
|
||||
status: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: 150,
|
||||
durchfluss: 1600,
|
||||
strasse: "Industriestraße",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.006,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.008,
|
||||
letztePruefung: vorTagen(45),
|
||||
hinweis: "Am Werkstor, ergiebigster Hydrant im Ort.",
|
||||
},
|
||||
);
|
||||
|
||||
for (const hydrant of hydranten) {
|
||||
await prisma.hydrant.create({
|
||||
data: {
|
||||
organisationId,
|
||||
...hydrant,
|
||||
betreiber: "Stadtwerke Musterhausen",
|
||||
quelle: "seed",
|
||||
externeNummer: `SW-${hydrant.bezeichnung}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Objekte mit Feuerwehrplan -------------------------------------------
|
||||
const OBJEKTE: Array<{
|
||||
name: string;
|
||||
kategorie: ObjektKategorie;
|
||||
strasse: string;
|
||||
hausnummer: string;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
personenzahl: number;
|
||||
zufahrt: string;
|
||||
gefahren: string;
|
||||
schluesseldepot: string;
|
||||
loeschwasser: string;
|
||||
kontakte: Array<{ name: string; funktion: string; telefon: string; jederzeit: boolean }>;
|
||||
}> = [
|
||||
{
|
||||
name: "Grundschule Musterhausen",
|
||||
kategorie: ObjektKategorie.SCHULE,
|
||||
strasse: "Schulstraße",
|
||||
hausnummer: "4",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.0015,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.0022,
|
||||
personenzahl: 180,
|
||||
zufahrt:
|
||||
"Aufstellfläche auf dem Pausenhof, Zufahrt über die Schulstraße. Poller mit Feuerwehrschlüssel, Durchfahrt 3,10 m.",
|
||||
gefahren:
|
||||
"Photovoltaik auf dem Süddach (Gleichstrom bis zum Wechselrichter im Keller). Chemieraum im 1. OG Nordseite.",
|
||||
schluesseldepot: "FSD rechts neben dem Haupteingang, Schlüssel für alle Türen.",
|
||||
loeschwasser: "Wandhydranten in jedem Geschoss, Steigleitung trocken im Treppenhaus B.",
|
||||
kontakte: [
|
||||
{ name: "Ursula Berger", funktion: "Schulleitung", telefon: "08721 4711", jederzeit: false },
|
||||
{ name: "Karl Steininger", funktion: "Hausmeister", telefon: "0170 1234567", jederzeit: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Seniorenheim St. Anna",
|
||||
kategorie: ObjektKategorie.PFLEGEHEIM,
|
||||
strasse: "Am Anger",
|
||||
hausnummer: "11",
|
||||
breitengrad: ORTSMITTE.breitengrad - 0.0018,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.0009,
|
||||
personenzahl: 84,
|
||||
zufahrt:
|
||||
"Anleiterbare Seite nur nach Norden. Aufstellfläche für DL im Innenhof, Einfahrt an der Ostseite.",
|
||||
gefahren:
|
||||
"62 Bewohner, davon 28 bettlägerig. Sauerstoffflaschen in den Zimmern 12, 14 und 31. Sammelplatz auf dem Parkplatz an der Ostseite.",
|
||||
schluesseldepot: "FSD links vom Haupteingang, dazu Objektschlüssel in der Pforte.",
|
||||
loeschwasser: "Wandhydranten je Geschoss, nächster Überflurhydrant 40 m westlich.",
|
||||
kontakte: [
|
||||
{ name: "Pflegedienstleitung", funktion: "Nachtdienst", telefon: "08721 5522", jederzeit: true },
|
||||
{ name: "Hermann Falk", funktion: "Heimleitung", telefon: "0171 9876543", jederzeit: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Aussiedlerhof Huber",
|
||||
kategorie: ObjektKategorie.LANDWIRTSCHAFT,
|
||||
strasse: "Hofweg",
|
||||
hausnummer: "2",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.0108,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.0178,
|
||||
personenzahl: 6,
|
||||
zufahrt:
|
||||
"Feldweg ab der Kreisstraße, 600 m unbefestigt. Für TLF befahrbar, bei Nässe nur mit Allrad.",
|
||||
gefahren:
|
||||
"Silo mit Gärgasen, Biogasanlage 80 m nordöstlich. 60 Rinder im Stall, Notausgang Südseite. PV auf allen Dächern.",
|
||||
schluesseldepot: "Kein FSD. Familie wohnt auf dem Hof, Schlüssel beim Betriebsleiter.",
|
||||
loeschwasser: "Zisterne 50 m³ an der Scheune, sonst kein Löschwasser im Umkreis von 1,5 km.",
|
||||
kontakte: [
|
||||
{ name: "Josef Huber", funktion: "Betriebsleiter", telefon: "0175 3344556", jederzeit: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const vorlage of OBJEKTE) {
|
||||
const { kontakte, ...felder } = vorlage;
|
||||
|
||||
await prisma.objekt.create({
|
||||
data: {
|
||||
organisationId,
|
||||
...felder,
|
||||
plz: "84359",
|
||||
ort: "Musterhausen",
|
||||
kontakte: {
|
||||
create: kontakte.map((kontakt, index) => ({
|
||||
organisationId,
|
||||
...kontakt,
|
||||
reihenfolge: index,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
log(
|
||||
`Karte: ${hydranten.length} Löschwasserstellen, ${OBJEKTE.length} Objekte mit Ansprechpartnern, ${standorte.length} Standorte verortet`,
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ablauf
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -2516,6 +2791,9 @@ async function main(): Promise<void> {
|
||||
// Phase 5 — Atemschutz nach FwDV 7
|
||||
await atemschutzAnlegen(organisation.id);
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
await kartendatenAnlegen(organisation.id);
|
||||
|
||||
log("Fertig.");
|
||||
log("");
|
||||
log("Anmeldung für den Erststart:");
|
||||
|
||||
@@ -184,6 +184,26 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "G26.3 und Belastungsübungen erfassen",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
{
|
||||
name: "34-karte",
|
||||
pfad: "/karte",
|
||||
beschreibung: "Karte mit Löschwasser, Objekten und Umkreissuche",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "35-objekte",
|
||||
pfad: "/karte/objekte",
|
||||
beschreibung: "Objekte mit Feuerwehrplan",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "36-wasserfoerderung",
|
||||
pfad: "/karte/wasserfoerderung",
|
||||
beschreibung: "Wasserförderung über lange Wegstrecke",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { env } from "@/lib/env";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
|
||||
import { Lagekarte } from "@/app/(app)/einsaetze/[id]/lagekarte";
|
||||
|
||||
export const metadata = { title: "Einsatz" };
|
||||
|
||||
@@ -21,6 +25,32 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Mittelpunkt der Lagekarte: die Einsatzstelle, sonst das Gerätehaus, sonst
|
||||
// die Vorgabe aus der Konfiguration.
|
||||
const orte = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const [einsatz, standort] = await Promise.all([
|
||||
db.einsatz.findUnique({
|
||||
where: { id },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
}),
|
||||
db.standort.findFirst({
|
||||
where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
}),
|
||||
]);
|
||||
return { einsatz, standort };
|
||||
});
|
||||
|
||||
const mittelpunkt = {
|
||||
breitengrad: Number(
|
||||
orte.einsatz?.breitengrad ?? orte.standort?.breitengrad ?? env.KARTE_BREITENGRAD,
|
||||
),
|
||||
laengengrad: Number(
|
||||
orte.einsatz?.laengengrad ?? orte.standort?.laengengrad ?? env.KARTE_LAENGENGRAD,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -37,6 +67,30 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Die Lagekarte (Phase 6). Sie steht unter dem Einsatz und nicht in einem
|
||||
eigenen Reiter: wer sie braucht, braucht sie zusammen mit den Zeiten und
|
||||
den Lagemeldungen, nicht statt ihnen.
|
||||
*/}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -53,8 +53,17 @@ export function useJetzt(taktMs = 1000): Date {
|
||||
const STILLE_MS = 8000;
|
||||
|
||||
export function useEreignisstrom(
|
||||
ueberwachungId: string | null,
|
||||
kennung: string | null,
|
||||
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 } {
|
||||
const [verbunden, setVerbunden] = React.useState(false);
|
||||
const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null);
|
||||
@@ -69,11 +78,9 @@ export function useEreignisstrom(
|
||||
rueckruf.current = beiAenderung;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ueberwachungId || typeof window === "undefined") return;
|
||||
if (!kennung || typeof window === "undefined") return;
|
||||
|
||||
const quelle = new EventSource(
|
||||
`/api/atemschutz/strom?id=${encodeURIComponent(ueberwachungId)}`,
|
||||
);
|
||||
const quelle = new EventSource(`${adresse}${encodeURIComponent(kennung)}`);
|
||||
|
||||
const gehoert = () => {
|
||||
letzteRef.current = Date.now();
|
||||
@@ -108,7 +115,7 @@ export function useEreignisstrom(
|
||||
clearInterval(wache);
|
||||
quelle.close();
|
||||
};
|
||||
}, [ueberwachungId]);
|
||||
}, [kennung, adresse]);
|
||||
|
||||
return { verbunden, letzteMeldung };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 5;
|
||||
const AKTUELLE_PHASE = 6;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -142,6 +142,25 @@ const schema = z.object({
|
||||
/// Sicherheitszuschlag auf den Rückzugsdruck in bar.
|
||||
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)
|
||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||
GEOCODING_ENABLED: boolAusText(false),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||