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

|
||||||
|
|
||||||
|
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
|
FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
|
||||||
betreibt:
|
betreibt:
|
||||||
@@ -1120,7 +1279,7 @@ GEOCODING_LAENDER=de,at,ch
|
|||||||
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
||||||
eintragen oder weglassen.
|
eintragen oder weglassen.
|
||||||
|
|
||||||
## 35. Rollen und was sie dürfen
|
## 39. Rollen und was sie dürfen
|
||||||
|
|
||||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||||
@@ -1149,7 +1308,7 @@ Das Terminal selbst ist nur die Tür.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 36. Anmeldung über authentik einrichten
|
## 40. Anmeldung über authentik einrichten
|
||||||
|
|
||||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||||
|
|
||||||
@@ -1202,7 +1361,7 @@ ganze Wehr aus.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 37. PIN und Ausweiskarte verwalten
|
## 41. PIN und Ausweiskarte verwalten
|
||||||
|
|
||||||
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||||
was dahinter liegt.
|
was dahinter liegt.
|
||||||
@@ -1225,7 +1384,7 @@ Huber), PIN `471203`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 38. Was protokolliert wird
|
## 42. Was protokolliert wird
|
||||||
|
|
||||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||||
@@ -1250,7 +1409,7 @@ jeweiligen Detailseite sichtbar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 39. Wenn etwas nicht geht
|
## 43. Wenn etwas nicht geht
|
||||||
|
|
||||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1274,6 +1433,11 @@ jeweiligen Detailseite sichtbar.
|
|||||||
| Die Überwachung lässt sich nicht beenden | Ein Trupp ist noch nicht als draußen gemeldet | Trupp abmelden, dann beenden. Die Meldung nennt die Anzahl |
|
| Die Überwachung lässt sich nicht beenden | Ein Trupp ist noch nicht als draußen gemeldet | Trupp abmelden, dann beenden. Die Meldung nennt die Anzahl |
|
||||||
| Oben steht „Verbindung unterbrochen", obwohl das Netz da ist | Der Ereignisstrom wurde von einem Proxy gepuffert oder gekappt | Die Ansicht arbeitet weiter; bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
| Oben steht „Verbindung unterbrochen", obwohl das Netz da ist | Der Ereignisstrom wurde von einem Proxy gepuffert oder gekappt | Die Ansicht arbeitet weiter; bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
||||||
| Das Atemschutzprotokoll fehlt | Die Überwachung ist noch nicht beendet | Ein Protokoll entsteht erst mit dem Ende — vorher wäre es unvollständig und sähe vollständig aus |
|
| Das Atemschutzprotokoll fehlt | Die Überwachung ist noch nicht beendet | Ein Protokoll entsteht erst mit dem Ende — vorher wäre es unvollständig und sähe vollständig aus |
|
||||||
|
| Die Karte zeigt keinen Hintergrund | Kein Kartenserver eingerichtet oder nicht erreichbar | `docker compose --profile karte up -d`, MBTiles nach `docker/karten/`, `KARTE_STIL_URL` setzen. Ohne läuft alles außer den Straßen |
|
||||||
|
| Der Import überspringt alle Zeilen | Keine Spalte wurde als Breite und Länge erkannt | Spalten umbenennen (`lat`/`lon`, `Breitengrad`/`Längengrad`) — die erkannten Namen stehen in `lib/karte/hydrant-import.ts` |
|
||||||
|
| Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen |
|
||||||
|
| Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
||||||
|
| Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern |
|
||||||
|
|
||||||
Zustand aller Randsysteme auf einen Blick:
|
Zustand aller Randsysteme auf einen Blick:
|
||||||
|
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert.
|
|||||||
|
|
||||||
## Phase 6 — Kartenmodule
|
## Phase 6 — Kartenmodule
|
||||||
|
|
||||||
**Vergleichsaufwand: 14–18 PT**
|
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:50 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -313,6 +313,30 @@ Das SVG-Set für die taktischen Zeichen ist Fleißarbeit, aber unvermeidbar —
|
|||||||
fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
|
fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
|
||||||
Physik mit klaren Formeln und deshalb gut testbar.
|
Physik mit klaren Formeln und deshalb gut testbar.
|
||||||
|
|
||||||
|
**Rückblick.** Beide Sätze stimmen. Das Zeichenset war Fleißarbeit — 22 Zeichen
|
||||||
|
als SVG-Pfade, keine davon schwierig, alle zusammen eine Stunde. Und die
|
||||||
|
Wasserförderung war tatsächlich der am besten testbare Teil des ganzen
|
||||||
|
Projekts: 21 Tests, die alle etwas über die Physik aussagen statt über den Code.
|
||||||
|
|
||||||
|
Dabei fiel auf, dass die Beiwerte im ersten Wurf um den Faktor zehn danebenlagen
|
||||||
|
— aufgefallen ist es nur, weil die Tests gegen die **Tabellenwerte der
|
||||||
|
Ausbildung** prüfen und nicht gegen das, was die Funktion gerade ausrechnet.
|
||||||
|
Ein Test, der die eigene Implementierung bestätigt, hätte hier fröhlich grün
|
||||||
|
gemeldet.
|
||||||
|
|
||||||
|
Billiger als angesetzt war die Umkreissuche. 3,0 PT für „Hydranten mit PostGIS,
|
||||||
|
Cluster, Filter, Umkreissuche" — davon entfiel der Löwenanteil auf eine einzige
|
||||||
|
SQL-Abfrage mit `ST_DWithin` und einen GiST-Index. PostGIS war seit Phase 0 im
|
||||||
|
Container; genutzt wurde es erst jetzt.
|
||||||
|
|
||||||
|
Teurer war die Frage, was passiert, wenn es **keine Karte** gibt. Eine MBTiles-
|
||||||
|
Datei des Ausrückegebiets ist hunderte Megabyte groß, gehört niemandem außer
|
||||||
|
der Wehr und kann nicht im Repository liegen. Das heißt: der Normalfall beim
|
||||||
|
ersten Start ist eine Karte ohne Hintergrund — und sie muss trotzdem etwas
|
||||||
|
taugen. Aus dieser Anforderung kam der leere Stil mit Hinweis, die Erkennung
|
||||||
|
eines nicht erreichbaren Servers und die Zusicherung im E2E-Test, dass genau
|
||||||
|
das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
|
||||||
|
|
||||||
## Phase 7 — Benachrichtigungen und Termine
|
## Phase 7 — Benachrichtigungen und Termine
|
||||||
|
|
||||||
**Vergleichsaufwand: 10–13 PT**
|
**Vergleichsaufwand: 10–13 PT**
|
||||||
|
|||||||
@@ -17,7 +17,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
|||||||
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||||||
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||||||
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
||||||
| **Summe** | **13:20 h** | **7** | |
|
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
||||||
|
| **Summe** | **16:10 h** | **8** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-04 · Phase 6 — Kartenmodule
|
||||||
|
|
||||||
|
**22:10 – 01:00 · 2:50 h · Version 0.7.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Alles, was einen Ort hat: Löschwasser, Objekte mit Feuerwehrplan, die Lagekarte
|
||||||
|
eines Einsatzes und die geplante Förderstrecke.
|
||||||
|
|
||||||
|
| Bereich | Dateien |
|
||||||
|
|---|---|
|
||||||
|
| Rechnen | `lib/karte/wasserfoerderung.ts`, `hydrant-import.ts`, `taktische-zeichen.ts` |
|
||||||
|
| Server | `server/routers/karte/hydrant.ts`, `objekt.ts`, `lagekarte.ts` |
|
||||||
|
| Karte | `components/karte/karte.tsx` (MapLibre), `api/lagekarte/strom` |
|
||||||
|
| Oberfläche | `app/(app)/karte/` mit Karte, Hydranten, Objekten, Wasserförderung; Lagekarte im Einsatz |
|
||||||
|
| Betrieb | tileserver-gl im Compose-Profil `karte`, `docker/karten/LIESMICH.md` |
|
||||||
|
| Tests | 48 Unit, 11 E2E je Browser |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Kein öffentlicher Kartendienst — auch nicht als Rückfall.** Wo eine Wehr
|
||||||
|
ausrückt und wo ihre Hydranten liegen, ist niemandes Geschäft. Die Kacheln
|
||||||
|
kommen aus dem eigenen tileserver-gl. Ohne ihn zeigt die Karte einen leeren
|
||||||
|
Grund **und sagt das**; Punkte, Entfernungen und Umkreissuche funktionieren
|
||||||
|
weiter. Ein stiller leerer Kasten wäre die schlechtere Lösung gewesen: er sieht
|
||||||
|
aus wie ein Fehler, ohne einer zu sein.
|
||||||
|
|
||||||
|
**Die Umkreissuche gehört in die Datenbank.** `ST_DWithin` auf der Geografie
|
||||||
|
mit GiST-Index rechnet in Metern und nutzt den Index. In der Anwendung wäre es
|
||||||
|
ein vollständiger Tabellendurchlauf mit Grad-Arithmetik — und gefragt wird
|
||||||
|
während der Fahrt.
|
||||||
|
|
||||||
|
**Der Import rät nicht.** Er erkennt viel („DN 100", „48,5031", „24.05.2026",
|
||||||
|
vertauschte Achsen), aber wo die Position fehlt, wird die Zeile übersprungen
|
||||||
|
statt geschätzt. Und ohne Statusangabe steht „ungeprüft" — nicht
|
||||||
|
„einsatzbereit". Eine Liste, die tausend Hydranten als geprüft ausweist, weil
|
||||||
|
die Spalte fehlte, wäre schlimmer als keine Liste.
|
||||||
|
|
||||||
|
**Ein Ereignisstrom für beides.** Die Lagekarte benutzt denselben Hook wie die
|
||||||
|
Atemschutzüberwachung aus Phase 5, nur mit anderer Adresse. Zwei Ströme mit
|
||||||
|
unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||||||
|
verstecken kann.
|
||||||
|
|
||||||
|
**Das Ergebnis der Wasserförderung wird nicht gespeichert.** Nur die Eingaben.
|
||||||
|
Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne falsch
|
||||||
|
auszusehen.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Der Druckverlust lag um den Faktor zehn daneben | Die Beiwerte waren aus dem Kopf gegriffen statt aus der Formel abgeleitet: gebraucht wird `verlust = beiwert × (menge/100)²`, also 0,0234 für den B-Schlauch, nicht 0,23 | Beiwerte aus den Tabellenwerten zurückgerechnet. Aufgefallen ist es nur, weil die Tests gegen die Ausbildungstabelle prüfen und nicht gegen die eigene Implementierung |
|
||||||
|
| Ein nicht erreichbarer Kartenserver ließ die Karte kaputt aussehen | Der Hinweis „kein Hintergrund" hing an `stilUrl === null`, nicht daran, ob der Stil geladen wurde | MapLibre meldet den Fehlschlag; die Karte fällt jetzt in denselben ehrlichen Zustand wie ohne Server |
|
||||||
|
| Die Klicks in die Karte gingen im E2E-Test ins Leere | Die Lagekarte liegt unten auf der Einsatzseite, außerhalb des Fensters. `page.mouse` klickt in Fensterkoordinaten | Karte vor dem Klick ins Bild holen |
|
||||||
|
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1 | DropIndex-Block aus der Migration entfernt, im Kopf vermerkt |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Keine Cluster-Darstellung.** Phase-6.md nennt sie. Bei 24 Löschwasserstellen
|
||||||
|
im Beispiel und einigen hundert in einer echten Wehr sind Cluster eine Lösung
|
||||||
|
für ein Problem, das erst bei Zehntausenden entsteht — und sie verstecken genau
|
||||||
|
das, was man sehen will: die Lücke im Netz. Nachrüstbar, wenn eine Wehr mit
|
||||||
|
großem Ausrückegebiet es vermisst.
|
||||||
|
|
||||||
|
**Kein Höhenprofil aus einem Geländemodell.** Die Höhe je Punkt wird von Hand
|
||||||
|
eingetragen. Ein Höhenmodell (SRTM, DGM) wäre ein weiterer Datensatz von
|
||||||
|
hunderten Megabyte, den jede Wehr selbst beschaffen müsste. Die Rechnung ist
|
||||||
|
ohne Höhe zu günstig — und das steht auch so an der Eingabe.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe
|
||||||
|
- `npm run test` — 354 Tests grün
|
||||||
|
- `npx playwright test tests/e2e/karte.spec.ts` — 22 grün (Desktop und Tablet)
|
||||||
|
- `npm run build` — 44 Routen
|
||||||
|
- `npm run db:seed` — 24 Löschwasserstellen, 3 Objekte, Standorte verortet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Phase 2 ████████████████████ fertig
|
|||||||
Phase 3 ████████████████████ fertig 0.4.0
|
Phase 3 ████████████████████ fertig 0.4.0
|
||||||
Phase 4 ████████████████████ fertig 0.5.0
|
Phase 4 ████████████████████ fertig 0.5.0
|
||||||
Phase 5 ████████████████████ fertig 0.6.0
|
Phase 5 ████████████████████ fertig 0.6.0
|
||||||
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
Phase 6 ████████████████████ fertig 0.7.0
|
||||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
||||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
||||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
||||||
@@ -135,19 +135,20 @@ eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
|
|||||||
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
|
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
|
||||||
analogen, bis die Wehr ausdrücklich anders entscheidet.
|
analogen, bis die Wehr ausdrücklich anders entscheidet.
|
||||||
|
|
||||||
## Phase 6 — Kartenmodule · als Nächstes
|
## Phase 6 — Kartenmodule · fertig
|
||||||
|
|
||||||
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
|
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
|
||||||
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
|
||||||
GeoJSON und Stadtwerke-CSV
|
GeoJSON und Stadtwerke-CSV
|
||||||
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar
|
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar
|
||||||
- Lagekarte mit taktischen Zeichen nach FwDV 102, Echtzeit-Sync
|
- Lagekarte mit taktischen Zeichen nach FwDV 102, Abgleich über denselben
|
||||||
|
Ereignisstrom wie der Atemschutz
|
||||||
- Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
|
- Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
|
||||||
|
|
||||||
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
||||||
und was ihn hergibt.
|
und was ihn hergibt.
|
||||||
|
|
||||||
## Phase 7 — Benachrichtigungen und Termine
|
## Phase 7 — Benachrichtigungen und Termine · als Nächstes
|
||||||
|
|
||||||
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
||||||
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
||||||
|
|||||||
|
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",
|
"name": "florian",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.8.1",
|
"ioredis": "^5.8.1",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"maplibre-gl": "^6.7.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
@@ -2009,6 +2010,92 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/point-geometry": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/vector-tile": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "26.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.1.tgz",
|
||||||
|
"integrity": "sha512-I/qcIKVFHFSg1Meu/eqHrkSTjIez0gCsSaK56TNPutM3TkE61aSq6F1cZ4Kg4KiNs3cpViLtubDjfyRcQCdEJQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||||
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/mlt": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-g45M8gEI4sMO3X9ib4K7n3ZKFf0qQOL+NMkHCnEK51lOrYFHiEssOuZncx1+miIgi1IEE2NcbRMcq8RBkL+QHA==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
@@ -4328,6 +4415,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -6732,6 +6825,12 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.21.0",
|
"version": "3.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
@@ -7984,6 +8083,12 @@
|
|||||||
"giget": "dist/cli.mjs"
|
"giget": "dist/cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "7.2.3",
|
"version": "7.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||||
@@ -8959,6 +9064,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -9036,6 +9147,12 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9593,6 +9710,38 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/maplibre-gl": {
|
||||||
|
"version": "6.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.7.0.tgz",
|
||||||
|
"integrity": "sha512-Y1Q1+UP9WXou0DUrnaFdDsoMqiMCWy6aS968IBUaQ2eZi6H1/kPCfpfZOWXFJZbeKMkX9Wo6OFzY/k48qqPf3Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.2.0",
|
||||||
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
|
"@mapbox/vector-tile": "^3.0.0",
|
||||||
|
"@maplibre/geojson-vt": "^6.1.1",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^26.4.1",
|
||||||
|
"@maplibre/mlt": "^1.2.0",
|
||||||
|
"@maplibre/vt-pbf": "^4.3.2",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"earcut": "^3.2.3",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
|
"kdbush": "^4.1.0",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^5.1.2",
|
||||||
|
"potpack": "^2.1.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14.0",
|
||||||
|
"npm": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -9759,6 +9908,12 @@
|
|||||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/murmurhash-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.18",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
@@ -10339,6 +10494,18 @@
|
|||||||
"node": ">= 14.16"
|
"node": ">= 14.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
@@ -10457,6 +10624,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/potpack": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.24.3",
|
"version": "10.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
@@ -10567,6 +10740,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -10650,6 +10829,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/rc9": {
|
"node_modules/rc9": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
@@ -10975,6 +11160,15 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -11926,6 +12120,12 @@
|
|||||||
"node": "^18.0.0 || >=20.0.0"
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/tinyrainbow": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.8.1",
|
"ioredis": "^5.8.1",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"maplibre-gl": "^6.7.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
-- Phase 6 — Kartenmodule
|
||||||
|
--
|
||||||
|
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert:
|
||||||
|
-- die Volltext- und GiST-Indizes aus den fruehereren Phasen stehen nicht im
|
||||||
|
-- Schema, weil Prisma sie nicht abbilden kann. Rechte, Mandantentrennung,
|
||||||
|
-- Koordinaten-Trigger und Umkreisindizes stehen in der Migration daneben.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "HydrantTyp" AS ENUM ('UNTERFLUR', 'UEBERFLUR', 'SAUGSTELLE', 'ZISTERNE', 'OFFENES_GEWAESSER', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "HydrantStatus" AS ENUM ('EINSATZBEREIT', 'EINGESCHRAENKT', 'DEFEKT', 'UNBEKANNT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ObjektKategorie" AS ENUM ('SCHULE', 'KINDERGARTEN', 'PFLEGEHEIM', 'KRANKENHAUS', 'VERSAMMLUNGSSTAETTE', 'HOCHHAUS', 'INDUSTRIE', 'LANDWIRTSCHAFT', 'GEFAHRSTOFFLAGER', 'SONDERBAU', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PlanArt" AS ENUM ('UEBERSICHTSPLAN', 'GESCHOSSPLAN', 'UMGEBUNGSPLAN', 'SONDERPLAN', 'ANLAGE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LageelementArt" AS ENUM ('ZEICHEN', 'LINIE', 'FLAECHE', 'TEXT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FoerderpunktArt" AS ENUM ('ENTNAHME', 'VERSTAERKER', 'WEGPUNKT', 'ZIEL');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "standort" ADD COLUMN "breitengrad" DECIMAL(9,6),
|
||||||
|
ADD COLUMN "laengengrad" DECIMAL(9,6);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "hydrant" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"bezeichnung" VARCHAR(120),
|
||||||
|
"typ" "HydrantTyp" NOT NULL DEFAULT 'UNTERFLUR',
|
||||||
|
"status" "HydrantStatus" NOT NULL DEFAULT 'UNBEKANNT',
|
||||||
|
"nennweite" INTEGER,
|
||||||
|
"durchfluss" INTEGER,
|
||||||
|
"strasse" VARCHAR(200),
|
||||||
|
"hausnummer" VARCHAR(20),
|
||||||
|
"ortsteil" VARCHAR(120),
|
||||||
|
"breitengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"laengengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"koordinaten" geography(Point, 4326),
|
||||||
|
"letztePruefung" DATE,
|
||||||
|
"naechstePruefung" DATE,
|
||||||
|
"betreiber" VARCHAR(200),
|
||||||
|
"hinweis" VARCHAR(1000),
|
||||||
|
"fotoDateiId" UUID,
|
||||||
|
"quelle" VARCHAR(40),
|
||||||
|
"externeNummer" VARCHAR(80),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "hydrant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "objekt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"kategorie" "ObjektKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||||
|
"strasse" VARCHAR(200),
|
||||||
|
"hausnummer" VARCHAR(20),
|
||||||
|
"plz" VARCHAR(10),
|
||||||
|
"ort" VARCHAR(120),
|
||||||
|
"breitengrad" DECIMAL(9,6),
|
||||||
|
"laengengrad" DECIMAL(9,6),
|
||||||
|
"koordinaten" geography(Point, 4326),
|
||||||
|
"zufahrt" VARCHAR(2000),
|
||||||
|
"gefahren" VARCHAR(2000),
|
||||||
|
"schluesseldepot" VARCHAR(500),
|
||||||
|
"loeschwasser" VARCHAR(1000),
|
||||||
|
"bemerkung" VARCHAR(2000),
|
||||||
|
"personenzahl" INTEGER,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "objekt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "objektkontakt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"objektId" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"funktion" VARCHAR(120),
|
||||||
|
"telefon" VARCHAR(60),
|
||||||
|
"mobil" VARCHAR(60),
|
||||||
|
"jederzeit" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"reihenfolge" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "objektkontakt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "objektplan" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"objektId" UUID NOT NULL,
|
||||||
|
"dateiId" UUID NOT NULL,
|
||||||
|
"art" "PlanArt" NOT NULL DEFAULT 'UEBERSICHTSPLAN',
|
||||||
|
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||||
|
"standAm" DATE,
|
||||||
|
"reihenfolge" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "objektplan_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lagekarte" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID NOT NULL,
|
||||||
|
"breitengrad" DECIMAL(9,6),
|
||||||
|
"laengengrad" DECIMAL(9,6),
|
||||||
|
"zoomstufe" INTEGER,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "lagekarte_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lageelement" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"lagekarteId" UUID NOT NULL,
|
||||||
|
"art" "LageelementArt" NOT NULL DEFAULT 'ZEICHEN',
|
||||||
|
"zeichen" VARCHAR(60),
|
||||||
|
"text" VARCHAR(300),
|
||||||
|
"farbe" VARCHAR(20),
|
||||||
|
"geometrie" JSONB NOT NULL,
|
||||||
|
"drehung" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltVonId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "lageelement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "wasserfoerderung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"einsatzId" UUID,
|
||||||
|
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||||
|
"durchmesser" INTEGER NOT NULL DEFAULT 75,
|
||||||
|
"foerdermenge" INTEGER NOT NULL DEFAULT 800,
|
||||||
|
"ausgangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 8,
|
||||||
|
"eingangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 1.5,
|
||||||
|
"bemerkung" VARCHAR(2000),
|
||||||
|
"geplantVonId" UUID,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "wasserfoerderung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "foerderpunkt" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"wasserfoerderungId" UUID NOT NULL,
|
||||||
|
"reihenfolge" INTEGER NOT NULL,
|
||||||
|
"art" "FoerderpunktArt" NOT NULL DEFAULT 'WEGPUNKT',
|
||||||
|
"bezeichnung" VARCHAR(200),
|
||||||
|
"breitengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"laengengrad" DECIMAL(9,6) NOT NULL,
|
||||||
|
"hoehe" DECIMAL(7,2),
|
||||||
|
"bemerkung" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "foerderpunkt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hydrant_organisationId_status_idx" ON "hydrant"("organisationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hydrant_organisationId_typ_idx" ON "hydrant"("organisationId", "typ");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "hydrant_organisationId_externeNummer_key" ON "hydrant"("organisationId", "externeNummer");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objekt_organisationId_kategorie_idx" ON "objekt"("organisationId", "kategorie");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objekt_organisationId_name_idx" ON "objekt"("organisationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objektkontakt_organisationId_objektId_idx" ON "objektkontakt"("organisationId", "objektId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "objektplan_organisationId_objektId_idx" ON "objektplan"("organisationId", "objektId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "lagekarte_einsatzId_key" ON "lagekarte"("einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "lageelement_organisationId_lagekarteId_geloeschtAm_idx" ON "lageelement"("organisationId", "lagekarteId", "geloeschtAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "wasserfoerderung_organisationId_einsatzId_idx" ON "wasserfoerderung"("organisationId", "einsatzId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "foerderpunkt_organisationId_wasserfoerderungId_idx" ON "foerderpunkt"("organisationId", "wasserfoerderungId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "foerderpunkt_wasserfoerderungId_reihenfolge_key" ON "foerderpunkt"("wasserfoerderungId", "reihenfolge");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objekt" ADD CONSTRAINT "objekt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_dateiId_fkey" FOREIGN KEY ("dateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_lagekarteId_fkey" FOREIGN KEY ("lagekarteId") REFERENCES "lagekarte"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_geplantVonId_fkey" FOREIGN KEY ("geplantVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_wasserfoerderungId_fkey" FOREIGN KEY ("wasserfoerderungId") REFERENCES "wasserfoerderung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 6: Mandantentrennung, Koordinaten und die Indizes für die Karte.
|
||||||
|
--
|
||||||
|
-- Vier Dinge:
|
||||||
|
--
|
||||||
|
-- 1. Row Level Security für die acht neuen Tabellen.
|
||||||
|
--
|
||||||
|
-- 2. Koordinaten doppelt halten. Prisma kann den PostGIS-Typ nicht lesen,
|
||||||
|
-- also stehen Dezimalgrade daneben — und ein Trigger sorgt dafür, dass
|
||||||
|
-- beide nie auseinanderlaufen. Dasselbe Muster wie beim Einsatz aus
|
||||||
|
-- Phase 3, jetzt auch für Standort, Hydrant, Objekt und Foerderpunkt.
|
||||||
|
--
|
||||||
|
-- 3. GiST-Indizes auf den Punkten. Ohne sie ist die Umkreissuche ein
|
||||||
|
-- vollstaendiger Durchlauf der Tabelle — bei tausend Hydranten im
|
||||||
|
-- Ausrueckegebiet auf der Anfahrt zu langsam.
|
||||||
|
--
|
||||||
|
-- 4. Volltextsuche ueber Objekte: der Name allein genuegt nicht, gesucht
|
||||||
|
-- wird auch nach Strasse, Gefahren und Ansprechpartner.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'hydrant',
|
||||||
|
'objekt',
|
||||||
|
'objektkontakt',
|
||||||
|
'objektplan',
|
||||||
|
'lagekarte',
|
||||||
|
'lageelement',
|
||||||
|
'wasserfoerderung',
|
||||||
|
'foerderpunkt'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten
|
||||||
|
--
|
||||||
|
-- Eine Funktion fuer alle Tabellen: sie erwartet die Spalten "breitengrad",
|
||||||
|
-- "laengengrad" und "koordinaten" und laesst sich damit an jede Tabelle
|
||||||
|
-- haengen, die einen Ort hat.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION koordinaten_setzen()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
breite double precision;
|
||||||
|
laenge double precision;
|
||||||
|
BEGIN
|
||||||
|
breite := (to_jsonb(NEW) ->> 'breitengrad')::double precision;
|
||||||
|
laenge := (to_jsonb(NEW) ->> 'laengengrad')::double precision;
|
||||||
|
|
||||||
|
IF breite IS NULL OR laenge IS NULL THEN
|
||||||
|
NEW := jsonb_populate_record(NEW, jsonb_build_object('koordinaten', NULL));
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Reihenfolge beachten: ST_MakePoint nimmt erst die Laenge, dann die Breite.
|
||||||
|
NEW := jsonb_populate_record(
|
||||||
|
NEW,
|
||||||
|
jsonb_build_object(
|
||||||
|
'koordinaten',
|
||||||
|
ST_AsEWKT(ST_SetSRID(ST_MakePoint(laenge, breite), 4326)::geography)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION koordinaten_setzen() IS
|
||||||
|
'Haelt den PostGIS-Punkt einer Tabelle mit ihren Dezimalgraden im Gleichlauf.';
|
||||||
|
|
||||||
|
CREATE TRIGGER standort_koordinaten
|
||||||
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "standort"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||||
|
|
||||||
|
CREATE TRIGGER hydrant_koordinaten
|
||||||
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "hydrant"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||||
|
|
||||||
|
CREATE TRIGGER objekt_koordinaten
|
||||||
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "objekt"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_koordinaten_gueltig" CHECK (
|
||||||
|
"breitengrad" >= -90 AND "breitengrad" <= 90
|
||||||
|
AND "laengengrad" >= -180 AND "laengengrad" <= 180
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "objekt" ADD CONSTRAINT "objekt_koordinaten_gueltig" CHECK (
|
||||||
|
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||||
|
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "standort" ADD CONSTRAINT "standort_koordinaten_gueltig" CHECK (
|
||||||
|
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||||
|
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_koordinaten_gueltig" CHECK (
|
||||||
|
"breitengrad" >= -90 AND "breitengrad" <= 90
|
||||||
|
AND "laengengrad" >= -180 AND "laengengrad" <= 180
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Eine Foerderstrecke ohne Wasser oder mit einem Schlauch, den es nicht gibt.
|
||||||
|
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_sinnvoll" CHECK (
|
||||||
|
"durchmesser" IN (52, 75, 110)
|
||||||
|
AND "foerdermenge" > 0 AND "foerdermenge" <= 10000
|
||||||
|
AND "ausgangsdruck" > 0 AND "ausgangsdruck" <= 16
|
||||||
|
AND "eingangsdruck" >= 0 AND "eingangsdruck" < "ausgangsdruck"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Indizes fuer die Umkreissuche
|
||||||
|
--
|
||||||
|
-- GiST auf der Geografie: damit beantwortet Postgres "was liegt im Umkreis von
|
||||||
|
-- 300 Metern" ueber den Index statt ueber die ganze Tabelle.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "hydrant_umkreis" ON "hydrant" USING GIST ("koordinaten")
|
||||||
|
WHERE "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX "objekt_umkreis" ON "objekt" USING GIST ("koordinaten")
|
||||||
|
WHERE "geloeschtAm" IS NULL;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Volltextsuche ueber Objekte
|
||||||
|
--
|
||||||
|
-- Trigram wie bei den Geraeten aus Phase 1: gesucht wird mit Tippfehlern und
|
||||||
|
-- Wortfragmenten, nicht mit vollstaendigen Woertern.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "objekt_suche_name" ON "objekt" USING GIN ("name" gin_trgm_ops);
|
||||||
|
CREATE INDEX "objekt_suche_strasse" ON "objekt" USING GIN ("strasse" gin_trgm_ops);
|
||||||
|
CREATE INDEX "objekt_suche_gefahren" ON "objekt" USING GIN ("gefahren" gin_trgm_ops);
|
||||||
|
CREATE INDEX "hydrant_suche_bezeichnung" ON "hydrant" USING GIN ("bezeichnung" gin_trgm_ops);
|
||||||
|
CREATE INDEX "hydrant_suche_strasse" ON "hydrant" USING GIN ("strasse" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte fuer die Anwendungsrolle
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -208,6 +208,16 @@ model Organisation {
|
|||||||
g26Untersuchungen G26Untersuchung[]
|
g26Untersuchungen G26Untersuchung[]
|
||||||
belastungsuebungen Belastungsuebung[]
|
belastungsuebungen Belastungsuebung[]
|
||||||
|
|
||||||
|
// Phase 6 — Kartenmodule
|
||||||
|
hydranten Hydrant[]
|
||||||
|
objekte Objekt[]
|
||||||
|
objektkontakte Objektkontakt[]
|
||||||
|
objektplaene Objektplan[]
|
||||||
|
lagekarten Lagekarte[]
|
||||||
|
lageelemente Lageelement[]
|
||||||
|
wasserfoerderungen Wasserfoerderung[]
|
||||||
|
foerderpunkte Foerderpunkt[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("organisation")
|
@@map("organisation")
|
||||||
}
|
}
|
||||||
@@ -310,6 +320,10 @@ model Person {
|
|||||||
g26Untersuchungen G26Untersuchung[] @relation("G26Person")
|
g26Untersuchungen G26Untersuchung[] @relation("G26Person")
|
||||||
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson")
|
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson")
|
||||||
|
|
||||||
|
// Phase 6 — Kartenmodule
|
||||||
|
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
|
||||||
|
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
|
||||||
|
|
||||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||||
/// Person kann in zwei Wehren Mitglied sein.
|
/// Person kann in zwei Wehren Mitglied sein.
|
||||||
@@unique([organisationId, email])
|
@@unique([organisationId, email])
|
||||||
@@ -548,6 +562,10 @@ model Datei {
|
|||||||
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
|
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
|
||||||
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
|
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
|
||||||
|
|
||||||
|
// Phase 6
|
||||||
|
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
|
||||||
|
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
|
||||||
|
|
||||||
@@unique([bucket, objektSchluessel])
|
@@unique([bucket, objektSchluessel])
|
||||||
@@index([organisationId, entitaet, entitaetId])
|
@@index([organisationId, entitaet, entitaetId])
|
||||||
@@index([organisationId, kategorie])
|
@@index([organisationId, kategorie])
|
||||||
@@ -646,9 +664,12 @@ model Standort {
|
|||||||
plz String? @db.VarChar(10)
|
plz String? @db.VarChar(10)
|
||||||
ort String? @db.VarChar(120)
|
ort String? @db.VarChar(120)
|
||||||
|
|
||||||
/// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht,
|
/// Koordinaten doppelt: als Dezimalgrad für die Anwendung und als
|
||||||
/// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage.
|
/// PostGIS-Punkt für die Karte. Prisma kann den PostGIS-Typ nicht lesen —
|
||||||
/// Ab Phase 6 zeigt die Karte diese Punkte an.
|
/// ohne die Grade daneben wäre jede Anzeige eine Rohabfrage. Ein Trigger
|
||||||
|
/// hält beides im Gleichlauf (Phase 6).
|
||||||
|
breitengrad Decimal? @db.Decimal(9, 6)
|
||||||
|
laengengrad Decimal? @db.Decimal(9, 6)
|
||||||
koordinaten Unsupported("geography(Point, 4326)")?
|
koordinaten Unsupported("geography(Point, 4326)")?
|
||||||
|
|
||||||
bemerkung String? @db.VarChar(1000)
|
bemerkung String? @db.VarChar(1000)
|
||||||
@@ -1680,6 +1701,10 @@ model Einsatz {
|
|||||||
// Phase 5
|
// Phase 5
|
||||||
atemschutz Atemschutzeinsatz[]
|
atemschutz Atemschutzeinsatz[]
|
||||||
|
|
||||||
|
// Phase 6
|
||||||
|
lagekarte Lagekarte?
|
||||||
|
wasserfoerderungen Wasserfoerderung[]
|
||||||
|
|
||||||
@@unique([organisationId, einsatznummer])
|
@@unique([organisationId, einsatznummer])
|
||||||
@@index([organisationId, alarmiertUm(sort: Desc)])
|
@@index([organisationId, alarmiertUm(sort: Desc)])
|
||||||
@@index([organisationId, status])
|
@@index([organisationId, status])
|
||||||
@@ -2573,3 +2598,369 @@ model Belastungsuebung {
|
|||||||
@@index([organisationId, personId, datum(sort: Desc)])
|
@@index([organisationId, personId, datum(sort: Desc)])
|
||||||
@@map("belastungsuebung")
|
@@map("belastungsuebung")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 6 — Kartenmodule
|
||||||
|
//
|
||||||
|
// Alles, was einen Ort hat: Hydranten, Objekte mit Feuerwehrplänen, die
|
||||||
|
// Lagekarte eines Einsatzes und die geplante Wasserförderstrecke.
|
||||||
|
//
|
||||||
|
// Koordinaten liegen überall doppelt — als Dezimalgrad und als PostGIS-Punkt.
|
||||||
|
// Der Grund ist derselbe wie beim Einsatz aus Phase 3: Prisma kann den
|
||||||
|
// PostGIS-Typ nicht lesen, und ohne die Grade daneben wäre jede Anzeige eine
|
||||||
|
// Rohabfrage. Ein Trigger hält beides zusammen; die Umkreissuche läuft über
|
||||||
|
// den PostGIS-Punkt, weil nur der einen Index hat, der Entfernungen kann.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Woher das Löschwasser kommt.
|
||||||
|
enum HydrantTyp {
|
||||||
|
/// Der Regelfall im Ort: Deckel auf der Straße, Standrohr drauf.
|
||||||
|
UNTERFLUR
|
||||||
|
/// Sichtbare Säule. Im Winter schneller zu finden.
|
||||||
|
UEBERFLUR
|
||||||
|
/// Vorbereitete Entnahmestelle an einem Gewässer.
|
||||||
|
SAUGSTELLE
|
||||||
|
/// Löschwasserbehälter, meist unterirdisch.
|
||||||
|
ZISTERNE
|
||||||
|
/// Bach, Weiher, Fluss — mit allem, was daran hängt (Zufahrt, Jahreszeit).
|
||||||
|
OFFENES_GEWAESSER
|
||||||
|
SONSTIGES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob man sich darauf verlassen kann.
|
||||||
|
enum HydrantStatus {
|
||||||
|
EINSATZBEREIT
|
||||||
|
/// Nutzbar, aber mit Einschränkung — geringe Leistung, schwer zugänglich.
|
||||||
|
EINGESCHRAENKT
|
||||||
|
DEFEKT
|
||||||
|
/// Noch nie geprüft oder aus einem Import ohne Angabe übernommen.
|
||||||
|
UNBEKANNT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grobe Einordnung eines Objekts. Bestimmt, was im Einsatz zuerst zählt.
|
||||||
|
enum ObjektKategorie {
|
||||||
|
SCHULE
|
||||||
|
KINDERGARTEN
|
||||||
|
PFLEGEHEIM
|
||||||
|
KRANKENHAUS
|
||||||
|
VERSAMMLUNGSSTAETTE
|
||||||
|
HOCHHAUS
|
||||||
|
INDUSTRIE
|
||||||
|
LANDWIRTSCHAFT
|
||||||
|
GEFAHRSTOFFLAGER
|
||||||
|
SONDERBAU
|
||||||
|
SONSTIGES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blattarten eines Feuerwehrplans nach DIN 14095.
|
||||||
|
enum PlanArt {
|
||||||
|
UEBERSICHTSPLAN
|
||||||
|
GESCHOSSPLAN
|
||||||
|
UMGEBUNGSPLAN
|
||||||
|
SONDERPLAN
|
||||||
|
/// Alles, was zusätzlich dazugehört: Betriebsanweisung, Stoffliste.
|
||||||
|
ANLAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was auf der Lagekarte liegt.
|
||||||
|
enum LageelementArt {
|
||||||
|
/// Taktisches Zeichen nach FwDV 102, siehe lib/karte/taktische-zeichen.ts.
|
||||||
|
ZEICHEN
|
||||||
|
/// Linienzug — Schlauchleitung, Absperrung, Anfahrtsweg.
|
||||||
|
LINIE
|
||||||
|
/// Geschlossene Fläche — Bereitstellungsraum, Gefahrenbereich.
|
||||||
|
FLAECHE
|
||||||
|
/// Freie Beschriftung.
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolle eines Punktes in der Förderstrecke.
|
||||||
|
enum FoerderpunktArt {
|
||||||
|
/// Wo das Wasser herkommt.
|
||||||
|
ENTNAHME
|
||||||
|
/// Verstärkerpumpe in der Strecke.
|
||||||
|
VERSTAERKER
|
||||||
|
/// Zwischenpunkt ohne Pumpe — nur für den Streckenverlauf und die Höhe.
|
||||||
|
WEGPUNKT
|
||||||
|
/// Die Einsatzstelle.
|
||||||
|
ZIEL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Hydrant oder eine andere Löschwasserstelle.
|
||||||
|
///
|
||||||
|
/// Die Frage, die dieses Modell beantworten muss, wird auf der Anfahrt
|
||||||
|
/// gestellt und lautet: **Was liegt in der Nähe, und was gibt es her?**
|
||||||
|
/// Deshalb stehen Typ, Nennweite und Durchfluss gleichrangig neben dem Ort —
|
||||||
|
/// ein Unterflurhydrant mit 80er Leitung ist etwas anderes als einer mit 150.
|
||||||
|
model Hydrant {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
|
||||||
|
/// Nummer oder Bezeichnung, wie sie die Wehr verwendet.
|
||||||
|
bezeichnung String? @db.VarChar(120)
|
||||||
|
typ HydrantTyp @default(UNTERFLUR)
|
||||||
|
status HydrantStatus @default(UNBEKANNT)
|
||||||
|
|
||||||
|
/// Nennweite der Leitung in Millimetern (80, 100, 150 …).
|
||||||
|
nennweite Int?
|
||||||
|
/// Ergiebigkeit in Litern je Minute, aus der Prüfung oder vom Versorger.
|
||||||
|
durchfluss Int?
|
||||||
|
|
||||||
|
strasse String? @db.VarChar(200)
|
||||||
|
hausnummer String? @db.VarChar(20)
|
||||||
|
ortsteil String? @db.VarChar(120)
|
||||||
|
|
||||||
|
breitengrad Decimal @db.Decimal(9, 6)
|
||||||
|
laengengrad Decimal @db.Decimal(9, 6)
|
||||||
|
koordinaten Unsupported("geography(Point, 4326)")?
|
||||||
|
|
||||||
|
letztePruefung DateTime? @db.Date
|
||||||
|
naechstePruefung DateTime? @db.Date
|
||||||
|
/// Wer die Leitung betreibt — im Zweifel der Ansprechpartner bei Defekt.
|
||||||
|
betreiber String? @db.VarChar(200)
|
||||||
|
/// Was man vor Ort wissen muss: „hinter der Hecke", „im Winter geräumt".
|
||||||
|
hinweis String? @db.VarChar(1000)
|
||||||
|
|
||||||
|
fotoDateiId String? @db.Uuid
|
||||||
|
|
||||||
|
/// Woher der Datensatz stammt: "hand", "geojson", "stadtwerke-csv".
|
||||||
|
quelle String? @db.VarChar(40)
|
||||||
|
/// Kennung beim Versorger, damit ein zweiter Import nichts doppelt anlegt.
|
||||||
|
externeNummer String? @db.VarChar(80)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
foto Datei? @relation("HydrantFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([organisationId, externeNummer])
|
||||||
|
@@index([organisationId, status])
|
||||||
|
@@index([organisationId, typ])
|
||||||
|
@@map("hydrant")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Objekt mit Feuerwehrplan.
|
||||||
|
///
|
||||||
|
/// Was hier steht, wird auf der Anfahrt gelesen — oft vom Beifahrersitz aus.
|
||||||
|
/// Deshalb sind Zufahrt, Gefahren und Schlüsseldepot eigene Felder und keine
|
||||||
|
/// Fließtextnotiz: im Einsatz sucht niemand in einem Absatz.
|
||||||
|
model Objekt {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
kategorie ObjektKategorie @default(SONSTIGES)
|
||||||
|
|
||||||
|
strasse String? @db.VarChar(200)
|
||||||
|
hausnummer String? @db.VarChar(20)
|
||||||
|
plz String? @db.VarChar(10)
|
||||||
|
ort String? @db.VarChar(120)
|
||||||
|
|
||||||
|
breitengrad Decimal? @db.Decimal(9, 6)
|
||||||
|
laengengrad Decimal? @db.Decimal(9, 6)
|
||||||
|
koordinaten Unsupported("geography(Point, 4326)")?
|
||||||
|
|
||||||
|
/// Wie man hinkommt: Tordurchfahrt, Höhenbegrenzung, Aufstellfläche.
|
||||||
|
zufahrt String? @db.VarChar(2000)
|
||||||
|
/// Was einen erwartet: Photovoltaik, Gasflaschen, Silo, Tiere.
|
||||||
|
gefahren String? @db.VarChar(2000)
|
||||||
|
/// Wo der Schlüssel liegt und wie man drankommt.
|
||||||
|
schluesseldepot String? @db.VarChar(500)
|
||||||
|
/// Löschwasser am Objekt: Wandhydranten, Sprinklerzentrale, Steigleitung.
|
||||||
|
loeschwasser String? @db.VarChar(1000)
|
||||||
|
bemerkung String? @db.VarChar(2000)
|
||||||
|
|
||||||
|
/// Anzahl der Personen, mit denen zu rechnen ist (Schule, Heim, Betrieb).
|
||||||
|
personenzahl Int?
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
kontakte Objektkontakt[]
|
||||||
|
plaene Objektplan[]
|
||||||
|
|
||||||
|
@@index([organisationId, kategorie])
|
||||||
|
@@index([organisationId, name])
|
||||||
|
@@map("objekt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wen man beim Objekt anruft.
|
||||||
|
model Objektkontakt {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
objektId String @db.Uuid
|
||||||
|
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
funktion String? @db.VarChar(120)
|
||||||
|
telefon String? @db.VarChar(60)
|
||||||
|
mobil String? @db.VarChar(60)
|
||||||
|
/// Erreichbar rund um die Uhr? Im Einsatz die einzige Frage, die zählt.
|
||||||
|
jederzeit Boolean @default(false)
|
||||||
|
bemerkung String? @db.VarChar(500)
|
||||||
|
|
||||||
|
reihenfolge Int @default(0)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([organisationId, objektId])
|
||||||
|
@@map("objektkontakt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Blatt des Feuerwehrplans nach DIN 14095.
|
||||||
|
model Objektplan {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
objektId String @db.Uuid
|
||||||
|
dateiId String @db.Uuid
|
||||||
|
|
||||||
|
art PlanArt @default(UEBERSICHTSPLAN)
|
||||||
|
bezeichnung String @db.VarChar(200)
|
||||||
|
/// Stand des Plans — ein Feuerwehrplan von 2009 ist eine Vermutung.
|
||||||
|
standAm DateTime? @db.Date
|
||||||
|
reihenfolge Int @default(0)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
|
||||||
|
datei Datei @relation("ObjektplanDatei", fields: [dateiId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([organisationId, objektId])
|
||||||
|
@@map("objektplan")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Lagekarte eines Einsatzes.
|
||||||
|
///
|
||||||
|
/// Eine je Einsatz. Sie ist der gemeinsame Zeichenblock: was einer einträgt,
|
||||||
|
/// sehen die anderen — verteilt über denselben Ereignisstrom wie die
|
||||||
|
/// Atemschutzüberwachung aus Phase 5.
|
||||||
|
model Lagekarte {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
einsatzId String @unique @db.Uuid
|
||||||
|
|
||||||
|
/// Kartenausschnitt, damit alle denselben Blick haben.
|
||||||
|
breitengrad Decimal? @db.Decimal(9, 6)
|
||||||
|
laengengrad Decimal? @db.Decimal(9, 6)
|
||||||
|
zoomstufe Int?
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||||
|
elemente Lageelement[]
|
||||||
|
|
||||||
|
@@map("lagekarte")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Element auf der Lagekarte.
|
||||||
|
///
|
||||||
|
/// Die Geometrie steht als JSON: ein Punkt für ein taktisches Zeichen, eine
|
||||||
|
/// Liste von Punkten für Linie und Fläche. PostGIS wäre hier zu viel —
|
||||||
|
/// gerechnet wird damit nichts, es wird nur gezeichnet.
|
||||||
|
///
|
||||||
|
/// Gelöscht wird weich: eine Lagekarte ist die Mitschrift einer Entscheidung,
|
||||||
|
/// und was jemand um 14:12 eingezeichnet hat, gehört auch dann noch dazu, wenn
|
||||||
|
/// es um 14:20 nicht mehr stimmte.
|
||||||
|
model Lageelement {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
lagekarteId String @db.Uuid
|
||||||
|
|
||||||
|
art LageelementArt @default(ZEICHEN)
|
||||||
|
/// Schlüssel aus dem Zeichensatz, etwa "loeschfahrzeug" oder "brandstelle".
|
||||||
|
zeichen String? @db.VarChar(60)
|
||||||
|
text String? @db.VarChar(300)
|
||||||
|
farbe String? @db.VarChar(20)
|
||||||
|
|
||||||
|
/// [[laenge, breite], …] in WGS 84.
|
||||||
|
geometrie Json @db.JsonB
|
||||||
|
/// Drehung eines Zeichens in Grad — ein Fahrzeug steht selten nach Norden.
|
||||||
|
drehung Int @default(0)
|
||||||
|
|
||||||
|
erstelltVonId String? @db.Uuid
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
lagekarte Lagekarte @relation(fields: [lagekarteId], references: [id], onDelete: Cascade)
|
||||||
|
erstelltVon Person? @relation("LageelementZeichner", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([organisationId, lagekarteId, geloeschtAm])
|
||||||
|
@@map("lageelement")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine geplante Wasserförderstrecke.
|
||||||
|
///
|
||||||
|
/// Die Rechnung dahinter ist Physik mit klaren Formeln (siehe
|
||||||
|
/// lib/karte/wasserfoerderung.ts) — deshalb steht hier nur, woraus gerechnet
|
||||||
|
/// wurde. Das Ergebnis wird nicht gespeichert, sondern bei jeder Anzeige neu
|
||||||
|
/// gerechnet: eine gespeicherte Zahl neben geänderten Eingaben wäre falsch,
|
||||||
|
/// ohne falsch auszusehen.
|
||||||
|
model Wasserfoerderung {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
einsatzId String? @db.Uuid
|
||||||
|
|
||||||
|
bezeichnung String @db.VarChar(200)
|
||||||
|
|
||||||
|
/// Schlauchdurchmesser in Millimetern: 75 (B) oder 110 (A).
|
||||||
|
durchmesser Int @default(75)
|
||||||
|
/// Geplante Fördermenge in Litern je Minute.
|
||||||
|
foerdermenge Int @default(800)
|
||||||
|
/// Ausgangsdruck der ersten Pumpe in bar.
|
||||||
|
ausgangsdruck Decimal @default(8) @db.Decimal(4, 1)
|
||||||
|
/// Mindestdruck am Eingang jeder weiteren Pumpe in bar.
|
||||||
|
eingangsdruck Decimal @default(1.5) @db.Decimal(4, 1)
|
||||||
|
|
||||||
|
bemerkung String? @db.VarChar(2000)
|
||||||
|
|
||||||
|
geplantVonId String? @db.Uuid
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
|
||||||
|
geplantVon Person? @relation("WasserfoerderungPlaner", fields: [geplantVonId], references: [id], onDelete: SetNull)
|
||||||
|
punkte Foerderpunkt[]
|
||||||
|
|
||||||
|
@@index([organisationId, einsatzId])
|
||||||
|
@@map("wasserfoerderung")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Punkt der Förderstrecke.
|
||||||
|
model Foerderpunkt {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
wasserfoerderungId String @db.Uuid
|
||||||
|
|
||||||
|
reihenfolge Int
|
||||||
|
art FoerderpunktArt @default(WEGPUNKT)
|
||||||
|
bezeichnung String? @db.VarChar(200)
|
||||||
|
|
||||||
|
breitengrad Decimal @db.Decimal(9, 6)
|
||||||
|
laengengrad Decimal @db.Decimal(9, 6)
|
||||||
|
/// Höhe über Normalnull in Metern. Der Höhenunterschied kostet Druck:
|
||||||
|
/// zehn Meter Steigung sind ein bar.
|
||||||
|
hoehe Decimal? @db.Decimal(7, 2)
|
||||||
|
|
||||||
|
bemerkung String? @db.VarChar(500)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
wasserfoerderung Wasserfoerderung @relation(fields: [wasserfoerderungId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([wasserfoerderungId, reihenfolge])
|
||||||
|
@@index([organisationId, wasserfoerderungId])
|
||||||
|
@@map("foerderpunkt")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
|
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
|
||||||
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
|
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
|
||||||
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
||||||
* Überwachung mit vollständigem Protokoll.
|
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
||||||
|
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
||||||
|
* Feuerwehrplan.
|
||||||
*
|
*
|
||||||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||||
@@ -52,6 +54,9 @@ import {
|
|||||||
QualifikationsArt,
|
QualifikationsArt,
|
||||||
RolleSchluessel,
|
RolleSchluessel,
|
||||||
SignaturRolle,
|
SignaturRolle,
|
||||||
|
HydrantStatus,
|
||||||
|
HydrantTyp,
|
||||||
|
ObjektKategorie,
|
||||||
StandortArt,
|
StandortArt,
|
||||||
TruppStatus,
|
TruppStatus,
|
||||||
UebungsArt,
|
UebungsArt,
|
||||||
@@ -2479,6 +2484,276 @@ async function atemschutzAnlegen(organisationId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Phase 6 — Kartenmodule
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Der Ortskern von Musterhausen — frei erfunden, aber in Niederbayern plausibel. */
|
||||||
|
const ORTSMITTE = { breitengrad: 48.5, laengengrad: 12.15 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löschwasser und Objekte.
|
||||||
|
*
|
||||||
|
* Die Hydranten liegen in einem Raster um den Ortskern, mit den Zuständen, die
|
||||||
|
* eine Wehr tatsächlich vorfindet: die meisten in Ordnung, einige ungeprüft,
|
||||||
|
* zwei defekt. Dazu ein Weiher und eine Zisterne am Ortsrand — im Außenbereich
|
||||||
|
* gibt es keine Leitung.
|
||||||
|
*
|
||||||
|
* Die drei Objekte sind die, für die eine Wehr zuerst einen Plan schreibt:
|
||||||
|
* Schule, Pflegeheim, Aussiedlerhof.
|
||||||
|
*/
|
||||||
|
async function kartendatenAnlegen(organisationId: string) {
|
||||||
|
const vorhanden = await prisma.hydrant.count({ where: { organisationId } });
|
||||||
|
if (vorhanden > 0) {
|
||||||
|
log(`Karte: bereits ${vorhanden} Löschwasserstellen vorhanden`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Standorte auf die Karte holen ---------------------------------------
|
||||||
|
//
|
||||||
|
// Ohne Koordinaten am Gerätehaus wüsste die Karte nicht, wohin sie schauen
|
||||||
|
// soll. Die Standorte gibt es seit Phase 1, die Dezimalgrade seit Phase 6.
|
||||||
|
const standorte = await prisma.standort.findMany({
|
||||||
|
where: { organisationId, geloeschtAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { erstelltAm: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [index, standort] of standorte.entries()) {
|
||||||
|
await prisma.standort.update({
|
||||||
|
where: { id: standort.id },
|
||||||
|
data: {
|
||||||
|
breitengrad: ORTSMITTE.breitengrad + index * 0.004,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + index * 0.006,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Löschwasserstellen ---------------------------------------------------
|
||||||
|
const STRASSEN = [
|
||||||
|
"Hauptstraße",
|
||||||
|
"Bahnhofstraße",
|
||||||
|
"Schulstraße",
|
||||||
|
"Kirchplatz",
|
||||||
|
"Am Anger",
|
||||||
|
"Industriestraße",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hydranten: Array<{
|
||||||
|
bezeichnung: string;
|
||||||
|
typ: HydrantTyp;
|
||||||
|
status: HydrantStatus;
|
||||||
|
nennweite: number | null;
|
||||||
|
durchfluss: number | null;
|
||||||
|
strasse: string;
|
||||||
|
breitengrad: number;
|
||||||
|
laengengrad: number;
|
||||||
|
letztePruefung: Date | null;
|
||||||
|
hinweis: string | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const heute = new Date();
|
||||||
|
|
||||||
|
function vorTagen(tage: number): Date {
|
||||||
|
const datum = new Date(heute);
|
||||||
|
datum.setUTCDate(datum.getUTCDate() - tage);
|
||||||
|
datum.setUTCHours(0, 0, 0, 0);
|
||||||
|
return datum;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let nummer = 1; nummer <= 20; nummer += 1) {
|
||||||
|
const strasse = STRASSEN[nummer % STRASSEN.length]!;
|
||||||
|
// Ein Raster von rund 120 Metern Kantenlänge um die Ortsmitte.
|
||||||
|
const reihe = Math.floor((nummer - 1) / 5);
|
||||||
|
const spalte = (nummer - 1) % 5;
|
||||||
|
|
||||||
|
const ungeprueft = nummer % 7 === 0;
|
||||||
|
const defekt = nummer === 9 || nummer === 17;
|
||||||
|
|
||||||
|
hydranten.push({
|
||||||
|
bezeichnung: `H-${String(nummer).padStart(4, "0")}`,
|
||||||
|
typ: nummer % 6 === 0 ? HydrantTyp.UEBERFLUR : HydrantTyp.UNTERFLUR,
|
||||||
|
status: defekt
|
||||||
|
? HydrantStatus.DEFEKT
|
||||||
|
: ungeprueft
|
||||||
|
? HydrantStatus.UNBEKANNT
|
||||||
|
: HydrantStatus.EINSATZBEREIT,
|
||||||
|
nennweite: nummer % 4 === 0 ? 150 : nummer % 3 === 0 ? 80 : 100,
|
||||||
|
durchfluss: nummer % 4 === 0 ? 1600 : nummer % 3 === 0 ? 400 : 800,
|
||||||
|
strasse,
|
||||||
|
breitengrad: ORTSMITTE.breitengrad + reihe * 0.0011 - 0.002,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + spalte * 0.0016 - 0.003,
|
||||||
|
letztePruefung: ungeprueft ? null : vorTagen(60 + nummer * 9),
|
||||||
|
hinweis: defekt
|
||||||
|
? "Deckel verzogen, Standrohr passt nicht. Stadtwerke gemeldet."
|
||||||
|
: nummer === 3
|
||||||
|
? "Im Winter oft zugeschoben — Schneepflug fährt hier durch."
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Im Außenbereich gibt es keine Leitung.
|
||||||
|
hydranten.push(
|
||||||
|
{
|
||||||
|
bezeichnung: "Weiher Kiesgrube",
|
||||||
|
typ: HydrantTyp.OFFENES_GEWAESSER,
|
||||||
|
status: HydrantStatus.EINSATZBEREIT,
|
||||||
|
nennweite: null,
|
||||||
|
durchfluss: 2400,
|
||||||
|
strasse: "Kiesgrubenweg",
|
||||||
|
breitengrad: ORTSMITTE.breitengrad - 0.008,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + 0.012,
|
||||||
|
letztePruefung: vorTagen(200),
|
||||||
|
hinweis: "Zufahrt für LF geeignet, im Winter vereist. Saughöhe rund 3 m.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bezeichnung: "Zisterne Aussiedlerhof",
|
||||||
|
typ: HydrantTyp.ZISTERNE,
|
||||||
|
status: HydrantStatus.EINSATZBEREIT,
|
||||||
|
nennweite: null,
|
||||||
|
durchfluss: 800,
|
||||||
|
strasse: "Hofweg 2",
|
||||||
|
breitengrad: ORTSMITTE.breitengrad + 0.011,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + 0.018,
|
||||||
|
letztePruefung: vorTagen(120),
|
||||||
|
hinweis: "50 m³, Saugstutzen an der Nordseite der Scheune.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bezeichnung: "Saugstelle Mühlbach",
|
||||||
|
typ: HydrantTyp.SAUGSTELLE,
|
||||||
|
status: HydrantStatus.EINGESCHRAENKT,
|
||||||
|
nennweite: null,
|
||||||
|
durchfluss: 600,
|
||||||
|
strasse: "Mühlweg",
|
||||||
|
breitengrad: ORTSMITTE.breitengrad - 0.004,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad - 0.009,
|
||||||
|
letztePruefung: vorTagen(400),
|
||||||
|
hinweis: "Im Sommer zu wenig Wasser. Nur nach Regen brauchbar.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bezeichnung: "H-0021",
|
||||||
|
typ: HydrantTyp.UEBERFLUR,
|
||||||
|
status: HydrantStatus.EINSATZBEREIT,
|
||||||
|
nennweite: 150,
|
||||||
|
durchfluss: 1600,
|
||||||
|
strasse: "Industriestraße",
|
||||||
|
breitengrad: ORTSMITTE.breitengrad + 0.006,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + 0.008,
|
||||||
|
letztePruefung: vorTagen(45),
|
||||||
|
hinweis: "Am Werkstor, ergiebigster Hydrant im Ort.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const hydrant of hydranten) {
|
||||||
|
await prisma.hydrant.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
...hydrant,
|
||||||
|
betreiber: "Stadtwerke Musterhausen",
|
||||||
|
quelle: "seed",
|
||||||
|
externeNummer: `SW-${hydrant.bezeichnung}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Objekte mit Feuerwehrplan -------------------------------------------
|
||||||
|
const OBJEKTE: Array<{
|
||||||
|
name: string;
|
||||||
|
kategorie: ObjektKategorie;
|
||||||
|
strasse: string;
|
||||||
|
hausnummer: string;
|
||||||
|
breitengrad: number;
|
||||||
|
laengengrad: number;
|
||||||
|
personenzahl: number;
|
||||||
|
zufahrt: string;
|
||||||
|
gefahren: string;
|
||||||
|
schluesseldepot: string;
|
||||||
|
loeschwasser: string;
|
||||||
|
kontakte: Array<{ name: string; funktion: string; telefon: string; jederzeit: boolean }>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: "Grundschule Musterhausen",
|
||||||
|
kategorie: ObjektKategorie.SCHULE,
|
||||||
|
strasse: "Schulstraße",
|
||||||
|
hausnummer: "4",
|
||||||
|
breitengrad: ORTSMITTE.breitengrad + 0.0015,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + 0.0022,
|
||||||
|
personenzahl: 180,
|
||||||
|
zufahrt:
|
||||||
|
"Aufstellfläche auf dem Pausenhof, Zufahrt über die Schulstraße. Poller mit Feuerwehrschlüssel, Durchfahrt 3,10 m.",
|
||||||
|
gefahren:
|
||||||
|
"Photovoltaik auf dem Süddach (Gleichstrom bis zum Wechselrichter im Keller). Chemieraum im 1. OG Nordseite.",
|
||||||
|
schluesseldepot: "FSD rechts neben dem Haupteingang, Schlüssel für alle Türen.",
|
||||||
|
loeschwasser: "Wandhydranten in jedem Geschoss, Steigleitung trocken im Treppenhaus B.",
|
||||||
|
kontakte: [
|
||||||
|
{ name: "Ursula Berger", funktion: "Schulleitung", telefon: "08721 4711", jederzeit: false },
|
||||||
|
{ name: "Karl Steininger", funktion: "Hausmeister", telefon: "0170 1234567", jederzeit: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Seniorenheim St. Anna",
|
||||||
|
kategorie: ObjektKategorie.PFLEGEHEIM,
|
||||||
|
strasse: "Am Anger",
|
||||||
|
hausnummer: "11",
|
||||||
|
breitengrad: ORTSMITTE.breitengrad - 0.0018,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + 0.0009,
|
||||||
|
personenzahl: 84,
|
||||||
|
zufahrt:
|
||||||
|
"Anleiterbare Seite nur nach Norden. Aufstellfläche für DL im Innenhof, Einfahrt an der Ostseite.",
|
||||||
|
gefahren:
|
||||||
|
"62 Bewohner, davon 28 bettlägerig. Sauerstoffflaschen in den Zimmern 12, 14 und 31. Sammelplatz auf dem Parkplatz an der Ostseite.",
|
||||||
|
schluesseldepot: "FSD links vom Haupteingang, dazu Objektschlüssel in der Pforte.",
|
||||||
|
loeschwasser: "Wandhydranten je Geschoss, nächster Überflurhydrant 40 m westlich.",
|
||||||
|
kontakte: [
|
||||||
|
{ name: "Pflegedienstleitung", funktion: "Nachtdienst", telefon: "08721 5522", jederzeit: true },
|
||||||
|
{ name: "Hermann Falk", funktion: "Heimleitung", telefon: "0171 9876543", jederzeit: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Aussiedlerhof Huber",
|
||||||
|
kategorie: ObjektKategorie.LANDWIRTSCHAFT,
|
||||||
|
strasse: "Hofweg",
|
||||||
|
hausnummer: "2",
|
||||||
|
breitengrad: ORTSMITTE.breitengrad + 0.0108,
|
||||||
|
laengengrad: ORTSMITTE.laengengrad + 0.0178,
|
||||||
|
personenzahl: 6,
|
||||||
|
zufahrt:
|
||||||
|
"Feldweg ab der Kreisstraße, 600 m unbefestigt. Für TLF befahrbar, bei Nässe nur mit Allrad.",
|
||||||
|
gefahren:
|
||||||
|
"Silo mit Gärgasen, Biogasanlage 80 m nordöstlich. 60 Rinder im Stall, Notausgang Südseite. PV auf allen Dächern.",
|
||||||
|
schluesseldepot: "Kein FSD. Familie wohnt auf dem Hof, Schlüssel beim Betriebsleiter.",
|
||||||
|
loeschwasser: "Zisterne 50 m³ an der Scheune, sonst kein Löschwasser im Umkreis von 1,5 km.",
|
||||||
|
kontakte: [
|
||||||
|
{ name: "Josef Huber", funktion: "Betriebsleiter", telefon: "0175 3344556", jederzeit: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const vorlage of OBJEKTE) {
|
||||||
|
const { kontakte, ...felder } = vorlage;
|
||||||
|
|
||||||
|
await prisma.objekt.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
...felder,
|
||||||
|
plz: "84359",
|
||||||
|
ort: "Musterhausen",
|
||||||
|
kontakte: {
|
||||||
|
create: kontakte.map((kontakt, index) => ({
|
||||||
|
organisationId,
|
||||||
|
...kontakt,
|
||||||
|
reihenfolge: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
`Karte: ${hydranten.length} Löschwasserstellen, ${OBJEKTE.length} Objekte mit Ansprechpartnern, ${standorte.length} Standorte verortet`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Ablauf
|
// Ablauf
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -2516,6 +2791,9 @@ async function main(): Promise<void> {
|
|||||||
// Phase 5 — Atemschutz nach FwDV 7
|
// Phase 5 — Atemschutz nach FwDV 7
|
||||||
await atemschutzAnlegen(organisation.id);
|
await atemschutzAnlegen(organisation.id);
|
||||||
|
|
||||||
|
// Phase 6 — Kartenmodule
|
||||||
|
await kartendatenAnlegen(organisation.id);
|
||||||
|
|
||||||
log("Fertig.");
|
log("Fertig.");
|
||||||
log("");
|
log("");
|
||||||
log("Anmeldung für den Erststart:");
|
log("Anmeldung für den Erststart:");
|
||||||
|
|||||||
@@ -184,6 +184,26 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
beschreibung: "G26.3 und Belastungsübungen erfassen",
|
beschreibung: "G26.3 und Belastungsübungen erfassen",
|
||||||
angemeldet: true,
|
angemeldet: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Phase 6 — Kartenmodule
|
||||||
|
{
|
||||||
|
name: "34-karte",
|
||||||
|
pfad: "/karte",
|
||||||
|
beschreibung: "Karte mit Löschwasser, Objekten und Umkreissuche",
|
||||||
|
angemeldet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "35-objekte",
|
||||||
|
pfad: "/karte/objekte",
|
||||||
|
beschreibung: "Objekte mit Feuerwehrplan",
|
||||||
|
angemeldet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "36-wasserfoerderung",
|
||||||
|
pfad: "/karte/wasserfoerderung",
|
||||||
|
beschreibung: "Wasserförderung über lange Wegstrecke",
|
||||||
|
angemeldet: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { aktuelleSitzung } from "@/lib/auth";
|
||||||
import { hatRecht } from "@/lib/authz";
|
import { hatRecht } from "@/lib/authz";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
|
import { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
|
||||||
|
import { Lagekarte } from "@/app/(app)/einsaetze/[id]/lagekarte";
|
||||||
|
|
||||||
export const metadata = { title: "Einsatz" };
|
export const metadata = { title: "Einsatz" };
|
||||||
|
|
||||||
@@ -21,6 +25,32 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
|||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Mittelpunkt der Lagekarte: die Einsatzstelle, sonst das Gerätehaus, sonst
|
||||||
|
// die Vorgabe aus der Konfiguration.
|
||||||
|
const orte = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||||
|
const [einsatz, standort] = await Promise.all([
|
||||||
|
db.einsatz.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { breitengrad: true, laengengrad: true },
|
||||||
|
}),
|
||||||
|
db.standort.findFirst({
|
||||||
|
where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } },
|
||||||
|
select: { breitengrad: true, laengengrad: true },
|
||||||
|
orderBy: { erstelltAm: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { einsatz, standort };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mittelpunkt = {
|
||||||
|
breitengrad: Number(
|
||||||
|
orte.einsatz?.breitengrad ?? orte.standort?.breitengrad ?? env.KARTE_BREITENGRAD,
|
||||||
|
),
|
||||||
|
laengengrad: Number(
|
||||||
|
orte.einsatz?.laengengrad ?? orte.standort?.laengengrad ?? env.KARTE_LAENGENGRAD,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -37,6 +67,30 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
|||||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||||
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
|
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Die Lagekarte (Phase 6). Sie steht unter dem Einsatz und nicht in einem
|
||||||
|
eigenen Reiter: wer sie braucht, braucht sie zusammen mit den Zeiten und
|
||||||
|
den Lagemeldungen, nicht statt ihnen.
|
||||||
|
*/}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lagekarte</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Taktische Zeichen nach FwDV 102. Was einer einträgt, sehen alle — auch auf einem
|
||||||
|
zweiten Gerät.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Lagekarte
|
||||||
|
einsatzId={id}
|
||||||
|
stilUrl={env.KARTE_STIL_URL ?? null}
|
||||||
|
mittelpunkt={mittelpunkt}
|
||||||
|
zoom={env.KARTE_ZOOM + 3}
|
||||||
|
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Droplets, Plus, Trash2, Upload } from "lucide-react";
|
||||||
|
import { HydrantStatus, HydrantTyp } from "@prisma/client";
|
||||||
|
import Papa from "papaparse";
|
||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
|
import { Karte } from "@/components/karte/karte";
|
||||||
|
import { alsDatum } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydranten pflegen.
|
||||||
|
*
|
||||||
|
* Zwei Wege hinein: einzeln über die Karte (antippen, Angaben eintragen) und
|
||||||
|
* als Liste über den Import. Der Import ist der übliche Fall — die Wehr
|
||||||
|
* bekommt eine Datei von den Stadtwerken oder vom Landratsamt und will sie
|
||||||
|
* nicht abtippen.
|
||||||
|
*
|
||||||
|
* Wie beim Geräteimport aus Phase 1 in zwei Schritten: erst zeigen, was
|
||||||
|
* herauskäme, dann übernehmen. Niemand schreibt tausend Datensätze in eine
|
||||||
|
* Datenbank, ohne vorher gesehen zu haben, was drinsteht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYP_TEXT: Record<HydrantTyp, string> = {
|
||||||
|
UNTERFLUR: "Unterflur",
|
||||||
|
UEBERFLUR: "Überflur",
|
||||||
|
SAUGSTELLE: "Saugstelle",
|
||||||
|
ZISTERNE: "Zisterne",
|
||||||
|
OFFENES_GEWAESSER: "offenes Gewässer",
|
||||||
|
SONSTIGES: "sonstige",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TEXT: Record<HydrantStatus, string> = {
|
||||||
|
EINSATZBEREIT: "einsatzbereit",
|
||||||
|
EINGESCHRAENKT: "eingeschränkt",
|
||||||
|
DEFEKT: "defekt",
|
||||||
|
UNBEKANNT: "ungeprüft",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HydrantenPflege({
|
||||||
|
stilUrl,
|
||||||
|
mittelpunkt,
|
||||||
|
zoom,
|
||||||
|
darfBearbeiten,
|
||||||
|
}: {
|
||||||
|
stilUrl: string | null;
|
||||||
|
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||||
|
zoom: number;
|
||||||
|
darfBearbeiten: boolean;
|
||||||
|
}) {
|
||||||
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
|
const [erfolg, setErfolg] = React.useState<string | null>(null);
|
||||||
|
const [neuerOrt, setNeuerOrt] = React.useState<{
|
||||||
|
breitengrad: number;
|
||||||
|
laengengrad: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [vorschau, setVorschau] = React.useState<{
|
||||||
|
format: "geojson" | "zeilen";
|
||||||
|
inhalt?: string;
|
||||||
|
zeilen?: Array<Record<string, unknown>>;
|
||||||
|
gefunden: number;
|
||||||
|
uebersprungen: Array<{ zeile: number; grund: string }>;
|
||||||
|
hinweise: string[];
|
||||||
|
beispiele: Array<{ bezeichnung: string | null; typ: string; strasse: string | null }>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const werkzeuge = trpc.useUtils();
|
||||||
|
const hydranten = trpc.hydrant.liste.useQuery({});
|
||||||
|
|
||||||
|
const anlegen = trpc.hydrant.anlegen.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setNeuerOrt(null);
|
||||||
|
setErfolg("Hydrant angelegt.");
|
||||||
|
void werkzeuge.hydrant.invalidate();
|
||||||
|
},
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const entfernen = trpc.hydrant.entfernen.useMutation({
|
||||||
|
onSuccess: () => void werkzeuge.hydrant.invalidate(),
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const importieren = trpc.hydrant.importieren.useMutation({
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Datei einlesen und beim Server prüfen lassen — noch nichts schreiben. */
|
||||||
|
async function dateiWaehlen(datei: File) {
|
||||||
|
setMeldung(null);
|
||||||
|
setErfolg(null);
|
||||||
|
|
||||||
|
const text = await datei.text();
|
||||||
|
const istGeoJson = datei.name.toLowerCase().endsWith(".geojson") || text.trimStart().startsWith("{");
|
||||||
|
|
||||||
|
if (istGeoJson) {
|
||||||
|
const ergebnis = await importieren.mutateAsync({
|
||||||
|
format: "geojson",
|
||||||
|
inhalt: text,
|
||||||
|
nurPruefen: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setVorschau({
|
||||||
|
format: "geojson",
|
||||||
|
inhalt: text,
|
||||||
|
gefunden: ergebnis.gefunden,
|
||||||
|
uebersprungen: ergebnis.uebersprungen,
|
||||||
|
hinweise: ergebnis.hinweise,
|
||||||
|
beispiele: ergebnis.vorschau.map((eintrag) => ({
|
||||||
|
bezeichnung: eintrag.bezeichnung,
|
||||||
|
typ: eintrag.typ,
|
||||||
|
strasse: eintrag.strasse,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV: mit demselben Werkzeug zerlegen wie der Geräteimport aus Phase 1.
|
||||||
|
const zerlegt = Papa.parse<Record<string, unknown>>(text, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
delimiter: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis = await importieren.mutateAsync({
|
||||||
|
format: "zeilen",
|
||||||
|
zeilen: zerlegt.data,
|
||||||
|
nurPruefen: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setVorschau({
|
||||||
|
format: "zeilen",
|
||||||
|
zeilen: zerlegt.data,
|
||||||
|
gefunden: ergebnis.gefunden,
|
||||||
|
uebersprungen: ergebnis.uebersprungen,
|
||||||
|
hinweise: ergebnis.hinweise,
|
||||||
|
beispiele: ergebnis.vorschau.map((eintrag) => ({
|
||||||
|
bezeichnung: eintrag.bezeichnung,
|
||||||
|
typ: eintrag.typ,
|
||||||
|
strasse: eintrag.strasse,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uebernehmen() {
|
||||||
|
if (!vorschau) return;
|
||||||
|
|
||||||
|
const ergebnis = await importieren.mutateAsync({
|
||||||
|
format: vorschau.format,
|
||||||
|
inhalt: vorschau.inhalt,
|
||||||
|
zeilen: vorschau.zeilen,
|
||||||
|
nurPruefen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
setVorschau(null);
|
||||||
|
setErfolg(
|
||||||
|
`${ergebnis.uebernommen} neu angelegt, ${ergebnis.aktualisiert} aktualisiert, ${ergebnis.uebersprungen.length} übersprungen.`,
|
||||||
|
);
|
||||||
|
void werkzeuge.hydrant.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{meldung && <Fehlermeldung meldung={meldung} kennung="hydrantfehler" />}
|
||||||
|
{erfolg && (
|
||||||
|
<p
|
||||||
|
className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 px-4 py-3 text-sm"
|
||||||
|
role="status"
|
||||||
|
data-testid="hydrant-erfolg"
|
||||||
|
>
|
||||||
|
{erfolg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfBearbeiten && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Import</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
GeoJSON oder eine Tabelle der Stadtwerke. Spaltennamen werden erkannt, deutsche
|
||||||
|
Kommazahlen und Datumsangaben ebenso. Hydranten mit einer Nummer des Versorgers
|
||||||
|
werden beim zweiten Import aktualisiert statt doppelt angelegt.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="importdatei">Datei</Label>
|
||||||
|
<Input
|
||||||
|
id="importdatei"
|
||||||
|
type="file"
|
||||||
|
accept=".geojson,.json,.csv,.txt"
|
||||||
|
onChange={(ereignis) => {
|
||||||
|
const datei = ereignis.target.files?.[0];
|
||||||
|
if (datei) void dateiWaehlen(datei);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vorschau && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||||
|
<p className="font-medium">
|
||||||
|
{vorschau.gefunden} {vorschau.gefunden === 1 ? "Hydrant" : "Hydranten"} gefunden
|
||||||
|
{vorschau.uebersprungen.length > 0
|
||||||
|
? `, ${vorschau.uebersprungen.length} übersprungen`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{vorschau.hinweise.map((hinweis) => (
|
||||||
|
<p key={hinweis} className="text-sm text-[var(--pruefung-faellig)]">
|
||||||
|
{hinweis}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{vorschau.beispiele.length > 0 && (
|
||||||
|
<ul className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{vorschau.beispiele.slice(0, 5).map((beispiel, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
{beispiel.bezeichnung ?? "ohne Bezeichnung"} ·{" "}
|
||||||
|
{TYP_TEXT[beispiel.typ as HydrantTyp]} · {beispiel.strasse ?? "ohne Straße"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{vorschau.gefunden > 5 && <li>… und {vorschau.gefunden - 5} weitere</li>}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vorschau.uebersprungen.length > 0 && (
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer">
|
||||||
|
Übersprungene Zeilen ansehen
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 space-y-1 text-[var(--muted-foreground)]">
|
||||||
|
{vorschau.uebersprungen.slice(0, 20).map((eintrag) => (
|
||||||
|
<li key={eintrag.zeile}>
|
||||||
|
Zeile {eintrag.zeile}: {eintrag.grund}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => void uebernehmen()}
|
||||||
|
disabled={vorschau.gefunden === 0 || importieren.isPending}
|
||||||
|
>
|
||||||
|
<Upload aria-hidden />
|
||||||
|
{vorschau.gefunden} übernehmen
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setVorschau(null)}>
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfBearbeiten && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Einzeln erfassen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
In die Karte tippen setzt den Ort — danach die Angaben eintragen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Karte
|
||||||
|
stilUrl={stilUrl}
|
||||||
|
mittelpunkt={neuerOrt ?? mittelpunkt}
|
||||||
|
zoom={zoom}
|
||||||
|
punkte={
|
||||||
|
neuerOrt
|
||||||
|
? [{ id: "neu", ...neuerOrt, farbe: "#1d4ed8", titel: "Neuer Hydrant" }]
|
||||||
|
: (hydranten.data ?? []).map((hydrant) => ({
|
||||||
|
id: hydrant.id,
|
||||||
|
breitengrad: Number(hydrant.breitengrad),
|
||||||
|
laengengrad: Number(hydrant.laengengrad),
|
||||||
|
titel: hydrant.bezeichnung ?? TYP_TEXT[hydrant.typ],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
beiKartenklick={setNeuerOrt}
|
||||||
|
hoehe="40vh"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{neuerOrt && (
|
||||||
|
<form
|
||||||
|
className="grid gap-3 sm:grid-cols-4"
|
||||||
|
onSubmit={(ereignis) => {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const formular = new FormData(ereignis.currentTarget);
|
||||||
|
|
||||||
|
anlegen.mutate({
|
||||||
|
...neuerOrt,
|
||||||
|
bezeichnung: String(formular.get("bezeichnung") ?? "").trim() || null,
|
||||||
|
typ: (formular.get("typ") as HydrantTyp) ?? HydrantTyp.UNTERFLUR,
|
||||||
|
status: (formular.get("status") as HydrantStatus) ?? HydrantStatus.UNBEKANNT,
|
||||||
|
nennweite: Number(formular.get("nennweite")) || null,
|
||||||
|
strasse: String(formular.get("strasse") ?? "").trim() || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="h-bezeichnung">Bezeichnung</Label>
|
||||||
|
<Input id="h-bezeichnung" name="bezeichnung" placeholder="H-0042" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="h-typ">Art</Label>
|
||||||
|
<select
|
||||||
|
id="h-typ"
|
||||||
|
name="typ"
|
||||||
|
className="h-10 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 text-sm"
|
||||||
|
defaultValue={HydrantTyp.UNTERFLUR}
|
||||||
|
>
|
||||||
|
{(Object.keys(TYP_TEXT) as HydrantTyp[]).map((wert) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{TYP_TEXT[wert]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="h-status">Zustand</Label>
|
||||||
|
<select
|
||||||
|
id="h-status"
|
||||||
|
name="status"
|
||||||
|
className="h-10 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 text-sm"
|
||||||
|
defaultValue={HydrantStatus.UNBEKANNT}
|
||||||
|
>
|
||||||
|
{(Object.keys(STATUS_TEXT) as HydrantStatus[]).map((wert) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{STATUS_TEXT[wert]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="h-nennweite">Nennweite</Label>
|
||||||
|
<Input id="h-nennweite" name="nennweite" type="number" placeholder="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 sm:col-span-3">
|
||||||
|
<Label htmlFor="h-strasse">Straße</Label>
|
||||||
|
<Input id="h-strasse" name="strasse" placeholder="Hauptstraße" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button type="submit" disabled={anlegen.isPending}>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setNeuerOrt(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bestand</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{hydranten.data?.length ?? 0} Löschwasserstellen
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(hydranten.data?.length ?? 0) === 0 ? (
|
||||||
|
<NichtsGefunden
|
||||||
|
titel="Noch kein Hydrant erfasst"
|
||||||
|
hinweis="Am schnellsten geht es über den Import — die Stadtwerke haben die Liste."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Bezeichnung</TableHead>
|
||||||
|
<TableHead>Art</TableHead>
|
||||||
|
<TableHead>Straße</TableHead>
|
||||||
|
<TableHead className="text-right">DN</TableHead>
|
||||||
|
<TableHead className="text-right">l/min</TableHead>
|
||||||
|
<TableHead>Zustand</TableHead>
|
||||||
|
<TableHead>geprüft</TableHead>
|
||||||
|
{darfBearbeiten && <TableHead className="w-16" aria-label="Aktion" />}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{hydranten.data?.slice(0, 200).map((hydrant) => (
|
||||||
|
<TableRow key={hydrant.id} data-testid={`hydrant-${hydrant.id}`}>
|
||||||
|
<TableCell>
|
||||||
|
<Droplets className="mr-1 inline size-4 text-[var(--muted-foreground)]" aria-hidden />
|
||||||
|
{hydrant.bezeichnung ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{TYP_TEXT[hydrant.typ]}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{hydrant.strasse} {hydrant.hausnummer}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabellenzahlen text-right">
|
||||||
|
{hydrant.nennweite ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabellenzahlen text-right">
|
||||||
|
{hydrant.durchfluss ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{STATUS_TEXT[hydrant.status]}</TableCell>
|
||||||
|
<TableCell className="tabellenzahlen text-sm">
|
||||||
|
{hydrant.letztePruefung ? alsDatum(hydrant.letztePruefung) : "nie"}
|
||||||
|
</TableCell>
|
||||||
|
{darfBearbeiten && (
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`${hydrant.bezeichnung ?? "Hydrant"} entfernen`}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("Diesen Hydranten entfernen?")) {
|
||||||
|
entfernen.mutate({ id: hydrant.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hydranten.data?.length ?? 0) > 200 && (
|
||||||
|
<p className="mt-3 text-sm text-[var(--muted-foreground)]">
|
||||||
|
Die ersten 200 von {hydranten.data?.length}. Die Karte zeigt alle.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
const STILLE_MS = 8000;
|
||||||
|
|
||||||
export function useEreignisstrom(
|
export function useEreignisstrom(
|
||||||
ueberwachungId: string | null,
|
kennung: string | null,
|
||||||
beiAenderung: () => void,
|
beiAenderung: () => void,
|
||||||
|
/**
|
||||||
|
* Adresse des Stroms, an die die Kennung angehängt wird.
|
||||||
|
*
|
||||||
|
* Vorgabe ist die Atemschutzüberwachung; die Lagekarte aus Phase 6 benutzt
|
||||||
|
* denselben Hook mit ihrer eigenen Adresse. Zwei Ereignisströme mit
|
||||||
|
* unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||||||
|
* verstecken kann.
|
||||||
|
*/
|
||||||
|
adresse = "/api/atemschutz/strom?id=",
|
||||||
): { verbunden: boolean; letzteMeldung: Date | null } {
|
): { verbunden: boolean; letzteMeldung: Date | null } {
|
||||||
const [verbunden, setVerbunden] = React.useState(false);
|
const [verbunden, setVerbunden] = React.useState(false);
|
||||||
const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null);
|
const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null);
|
||||||
@@ -69,11 +78,9 @@ export function useEreignisstrom(
|
|||||||
rueckruf.current = beiAenderung;
|
rueckruf.current = beiAenderung;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!ueberwachungId || typeof window === "undefined") return;
|
if (!kennung || typeof window === "undefined") return;
|
||||||
|
|
||||||
const quelle = new EventSource(
|
const quelle = new EventSource(`${adresse}${encodeURIComponent(kennung)}`);
|
||||||
`/api/atemschutz/strom?id=${encodeURIComponent(ueberwachungId)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const gehoert = () => {
|
const gehoert = () => {
|
||||||
letzteRef.current = Date.now();
|
letzteRef.current = Date.now();
|
||||||
@@ -108,7 +115,7 @@ export function useEreignisstrom(
|
|||||||
clearInterval(wache);
|
clearInterval(wache);
|
||||||
quelle.close();
|
quelle.close();
|
||||||
};
|
};
|
||||||
}, [ueberwachungId]);
|
}, [kennung, adresse]);
|
||||||
|
|
||||||
return { verbunden, letzteMeldung };
|
return { verbunden, letzteMeldung };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||||
const AKTUELLE_PHASE = 5;
|
const AKTUELLE_PHASE = 6;
|
||||||
|
|
||||||
export interface SeitenleisteProps {
|
export interface SeitenleisteProps {
|
||||||
rechte: Recht[];
|
rechte: Recht[];
|
||||||
|
|||||||
@@ -142,6 +142,25 @@ const schema = z.object({
|
|||||||
/// Sicherheitszuschlag auf den Rückzugsdruck in bar.
|
/// Sicherheitszuschlag auf den Rückzugsdruck in bar.
|
||||||
ATEMSCHUTZ_ZUSCHLAG: zahl(10),
|
ATEMSCHUTZ_ZUSCHLAG: zahl(10),
|
||||||
|
|
||||||
|
// Kartenmodule (Phase 6)
|
||||||
|
/**
|
||||||
|
* Adresse des eigenen Kartenservers.
|
||||||
|
*
|
||||||
|
* FLORIAN lädt keine Kacheln aus dem Netz — weder von OpenStreetMap noch von
|
||||||
|
* einem Anbieter. Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche
|
||||||
|
* Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver läuft
|
||||||
|
* deshalb selbst (tileserver-gl im Compose-Profil "karte", siehe README).
|
||||||
|
*
|
||||||
|
* Ohne Adresse zeigt die Karte einen leeren Hintergrund mit Gitternetz —
|
||||||
|
* Hydranten, Objekte und Lagekarte funktionieren trotzdem, nur ohne Kacheln
|
||||||
|
* darunter.
|
||||||
|
*/
|
||||||
|
KARTE_STIL_URL: z.string().optional(),
|
||||||
|
/** Kartenmittelpunkt beim Öffnen, falls kein Standort hinterlegt ist. */
|
||||||
|
KARTE_BREITENGRAD: z.coerce.number().min(-90).max(90).default(48.5),
|
||||||
|
KARTE_LAENGENGRAD: z.coerce.number().min(-180).max(180).default(12.15),
|
||||||
|
KARTE_ZOOM: zahl(13),
|
||||||
|
|
||||||
// Einsätze und Übungen (Phase 3)
|
// Einsätze und Übungen (Phase 3)
|
||||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||||
GEOCODING_ENABLED: boolAusText(false),
|
GEOCODING_ENABLED: boolAusText(false),
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||