Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2b31ed00a | ||
|
|
e500ad6c0e | ||
|
|
791523027f | ||
|
|
d8d692098d | ||
|
|
91954c402b | ||
|
|
bc62a9c1e8 | ||
|
|
e503aafc18 | ||
|
|
7f866ac35c | ||
|
|
1744403620 | ||
|
|
47b8e36a38 | ||
|
|
26b62a3477 |
@@ -185,6 +185,21 @@ KIOSK_SPERRDAUER=300
|
||||
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
|
||||
KIOSK_IDLE_TIMEOUT=120
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wetterwarnungen auf dem Hallenmonitor (optional, DWD)
|
||||
# Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut —
|
||||
# deshalb standardmaessig aus. Ohne WETTER_ENABLED=true wird nichts abgerufen.
|
||||
# -----------------------------------------------------------------------------
|
||||
WETTER_ENABLED=false
|
||||
# Warnzellen-Nummer der eigenen Gemeinde aus der DWD-Warnzellenliste:
|
||||
# https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv
|
||||
DWD_WARNCELL=
|
||||
DWD_WARNUNGEN_URL=https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json
|
||||
# Zeitlimit fuer den Abruf, in Sekunden. Der Monitor darf davon nicht haengen.
|
||||
WETTER_TIMEOUT=5
|
||||
# Wie lange eine Antwort gilt, bevor neu gefragt wird, in Sekunden.
|
||||
WETTER_ZWISCHENSPEICHER=300
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# E-Mail-Versand (optional, hinter lib/integrations/mail.ts abstrahiert)
|
||||
# Ohne MAIL_ENABLED=true wird nur ins Log geschrieben — praktisch für Tests.
|
||||
@@ -213,6 +228,39 @@ LLM_BASE_URL=http://ollama:11434/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODELL=qwen2.5:14b
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Einsätze und Übungen (Phase 3)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Adressen zu Koordinaten auflösen. Vorgesehen ist ein SELBST betriebener
|
||||
# Nominatim — Einsatzadressen gehören nicht an einen öffentlichen Dienst, und
|
||||
# dessen Nutzungsbedingungen erlauben es ohnehin nicht.
|
||||
GEOCODING_ENABLED=false
|
||||
# NOMINATIM_URL=http://nominatim:8080
|
||||
GEOCODING_TIMEOUT=5
|
||||
# Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt).
|
||||
GEOCODING_LAENDER=de,at,ch
|
||||
|
||||
# Einsatzportal für Fremdwehren: ein signierter Link ohne Anmeldung, über den
|
||||
# eine Nachbarwehr den Stand sieht und Lagemeldungen absetzen kann.
|
||||
PORTAL_ENABLED=true
|
||||
# Wie lange ein neu erzeugter Link gilt.
|
||||
PORTAL_GUELTIGKEIT_STUNDEN=24
|
||||
# Höchstzahl Zugriffe je Link und Stunde.
|
||||
PORTAL_MAX_ZUGRIFFE=120
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wartung und Prüfung (Phase 2)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wann der nächtliche Lauf Prüftermine plant. Cron-Syntax, Zeitzone
|
||||
# Europe/Berlin. Der Worker trägt den Zeitplan beim Start je Wehr selbst ein.
|
||||
WARTUNG_NACHTLAUF=15 3 * * *
|
||||
# Wann die Erinnerungen verschickt werden. Bewusst nach der Planung: sonst
|
||||
# erinnert der Lauf an Termine, die es noch gar nicht gibt.
|
||||
WARTUNG_ERINNERUNG_LAUF=45 3 * * *
|
||||
# Wie weit im Voraus Termine angelegt werden. Ohne Horizont stünden Termine für
|
||||
# die nächsten zehn Jahre im Dashboard.
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE=180
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Datenschutz und Aufbewahrung (DSGVO)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,477 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
Noch nichts. Nächster Schritt ist Phase 1 (Stammdatenverwaltung).
|
||||
Nächster Schritt ist Phase 5 (Atemschutzverwaltung nach FwDV 7).
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] — 2026-09-03
|
||||
|
||||
Phase 4: das Terminal im Gerätehaus. Ein Wandtablet, das mit Handschuhen
|
||||
bedienbar ist und weiterarbeitet, wenn das Netz weg ist.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Fahrt` — Fahrtenbuch je Fahrzeug: Fahrer, Zweck, Ziel, Kilometerstand bei
|
||||
Abfahrt und Rückkehr, getankte Menge. Eine Fahrt ohne Rückkehr ist die
|
||||
laufende Fahrt.
|
||||
- `Anwesenheit` — wer im Gerätehaus ist, mit Anlass (Dienst, Übung, Einsatz,
|
||||
Arbeitsdienst) und offener Abmeldung, solange jemand da ist.
|
||||
- `Waescheabgabe` — Schutzkleidung in der Reinigung, mit Grund und Stand von
|
||||
„abgegeben" bis „zurück".
|
||||
- `Inventurgang` und `Inventurposten` — der Gerätewart geht mit dem Tablet durch
|
||||
die Halle und scannt; am Ende steht die Liste dessen, was fehlt.
|
||||
- `KioskVorgang` — jeder vom Terminal übermittelte Vorgang, **mit der im
|
||||
Browser erzeugten Kennung als Primärschlüssel**. Das ist der Kern des
|
||||
Offline-Betriebs: kommt derselbe Vorgang nach einem abgebrochenen
|
||||
Sendeversuch ein zweites Mal an, wird er wiedererkannt statt neu angelegt.
|
||||
- `SyncKonflikt` — was sich beim Nachtragen nicht anwenden ließ. Nichts geht
|
||||
verloren, es wartet auf eine Entscheidung.
|
||||
|
||||
**Terminaloberfläche unter `/kiosk`**
|
||||
|
||||
- Kachelschirm: sieben Kacheln, jede mindestens 160 Pixel hoch, jede mit Symbol
|
||||
und Text, sichtbar nach Rechten. Keine Navigationsleiste.
|
||||
- Anmeldung auf drei Wegen: Ausweis auflegen (der RFID-Leser tippt die Kennung
|
||||
als Tastatureingabe, erkannt am Anschlagstempo), QR-Code vom Handy oder
|
||||
Mitgliedsnummer samt PIN.
|
||||
- Mangel melden in drei Schritten — Gerät scannen, Foto und ein Satz dazu,
|
||||
Dringlichkeit antippen. Anspruch war „unter dreißig Sekunden".
|
||||
- Kommen und Gehen mit zwei Tipps, Fahrtenbuch, Wäscheabgabe und Inventurgang.
|
||||
- Abmeldeuhr: nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit meldet sich das
|
||||
Terminal von selbst ab, mit sichtbarer Warnung vorher.
|
||||
- Hallenmonitor unter `/kiosk/monitor`: Uhrzeit, laufender Einsatz, wer im Haus
|
||||
ist, Fahrzeuge, fällige Prüfungen und die nächsten Termine — Vollbild ohne
|
||||
Bedienung.
|
||||
|
||||
**Offline-Betrieb**
|
||||
|
||||
- Ein von Hand geschriebener Service Worker (`public/sw.js`): App-Shell und
|
||||
Stammdaten im Zwischenspeicher, `network-first` für Seiten mit
|
||||
Ausweichseite `/kiosk/offline`, `stale-while-revalidate` für lesende
|
||||
tRPC-Aufrufe. Schreibende Anfragen und alles unter `/api/auth` fasst er nicht
|
||||
an — eine zwischengespeicherte Anmeldung wäre eine Sicherheitslücke.
|
||||
- Warteschlange in einer IndexedDB (Dexie): **jede** schreibende Aktion geht
|
||||
zuerst dorthin, auch online. Sonst gäbe es zwei Wege durch den Code, und der
|
||||
seltenere wäre der ungetestete.
|
||||
- Fotos liegen als Blob in derselben Warteschlange und werden beim Übertragen
|
||||
zuerst hochgeladen.
|
||||
- Sichtbarer Zustand als Leiste über allem: verbunden, wie viele Änderungen
|
||||
warten, kein Netz — in Klartext, nicht nur als Farbe.
|
||||
- Erledigt heißt: angenommen **oder** als bereits verarbeitet erkannt **oder**
|
||||
in der Konfliktliste gelandet. In allen drei Fällen ist der Vorgang beim
|
||||
Server und darf aus der Warteschlange verschwinden.
|
||||
|
||||
**Anmeldemarke**
|
||||
|
||||
- `/anmeldemarke` zeigt einen QR-Code fürs Handy: signiert mit `AUTH_SECRET`,
|
||||
gültig 60 Sekunden, nicht gespeichert. Ein abfotografierter Code aus der
|
||||
Umkleide nützt nach einer Minute nichts mehr.
|
||||
|
||||
**Terminalübersicht in der Verwaltung**
|
||||
|
||||
- `/terminal` zeigt Konflikte zuerst — das sind die Vorgänge, die ein Mensch
|
||||
anfassen muss —, danach Anwesende, Wäsche und Fahrtenbuch.
|
||||
|
||||
**Installierbar als App**
|
||||
|
||||
- Web-App-Manifest, eigene Symbole (`npm run icons`), Startseite `/kiosk`. Kein
|
||||
externes CDN, keine fremde Schriftart.
|
||||
|
||||
**Wetterwarnungen (optional, aus)**
|
||||
|
||||
- Warnungen des DWD auf dem Hallenmonitor, hinter einer Schnittstelle in
|
||||
`lib/integrations/`. Standardmäßig **aus** — es ist die einzige Verbindung,
|
||||
die FLORIAN von sich aus nach außen aufbaut. Fällt sie aus, bleibt die Kachel
|
||||
leer.
|
||||
|
||||
**Sonstiges**
|
||||
|
||||
- Der Seed legt ein Fahrtenbuch mit vier Fahrten (eine läuft noch), vier
|
||||
Anwesenheiten (eine Person ist noch im Haus) und drei Wäscheabgaben an.
|
||||
- 31 neue Tests: 20 Unit-Tests zu Anmeldemarke und Warteschlange (letztere
|
||||
unter jsdom mit `fake-indexeddb`), 11 gegen eine echte Datenbank
|
||||
(Kilometerstände, Anwesenheitszeiten, Unveränderbarkeit der Vorgänge,
|
||||
Mandantentrennung) und 9 Playwright-Durchläufe je Browser — darunter der
|
||||
Offline-Fall mit `context.setOffline(true)` und der doppelt gesendete
|
||||
Vorgang, dessen Antwort unterwegs verloren geht.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Die Datenbank setzt vier neue Regeln selbst durch: Kilometerstände laufen
|
||||
vorwärts, eine Rückkehr liegt nicht vor der Abfahrt, ein Gehen nicht vor dem
|
||||
Kommen, eine Wäscheabgabe umfasst mindestens ein Stück. Dazu `REVOKE UPDATE,
|
||||
DELETE` auf `kiosk_vorgang` — ein übermittelter Vorgang lässt sich nicht
|
||||
nachträglich glätten.
|
||||
- Die Beschriftung der DWD-Warnstufen liegt in
|
||||
`src/lib/integrations/wetter-texte.ts`, getrennt vom `server-only`-Modul
|
||||
daneben — dieselbe Trennung wie bei `qr.ts` und `qr-parser.ts`.
|
||||
- Neue Rechte `fahrtenbuch.lesen` und `fahrtenbuch.fuehren`, beide im
|
||||
Grundrechtebündel.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Warteschlange rief sich selbst in eine Renderschleife: `jetztSenden`
|
||||
hatte das Mutationsobjekt von React Query in seinen Abhängigkeiten, und das
|
||||
ist bei jedem Rendern ein neues. Damit wurde der 20-Sekunden-Zeitgeber
|
||||
ständig neu aufgezogen und lief nie ab — ein liegen gebliebener Vorgang wäre
|
||||
ohne Zutun nie ein zweites Mal losgeschickt worden. Gefunden vom
|
||||
Offline-E2E-Test, der genau darauf wartet.
|
||||
- `/kiosk/einrichten` ließ sich nicht vorab erzeugen, weil `useSearchParams()`
|
||||
ohne Suspense-Grenze stand. Der Produktionsbuild brach dabei ab.
|
||||
- Der Anwesenheits- und der Fahrten-Wizard zeigten erst die eine, nach dem
|
||||
Laden des eigenen Stands die andere Fassung — wer zügig tippte, drückte ins
|
||||
Leere. Bis der Stand bekannt ist, steht dort jetzt ein Platzhalter.
|
||||
- Die Offline-Seite blieb stehen, wenn nur eine einzelne Anfrage abgebrochen
|
||||
war. Am Hallenmonitor, den niemand bedient, hieße das: bis jemand vorbeikommt.
|
||||
Sie versucht es jetzt alle zehn Sekunden von selbst.
|
||||
- Der Hallenmonitor stapelte alle laufenden Einsätze übereinander und schob
|
||||
damit Anwesenheit, Fahrzeuge und Prüfungen aus dem Bild. Zwei stehen groß,
|
||||
der Rest als Zeile.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] — 2026-09-03
|
||||
|
||||
Phase 3: Einsätze und Übungen. Ab hier dokumentiert die Wehr, was sie tut —
|
||||
für die Gemeinde, für die Versicherung und für die eigene Mannschaft.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Einsatz` mit den sechs Zeitstempeln (alarmiert, ausgerückt, eingetroffen,
|
||||
Einsatzende, eingerückt, einsatzbereit), Ort samt Koordinaten, Kategorie nach
|
||||
der bayerischen Feuerwehrstatistik und den Zahlen für die Gemeindemeldung.
|
||||
- `Einsatzfahrzeug` mit eigenen Zeiten je Fahrzeug — Nachalarmierung bleibt
|
||||
sichtbar —, Kilometerstand und Wasserabgabe.
|
||||
- `Einsatzkraft` mit Funktion nach FwDV 3, Fahrzeug, Zeitraum und
|
||||
Atemschutz-Kennzeichen.
|
||||
- `Materialverbrauch` und `Lagemeldung` mit Foto und Herkunft.
|
||||
- `PortalZugang` — ein zeitlich begrenzter Link für eine Fremdwehr, gespeichert
|
||||
nur als Hash.
|
||||
- `Uebung` und `Uebungsteilnahme` mit Thema, Lernzielen und Anwesenheit.
|
||||
|
||||
**Schnellerfassung**
|
||||
|
||||
- Stichwort, Ort, ausgerückte Fahrzeuge — mehr fragt die Erfassung im Alarmfall
|
||||
nicht. Alarmzeit ist der Moment des Anlegens, alles Weitere wird nachgetragen.
|
||||
- Fortlaufende Einsatznummer `JJJJ-NNNN` je Wehr und Jahr, vergeben unter einer
|
||||
Transaktionssperre.
|
||||
|
||||
**Zeiten und Stärke**
|
||||
|
||||
- Ein Knopf je Zeitstempel, solange der Einsatz läuft.
|
||||
- Daraus gerechnet: Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
|
||||
- Stärke nach FwDV 3 in der Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`,
|
||||
mit Zuordnung zur taktischen Einheit (Trupp, Staffel, Gruppe, Zug).
|
||||
- Hinweise statt Sperren: eine verdrehte Zeitreihenfolge oder eine fehlende
|
||||
Führungskraft wird angezeigt, verhindert das Speichern aber nicht — ein
|
||||
Einsatz, der drei Wochen später nachgetragen wird, ist immer noch besser als
|
||||
einer, den niemand erfasst.
|
||||
|
||||
**Personalvorschlag**
|
||||
|
||||
- Sortiert nach Einsatzerfahrung der letzten zwölf Monate, mit dem
|
||||
Qualifikationsstand daneben: eine abgelaufene G26.3 fällt auf, bevor jemand
|
||||
unter Atemschutz geht.
|
||||
|
||||
**Einsatzbericht und Freigabe**
|
||||
|
||||
- Bericht als PDF über Gotenberg: Kopf, Zeitleiste mit Kennzahlen, Fahrzeuge,
|
||||
Mannschaft mit Stärke, Material, Verlauf und Bericht.
|
||||
- Mit der Freigabe wird der Einsatz festgeschrieben — durchgesetzt von einem
|
||||
Datenbanktrigger, nicht vom Anwendungscode. Ausgenommen bleiben das
|
||||
nachgetragene PDF und die Lagemeldungen.
|
||||
- Jahresexport als quer gedrucktes PDF unter `/api/einsaetze/jahresexport`.
|
||||
|
||||
**Statistik**
|
||||
|
||||
- Einsätze nach Kategorie und Monat, Verteilung der Hilfsfristen mit dem
|
||||
80-Prozent-Wert, Beteiligung je Person (Einsätze, davon unter Atemschutz,
|
||||
Übungen) und Auslastung je Fahrzeug.
|
||||
|
||||
**Einsatzportal für Fremdwehren**
|
||||
|
||||
- Signierter Link ohne Anmeldung, gültig 24 Stunden, jederzeit widerrufbar,
|
||||
jeder Aufruf wird mitgezählt.
|
||||
- Zeigt Stichwort, Ort, Zeitstempel und Lagemeldungen — keine Namen von
|
||||
Einsatzkräften, keinen Bericht, keinen Weg in den Rest der Anwendung.
|
||||
- Nimmt Lagemeldungen mit Foto an, begrenzt auf 30 Meldungen und 10 Fotos je
|
||||
Zugang und Stunde.
|
||||
- Eigene Zugriffsbegrenzung über Redis (`src/lib/ratelimit.ts`), die bei
|
||||
Störung durchlässt statt zu blockieren.
|
||||
|
||||
**Übungen**
|
||||
|
||||
- Thema, Art, Lernziele, Ort und Leitung; Anwesenheit als Liste zum Abhaken.
|
||||
- Fließt in die Beteiligungsstatistik ein.
|
||||
|
||||
**Adressen zu Koordinaten**
|
||||
|
||||
- Anbindung an einen **selbst betriebenen** Nominatim hinter einer Schnittstelle
|
||||
in `lib/integrations/`. Ohne Konfiguration bleibt die Suche leer und die
|
||||
Koordinaten werden von Hand eingetragen.
|
||||
- Koordinaten liegen doppelt: als Dezimalgrad für die Anwendung und als
|
||||
PostGIS-Punkt für die Karte ab Phase 6, zusammengehalten von einem Trigger.
|
||||
|
||||
**Sonstiges**
|
||||
|
||||
- Der Seed legt acht Einsätze über das Jahr verteilt an — Brände, technische
|
||||
Hilfeleistungen, ein Fehlalarm einer Brandmeldeanlage — dazu sechs Übungen
|
||||
und die Beteiligung der Mannschaft.
|
||||
- 57 neue Tests: 32 Unit-Tests zu Stärke, Zeiten und Nummernkreis, 14 gegen eine
|
||||
echte Datenbank (Koordinaten-Trigger, Freigabe, Mandantentrennung) und 11
|
||||
Playwright-Durchläufe einschließlich des Portals in einem Browser **ohne**
|
||||
Sitzung.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Der Nummernkreis `JJJJ-NNNN` liegt jetzt in `src/lib/nummernkreis.ts` und wird
|
||||
von Prüfprotokollen, Einsätzen und Übungen gemeinsam verwendet.
|
||||
- Die Seitenleiste zeigt Einsätze und Übungen als verfügbar.
|
||||
- Das Zeitlimit der E2E-Erwartungen steigt von 15 auf 25 Sekunden: mit fünfzig
|
||||
Routen dauert die erste Übersetzung einer Seite im Entwicklungsserver unter
|
||||
Last länger als vorher, und daran scheiterten gelegentlich Tests, die einzeln
|
||||
grün sind.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Anwesenheitsliste einer Übung forderte 300 Mitglieder an und lief damit
|
||||
in die Höchstzahl von 200 je Seite — die Liste blieb leer. Gefunden über die
|
||||
Server-Ausgabe während der E2E-Tests.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-09-03
|
||||
|
||||
Phase 2: Wartungs- und Prüfmanagement. Das Modul, wegen dem eine Wehr so ein
|
||||
Programm einführt — Fristen im Blick behalten und im Streitfall nachweisen
|
||||
können, dass geprüft wurde.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Wartungsvorlage` — was an einem Gerätetyp in welchem Takt geprüft wird, mit
|
||||
Intervall, Vorwarnzeit, Prüfvorschrift und Erfahrungswert für die Dauer.
|
||||
- `Checklistenpunkt` in vier Ausprägungen: Ja/Nein, Messwert (mit zulässigem
|
||||
Bereich und Einheit), Text und Foto. Pflichtpunkte sind die, an denen die
|
||||
Einsatzbereitschaft hängt.
|
||||
- `Wartungstermin` — ein fälliger Termin an einem konkreten Gerät. Eindeutig
|
||||
über Gerät, Vorlage und Fälligkeitstag, damit ein zweiter nächtlicher Lauf
|
||||
nichts doppelt anlegt.
|
||||
- `Pruefung`, `Pruefergebnis` und `Signatur` — die durchgeführte Prüfung mit
|
||||
Antworten je Punkt und Unterschrift.
|
||||
- `Mangel` mit dem Weg melden → zugewiesen → in Arbeit → behoben.
|
||||
- `Rueckruf`, `RueckrufGeraetetyp` und `RueckrufBetroffenheit` — eine
|
||||
Herstellerwarnung, die betroffene Bauart und der Stand je Gerät.
|
||||
- `Erinnerung` als Nachweis und gleichzeitig als Sperre gegen Doppelversand.
|
||||
|
||||
**Unveränderbarkeit der Protokolle**
|
||||
|
||||
- Ein Trigger in der Datenbank weist jede Änderung an einer abgeschlossenen
|
||||
Prüfung ab. Zulässig bleiben genau drei Dinge: der Wechsel auf „ersetzt", das
|
||||
Nachtragen des PDF, solange keines hinterlegt ist, und `geaendertAm`.
|
||||
- Dieselbe Sperre gilt für die Bestandteile: an einem abgeschlossenen Protokoll
|
||||
lassen sich weder Einzelergebnisse ändern noch Unterschriften nachtragen.
|
||||
- Korrekturen erzeugen eine **neue** Prüfung mit Verweis auf die alte. Beide
|
||||
bleiben lesbar.
|
||||
- Gelöscht wird ein Protokoll nur im gekennzeichneten Aufbewahrungslauf —
|
||||
derselbe Schalter wie beim Audit-Log.
|
||||
|
||||
**Fristen-Dashboard** (`/pruefungen`)
|
||||
|
||||
- Vier Töpfe nach Dringlichkeit: überfällig, diese Woche, diesen Monat, nächste
|
||||
90 Tage. Jede Zahl ist gleichzeitig ein Filter.
|
||||
- Bündelung nach Fahrzeug, Standort, Gerätetyp oder Zuständigem — wer einen
|
||||
Prüftag ansetzt, arbeitet ein Fahrzeug ab, keine alphabetische Liste.
|
||||
- Sammelzuweisung mehrerer Termine an eine Person.
|
||||
- Daneben die Lebensdauerüberwachung (`/pruefungen/lebensdauer`) mit Vorschlag
|
||||
zur Aussonderung.
|
||||
|
||||
**Prüfwizard** (`/pruefungen/neu`)
|
||||
|
||||
- Ein Prüfpunkt je Bildschirm, große Schaltflächen, Foto direkt aus der Kamera.
|
||||
- Zeiterfassung läuft mit und landet im Protokoll.
|
||||
- Unterschrift auf einem Zeichenfeld, das Finger, Stift und Maus versteht; das
|
||||
PNG landet in MinIO.
|
||||
- Jede Antwort geht sofort zum Server — bricht das WLAN weg, ist höchstens die
|
||||
letzte verloren, und der Entwurf wird beim nächsten Öffnen fortgesetzt.
|
||||
- Mängel werden aus den beanstandeten Punkten vorgeschlagen.
|
||||
- Externe Wartung: Fachfirma, Kosten und das Fremdprotokoll als PDF.
|
||||
|
||||
**Prüfprotokoll als PDF**
|
||||
|
||||
- Erzeugt der Hintergrundprozess über Gotenberg und legt es in MinIO ab: Kopf
|
||||
mit Wehr, Gerät und Prüfer-Kennung, Checkliste mit Soll, Ist und Befund,
|
||||
eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift, Prüfdatum
|
||||
und Seitenzahl.
|
||||
- Laufende Protokollnummer `JJJJ-NNNN` je Wehr und Jahr.
|
||||
|
||||
**Nächtlicher Lauf**
|
||||
|
||||
- `fristen.pruefen` (3:15 Uhr) plant Termine aus den Vorlagen und zieht Frist
|
||||
und Status an jedem Gerät nach.
|
||||
- `erinnerung.senden` (3:45 Uhr) verschickt Vorwarnungen bei T-30, T-7, T-0 und
|
||||
einmal bei Überfälligkeit — als Sammelnachricht je Empfänger.
|
||||
- Beide Läufe sind wiederholbar und tragen sich beim Start des Workers je Wehr
|
||||
selbst ein.
|
||||
|
||||
**Mängel und Rückrufe**
|
||||
|
||||
- Melden darf jedes Mitglied; ein kritischer Mangel sperrt das Gerät sofort.
|
||||
- Beim Beheben wird die Sperre aufgehoben — aber nur, wenn kein anderer offener
|
||||
Mangel dasselbe Gerät sperrt.
|
||||
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum. Das Ergebnis ist ein Vorschlag, keine automatische
|
||||
Sperre.
|
||||
|
||||
**Sonstiges**
|
||||
|
||||
- Datei-Upload unter `/api/dateien` für Fotos, Unterschriften und
|
||||
Fremdprotokolle — als Multipart, nicht Base64 durch eine JSON-Antwort.
|
||||
- Der Seed legt 5 Wartungsvorlagen mit echten Prüfvorgaben an (DGUV 112-190,
|
||||
DIN 14811, DGUV Vorschrift 3, DIN 14920), dazu die fälligen Termine, ein
|
||||
abgeschlossenes Protokoll samt Mangel und einen Beispielrückruf.
|
||||
- 79 neue Tests: 47 Unit-Tests zu Fristen, Bewertung und Planung, 10 gegen eine
|
||||
echte Datenbank zur Unveränderbarkeit und Mandantentrennung, 22 Playwright-
|
||||
Durchläufe über Dashboard, Vorlagen, Wizard, Mängel und Rückrufe.
|
||||
|
||||
**Nachzügler aus Phase 1**
|
||||
|
||||
Mit dieser Version wird auch das Geräteformular ausgeliefert, das nach der
|
||||
0.2.0 entstanden ist:
|
||||
|
||||
- **Formular zum Anlegen und Bearbeiten eines Geräts** (`/geraete/neu` und
|
||||
`/geraete/<id>/bearbeiten`). Damit ist die letzte offene Lücke aus Phase 1
|
||||
geschlossen: Ein Neuzugang lässt sich einzeln erfassen, ohne den Umweg über
|
||||
eine Importdatei.
|
||||
- Die Lagerung wird als Umschalter geführt statt als zwei Auswahlfelder, die
|
||||
man versehentlich beide füllen kann.
|
||||
- Prüftermin und Lebensdauerende werden schon beim Tippen ausgerechnet und
|
||||
angezeigt — mit denselben Funktionen, mit denen der Server anschließend
|
||||
rechnet.
|
||||
- Ist nur ein Standort angelegt, wird er vorbelegt.
|
||||
- 7 weitere Playwright-Tests zum Formular (nun 58 statt 44, zwei davon unter
|
||||
Touch-Emulation begründet übersprungen).
|
||||
|
||||
Dabei behoben:
|
||||
|
||||
- **Das Formular stürzte beim ersten Rechnen ab.** Die Vorgabewerte lagen in
|
||||
einem `"use client"`-Modul und wurden von einer Server-Komponente importiert;
|
||||
dort kommt nur eine Client-Referenz an, kein Objekt. Das Spreaden davon ergab
|
||||
lauter `undefined`. Die Werte liegen jetzt in einem eigenen Modul ohne
|
||||
`"use client"`.
|
||||
- **Das Sternchen an Pflichtfeldern wurde vorgelesen.** Eine Sprachausgabe
|
||||
nannte das Feld „Bezeichnung Stern". Das Sternchen trägt jetzt `aria-hidden`;
|
||||
dass ein Feld ausgefüllt werden muss, sagt das `required`-Attribut.
|
||||
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) —
|
||||
Next.js hängt einen eigenen Routen-Ansager mit derselben Rolle in jede Seite.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Die Datumsarithmetik aus Phase 1 (`utcTag`, `monateAddieren`, `heutigerTag`,
|
||||
`tagesabstand`) ist jetzt exportiert. Zwei Umsetzungen derselben Rechnung
|
||||
wären früher oder später um einen Tag auseinandergelaufen.
|
||||
- Die Seitenleiste zeigt Prüfungen, Mängel und Rückrufe als verfügbar.
|
||||
- Die Gerätedetailseite zeigt Prüfhistorie, offene Mängel und anstehende
|
||||
Termine — und startet die Prüfung.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer.**
|
||||
Die eindeutige Bedingung in der Datenbank fing das ab, aber der zweite Prüfer
|
||||
bekam einen Datenbankfehler statt eines Protokolls. Die Nummer wird jetzt
|
||||
unter einer Transaktionssperre je Wehr und Jahr vergeben — gefunden von den
|
||||
E2E-Tests, weil Desktop- und Tablet-Durchlauf parallel prüften.
|
||||
- **Der Worker ließ sich außerhalb des Containers nicht starten.** Ihm fehlte
|
||||
das Laden der `.env`, und `server-only` warf beim Import unter Node. Beide
|
||||
Startbefehle laufen jetzt mit `--conditions=react-server`, womit der leere
|
||||
Export dieses Pakets greift.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-09-03
|
||||
|
||||
Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit
|
||||
Standorten, Fahrzeugen samt Beladeordnung, Gerätetypen als Vorgabe und
|
||||
Geräten, die per QR-Code am Regal auffindbar sind.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Standort` (Gerätehaus, Lager, Außenstelle, Werkstatt) mit PostGIS-Punkt für
|
||||
die Karte ab Phase 6.
|
||||
- `Geraeteraum` — die Fächer am Fahrzeug (G1 bis G6, Heck, Dach,
|
||||
Mannschaftsraum), in Beladereihenfolge sortierbar.
|
||||
- `Geraetetyp` als Vorgabe für Prüfintervall, Prüfvorschrift und Lebensdauer.
|
||||
Ein einzelnes Gerät darf davon abweichen; sonst gilt der Typ.
|
||||
- `Qualifikationstyp` und `Qualifikation` mit Gültigkeitsdauer und eigener
|
||||
Vorwarnzeit je Typ.
|
||||
- `Fahrzeug` erweitert um Fahrgestellnummer, DIN-Typkurzzeichen, Erstzulassung,
|
||||
Sitzplätze, Tankinhalt, Gesamtmasse und die UVV-Frist.
|
||||
- `Geraet` erweitert um Gerätetyp, Lebensdauerkategorie, errechnetes
|
||||
Lebensdauerende, Anschaffungspreis und die Lagerung.
|
||||
|
||||
**Regeln in der Datenbank**
|
||||
|
||||
- Ein Gerät steht entweder an einem Standort oder ist auf einem Fahrzeug
|
||||
verlastet — eine Prüfbedingung setzt das durch, ein Trigger stellt zusätzlich
|
||||
sicher, dass ein angegebener Geräteraum auch zum angegebenen Fahrzeug gehört.
|
||||
- Row Level Security auf allen neuen Tabellen.
|
||||
- Trigram-Indizes für die Volltextsuche über Geräte, Fahrzeuge und Personen.
|
||||
|
||||
**Funktionen**
|
||||
|
||||
- Geräteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
|
||||
Blättern und Spaltenauswahl. Die Spaltenauswahl bleibt je Browser gespeichert.
|
||||
- Massenbearbeitung: Status mehrerer Geräte auf einmal setzen, Geräte umlagern.
|
||||
Jede Änderung erzeugt einen eigenen Protokolleintrag am jeweiligen Gerät.
|
||||
- QR-Code je Gerät. Der Code zeigt auf `/g/<inventarnummer>` und führt nach der
|
||||
Anmeldung direkt zur Gerätedetailseite.
|
||||
- Etikettenbogen als PDF über Gotenberg: DIN A4 mit 65 Etiketten
|
||||
(38,1 × 21,2 mm). Der Druck kann an einer beliebigen Position beginnen, damit
|
||||
angebrochene Bögen weiterverwendet werden können.
|
||||
- QR-Scan über die Kamera im Browser, mit Eingabefeld als Rückfallweg.
|
||||
- Import aus CSV und Excel: Spalten werden nach den Überschriften automatisch
|
||||
zugeordnet, die Vorschau zeigt Zeile für Zeile, was übernommen würde und was
|
||||
daran nicht stimmt. Geschrieben wird erst nach Bestätigung.
|
||||
- Gerätehistorie und Fahrzeughistorie, gerendert aus dem Audit-Log.
|
||||
- Qualifikationen mit Ablaufwarnung, gerechnet mit der Vorwarnzeit des
|
||||
jeweiligen Typs.
|
||||
- Fahrzeugdetail zeigt die Beladung nach Geräteräumen statt alphabetisch.
|
||||
|
||||
**Daten und Tests**
|
||||
|
||||
- Der Seed legt jetzt zusätzlich 3 Standorte, 20 Geräteräume, 14 Gerätetypen
|
||||
mit echten Prüfvorgaben (DGUV, DIN, vfdb), 49 Geräte und 107 Qualifikationen
|
||||
an. Fristen sind bewusst gestreut, damit die Ampeln etwas zeigen.
|
||||
- 104 Vitest-Tests (vorher 32), davon 40 zum Import und 32 zu QR und Fristen.
|
||||
- 44 Playwright-Tests (vorher 10) über Desktop und Tablet.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Fristenrechnung auf UTC-Kalenderarithmetik umgestellt.** Vorher wurde über
|
||||
`startOfDay` aus date-fns gerechnet, das die Zeitzone des Servers verwendet.
|
||||
Ein Prüfdatum kommt aus der Datenbank als UTC-Mitternacht und wurde dabei auf
|
||||
den Vortag zurückgeschoben — Fristen wären einen Tag zu früh fällig geworden.
|
||||
- `CardTitle` rendert eine echte Überschrift statt eines `div`. Wer mit einer
|
||||
Sprachausgabe arbeitet, springt von Überschrift zu Überschrift; Karten
|
||||
tauchten in dieser Navigation vorher gar nicht auf.
|
||||
- Die Entwicklungsanzeige von Next.js ist abgeschaltet, damit sie nicht in den
|
||||
Bildern der Anleitung mitläuft.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Der Etikettendruck wird protokolliert (`EXPORTIERT`) — damit ist
|
||||
nachvollziehbar, wann welche Aufkleber erzeugt wurden.
|
||||
- Der Import übernimmt keine Werte, die der Browser geschickt hat: die Datei
|
||||
wird beim Übernehmen erneut serverseitig eingelesen und geprüft. Sonst ließe
|
||||
sich die Vorschau umgehen.
|
||||
|
||||
## [0.1.0] — 2026-09-03
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
| Bereich | Stand |
|
||||
|---|---|
|
||||
| Phase 0 — Fundament | fertig |
|
||||
| Phase 1 — Stammdaten | offen |
|
||||
| Phase 2 — Wartung und Prüfung | offen |
|
||||
| Phase 3 — Einsätze und Übungen | offen |
|
||||
| Phase 4 — Kiosk-PWA | offen |
|
||||
| Phase 1 — Stammdaten | fertig |
|
||||
| Phase 2 — Wartung und Prüfung | fertig |
|
||||
| Phase 3 — Einsätze und Übungen | fertig |
|
||||
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
|
||||
| Phase 5 — Atemschutz (FwDV 7) | offen |
|
||||
| Phase 6 — Kartenmodule | offen |
|
||||
| Phase 7 — Benachrichtigungen und Termine | offen |
|
||||
@@ -154,6 +154,381 @@ Downloads laufen über presignte Links mit begrenzter Gültigkeit
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Wartung und Prüfung
|
||||
|
||||
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt: Prüffristen
|
||||
im Blick behalten und im Streitfall nachweisen können, dass geprüft wurde.
|
||||
|
||||
### Der Weg durch das Modul
|
||||
|
||||
```
|
||||
Wartungsvorlage was an einem Gerätetyp in welchem Takt geprüft wird
|
||||
└─ Checklistenpunkt die einzelne Frage: ja/nein, Messwert, Text, Foto
|
||||
↓ nächtlicher Lauf
|
||||
Wartungstermin ein fälliger Termin an einem konkreten Gerät
|
||||
↓ Prüfwizard
|
||||
Pruefung durchgeführte Prüfung, unterschrieben, festgeschrieben
|
||||
├─ Pruefergebnis je Checklistenpunkt eine Antwort
|
||||
├─ Signatur Unterschrift aus dem Zeichenfeld, PNG in MinIO
|
||||
└─ Mangel was aufgefallen ist und behoben werden muss
|
||||
```
|
||||
|
||||
Ohne Vorlage gibt es keine Termine, ohne Termine kein Fristen-Dashboard. Die
|
||||
erste Seite, die eine Wehr in Phase 2 ausfüllt, ist deshalb
|
||||
`/pruefungen/vorlagen`.
|
||||
|
||||
### Das Protokoll ist unveränderbar
|
||||
|
||||
Ein abgeschlossenes Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse.
|
||||
Wäre es nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
|
||||
deshalb in der Datenbank (Trigger `pruefung_unveraenderlich`, Migration
|
||||
`20260903000300_wartung_rls`) und nicht nur im Anwendungscode — ein vergessener
|
||||
Zustandscheck in einem Router würde sonst reichen, um sie auszuhebeln.
|
||||
|
||||
Nach dem Abschluss sind genau drei Änderungen zulässig, und alle drei ergänzen
|
||||
das Protokoll, statt es zu verfälschen:
|
||||
|
||||
| Änderung | Warum |
|
||||
|---|---|
|
||||
| `ABGESCHLOSSEN` → `ERSETZT` | eine Korrekturprüfung ist nachgefolgt |
|
||||
| `protokollDateiId`, solange leer | das PDF entsteht im Hintergrund und wird nachgetragen |
|
||||
| `geaendertAm` | setzt Prisma bei jedem Schreibvorgang mit |
|
||||
|
||||
Dasselbe gilt für die Bestandteile: an einem abgeschlossenen Protokoll lassen
|
||||
sich weder Einzelergebnisse ändern noch Unterschriften nachtragen. **Korrekturen
|
||||
erzeugen eine neue Prüfung** mit Verweis auf die alte; beide bleiben lesbar.
|
||||
|
||||
Gelöscht wird ein Protokoll nie — außer im ausdrücklich gekennzeichneten
|
||||
Aufbewahrungslauf, demselben Schalter wie beim Audit-Log
|
||||
(`SET LOCAL app.audit_bereinigung = 'an'`).
|
||||
|
||||
### Der nächtliche Lauf
|
||||
|
||||
Der Worker trägt sich beim Start je Wehr zwei wiederkehrende Aufträge ein:
|
||||
|
||||
| Auftrag | Vorgabe | Was er tut |
|
||||
|---|---|---|
|
||||
| `fristen.pruefen` | 03:15 Uhr | Termine aus den Vorlagen planen, Frist und Status an jedem Gerät nachziehen |
|
||||
| `erinnerung.senden` | 03:45 Uhr | Vorwarnungen zu offenen Terminen verschicken |
|
||||
|
||||
Beide sind wiederholbar: doppelte Termine verhindert die Eindeutigkeit über
|
||||
Gerät, Vorlage und Fälligkeitstag, doppelte Mails ein Erinnerungseintrag je
|
||||
Termin und Stufe. Erinnert wird bei T-30 (bzw. der Vorwarnzeit der Vorlage),
|
||||
T-7, T-0 und einmal bei Überfälligkeit — als **eine** Sammelnachricht je
|
||||
Empfänger, nicht als eine Mail je Gerät.
|
||||
|
||||
„Überfällig" ist bewusst kein gespeicherter Zustand, sondern wird aus dem
|
||||
Fälligkeitsdatum und dem heutigen Tag gerechnet. Gespeicherte Zustände laufen
|
||||
der Uhr hinterher; berechnete nicht.
|
||||
|
||||
### Prüfwizard
|
||||
|
||||
Zugeschnitten auf das Tablet im Gerätehaus: ein Prüfpunkt je Bildschirm, große
|
||||
Schaltflächen, Foto direkt aus der Kamera, Unterschrift auf einem Zeichenfeld,
|
||||
das Finger, Stift und Maus gleichermaßen versteht.
|
||||
|
||||
Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens die
|
||||
letzte Antwort verloren, nicht die ganze Prüfung — beim erneuten Öffnen wird der
|
||||
Entwurf fortgesetzt.
|
||||
|
||||
Die Bewertung rechnet der Server, nicht der Browser
|
||||
([`src/lib/wartung/bewertung.ts`](src/lib/wartung/bewertung.ts)):
|
||||
|
||||
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
|
||||
- ein beanstandeter **Pflichtpunkt** bedeutet „nicht bestanden" und sperrt das
|
||||
Gerät,
|
||||
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
|
||||
|
||||
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er
|
||||
trägt die Verantwortung.
|
||||
|
||||
### Prüfprotokoll als PDF
|
||||
|
||||
Erzeugt der Worker über Gotenberg
|
||||
([`src/lib/wartung/protokoll.ts`](src/lib/wartung/protokoll.ts)) und legt es in
|
||||
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
|
||||
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
|
||||
Prüfdatum und Seitenzahl.
|
||||
|
||||
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder.
|
||||
Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
|
||||
|
||||
Die laufende Protokollnummer (`JJJJ-NNNN`) wird je Wehr und Jahr unter einer
|
||||
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
|
||||
verschiedene Nummern.
|
||||
|
||||
### Mängel und Rückrufe
|
||||
|
||||
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben.
|
||||
**Melden darf jedes Mitglied** — der Mangel fällt dem auf, der das Gerät
|
||||
benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das
|
||||
Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn
|
||||
kein anderer offener Mangel dasselbe Gerät sperrt.
|
||||
|
||||
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein
|
||||
**Vorschlag**, keine automatische Sperre: eine Chargenangabe des Herstellers
|
||||
passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
|
||||
|
||||
### Zusätzliche Umgebungsvariablen
|
||||
|
||||
| Variable | Vorgabe | Bedeutung |
|
||||
|---|---|---|
|
||||
| `WARTUNG_NACHTLAUF` | `15 3 * * *` | wann Termine geplant werden (cron, Europe/Berlin) |
|
||||
| `WARTUNG_ERINNERUNG_LAUF` | `45 3 * * *` | wann Erinnerungen verschickt werden |
|
||||
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
|
||||
Was die Wehr tut, wird hier dokumentiert — für die Gemeinde, für die
|
||||
Versicherung und für die eigene Mannschaft.
|
||||
|
||||
### Der Weg durch das Modul
|
||||
|
||||
```
|
||||
Schnellerfassung Stichwort, Ort, Fahrzeuge — im Alarmfall in 20 Sekunden
|
||||
↓ während des Einsatzes
|
||||
Zeitstempel alarmiert, ausgerückt, eingetroffen, Ende, eingerückt,
|
||||
einsatzbereit — je ein Knopf
|
||||
Lagemeldungen aus der Wehr und über das Portal einer Fremdwehr
|
||||
↓ danach, in Ruhe
|
||||
Kräfte und Material wer war dabei, was wurde verbraucht
|
||||
Bericht der Text des Einsatzleiters
|
||||
↓ Freigabe
|
||||
Einsatzbericht als PDF, festgeschrieben, an die Gemeinde
|
||||
```
|
||||
|
||||
### Die sechs Zeitstempel
|
||||
|
||||
Sie sind der Kern jeder Einsatzauswertung. Aus ihnen ergeben sich drei Zahlen:
|
||||
|
||||
| Kennzahl | Rechnung | sagt etwas aus über |
|
||||
|---|---|---|
|
||||
| **Ausrückzeit** | Alarm bis Ausrücken | die Wehr selbst |
|
||||
| **Anfahrtszeit** | Ausrücken bis Eintreffen | Lage und Wegstrecke |
|
||||
| **Hilfsfrist** | Alarm bis Eintreffen | das, wonach die Gemeinde fragt |
|
||||
|
||||
In Bayern gilt als Ziel: **in 80 Prozent der Fälle innerhalb von zehn Minuten**.
|
||||
Genau dieser Wert steht in der Statistik — nicht der Durchschnitt, denn der
|
||||
verdeckt die Ausreißer, um die es geht.
|
||||
|
||||
### Stärke nach FwDV 3
|
||||
|
||||
Aus den Funktionen der erfassten Kräfte errechnet FLORIAN die Stärke in der
|
||||
üblichen Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`:
|
||||
|
||||
| Einheit | Stärke | Besetzung |
|
||||
|---|---|---|
|
||||
| Trupp | 0/1/1/2 | Truppführer, Truppmann |
|
||||
| Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps |
|
||||
| Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps |
|
||||
| Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel |
|
||||
|
||||
**Regional unterschiedlich.** Manche Wehren zählen den Maschinisten zu den
|
||||
Unterführern, andere schreiben dreistellig ohne Unterführer (Gruppe = 1/8/9).
|
||||
Die Zuordnung steht an einer Stelle — [`src/lib/einsatz/staerke.ts`](src/lib/einsatz/staerke.ts) —
|
||||
und ist bewusst leicht zu ändern.
|
||||
|
||||
### Der freigegebene Bericht ist unveränderbar
|
||||
|
||||
Dieselbe Logik wie beim Prüfprotokoll aus Phase 2, denn der Bericht geht an
|
||||
die Gemeinde und im Schadensfall an die Versicherung. Bis zur Freigabe ist
|
||||
alles änderbar — ein Einsatz wird tagelang ergänzt, und genau dafür ist der
|
||||
Entwurfsstand da. Mit der Freigabe ist Schluss; der Trigger
|
||||
`einsatz_freigabe_schuetzen` setzt das durch.
|
||||
|
||||
Zwei Ausnahmen bleiben, beide nachtragend: das PDF, solange keines hinterlegt
|
||||
ist, und `geaendertAm`.
|
||||
|
||||
**Lagemeldungen sind ausdrücklich ausgenommen.** Sie sind das Protokoll des
|
||||
Verlaufs, kein Bestandteil des Berichts — und eine Meldung aus dem Portal kann
|
||||
eintreffen, wenn der Bericht längst geschrieben ist.
|
||||
|
||||
### Einsatzportal für Fremdwehren
|
||||
|
||||
Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor
|
||||
Ort. Die haben kein Konto in FLORIAN und sollen auch keines bekommen. Der
|
||||
Einsatzleiter erzeugt stattdessen einen Link, den er als QR-Code zeigt oder
|
||||
weiterschickt.
|
||||
|
||||
Vier Schranken sichern ihn ab:
|
||||
|
||||
1. **Zufall** — 32 zufällige Bytes im Link.
|
||||
2. **Nur ein Hash in der Datenbank** — wer sie liest, bekommt keinen gültigen Link.
|
||||
3. **Zeitlich begrenzt** — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder
|
||||
Aufruf wird mitgezählt.
|
||||
4. **Zugriffsbegrenzung** — 120 Aufrufe je Link und Stunde, 30 Meldungen, 10
|
||||
Fotos.
|
||||
|
||||
Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort,
|
||||
Zeitstempel und Lagemeldungen. **Keine Namen von Einsatzkräften, keine
|
||||
Berichte, kein Weg in den Rest der Anwendung.**
|
||||
|
||||
### Adressen zu Koordinaten
|
||||
|
||||
Über einen **selbst betriebenen** Nominatim (`NOMINATIM_URL`). Einsatzadressen
|
||||
sagen, wo es gebrannt hat und wer betroffen war; solche Anfragen gehören nicht
|
||||
an einen fremden Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim
|
||||
erlauben es ohnehin nicht.
|
||||
|
||||
Ohne Konfiguration bleibt das Feld leer und die Koordinaten werden von Hand
|
||||
eingetragen — kein Fehler, sondern der Normalfall in einer frischen
|
||||
Installation. Die Karte dazu kommt in Phase 6.
|
||||
|
||||
Gespeichert wird doppelt: als Dezimalgrad, die Prisma lesen kann, und als
|
||||
PostGIS-Punkt für die Karte. Ein Trigger hält beides zusammen.
|
||||
|
||||
### Statistik und Jahresexport
|
||||
|
||||
`/einsaetze/statistik` beantwortet beide Fragen: die der Gemeinde (Anzahl nach
|
||||
Kategorie der Landesstatistik, Hilfsfristen) und die der Wehr (Beteiligung je
|
||||
Person, Auslastung je Fahrzeug). Der Jahresexport als PDF liegt unter
|
||||
`/api/einsaetze/jahresexport?jahr=…` und wird bewusst **nicht** abgelegt — er
|
||||
ist eine Auswertung, keine Urkunde, und ändert sich, solange noch Einsätze
|
||||
nachgetragen werden.
|
||||
|
||||
### Zusätzliche Umgebungsvariablen
|
||||
|
||||
| Variable | Vorgabe | Bedeutung |
|
||||
|---|---|---|
|
||||
| `GEOCODING_ENABLED` | `false` | Adresssuche einschalten |
|
||||
| `NOMINATIM_URL` | — | Adresse des eigenen Nominatim |
|
||||
| `GEOCODING_LAENDER` | `de,at,ch` | Eingrenzung der Suche |
|
||||
| `PORTAL_ENABLED` | `true` | Einsatzportal für Fremdwehren |
|
||||
| `PORTAL_GUELTIGKEIT_STUNDEN` | `24` | Lebensdauer eines Links |
|
||||
| `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Das Terminal im Gerätehaus
|
||||
|
||||
Ein Wandtablet neben der Fahrzeughalle. Es muss zwei Dinge können, die eine
|
||||
Verwaltungsoberfläche am Schreibtisch nicht können muss: **mit Handschuhen
|
||||
bedienbar** sein und **weiterarbeiten, wenn das Netz weg ist**. Stahlbeton,
|
||||
Metalltore und ein Repeater, der irgendwann ausfällt — das ist der Normalfall,
|
||||
nicht die Ausnahme.
|
||||
|
||||
Die Oberfläche liegt unter `/kiosk` und ist eine eigene: große Kacheln statt
|
||||
Navigationsleiste, Schaltflächen ab 64 Pixel Höhe, Symbol **und** Text.
|
||||
|
||||
### Der Weg durch das Modul
|
||||
|
||||
1. **Einmal einrichten** — `/kiosk/einrichten` verlangt den Kurznamen der Wehr
|
||||
und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`). Beides nimmt
|
||||
die Seite auch aus der Adresse an, damit am Tablet nichts abgetippt werden
|
||||
muss:
|
||||
|
||||
```
|
||||
/kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
|
||||
```
|
||||
|
||||
2. **Anmelden** — Ausweis auflegen, QR-Code vom Handy oder Mitgliedsnummer und
|
||||
PIN. Ohne eingerichtetes Gerät kommt niemand hinein, auch nicht mit richtiger
|
||||
PIN: das Gerätegeheimnis ist die erste Schranke.
|
||||
3. **Erfassen** — Mangel melden, Kommen/Gehen, Fahrt beginnen und beenden,
|
||||
Wäsche abgeben, Inventurgang.
|
||||
4. **Von selbst abmelden** — nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit,
|
||||
mit sichtbarer Warnung vorher. Ein Terminal, an dem die letzte Anmeldung
|
||||
stehen bleibt, erfasst irgendwann alles auf den Falschen.
|
||||
|
||||
Was dabei zusammenkommt, steht in der Verwaltung unter `/terminal`.
|
||||
|
||||
### Mangel melden in unter dreißig Sekunden
|
||||
|
||||
Der Vorgang, an dem sich das Terminal misst. Drei Schritte, kein Formular:
|
||||
|
||||
1. QR-Code am Gerät scannen — oder die Inventarnummer eintippen.
|
||||
2. Foto aufnehmen und einen Satz dazu.
|
||||
3. Dringlichkeit antippen. Damit ist es abgeschickt.
|
||||
|
||||
„Kritisch" sperrt das Gerät sofort. Eine unbekannte Inventarnummer hält die
|
||||
Meldung **nicht** auf: sie geht raus und landet notfalls in der Konfliktliste —
|
||||
lieber ein Zettel zu viel als ein Riss im Schlauch, den niemand gemeldet hat.
|
||||
|
||||
### Offline: wie nichts verloren geht
|
||||
|
||||
Drei Bausteine, und keiner davon ist optional:
|
||||
|
||||
**Service Worker** (`public/sw.js`, von Hand geschrieben, keine Bibliothek):
|
||||
App-Shell und Symbole liegen im Zwischenspeicher, Seitenaufrufe gehen
|
||||
`network-first` mit Ausweichseite `/kiosk/offline`, lesende tRPC-Aufrufe
|
||||
`stale-while-revalidate`. Schreibende Anfragen und alles unter `/api/auth`
|
||||
fasst er **nicht** an — eine zwischengespeicherte Anmeldung wäre eine Lücke,
|
||||
kein Komfort.
|
||||
|
||||
**Warteschlange in der IndexedDB** (`src/lib/kiosk/outbox.ts`, Dexie): jede
|
||||
schreibende Aktion geht zuerst dorthin — auch online. Sonst gäbe es zwei Wege
|
||||
durch den Code, und der seltenere wäre der ungetestete. Fotos liegen als Blob
|
||||
daneben und werden beim Übertragen zuerst hochgeladen.
|
||||
|
||||
**Idempotenzschlüssel**: die Kennung erzeugt der Browser beim Einreihen
|
||||
(`crypto.randomUUID()`), nicht der Server beim Empfangen. Bricht die
|
||||
Übertragung ab und wird wiederholt, erkennt der Server denselben Vorgang an
|
||||
derselben Kennung wieder und antwortet mit dem Ergebnis von damals. Die Tabelle
|
||||
`kiosk_vorgang` hat diese Kennung als Primärschlüssel, und die Datenbank
|
||||
verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf.
|
||||
|
||||
Ein Vorgang verschwindet aus der Warteschlange, wenn der Server ihn angenommen
|
||||
**oder** als bereits verarbeitet erkannt **oder** in die Konfliktliste gelegt
|
||||
hat. In allen drei Fällen ist er angekommen. Liegen bleibt nur, was technisch
|
||||
nicht durchging.
|
||||
|
||||
Der Zustand steht als Leiste über allem — verbunden, wie viele Änderungen
|
||||
warten, kein Netz — in Klartext und nicht nur als Farbe.
|
||||
|
||||
**Wem ein Vorgang zugeschrieben wird.** Der Server nimmt dafür die Person, die
|
||||
beim Empfang angemeldet ist. Im Regelfall ist das dieselbe, die erfasst hat —
|
||||
wer offline erfasst, bleibt angemeldet, weil auch das Abmelden Netz braucht.
|
||||
Damit dazwischen kein Fenster bleibt, überträgt das Terminal vor jedem
|
||||
Abmelden — von Hand wie durch die Abmeldeuhr — noch einmal.
|
||||
|
||||
### Konflikte
|
||||
|
||||
Ein Vorgang, der sich nicht anwenden lässt (Gerät zwischenzeitlich ausgesondert,
|
||||
Prüfung inzwischen festgeschrieben), wird nicht verworfen und nicht erzwungen,
|
||||
sondern in `sync_konflikt` gelegt. Der Gerätewart sieht ihn unter `/terminal`
|
||||
und entscheidet. Das ist der ehrliche Umgang mit einem Tablet, das drei Tage
|
||||
offline war.
|
||||
|
||||
### Anmeldemarke fürs Handy
|
||||
|
||||
Wer den Ausweis vergessen hat, öffnet `/anmeldemarke` und hält den QR-Code vor
|
||||
die Kamera des Terminals. Die Marke ist mit `AUTH_SECRET` signiert, **nicht
|
||||
gespeichert**, und gilt 60 Sekunden — ein abfotografierter Code aus der Umkleide
|
||||
nützt danach nichts mehr. Die Seite erneuert ihn von selbst.
|
||||
|
||||
### Hallenmonitor
|
||||
|
||||
`/kiosk/monitor` läuft im Vollbild ohne Bedienung: Uhrzeit, laufender Einsatz,
|
||||
wer im Haus ist, Fahrzeuge samt laufenden Fahrten, fällige Prüfungen und die
|
||||
nächsten Termine. Optional Wetterwarnungen des DWD — die einzige Verbindung,
|
||||
die FLORIAN von sich aus nach außen aufbaut, und deshalb standardmäßig aus.
|
||||
|
||||
### Als App installieren
|
||||
|
||||
Das Manifest liegt unter `/manifest.webmanifest`, die Symbole entstehen mit
|
||||
`npm run icons` und liegen unter `public/icons/`. Startseite ist `/kiosk`. Im
|
||||
Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile.
|
||||
|
||||
### Zusätzliche Umgebungsvariablen
|
||||
|
||||
| Variable | Vorgabe | Bedeutung |
|
||||
|---|---|---|
|
||||
| `KIOSK_ENABLED` | `true` | Terminalanmeldung freischalten |
|
||||
| `KIOSK_DEVICE_SECRET` | — | Geheimnis, mit dem sich ein Tablet ausweist |
|
||||
| `KIOSK_IDLE_TIMEOUT` | `120` | Abmeldung nach Untätigkeit, in Sekunden |
|
||||
| `WETTER_ENABLED` | `false` | Wetterwarnungen auf dem Monitor |
|
||||
| `DWD_WARNCELL` | — | Warnzellen-Nummer der eigenen Gemeinde |
|
||||
| `DWD_WARNUNGEN_URL` | DWD-Warnapp | Quelle der Warnungen |
|
||||
| `WETTER_TIMEOUT` | `5` | Zeitlimit des Abrufs, in Sekunden |
|
||||
| `WETTER_ZWISCHENSPEICHER` | `300` | Gültigkeit einer Antwort, in Sekunden |
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
```bash
|
||||
|
||||
@@ -47,6 +47,11 @@ x-app-env: &app-env
|
||||
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
||||
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
||||
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
|
||||
WETTER_ENABLED: ${WETTER_ENABLED:-false}
|
||||
DWD_WARNCELL: ${DWD_WARNCELL:-}
|
||||
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
|
||||
WETTER_TIMEOUT: ${WETTER_TIMEOUT:-5}
|
||||
WETTER_ZWISCHENSPEICHER: ${WETTER_ZWISCHENSPEICHER:-300}
|
||||
MAIL_ENABLED: ${MAIL_ENABLED:-false}
|
||||
MAIL_HOST: ${MAIL_HOST:-}
|
||||
MAIL_PORT: ${MAIL_PORT:-587}
|
||||
@@ -61,6 +66,16 @@ x-app-env: &app-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODELL: ${LLM_MODELL:-}
|
||||
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
|
||||
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
||||
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
||||
GEOCODING_LAENDER: ${GEOCODING_LAENDER:-de,at,ch}
|
||||
PORTAL_ENABLED: ${PORTAL_ENABLED:-true}
|
||||
PORTAL_GUELTIGKEIT_STUNDEN: ${PORTAL_GUELTIGKEIT_STUNDEN:-24}
|
||||
PORTAL_MAX_ZUGRIFFE: ${PORTAL_MAX_ZUGRIFFE:-120}
|
||||
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
|
||||
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
|
||||
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
|
||||
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
||||
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
||||
@@ -298,7 +313,11 @@ services:
|
||||
image: florian-app:${IMAGE_TAG:-latest}
|
||||
container_name: florian-worker
|
||||
restart: unless-stopped
|
||||
command: ["node", "--enable-source-maps", "dist/worker/index.js"]
|
||||
# --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
|
||||
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
|
||||
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
|
||||
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
|
||||
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
|
||||
environment:
|
||||
<<: *app-env
|
||||
ROLLE: worker
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 0 (Version 0.1.0). Die Bilder zeigen die
|
||||
Bedienung von FLORIAN, Stand Phase 4 (Version 0.5.0). Die Bilder zeigen die
|
||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||
|
||||
> Was hier noch fehlt — Mitglieder, Geräte, Prüfungen — kommt in den
|
||||
> folgenden Phasen dazu. Der Stand ist bewusst schmal: Phase 0 baut das
|
||||
> Fundament, nicht die Fachmodule. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
> Atemschutz und Karten kommen in den folgenden Phasen dazu. Siehe
|
||||
> [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
|
||||
### Die Seitenleiste
|
||||
|
||||
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt
|
||||
rechts sein Phasenkürzel — `P2` heißt „kommt in Phase 2".
|
||||
rechts sein Phasenkürzel — `P4` heißt „kommt in Phase 4".
|
||||
|
||||
Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
|
||||
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
|
||||
@@ -94,7 +93,879 @@ nicht geblendet werden.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rollen und was sie dürfen
|
||||
## 4. Geräte
|
||||
|
||||

|
||||
|
||||
Die Geräteliste ist die Ansicht, die am häufigsten offen ist. Von links nach
|
||||
rechts steht dort, was man beim Suchen braucht: Inventarnummer, was es ist, von
|
||||
wem, wo es liegt, in welchem Zustand und wann es wieder geprüft werden muss.
|
||||
|
||||
### Suchen und filtern
|
||||
|
||||
**Das Suchfeld** durchsucht Inventarnummer, Bezeichnung, Hersteller, Modell und
|
||||
Seriennummer gleichzeitig. Es genügt ein Teil — „press" findet alle
|
||||
Pressluftatmer. Gesucht wird erst, wenn man kurz aufhört zu tippen; das
|
||||
verhindert eine Abfrage bei jedem Anschlag.
|
||||
|
||||
**Alle Zustände** schränkt auf einen Status ein. Praktisch, wenn man wissen
|
||||
will, was gerade fällig oder gesperrt ist.
|
||||
|
||||
**Spalten** blendet Spalten ein und aus. Zwölf stehen zur Auswahl, sechs sind
|
||||
voreingestellt. Die Auswahl bleibt im Browser gespeichert — der Gerätewart
|
||||
schaut auf Prüftermine, der Kassier auf Anschaffungspreise, und beide finden
|
||||
ihre Einstellung beim nächsten Mal wieder.
|
||||
|
||||
**Sortiert** wird durch einen Klick auf die Spaltenüberschrift, ein zweiter
|
||||
Klick dreht die Richtung um.
|
||||
|
||||
### Farben und Fristen
|
||||
|
||||
| Farbe | Bedeutung |
|
||||
|---|---|
|
||||
| grün | einsatzbereit |
|
||||
| gelb | Prüfung fällig, oder eine Frist läuft in den nächsten 30 Tagen ab |
|
||||
| rot | gesperrt, ausgesondert oder überfällig |
|
||||
| grau | in Reparatur |
|
||||
|
||||
Hinter jedem Datum steht, wie viele Tage noch bleiben — „in 12 Tagen" sagt mehr
|
||||
als ein Datum, das man erst im Kopf ausrechnen muss. Was vorbei ist, steht als
|
||||
„seit 62 Tagen" da.
|
||||
|
||||
Die Farbe allein trägt nie die Information: daneben steht immer Klartext.
|
||||
|
||||
### Mehrere Geräte auf einmal
|
||||
|
||||
Über die Kästchen links lassen sich Geräte auswählen. Sobald mindestens eines
|
||||
ausgewählt ist, erscheint eine Leiste mit zwei Möglichkeiten:
|
||||
|
||||
- **Etiketten drucken** erzeugt den Etikettenbogen als PDF.
|
||||
- **Status setzen auf …** ändert den Zustand aller ausgewählten Geräte. Der
|
||||
häufigste Handgriff überhaupt: Nach einer Sammelprüfung stehen zwanzig Geräte
|
||||
auf einmal wieder auf einsatzbereit.
|
||||
|
||||
Jede Änderung wird einzeln protokolliert — am jeweiligen Gerät, nicht als
|
||||
Sammelvorgang, den später niemand mehr zuordnen kann.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ein einzelnes Gerät
|
||||
|
||||

|
||||
|
||||
### Woher das Prüfintervall kommt
|
||||
|
||||
Steht am Gerät kein eigenes Intervall, gilt die Vorgabe des Gerätetyps. Das
|
||||
Bild zeigt es: „alle 12 Monate (aus dem Gerätetyp)". So muss man bei vierzig
|
||||
Pressluftatmern nicht vierzigmal dasselbe eintragen. Schreibt der Hersteller
|
||||
für eine bestimmte Charge etwas anderes vor, wird der Wert am Gerät gesetzt und
|
||||
gewinnt dann.
|
||||
|
||||
Dasselbe gilt für die Lebensdauer. Ob sie ab Herstellung oder ab
|
||||
Indienststellung zählt, hängt von der Vorschrift ab: Seile und Druckbehälter
|
||||
altern ab Herstellung, Schutzkleidung meist ab Indienststellung.
|
||||
|
||||
### QR-Code
|
||||
|
||||
Rechts steht der QR-Code des Geräts. **Etikett drucken** erzeugt daraus einen
|
||||
Aufkleber für den Geräteraum.
|
||||
|
||||
Der Code zeigt auf `.../g/<Inventarnummer>`. Wer ihn scannt und angemeldet ist,
|
||||
landet direkt auf dieser Seite. Wer nicht angemeldet ist, landet zuerst auf der
|
||||
Anmeldung und danach hier — **der Aufkleber ist keine Berechtigung.** Wer ihn
|
||||
fotografiert, bekommt dadurch keinen Zugang.
|
||||
|
||||
Bewusst steht die Inventarnummer im Code und nicht die interne Kennung: Eine
|
||||
UUID wäre 36 Zeichen lang, das Muster entsprechend fein, und auf einem
|
||||
38 × 21 mm großen Aufkleber im staubigen Geräteraum wird das unzuverlässig.
|
||||
|
||||
### Verlauf
|
||||
|
||||
Unten steht, was mit dem Gerät passiert ist — direkt aus dem Audit-Log. Es gibt
|
||||
bewusst keine zweite Historie: zwei Quellen könnten auseinanderlaufen.
|
||||
|
||||
---
|
||||
|
||||
## 5a. Ein Gerät anlegen oder ändern
|
||||
|
||||

|
||||
|
||||
Über **Neues Gerät** in der Geräteliste, oder über **Bearbeiten** auf der
|
||||
Detailseite eines vorhandenen Geräts. Für größere Bestände ist der Import der
|
||||
schnellere Weg; dieses Formular ist für den Neuzugang zwischendurch.
|
||||
|
||||
Pflicht sind nur zwei Angaben: **Inventarnummer** und **Bezeichnung**. Alles
|
||||
andere kann später nachgetragen werden.
|
||||
|
||||
### Der Gerätetyp nimmt Arbeit ab
|
||||
|
||||
Wird oben ein Gerätetyp gewählt, gelten dessen Prüfintervall, Prüfvorschrift
|
||||
und Lebensdauer — man muss sie nicht eintragen. Die Felder darunter bleiben
|
||||
trotzdem änderbar: Was dort steht, überschreibt die Vorgabe für dieses eine
|
||||
Gerät. Das braucht man, wenn der Hersteller für eine bestimmte Charge etwas
|
||||
anderes vorschreibt.
|
||||
|
||||
Ist die Bezeichnung noch leer, wird sie aus dem Typ übernommen.
|
||||
|
||||
### „Daraus ergibt sich"
|
||||
|
||||
Der graue Kasten zeigt, was aus den Angaben folgt: wann die nächste Prüfung
|
||||
fällig wird und wann die Nutzungsdauer endet. Er rechnet beim Tippen mit,
|
||||
sodass man vor dem Speichern sieht, was herauskommt. Gerechnet wird mit
|
||||
denselben Regeln wie auf dem Server — beim Speichern rechnet dieser erneut und
|
||||
behält das letzte Wort.
|
||||
|
||||
Steht dort „keine", fehlt entweder das Prüfintervall oder die Indienststellung.
|
||||
Das ist kein Fehler: Für Geräte ohne Prüfpflicht ist es der Normalfall.
|
||||
|
||||
### Wo liegt es?
|
||||
|
||||
Entweder an einem Standort **oder** auf einem Fahrzeug. Der Umschalter lässt
|
||||
nur eines von beidem zu — deshalb kann hier gar nicht erst der Fehler
|
||||
entstehen, den die Datenbank sonst abweisen würde.
|
||||
|
||||
Bei „Auf einem Fahrzeug" lässt sich zusätzlich der Geräteraum angeben. Er darf
|
||||
leer bleiben: Manches liegt lose in der Kabine. Wird das Fahrzeug gewechselt,
|
||||
wird die Raumangabe zurückgesetzt — der Raum gehört zum Fahrzeug.
|
||||
|
||||
Gibt es in der Wehr nur einen Standort, ist er beim Anlegen schon ausgewählt.
|
||||
|
||||
> Jede Änderung landet im Verlauf des Geräts. Wer wissen will, wann ein
|
||||
> Prüftermin verschoben wurde und von wem, findet es dort.
|
||||
|
||||
---
|
||||
|
||||
## 6. Etiketten drucken
|
||||
|
||||
Der Bogen ist auf das gängige Format mit **65 Etiketten je DIN-A4-Seite**
|
||||
(38,1 × 21,2 mm, 5 Spalten × 13 Zeilen) ausgelegt — den gibt es in jedem
|
||||
Schreibwarenladen und er passt in jeden Laserdrucker.
|
||||
|
||||
Auf jedem Etikett stehen drei Dinge: der QR-Code, die Inventarnummer zum
|
||||
Ablesen ohne Handy, und die Bezeichnung. Bei verlasteten Geräten kommt der
|
||||
Funkrufname des Fahrzeugs dazu.
|
||||
|
||||
Der Druck kann an einer beliebigen Position des Bogens beginnen. Wer drei
|
||||
Etiketten braucht, muss so keinen ganzen Bogen opfern.
|
||||
|
||||
> Erzeugt wird das PDF von Gotenberg, einem eigenen Container im Stack. Kommt
|
||||
> die Meldung „Der Etikettenbogen ließ sich nicht erzeugen", läuft er nicht:
|
||||
> `docker compose ps` zeigt es.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gerät scannen
|
||||
|
||||
Über **Scannen** in der Geräteliste öffnet sich der QR-Scanner. Der Browser
|
||||
fragt beim ersten Mal nach der Kamerafreigabe. Das Bild verlässt das Gerät
|
||||
nicht — ausgewertet wird direkt im Browser.
|
||||
|
||||
Zwei Dinge sind bewusst so gebaut:
|
||||
|
||||
- **Es geht immer auch ohne Kamera.** Darunter steht ein Eingabefeld für die
|
||||
Inventarnummer. Wenn die Freigabe verweigert wurde oder das Etikett zerkratzt
|
||||
ist, tippt man sie ein.
|
||||
- **Fremde Codes brechen nichts ab.** Der Barcode des Herstellers klebt oft
|
||||
direkt daneben. Wird er erkannt, kommt ein Hinweis statt einer Fehlermeldung.
|
||||
|
||||
---
|
||||
|
||||
## 8. Fahrzeuge
|
||||
|
||||

|
||||
|
||||
Die Übersicht zeigt die Fahrzeuge als Kacheln — eine Wehr hat drei bis zehn,
|
||||
nicht dreihundert. Auf jeder Kachel stehen der nächste TÜV-Termin und die Zahl
|
||||
der verlasteten Geräte.
|
||||
|
||||
Auf der Detailseite steht die **Beladung nach Geräteräumen**, in der Reihenfolge
|
||||
der Beladeordnung: G1 bis G6, Heck, Dach, Mannschaftsraum. Genau so sucht der
|
||||
Gerätewart — nicht „wo ist die Motorsäge", sondern „was ist in G3".
|
||||
|
||||
Beim Anlegen eines neuen Fahrzeugs werden diese Räume auf Wunsch gleich
|
||||
mitangelegt. Ein Mannschaftstransportwagen bekommt weniger Fächer als ein
|
||||
Löschfahrzeug.
|
||||
|
||||
Ein Gerät liegt **entweder** an einem Standort **oder** auf einem Fahrzeug.
|
||||
Beides gleichzeitig lässt die Datenbank nicht zu — das ist keine Formalität,
|
||||
sondern verhindert, dass ein Gerät zweimal gezählt wird.
|
||||
|
||||
Was verlastet ist, aber keinem Raum zugeordnet wurde, erscheint unter „Ohne
|
||||
Raumangabe" statt stillschweigend zu verschwinden.
|
||||
|
||||
---
|
||||
|
||||
## 9. Mitglieder und Qualifikationen
|
||||
|
||||

|
||||
|
||||
Die Mitgliederliste ist nach Abteilung gruppiert — Aktive, Jugendfeuerwehr,
|
||||
Passive. Eine durchlaufende alphabetische Liste vermischt Gruppen, die in der
|
||||
Wehr nichts miteinander zu tun haben.
|
||||
|
||||
Auf der Seite **Qualifikationen** steht oben, was abgelaufen ist oder demnächst
|
||||
abläuft. Das ist der Grund, warum jemand diese Seite öffnet.
|
||||
|
||||
**Gewarnt wird mit der Vorwarnzeit des jeweiligen Typs**, nicht mit einer
|
||||
pauschalen Frist. Eine G26.3-Untersuchung braucht 120 Tage Vorlauf, weil erst
|
||||
ein Termin beim Betriebsarzt gefunden werden muss; eine jährliche Unterweisung
|
||||
kommt mit deutlich weniger aus. Einsatzrelevante Qualifikationen sind
|
||||
gekennzeichnet.
|
||||
|
||||
Das Ablaufdatum wird beim Speichern aus der Gültigkeitsdauer des Typs
|
||||
gerechnet — lässt sich aber überschreiben. Auf einer Urkunde steht manchmal
|
||||
etwas anderes, als die Regel hergäbe.
|
||||
|
||||
---
|
||||
|
||||
## 10. Daten importieren
|
||||
|
||||

|
||||
|
||||
Der Import geht in drei Schritten, und der mittlere ist der eigentliche Punkt.
|
||||
|
||||
### Schritt 1: Datei wählen
|
||||
|
||||
CSV oder Excel (`.xlsx`). Die erste Zeile muss die Spaltenüberschriften
|
||||
enthalten. Höchstens 10 MB und 5.000 Zeilen.
|
||||
|
||||
Erkannt werden die Formate, die in der Praxis herauskommen:
|
||||
|
||||
| Fall | Wird verstanden |
|
||||
|---|---|
|
||||
| Trennzeichen | Semikolon, Komma, Tabulator, senkrechter Strich |
|
||||
| Zeichensatz | UTF-8 (auch mit BOM) und Windows-1252 |
|
||||
| Datum | 02.09.2026, 2.9.26, 2026-09-02, und die Seriennummer aus Excel |
|
||||
| Zahlen | 1.234,56 und 1,234.56, mit und ohne Eurozeichen |
|
||||
| Ja/Nein | ja, j, x, 1, wahr — und die Gegenstücke |
|
||||
|
||||
### Schritt 2: Zuordnung prüfen
|
||||
|
||||
FLORIAN rät die Zuordnung anhand der Überschriften. „Inv.-Nr." landet bei
|
||||
Inventarnummer, „Fabrikat" bei Hersteller. Jede Zuordnung lässt sich ändern,
|
||||
und die Vorschau passt sich sofort an.
|
||||
|
||||
**Die Vorschau ist der Grund, warum es diesen Zwischenschritt gibt.** Sie zeigt
|
||||
Zeile für Zeile, was übernommen würde, und markiert rot, was nicht stimmt:
|
||||
fehlende Pflichtfelder, unlesbare Daten, Inventarnummern, die in der Datei
|
||||
doppelt vorkommen. Eine falsch zugeordnete Spalte verteilt sonst Seriennummern
|
||||
auf Inventarnummern, und das merkt man erst Wochen später am Regal.
|
||||
|
||||
### Schritt 3: Übernehmen
|
||||
|
||||
Übernommen wird nur, was fehlerfrei ist. Zeilen mit Fehlern bleiben in der
|
||||
Datei und lassen sich nach der Korrektur erneut einlesen.
|
||||
|
||||
**Vorhandene Datensätze aktualisieren** entscheidet, was mit Datensätzen
|
||||
passiert, die es schon gibt: überspringen (Vorgabe) oder überschreiben.
|
||||
Erkannt werden sie über die Inventarnummer, bei Mitgliedern über die
|
||||
Mitgliedsnummer oder die E-Mail-Adresse.
|
||||
|
||||
Zum Schluss steht da, wie viele Datensätze angelegt, aktualisiert und
|
||||
übersprungen wurden. Der ganze Vorgang steht auch im Protokoll.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 11. Prüfungen und Fristen
|
||||
|
||||

|
||||
|
||||
Die Seite, die der Gerätewart morgens aufmacht. Sie beantwortet eine Frage:
|
||||
**was ist zu tun.**
|
||||
|
||||
Ganz oben stehen vier Zahlen, nach Dringlichkeit geordnet:
|
||||
|
||||
| Topf | Bedeutung |
|
||||
|---|---|
|
||||
| **überfällig** | hätte längst geprüft sein müssen |
|
||||
| **diese Woche** | in den nächsten sieben Tagen |
|
||||
| **diesen Monat** | in den nächsten 30 Tagen |
|
||||
| **nächste 90 Tage** | mittelfristig zu planen |
|
||||
|
||||
Jede Zahl ist gleichzeitig ein Filter: ein Klick, und die Liste darunter zeigt
|
||||
nur noch diesen Zeitraum. Nochmal klicken hebt den Filter wieder auf.
|
||||
|
||||
„Überfällig" wird jeden Tag neu gerechnet und nicht gespeichert. Ein Termin,
|
||||
der heute Nacht fällig geworden ist, steht morgen früh im richtigen Topf, ohne
|
||||
dass jemand etwas anstoßen muss.
|
||||
|
||||
### Verteilung
|
||||
|
||||
Darunter steht, **wo** die offenen Termine hängen — wahlweise nach Fahrzeug,
|
||||
Standort, Gerätetyp oder Zuständigem. Wer einen Prüftag ansetzt, arbeitet ein
|
||||
Fahrzeug ab und keine alphabetische Liste; deshalb ist Fahrzeug die Vorgabe.
|
||||
|
||||
### Termine zuweisen
|
||||
|
||||
In der Terminliste lassen sich mehrere Zeilen ankreuzen und in einem Zug jemandem
|
||||
zuweisen. Der Zuständige bekommt die Erinnerungen; ist niemand zuständig, gehen
|
||||
sie an die Gerätewarte der Wehr.
|
||||
|
||||
### Erinnerungen
|
||||
|
||||
Nachts um 3:45 Uhr verschickt FLORIAN eine **Sammelnachricht je Empfänger** —
|
||||
nicht eine Mail je Gerät. Erinnert wird bei 30 Tagen (oder der Vorwarnzeit der
|
||||
Vorlage), bei 7 Tagen, am Fälligkeitstag und einmal, wenn ein Termin überfällig
|
||||
geworden ist. Jede Stufe genau einmal.
|
||||
|
||||
Solange `MAIL_ENABLED=false` steht, landen die Nachrichten im Protokoll statt
|
||||
im Postfach.
|
||||
|
||||
---
|
||||
|
||||
## 12. Wartungsvorlagen
|
||||
|
||||

|
||||
|
||||
Eine Wartungsvorlage sagt, **was** an einem Gerätetyp **in welchem Takt**
|
||||
geprüft wird. Sie ist die Regel; der Termin ist ihre Anwendung auf ein einzelnes
|
||||
Gerät.
|
||||
|
||||
**Ohne Vorlage gibt es keine Termine.** Das ist die erste Seite, die eine Wehr
|
||||
in diesem Modul ausfüllt.
|
||||
|
||||
| Angabe | Wofür |
|
||||
|---|---|
|
||||
| **Gerätetyp** | für welche Bauart die Vorlage gilt. Ohne Gerätetyp plant der nächtliche Lauf nichts. |
|
||||
| **Intervall in Monaten** | zählt ab der letzten durchgeführten Prüfung |
|
||||
| **Vorwarnzeit in Tagen** | wann die erste Erinnerung rausgeht |
|
||||
| **Prüfvorschrift** | steht später im Protokoll und in der Fußzeile des PDF |
|
||||
| **Übliche Dauer** | Erfahrungswert für die Planung eines Prüftags |
|
||||
|
||||
### Die Checkliste
|
||||
|
||||

|
||||
|
||||
Jeder Punkt hat eine von vier Antwortarten:
|
||||
|
||||
| Art | Wann |
|
||||
|---|---|
|
||||
| **Ja / Nein** | Sichtprüfungen: „ohne Risse", „Kennzeichnung lesbar" |
|
||||
| **Messwert** | alles Gemessene, mit zulässigem Bereich und Einheit |
|
||||
| **Text** | Befunde, die sich nicht ankreuzen lassen |
|
||||
| **Foto** | wenn der Zustand belegt werden soll |
|
||||
|
||||
Bei einem Messwert entscheidet der zulässige Bereich, ob der Punkt in Ordnung
|
||||
ist. Gerechnet wird das auf dem Server, nicht im Browser.
|
||||
|
||||
**Pflichtpunkt heißt: davon hängt die Einsatzbereitschaft ab.** Ein
|
||||
Pflichtpunkt, der durchfällt, bedeutet „nicht bestanden" und sperrt das Gerät.
|
||||
Ein Kann-Punkt, der durchfällt, bedeutet „bestanden mit Mängeln". Wer einen
|
||||
Punkt als Pflicht anlegt, trifft also eine fachliche Entscheidung.
|
||||
|
||||
Die Reihenfolge zählt: der Prüfwizard arbeitet die Punkte genau so ab, wie sie
|
||||
hier stehen. Umsortiert wird mit den Pfeiltasten — das geht auch mit Handschuhen
|
||||
und mit der Tastatur.
|
||||
|
||||
---
|
||||
|
||||
## 13. Eine Prüfung durchführen
|
||||
|
||||
Gestartet wird sie entweder im Fristen-Dashboard über **Prüfen** oder auf der
|
||||
Gerätedetailseite über **Prüfung durchführen**. Wer das Gerät am Regal
|
||||
gescannt hat, ist schon auf der richtigen Seite.
|
||||
|
||||
Der Wizard zeigt **einen Prüfpunkt je Bildschirm**. Oben läuft die Zeit mit;
|
||||
sie landet im Protokoll und gehört zum Nachweis, wie gründlich geprüft wurde.
|
||||
|
||||
Zu jedem Punkt lassen sich zusätzlich ein Foto und eine Bemerkung erfassen.
|
||||
Auf dem Tablet öffnet der Fotoknopf unmittelbar die Kamera.
|
||||
|
||||
**Jede Antwort geht sofort zum Server.** Wenn im Gerätehaus das WLAN wegbricht,
|
||||
ist höchstens die letzte Antwort verloren. Beim erneuten Öffnen wird der
|
||||
angefangene Durchgang fortgesetzt — es entsteht keine zweite halbe Prüfung.
|
||||
|
||||
### Der Abschluss
|
||||
|
||||
Am Ende steht:
|
||||
|
||||
1. **Ergebnis** — vorgeschlagen wird, was sich aus den Antworten ergibt.
|
||||
Übersteuern darf der Prüfer; er trägt die Verantwortung, nicht die Software.
|
||||
2. **Mängel** — aus den beanstandeten Punkten vorgeschlagen, frei ergänzbar.
|
||||
Ein kritischer Mangel sperrt das Gerät.
|
||||
3. **Unterschrift** — auf dem Zeichenfeld, mit Finger, Stift oder Maus. Ohne
|
||||
Unterschrift lässt sich nicht abschließen: ein Protokoll ohne Unterschrift
|
||||
ist kein Nachweis.
|
||||
|
||||
Wurde die Prüfung **extern** durch eine Fachfirma gemacht, lassen sich hier
|
||||
Firma, Kosten und das Fremdprotokoll als PDF anhängen.
|
||||
|
||||
Mit dem Abschluss passiert alles auf einmal: die Prüfung wird festgeschrieben,
|
||||
der Termin gilt als erledigt, der nächste wird geplant, der Gerätestatus zieht
|
||||
nach, und das PDF entsteht im Hintergrund.
|
||||
|
||||
---
|
||||
|
||||
## 14. Prüfprotokolle
|
||||
|
||||

|
||||
|
||||
Jedes abgeschlossene Protokoll bekommt eine laufende Nummer im Format
|
||||
`JJJJ-NNNN` — das ist die Nummer, die man bei einer Rückfrage der Unfallkasse
|
||||
nennt.
|
||||
|
||||
Über **Protokoll als PDF** kommt die Fassung zum Ausdrucken oder Weiterreichen:
|
||||
Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist und
|
||||
Befund, die Fotos, die Unterschriften und eine Fußzeile mit Prüfvorschrift und
|
||||
Prüfdatum. Alles ist im PDF eingebettet — es lädt nichts nach und sieht in zehn
|
||||
Jahren noch genauso aus.
|
||||
|
||||
### Ein Protokoll lässt sich nicht ändern
|
||||
|
||||
Das ist keine Bequemlichkeitsfrage, sondern der Kern der Sache: ein Nachweis,
|
||||
den man nachträglich ändern kann, ist keiner. FLORIAN verhindert das in der
|
||||
Datenbank, nicht bloß in der Oberfläche.
|
||||
|
||||
Wenn etwas falsch ist, legt **Korrektur anlegen** eine neue Prüfung an — mit den
|
||||
bisherigen Antworten vorbelegt und einem Verweis auf das alte Protokoll. Sobald
|
||||
sie abgeschlossen ist, gilt das alte als „ersetzt". **Beide bleiben lesbar**,
|
||||
und im Korrekturprotokoll steht, warum korrigiert wurde.
|
||||
|
||||
---
|
||||
|
||||
## 15. Mängel
|
||||
|
||||

|
||||
|
||||
Der Weg ist immer derselbe:
|
||||
|
||||
```
|
||||
gemeldet → zugewiesen → in Arbeit → behoben
|
||||
```
|
||||
|
||||
**Melden darf jedes Mitglied.** Der Riss im Schlauch fällt dem auf, der ihn in
|
||||
der Hand hat, nicht dem Gerätewart am Schreibtisch. Zum Melden gehören das
|
||||
Gerät, eine Beschreibung, eine Dringlichkeit und auf Wunsch ein Foto.
|
||||
|
||||
Ein Mangel der Stufe **kritisch** sperrt das Gerät sofort. Beim Beheben wird
|
||||
die Sperre wieder aufgehoben — aber nur, wenn kein anderer offener Mangel
|
||||
dasselbe Gerät sperrt. Ein behobener Kratzer gibt kein Gerät frei, das wegen
|
||||
eines Risses gesperrt ist.
|
||||
|
||||
Eine Fehlmeldung wird **verworfen**, nicht gelöscht: dass gemeldet wurde und
|
||||
was, bleibt nachvollziehbar.
|
||||
|
||||
---
|
||||
|
||||
## 16. Rückrufe
|
||||
|
||||

|
||||
|
||||
Kommt ein Sicherheitshinweis des Herstellers, wird er hier erfasst — mit der
|
||||
betroffenen Bauart, wahlweise eingegrenzt über Modell, Seriennummernbereich und
|
||||
Herstellungszeitraum.
|
||||
|
||||
FLORIAN sucht dann im Bestand nach passenden Geräten und zeigt sie als
|
||||
**Vorschlag**. Zugeordnet wird nur, was der Gerätewart bestätigt — eine
|
||||
Chargenangabe des Herstellers passt selten eins zu eins auf die eigenen
|
||||
Seriennummern.
|
||||
|
||||
Zugeordnete Geräte lassen sich in einem Zug sperren. Je Gerät wird
|
||||
fortgeschrieben, wie es steht: offen, nicht betroffen, Maßnahme läuft, erledigt.
|
||||
Mit „erledigt" fällt die Sperre wieder — sofern kein Mangel dasselbe Gerät
|
||||
sperrt. Abschließen lässt sich der Rückruf erst, wenn zu jedem betroffenen Gerät
|
||||
ein Ergebnis steht.
|
||||
|
||||
---
|
||||
|
||||
## 17. Lebensdauer
|
||||
|
||||

|
||||
|
||||
Helme, Seile, Schutzkleidung und Pressluftatmer sind ab einem Stichtag nicht
|
||||
mehr zugelassen — unabhängig davon, wie gut sie aussehen. Diese Seite zeigt,
|
||||
was abgelaufen ist und was in den nächsten Monaten abläuft.
|
||||
|
||||
Ein Gerät mit abgelaufener Nutzungsdauer wird nachts automatisch **gesperrt**.
|
||||
**Ausgesondert** wird es von einem Menschen: das entscheidet, wer weiß, ob
|
||||
Ersatz da ist. Der Knopf dazu steht in der Zeile.
|
||||
|
||||
Voraussetzung ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist
|
||||
(Kapitel 5).
|
||||
|
||||
---
|
||||
|
||||
## 18. Einen Einsatz erfassen
|
||||
|
||||

|
||||
|
||||
Im Alarmfall zählt eines: dass der Einsatz überhaupt angelegt wird. Die
|
||||
**Schnellerfassung** fragt deshalb nur drei Dinge:
|
||||
|
||||
1. **Stichwort** — das, was die Leitstelle durchgegeben hat: „B 3 Wohnhausbrand"
|
||||
2. **Ort** — Straße, Hausnummer, gegebenenfalls das Objekt
|
||||
3. **Fahrzeuge** — welche ausgerückt sind
|
||||
|
||||
Die Alarmzeit ist der Moment des Anlegens. Alles andere — Mannschaft, Zeiten,
|
||||
Bericht — wird später nachgetragen. Der Einsatz bekommt sofort eine
|
||||
fortlaufende Nummer im Format `JJJJ-NNNN`.
|
||||
|
||||
**Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr
|
||||
einen eigenen Nominatim betreibt (Kapitel 30). Ohne den bleibt das Feld leer —
|
||||
das ist in Ordnung, die Karte kommt in Phase 6.
|
||||
|
||||
---
|
||||
|
||||
## 19. Der Einsatz von innen
|
||||
|
||||

|
||||
|
||||
### Zeitstempel
|
||||
|
||||
Sechs Stempel beschreiben einen Einsatz vollständig:
|
||||
|
||||
| Stempel | wann |
|
||||
|---|---|
|
||||
| alarmiert | die Leitstelle hat alarmiert |
|
||||
| ausgerückt | das erste Fahrzeug verlässt das Gerätehaus |
|
||||
| eingetroffen | das erste Fahrzeug ist an der Einsatzstelle |
|
||||
| Einsatzende | die Arbeit an der Einsatzstelle ist beendet |
|
||||
| eingerückt | zurück im Gerätehaus |
|
||||
| einsatzbereit | Fahrzeuge und Geräte sind wieder hergerichtet |
|
||||
|
||||
Jeder Stempel ist ein Knopf und setzt die aktuelle Uhrzeit. Ein Klick auf
|
||||
„zurücknehmen" macht ihn wieder leer, falls er zu früh gedrückt wurde.
|
||||
|
||||
Daraus errechnet FLORIAN vier Zahlen:
|
||||
|
||||
- **Ausrückzeit** — Alarm bis Ausrücken. Sagt etwas über die Wehr aus.
|
||||
- **Anfahrt** — Ausrücken bis Eintreffen. Sagt etwas über die Lage aus.
|
||||
- **Hilfsfrist** — Alarm bis Eintreffen. Die Zahl, nach der die Gemeinde fragt.
|
||||
- **Einsatzdauer** — Alarm bis Einrücken.
|
||||
|
||||
Stimmt die Reihenfolge nicht, erscheint ein Hinweis. **Gespeichert wird
|
||||
trotzdem**: Zeiten werden oft Tage später aus dem Gedächtnis nachgetragen, und
|
||||
ein Einsatz mit unsicheren Zeiten ist besser als einer, den niemand erfasst.
|
||||
|
||||
### Mannschaft
|
||||
|
||||
„Mannschaft erfassen" zeigt einen Vorschlag, sortiert danach, wer im letzten
|
||||
Jahr am häufigsten dabei war. Daneben steht, ob die einsatzrelevanten
|
||||
Qualifikationen noch gelten — eine abgelaufene G26.3 fällt so auf, **bevor**
|
||||
jemand unter Atemschutz geht.
|
||||
|
||||
Ein Klick übernimmt die Person, danach lassen sich Funktion und Atemschutz in
|
||||
der Tabelle einstellen.
|
||||
|
||||
Rechts steht laufend die **Stärke nach FwDV 3**:
|
||||
|
||||
```
|
||||
1/3/5/9 entspricht einer Gruppe
|
||||
│ │ │ └── gesamt
|
||||
│ │ └──── Mannschaft
|
||||
│ └────── Unterführer (Truppführer)
|
||||
└──────── Führer (Gruppen-, Zug-, Staffelführer, Einsatzleiter)
|
||||
```
|
||||
|
||||
> **Regional unterschiedlich.** Hier zählt der Maschinist zur Mannschaft. Manche
|
||||
> Wehren rechnen ihn zu den Unterführern, andere schreiben dreistellig ohne
|
||||
> Unterführer. Die Zuordnung steht an einer Stelle im Code und lässt sich
|
||||
> anpassen.
|
||||
|
||||
### Verlauf
|
||||
|
||||
Lagemeldungen halten fest, was währenddessen passiert ist — mit Foto und
|
||||
wahlweise als **wichtig** gekennzeichnet (Menschenrettung, Nachforderung,
|
||||
Lageänderung). Meldungen bleiben auch nach der Freigabe möglich.
|
||||
|
||||
---
|
||||
|
||||
## 20. Bericht und Freigabe
|
||||
|
||||
Der Bericht ist der Text des Einsatzleiters: Lage bei Eintreffen, was getan
|
||||
wurde, wie es endete. Er lässt sich jederzeit speichern und weiter ergänzen.
|
||||
|
||||
**Freigeben** schließt den Einsatz ab. Danach:
|
||||
|
||||
- ist der Bericht **unveränderbar** — er geht an die Gemeinde und im
|
||||
Schadensfall an die Versicherung,
|
||||
- entsteht im Hintergrund das PDF (Knopf „Bericht als PDF"),
|
||||
- bleiben nur die Lagemeldungen weiter möglich.
|
||||
|
||||
Vor der Freigabe prüft FLORIAN zwei Dinge: dass eine Alarmzeit gesetzt ist und
|
||||
dass mindestens eine Einsatzkraft erfasst wurde. Ein Bericht ohne Mannschaft
|
||||
ist kein Nachweis.
|
||||
|
||||
---
|
||||
|
||||
## 21. Einsatzportal für Fremdwehren
|
||||
|
||||

|
||||
|
||||
Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor
|
||||
Ort. Die brauchen kein Konto in FLORIAN — sie bekommen einen **Link**.
|
||||
|
||||
So geht es:
|
||||
|
||||
1. Auf der Einsatzseite unter „Portal für Fremdwehren" eintragen, für wen der
|
||||
Link ist („FF Nachbarhausen").
|
||||
2. **Erzeugen** klicken. Der Link erscheint **einmal** — danach steht in der
|
||||
Datenbank nur noch sein Hash.
|
||||
3. Link kopieren und weitergeben, oder als QR-Code zeigen.
|
||||
|
||||
Was die Fremdwehr sieht: Stichwort, Ort, die Zeitstempel und den Verlauf. Was
|
||||
sie **nicht** sieht: Namen von Einsatzkräften, den Bericht, irgendetwas
|
||||
außerhalb dieses Einsatzes.
|
||||
|
||||
Was sie tun kann: eine Lagemeldung mit Foto absetzen.
|
||||
|
||||
Der Link gilt 24 Stunden, lässt sich jederzeit **widerrufen**, und jeder Aufruf
|
||||
wird mitgezählt — ein Link, der nach dem Einsatz noch benutzt wird, fällt auf.
|
||||
|
||||
---
|
||||
|
||||
## 22. Statistik
|
||||
|
||||

|
||||
|
||||
Zwei Fragen, eine Seite.
|
||||
|
||||
**Was die Gemeinde fragt:** wie viele Einsätze welcher Art, und wie schnell wart
|
||||
ihr da? Dafür stehen oben die Zahlen nach der Landesstatistik und die
|
||||
**Hilfsfrist als 80-Prozent-Wert**.
|
||||
|
||||
> Warum nicht der Durchschnitt? Weil ein einzelner Einsatz auf der Autobahn ihn
|
||||
> nach oben zieht, während neunzig Prozent der Fälle im Ort liegen. In Bayern
|
||||
> gilt als Ziel: in 80 Prozent der Fälle binnen zehn Minuten. Genau diese Zahl
|
||||
> steht hier.
|
||||
|
||||
**Was die Wehr fragt:** wer trägt die Last? Dafür steht unten die Beteiligung je
|
||||
Person — Einsätze, davon unter Atemschutz, und Übungen.
|
||||
|
||||
**Jahresexport als PDF** erzeugt das Blatt für die Gemeinde: Überblick,
|
||||
Kategorien und alle Einsätze des Jahres in einer Tabelle. Es wird nicht
|
||||
gespeichert, sondern jedes Mal neu gerechnet — es ist eine Auswertung, keine
|
||||
Urkunde.
|
||||
|
||||
---
|
||||
|
||||
## 23. Übungen
|
||||
|
||||

|
||||
|
||||
Eine Übung besteht aus Thema, Art, Datum und den **Lernzielen** — was danach
|
||||
sitzen soll.
|
||||
|
||||

|
||||
|
||||
Die Anwesenheit wird als Liste abgehakt: alle aktiven Mitglieder auf einmal,
|
||||
nicht einzeln gesucht. Bei einer Übung ist die halbe Wehr da.
|
||||
|
||||
Die Teilnahme fließt in die Beteiligungsstatistik ein — das ist die Zahl, nach
|
||||
der in der Jahreshauptversammlung gefragt wird und die bei Beförderungen zählt.
|
||||
|
||||
---
|
||||
|
||||
## 24. Das Terminal im Gerätehaus
|
||||
|
||||

|
||||
|
||||
Das Terminal ist eine **eigene Oberfläche** unter `/kiosk`, gedacht für ein
|
||||
Wandtablet neben der Fahrzeughalle. Große Kacheln statt Seitenleiste, alles
|
||||
Bedienbare mindestens 64 Pixel hoch, jede Kachel mit Symbol **und** Text — das
|
||||
ist kein Geschmack, sondern die Voraussetzung dafür, dass es mit
|
||||
Feuerwehrhandschuhen bedienbar bleibt.
|
||||
|
||||
### Einmal je Tablet einrichten
|
||||
|
||||
Bevor sich jemand anmelden kann, muss das Tablet wissen, zu welcher Wehr es
|
||||
gehört. Das macht der Administrator einmal unter `/kiosk/einrichten`: Kurzname
|
||||
der Wehr und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`).
|
||||
|
||||
Damit am Tablet nichts abgetippt werden muss, nimmt die Seite beides auch aus
|
||||
der Adresse an:
|
||||
|
||||
```
|
||||
http://florian.example/kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
|
||||
```
|
||||
|
||||
Diesen Link schickt sich der Administrator einmal selbst — etwa als QR-Code vom
|
||||
eigenen Rechner — und ruft ihn am Tablet auf.
|
||||
|
||||
> **Das Gerätegeheimnis ist die erste Schranke.** Ohne eingerichtetes Tablet
|
||||
> nimmt der Server keine PIN und keine Karte an. Ein fremdes Handy, das
|
||||
> `/kiosk` aufruft, kommt nicht weiter, auch nicht mit richtiger PIN.
|
||||
|
||||
### Anmelden
|
||||
|
||||

|
||||
|
||||
Drei Wege, in dieser Reihenfolge:
|
||||
|
||||
1. **Ausweis auflegen** — der Regelfall. Der Leser am Tablet gibt die
|
||||
Kartenkennung wie eine Tastatur aus; es muss vorher nichts angetippt werden.
|
||||
2. **QR-Code vom Handy** — für den, der den Ausweis vergessen hat (Kapitel 29).
|
||||
3. **Mitgliedsnummer und PIN** — wenn beides fehlt.
|
||||
|
||||
Nach `KIOSK_IDLE_TIMEOUT` Sekunden ohne Bedienung (Vorgabe: 120) meldet sich das
|
||||
Terminal von selbst ab, mit sichtbarer Warnung vorher. Ein Terminal, an dem die
|
||||
letzte Anmeldung stehen bleibt, schreibt irgendwann alles dem Falschen zu.
|
||||
|
||||
### Was auf dem Kachelschirm steht
|
||||
|
||||
Die Kacheln richten sich nach zwei Dingen: den Rechten der angemeldeten Person
|
||||
und ihrem aktuellen Zustand.
|
||||
|
||||
| Kachel | Wann sie sich ändert |
|
||||
|---|---|
|
||||
| Kommen / **Gehen** | Wer eingecheckt ist, sieht „Gehen" samt Zeit, seit wann |
|
||||
| Fahrt beginnen / **Fahrt beenden** | Wer mit einem Fahrzeug unterwegs ist, sieht dessen Funkrufname |
|
||||
| Wäsche abgeben | Zeigt an, wie viele Stücke noch nicht zurück sind |
|
||||
| Inventur | Nur für den Gerätewart |
|
||||
| Prüfung durchführen | Ohne Netz gesperrt, mit dem Hinweis „braucht Netz" |
|
||||
|
||||
---
|
||||
|
||||
## 25. Mangel melden am Terminal
|
||||
|
||||

|
||||
|
||||
Der Vorgang, an dem sich das Terminal misst — Anspruch war **unter dreißig
|
||||
Sekunden**. Deshalb drei Schritte und kein Formular:
|
||||
|
||||
1. **QR-Code am Gerät scannen.** Oder die Inventarnummer eintippen, wenn das
|
||||
Etikett unleserlich ist. Steht die Nummer im Bestand, erscheint sofort
|
||||
Bezeichnung und Zustand.
|
||||
2. **Foto und ein Satz dazu.** Das Foto ist kein Pflichtfeld, macht aber die
|
||||
halbe Arbeit: „Riss im Schlauch, etwa 20 cm von der Kupplung" plus Bild sagt
|
||||
dem Gerätewart mehr als jedes Formular.
|
||||
3. **Dringlichkeit antippen.** Damit ist die Meldung abgeschickt.
|
||||
|
||||
| Dringlichkeit | Was passiert |
|
||||
|---|---|
|
||||
| Kleinigkeit | kann warten |
|
||||
| Normal | beim nächsten Dienst |
|
||||
| Dringend | vor dem nächsten Einsatz |
|
||||
| **Kritisch** | Das Gerät wird **sofort gesperrt** |
|
||||
|
||||
Eine unbekannte Inventarnummer hält die Meldung nicht auf. Sie geht trotzdem
|
||||
raus und landet notfalls in der Konfliktliste (Kapitel 27) — lieber ein Zettel
|
||||
zu viel als ein Riss im Schlauch, den niemand gemeldet hat.
|
||||
|
||||
---
|
||||
|
||||
## 26. Ohne Netz weiterarbeiten
|
||||
|
||||
In einer Fahrzeughalle ist das Netz regelmäßig weg: Stahlbeton, Tore aus
|
||||
Metall, ein Repeater, der irgendwann ausfällt. Das Terminal ist dafür gebaut,
|
||||
nicht dagegen abgesichert.
|
||||
|
||||
Über jeder Terminalseite läuft eine Leiste, die den Zustand in Klartext sagt:
|
||||
|
||||
| Leiste | Bedeutung |
|
||||
|---|---|
|
||||
| **Verbunden · alles übertragen** | Alles, was erfasst wurde, ist beim Server |
|
||||
| **Verbunden · 2 Änderungen warten** | Wird gerade übertragen |
|
||||
| **Kein Netz · 1 Änderung wartet** | Erfasst und gespeichert, wartet auf Netz |
|
||||
|
||||
Und so funktioniert es: **jede** erfasste Aktion geht zuerst in eine
|
||||
Warteschlange im Tablet — auch dann, wenn Netz da ist. Erst von dort geht sie
|
||||
zum Server. Deshalb ist der Ablauf ohne Netz derselbe wie mit Netz, nur dauert
|
||||
er länger.
|
||||
|
||||
Was das im Betrieb bedeutet:
|
||||
|
||||
- **Die Bestätigung kommt sofort.** Wer eine Meldung abschickt, kann das Tablet
|
||||
weglegen. Es ist gespeichert, auch wenn es noch nicht übertragen ist.
|
||||
- **Ein Neustart des Tablets schadet nicht.** Die Warteschlange liegt im
|
||||
Speicher des Browsers, nicht im Arbeitsspeicher.
|
||||
- **Fotos warten mit.** Das Bild liegt neben der Meldung und wird beim
|
||||
Übertragen zuerst hochgeladen.
|
||||
- **Nichts wird doppelt gebucht.** Jeder Vorgang bekommt beim Erfassen eine
|
||||
eigene Kennung. Bricht die Übertragung mitten drin ab und wird wiederholt,
|
||||
erkennt der Server dieselbe Kennung wieder und bucht nicht erneut.
|
||||
- **Die Prüfung braucht Netz.** Ein Prüfprotokoll mit Checkliste, Messwerten,
|
||||
Fotos und Unterschrift lässt sich nicht sinnvoll zwischenspeichern — ein halb
|
||||
erfasstes Protokoll wäre schlechter als keins. Die Kachel sagt das, statt ins
|
||||
Leere zu führen.
|
||||
|
||||
Bleibt ein Vorgang mehr als fünf Versuche liegen, lässt er sich über „anzeigen"
|
||||
einsehen und im Notfall verwerfen. Vorher nicht — und das ist Absicht.
|
||||
|
||||
---
|
||||
|
||||
## 27. Konflikte nachbearbeiten
|
||||
|
||||

|
||||
|
||||
Ein Tablet, das drei Tage offline war, schickt irgendwann Vorgänge, deren Ziel
|
||||
es nicht mehr gibt: das Gerät wurde ausgesondert, die Prüfung inzwischen
|
||||
abgeschlossen und damit festgeschrieben.
|
||||
|
||||
FLORIAN erzwingt in diesem Fall nichts und verwirft nichts. Der Vorgang landet
|
||||
als **Konflikt** in der Übersicht unter `/terminal` — mit Zeitpunkt, Art,
|
||||
Grund und der Person, die ihn erfasst hat. Der Gerätewart entscheidet und
|
||||
vermerkt, wie es gelöst wurde.
|
||||
|
||||
Auf derselben Seite steht das Übrige, was am Terminal zusammenkommt: wer im
|
||||
Haus ist, welche Wäsche in der Reinigung ist und das Fahrtenbuch.
|
||||
|
||||
---
|
||||
|
||||
## 28. Hallenmonitor
|
||||
|
||||

|
||||
|
||||
`/kiosk/monitor` ist für einen Fernseher in der Fahrzeughalle gedacht: Vollbild,
|
||||
große Schrift, **keine Bedienung**. Zu sehen sind Uhrzeit und Datum, ein
|
||||
laufender Einsatz, wer gerade im Haus ist, die Fahrzeuge samt laufenden Fahrten,
|
||||
fällige Prüfungen und die nächsten Termine.
|
||||
|
||||
Optional dazu **Wetterwarnungen des DWD**:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
WETTER_ENABLED=true
|
||||
DWD_WARNCELL=809162000
|
||||
```
|
||||
|
||||
> Das ist die einzige Verbindung, die FLORIAN von sich aus nach außen aufbaut,
|
||||
> und sie ist deshalb standardmäßig aus. Nach draußen geht nur die
|
||||
> Warnzellen-Nummer, keine Daten der Wehr. Fällt der Abruf aus, bleibt die
|
||||
> Kachel leer — eine Wetterwarnung ist eine Zugabe, alarmiert wird über die
|
||||
> Leitstelle.
|
||||
|
||||
Die Nummer der eigenen Gemeinde steht in der
|
||||
[Warnzellenliste des DWD](https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv).
|
||||
|
||||
---
|
||||
|
||||
## 29. Anmeldemarke fürs Handy
|
||||
|
||||

|
||||
|
||||
Wer den Ausweis vergessen hat, öffnet in FLORIAN auf dem Handy die Seite
|
||||
**Anmeldemarke** und hält den QR-Code vor die Kamera des Terminals.
|
||||
|
||||
Der Code gilt **60 Sekunden** und wird von der Seite selbst erneuert. Er ist
|
||||
signiert und nirgends gespeichert: ein abfotografierter Code aus der Umkleide
|
||||
nützt eine Minute später nichts mehr.
|
||||
|
||||
---
|
||||
|
||||
## 30. Adressen zu Koordinaten (Nominatim)
|
||||
|
||||
FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
|
||||
betreibt:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
GEOCODING_ENABLED=true
|
||||
NOMINATIM_URL=http://nominatim:8080
|
||||
GEOCODING_LAENDER=de,at,ch
|
||||
```
|
||||
|
||||
> **Kein öffentlicher Dienst.** Einsatzadressen sagen, wo es gebrannt hat und
|
||||
> wer betroffen war. Solche Anfragen gehören nicht an einen fremden Server —
|
||||
> und die Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin
|
||||
> nicht.
|
||||
|
||||
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
||||
eintragen oder weglassen.
|
||||
|
||||
## 31. Rollen und was sie dürfen
|
||||
|
||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||
@@ -123,7 +994,7 @@ Das Terminal selbst ist nur die Tür.
|
||||
|
||||
---
|
||||
|
||||
## 5. Anmeldung über authentik einrichten
|
||||
## 32. Anmeldung über authentik einrichten
|
||||
|
||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||
|
||||
@@ -176,9 +1047,10 @@ ganze Wehr aus.
|
||||
|
||||
---
|
||||
|
||||
## 6. Terminal-Anmeldung im Gerätehaus
|
||||
## 33. PIN und Ausweiskarte verwalten
|
||||
|
||||
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
|
||||
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||
was dahinter liegt.
|
||||
|
||||
Am Terminal wird zweifach geprüft:
|
||||
|
||||
@@ -198,7 +1070,7 @@ Huber), PIN `471203`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Was protokolliert wird
|
||||
## 34. Was protokolliert wird
|
||||
|
||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||
@@ -218,11 +1090,12 @@ einzige Weg, auf dem etwas aus dem Log verschwindet.
|
||||
dem Schreiben herausgenommen.
|
||||
|
||||
Einsehbar ist der Log mit dem Recht `system.auditlog`, das Kommandant und
|
||||
Administrator haben. Die Ansicht dazu kommt in Phase 1.
|
||||
Administrator haben. Am Gerät und am Fahrzeug ist der Verlauf bereits auf der
|
||||
jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 8. Wenn etwas nicht geht
|
||||
## 35. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
@@ -231,7 +1104,17 @@ Administrator haben. Die Ansicht dazu kommt in Phase 1.
|
||||
| Listen sind leer, obwohl Daten da sind | Kein Mandantenkontext gesetzt | Das ist der Schutzmechanismus. Der Zugriff muss über `fuerOrganisation()` laufen. |
|
||||
| Dateien lassen sich nicht hochladen | MinIO nicht erreichbar oder Bucket fehlt | `http://localhost:9011` öffnen; `docker compose logs minio-init` |
|
||||
| Der Knopf für authentik fehlt | Issuer, Client-ID oder Secret nicht gesetzt | Alle drei sind nötig, sonst wird der Weg ausgeblendet |
|
||||
| Etikettenbogen lässt sich nicht erzeugen | Gotenberg läuft nicht | `docker compose ps`, dann `docker compose up -d gotenberg` |
|
||||
| Kamera bleibt schwarz | Freigabe abgelehnt, oder die Seite läuft nicht über HTTPS | Der Browser fragt nur bei HTTPS oder localhost. Sonst die Inventarnummer eintippen |
|
||||
| Import meldet fehlende Pflichtfelder | Keine Spalte ist Inventarnummer oder Bezeichnung zugeordnet | In Schritt 2 die Zuordnung von Hand setzen |
|
||||
| Umlaute im Import verstümmelt | Die Datei ist weder UTF-8 noch Windows-1252 | In Excel als „CSV UTF-8" speichern |
|
||||
| Gerät lässt sich nicht speichern | Standort **und** Fahrzeug gesetzt, oder keines von beidem | Genau eines von beiden angeben |
|
||||
| `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben |
|
||||
| Terminal zeigt „Terminal nicht eingerichtet" | Das Tablet kennt keine Wehr, oder der Browserspeicher wurde geleert | `/kiosk/einrichten` erneut aufrufen |
|
||||
| Terminal weist jede PIN ab | Gerätegeheimnis stimmt nicht mit `KIOSK_DEVICE_SECRET` überein | Einrichtung mit dem aktuellen Wert wiederholen |
|
||||
| Die Leiste zeigt dauerhaft wartende Änderungen | Server erreichbar, aber der Vorgang wird abgelehnt | Über „anzeigen" den Grund lesen; landet er als Konflikt, steht er unter `/terminal` |
|
||||
| „Der Code wurde nicht erkannt" beim QR-Login | Marke älter als 60 Sekunden | Auf dem Handy die Seite Anmeldemarke offen halten, sie erneuert den Code selbst |
|
||||
| Der Monitor zeigt keine Wetterwarnung | `WETTER_ENABLED` aus oder `DWD_WARNCELL` leer | Beides setzen; ohne Warnzelle wird nichts abgerufen |
|
||||
|
||||
Zustand aller Randsysteme auf einen Blick:
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ Faktoren, die in die Schätzung eingehen:
|
||||
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|
||||
|---|---|---:|---:|---:|
|
||||
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
|
||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | offen |
|
||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen |
|
||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
|
||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
|
||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
|
||||
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
|
||||
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
||||
@@ -72,7 +72,7 @@ das nachträglich einbaut, zahlt ein Vielfaches.
|
||||
|
||||
## Phase 1 — Stammdaten
|
||||
|
||||
**Vergleichsaufwand: 10–14 PT**
|
||||
**Vergleichsaufwand: 10–14 PT** · **Tatsächlich: 2:05 h Sitzungszeit** (1:15 h Hauptteil, 0:50 h Nachtrag)
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -89,9 +89,23 @@ Der Import ist der Posten, der erfahrungsgemäß aus dem Ruder läuft. Jede Wehr
|
||||
bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist
|
||||
kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut.
|
||||
|
||||
**Rückblick.** Die Schätzung hat gut getroffen — mit einer Verschiebung: Der
|
||||
Import war teurer als angesetzt (deutsche Datumsformate, Semikolon-CSV,
|
||||
Windows-1252, Excel-Seriennummern, Dubletten in der Datei), die Tabellen
|
||||
dagegen billiger, weil Sortierung und Blättern serverseitig laufen und keine
|
||||
Tabellenbibliothek nötig war. Nicht eingeplant war die Zeitzonen-Falle in der
|
||||
Fristenrechnung; sie hätte in Phase 2 deutlich mehr gekostet, wenn sie erst
|
||||
dort aufgefallen wäre.
|
||||
|
||||
Das zunächst offen gebliebene Formular zum einzelnen Anlegen von Geräten wurde
|
||||
als Nachtrag ergänzt — geschätzt waren 0,5 PT, tatsächlich 0:50 h Sitzungszeit.
|
||||
Die Hälfte davon ging für zwei Dinge drauf, die nichts mit dem Formular zu tun
|
||||
hatten: eine Server/Client-Grenze, die stillschweigend `undefined` liefert, und
|
||||
Datumsfelder, die sich in Chromium nicht automatisiert füllen lassen.
|
||||
|
||||
## Phase 2 — Wartung und Prüfung
|
||||
|
||||
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase
|
||||
**Vergleichsaufwand: 18–24 PT** · **Tatsächlich: 2:25 h Sitzungszeit** — die aufwendigste Phase
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -110,9 +124,32 @@ der Unfallkasse. Das heißt aber auch, dass jeder Fehler teuer ist. Die
|
||||
Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
|
||||
warum der Audit-Log schon in Phase 0 append-only ist.
|
||||
|
||||
**Rückblick.** Deutlich unter der geschätzten Sitzungszeit, und das aus einem
|
||||
Grund, der sich benennen lässt: die Fristenrechnung aus Phase 1 war schon da
|
||||
und musste nur exportiert werden. Wäre sie hier entstanden — samt der
|
||||
Zeitzonen-Falle, über die Phase 1 gestolpert ist —, sähe die Zahl anders aus.
|
||||
|
||||
Teurer als gedacht war das, was zwischen den Bausteinen liegt. Drei Beispiele:
|
||||
|
||||
- Die Reihenfolge beim Abschluss einer Prüfung ist nicht beliebig.
|
||||
Unterschriften und Mängel müssen vor dem Statuswechsel entstehen, denn danach
|
||||
lässt die Datenbank am Protokoll nichts mehr zu. Derselbe Stolperstein traf
|
||||
auch den Seed, der die Beispielprüfung zunächst fertig anlegen wollte.
|
||||
- Die Protokollnummer muss unter einer Sperre vergeben werden. Aufgefallen ist
|
||||
das erst, weil die E2E-Tests Desktop und Tablet gleichzeitig prüfen ließen —
|
||||
zwei Prüfer, die im selben Moment abschließen, sind im Gerätehaus kein
|
||||
konstruierter Fall.
|
||||
- Das PDF entsteht zwischen zwei Datenbanktransaktionen, nicht in einer. Fotos
|
||||
aus MinIO holen und Gotenberg rendern lassen dauert länger, als eine offene
|
||||
Transaktion warten darf.
|
||||
|
||||
Billiger als angesetzt war das Unterschriftenfeld: Pointer-Events decken Finger,
|
||||
Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und
|
||||
`touch-action: none` braucht es nicht.
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
|
||||
**Vergleichsaufwand: 12–16 PT**
|
||||
**Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 2:10 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -129,9 +166,33 @@ Das externe Portal für Fremdwehren ist sicherheitsrelevant: ein Zugang ohne
|
||||
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
|
||||
steckt weniger in der Funktion als in der Absicherung.
|
||||
|
||||
**Rückblick.** Die Schätzung lag richtig darin, wo der Aufwand steckt — nur
|
||||
nicht in welcher Reihenfolge.
|
||||
|
||||
Billiger als gedacht war das Portal. Der Grund liegt in Phase 0 und 2: die
|
||||
Mandantentrennung, das Hashen von Geheimnissen und die Ablage von Dateien gab
|
||||
es schon, und der Zugang war am Ende ein Datensatz mit Hash, ein Zeitfenster
|
||||
und ein Zähler in Redis. Was blieb, war die Frage, was das Portal **nicht**
|
||||
zeigen darf — und die ist fachlicher Natur, nicht technischer.
|
||||
|
||||
Teurer war die Statistik. Nicht das Rechnen, sondern die Entscheidung, welche
|
||||
Zahl gilt: der Durchschnitt der Eintreffzeiten sagt nichts, weil ein einzelner
|
||||
Einsatz auf der Autobahn ihn verschiebt. Es zählt der 80-Prozent-Wert, weil die
|
||||
Gemeinde danach fragt — und das steht in keinem Pflichtenheft, das muss man
|
||||
wissen.
|
||||
|
||||
Zwei Dinge fielen erst beim Tun auf:
|
||||
|
||||
- Koordinaten müssen doppelt liegen. Prisma kann den PostGIS-Typ nicht lesen;
|
||||
ohne die Dezimalgrade daneben wäre jede Anzeige eine Rohabfrage geworden. Ein
|
||||
Trigger hält beides zusammen.
|
||||
- Der Nummernkreis war schon zweimal da (Prüfprotokolle, jetzt Einsätze und
|
||||
Übungen). Beim dritten Mal wurde daraus ein eigener Baustein — die passende
|
||||
Gelegenheit war genau diese, keine früher.
|
||||
|
||||
## Phase 4 — Kiosk-PWA
|
||||
|
||||
**Vergleichsaufwand: 16–22 PT**
|
||||
**Vergleichsaufwand: 16–22 PT** · **Tatsächlich: 3:00 h Sitzungszeit**
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -150,6 +211,42 @@ unter Verbindungsabbruch, Doppelklick und parallelem Zugriff von zwei
|
||||
Terminals nichts verliert und nichts doppelt schreibt, ist die eigentliche
|
||||
Arbeit. Die Idempotenzschlüssel sind dafür der Schlüssel, nicht die Synchronisation.
|
||||
|
||||
**Rückblick.** Der Satz oben hat sich bestätigt, nur anders als gedacht. Die
|
||||
Outbox selbst war in einer halben Stunde geschrieben und mit zwölf Unit-Tests
|
||||
belegt. Zwei Stellen kosteten die Zeit, und keine davon steht in der
|
||||
Aufstellung:
|
||||
|
||||
- **Der Zeitgeber, der nie ablief.** Die Warteschlange hatte einen
|
||||
Wiederholungstakt von 20 Sekunden — und der wurde bei jedem Rendern neu
|
||||
aufgezogen, weil das Mutationsobjekt von React Query in den Abhängigkeiten
|
||||
stand. Alles sah richtig aus, alle Unit-Tests waren grün, und der liegen
|
||||
gebliebene Vorgang wäre im Gerätehaus trotzdem liegen geblieben. Gefunden hat
|
||||
das nur der Playwright-Test, der `context.setOffline(true)` setzt und dann
|
||||
wartet. Die 1,5 PT für „Playwright-Tests für den Offline-Fall" waren der
|
||||
bestangelegte Posten der Phase.
|
||||
- **Die Frage, wann ein Vorgang aus der Warteschlange darf.** Nicht „wenn
|
||||
erfolgreich", sondern „wenn er beim Server angekommen ist" — dazu gehört auch
|
||||
„bereits verarbeitet" und „in der Konfliktliste". Diese Unterscheidung ist die
|
||||
ganze Idempotenz; ohne sie kreist ein Vorgang für immer.
|
||||
|
||||
Billiger als angesetzt war die Anmeldung per RFID: die Aufstellung nennt WebHID,
|
||||
gebraucht wurde es nicht. Handelsübliche Leser geben die Kartenkennung als
|
||||
Tastatureingabe aus. Ein Zuhörer auf dem Fenster, der am Anschlagstempo
|
||||
erkennt, dass da keine Hand tippt, ersetzt eine ganze Geräteschnittstelle.
|
||||
|
||||
Billiger war auch der Service Worker — 80 Zeilen von Hand, ohne Workbox. Die
|
||||
Regeln sind einfach, solange man sie sich traut aufzuschreiben: Seiten aus dem
|
||||
Netz mit Ausweichseite, lesende Abfragen aus dem Zwischenspeicher mit
|
||||
Nachladen, schreibende **überhaupt nicht** anfassen. Eine Bibliothek hätte hier
|
||||
mehr Fragen aufgeworfen als beantwortet.
|
||||
|
||||
Teurer war die Terminaloberfläche selbst. Nicht die Kacheln, sondern die
|
||||
Zustände dahinter: Wer schon eingecheckt ist, sieht „Gehen" statt „Kommen". Wer
|
||||
mit einem Fahrzeug unterwegs ist, sieht „Fahrt beenden". Wer keine Rechte für
|
||||
die Inventur hat, sieht die Kachel nicht. Jede dieser Verzweigungen ist
|
||||
trivial — sechs davon in einem Schirm, der ohne Nachdenken bedienbar bleiben
|
||||
soll, sind es nicht.
|
||||
|
||||
## Phase 5 — Atemschutz nach FwDV 7
|
||||
|
||||
**Vergleichsaufwand: 14–18 PT**
|
||||
|
||||
@@ -11,7 +11,458 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
||||
|---|---:|---:|---|
|
||||
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **1:00 h** | **1** | |
|
||||
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
|
||||
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
|
||||
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
|
||||
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||||
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **10:40 h** | **6** | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 · Phase 4 — Kiosk-PWA im Gerätehaus
|
||||
|
||||
**14:40 – 17:40 · 3:00 h · Version 0.5.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Das Terminal an der Wand. Sieben neue Tabellen, eine eigene Oberfläche unter
|
||||
`/kiosk`, ein Service Worker und eine Warteschlange, die den Betrieb ohne Netz
|
||||
trägt.
|
||||
|
||||
| Bereich | Dateien |
|
||||
|---|---|
|
||||
| Datenmodell | `Fahrt`, `Anwesenheit`, `Waescheabgabe`, `Inventurgang`, `Inventurposten`, `KioskVorgang`, `SyncKonflikt` |
|
||||
| Offline | `public/sw.js`, `lib/kiosk/outbox.ts`, `components/kiosk/warteschlange.tsx` |
|
||||
| Anmeldung | `lib/kiosk/anmeldemarke.ts`, Anbieter `terminal-marke` in `lib/auth.ts`, `components/kiosk/anmeldung.tsx` |
|
||||
| Oberfläche | `app/kiosk/` mit Kacheln, sechs Wizards, Monitor und Einrichtung |
|
||||
| Verwaltung | `app/(app)/terminal/`, `app/(app)/anmeldemarke/` |
|
||||
| Server | `server/routers/kiosk/kiosk.ts`, `lib/integrations/wetter.ts` |
|
||||
| Tests | `tests/unit/kiosk-marke.test.ts`, `kiosk-outbox.test.ts`, `kiosk-datenbank.test.ts`, `tests/e2e/kiosk.spec.ts` |
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Die Warteschlange ist der einzige Weg, auch online.** Es wäre naheliegend,
|
||||
online direkt zu senden und nur im Fehlerfall zu puffern. Dann gäbe es zwei
|
||||
Wege durch den Code — und der seltenere wäre der ungetestete, also genau der,
|
||||
der im Gerätehaus gebraucht wird. Jetzt läuft alles durch dieselbe Bahn, und
|
||||
der Offline-Fall ist der Normalfall mit längerer Wartezeit.
|
||||
|
||||
**Die Kennung erzeugt der Browser, nicht der Server.** `crypto.randomUUID()`
|
||||
beim Einreihen. Damit ist sie derselbe Wert über beliebig viele Sendeversuche
|
||||
hinweg, und der Server kann einen wiederholten Vorgang erkennen, statt ihn
|
||||
doppelt zu buchen. Die Kennung ist der Primärschlüssel von `kiosk_vorgang`, und
|
||||
die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf —
|
||||
sonst ließe sich die Spur nachträglich glätten.
|
||||
|
||||
**Konflikte werden nicht erzwungen und nicht verworfen.** Ein Tablet, das drei
|
||||
Tage offline war, schickt Vorgänge, deren Ziel es nicht mehr gibt oder das
|
||||
inzwischen festgeschrieben ist. Beides ist kein Fehler des Anwenders. Der
|
||||
Vorgang landet in `sync_konflikt`, verschwindet aus der Warteschlange (er ist
|
||||
angekommen) und wartet unter `/terminal` auf eine Entscheidung.
|
||||
|
||||
**Die Prüfung bleibt online.** Der Wizard aus Phase 2 mit Checkliste, Messwerten,
|
||||
Fotos und Unterschrift lässt sich nicht sinnvoll in eine Warteschlange legen —
|
||||
und ein halb erfasstes Protokoll ist schlechter als keins. Die Kachel zeigt
|
||||
deshalb „braucht Netz" und ist ohne Verbindung gesperrt, statt ins Leere zu
|
||||
führen. `PRUEFUNG_ERFASSEN` steht trotzdem im Datenmodell: die Atemschutzprüfung
|
||||
in Phase 5 ist kurz genug dafür.
|
||||
|
||||
**Die Anmeldemarke wird signiert, nicht gespeichert.** HMAC über Person, Wehr
|
||||
und Zeitstempel mit `AUTH_SECRET`, gültig 60 Sekunden. Keine Tabelle, kein
|
||||
Aufräumjob, kein zweiter Ort mit einem Geheimnis — und ein abfotografierter
|
||||
Code aus der Umkleide ist nach einer Minute wertlos.
|
||||
|
||||
**Der Kartenleser hängt am Fenster, nicht an einem Eingabefeld.** Ein RFID-Leser
|
||||
tippt die Kennung in Millisekunden und schließt mit Enter ab. Wer die Karte
|
||||
auflegt, soll vorher nichts antippen müssen; unterschieden wird am
|
||||
Anschlagstempo (60 ms), nicht am Fokus.
|
||||
|
||||
**Wetterwarnungen sind aus.** Es ist die einzige Verbindung, die FLORIAN von
|
||||
sich aus nach außen aufbaut. Wer sie einschaltet, soll das bewusst tun; ohne
|
||||
`DWD_WARNCELL` passiert nichts, und bei Ausfall bleibt die Kachel leer statt
|
||||
den Monitor aufzuhalten.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Die Warteschlange schickte einen liegen gebliebenen Vorgang nie ein zweites Mal | `jetztSenden` hatte das Mutationsobjekt von React Query in den Abhängigkeiten. Das ist bei jedem Rendern ein neues, also änderte sich die Identität der Funktion dauernd, der Effekt lief neu, zog den 20-Sekunden-Zeitgeber neu auf — und der lief nie ab. Dazu wurde der `online`-Zuhörer ständig neu registriert und verpasste das Ereignis manchmal | Mutation, tRPC-Werkzeuge und das „läuft gerade"-Kennzeichen über Referenzen statt über Abhängigkeiten. Damit ist `jetztSenden` stabil |
|
||||
| Der Produktionsbuild brach bei `/kiosk/einrichten` ab | `useSearchParams()` ohne Suspense-Grenze lässt sich nicht vorab erzeugen | `<Suspense>` um das Formular |
|
||||
| Der Build brach bei `wetter.ts` ab | Der Hallenmonitor ist eine Client-Komponente und importierte die Warnstufentexte aus einem `server-only`-Modul | Texte nach `lib/integrations/wetter-texte.ts`, dieselbe Trennung wie bei `qr.ts` / `qr-parser.ts` |
|
||||
| Der Outbox-Test lief ins Leere | `speicherVerfuegbar()` fragt `window`, und die Testsuite läuft in Node | Diese Datei unter jsdom (`@vitest-environment`) mit `fake-indexeddb` |
|
||||
| Das Einrichtungsformular ließ sich im Test nicht abschicken | Playwright klickte, bevor der Effekt die Felder aus der Adresse gefüllt hatte — das Pflichtfeld war leer | Im Test auf die Feldwerte warten |
|
||||
| Der Idempotenztest sah in der Verwaltung kein Anmeldeformular | Im selben Browserkontext lief noch die Terminalsitzung, und `/anmelden` leitet Angemeldete weiter | Prüfung in einen eigenen Test mit frischem Kontext |
|
||||
| `getByText("B 3 Wohnhausbrand")` in den Einsatztests wurde mehrdeutig | Frühere E2E-Läufe haben Einsätze mit ähnlichem Stichwort in der Demowehr angelegt | `exact: true`; die eigentliche Ursache bleibt offen (siehe unten) |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**Kein Auto-Logout nach 90 Sekunden.** Die Roadmap nennt 90 Sekunden, die
|
||||
Konfiguration hatte schon `KIOSK_IDLE_TIMEOUT=120`. Der konfigurierte Wert
|
||||
gewinnt — 90 Sekunden sind knapp, wenn jemand mitten in einer Mangelmeldung
|
||||
noch das Foto machen muss. Die Roadmap ist entsprechend berichtigt.
|
||||
|
||||
**`npm run lint` läuft in diesem Projekt nicht.** Es gibt keine
|
||||
ESLint-Konfiguration; `next lint` fragt interaktiv nach einer. Das ist älter als
|
||||
diese Phase und in ihr nicht behoben — es würde quer über alle bisherigen
|
||||
Dateien Befunde erzeugen und gehört in einen eigenen Schritt.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npx tsc --noEmit` — ohne Ausgabe
|
||||
- `npm run test` — 258 Tests grün, davon 19 gegen eine echte Datenbank
|
||||
- `npx playwright test tests/e2e/kiosk.spec.ts` — 18 Tests grün (Desktop und Tablet)
|
||||
- `npm run build` — 34 Routen, 103 kB gemeinsames JavaScript
|
||||
- `npm run db:seed` — Fahrtenbuch, Anwesenheiten und Wäsche angelegt
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 · Phase 3 — Einsätze und Übungen
|
||||
|
||||
**12:25 – 14:35 · 2:10 h · Version 0.4.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Ab hier dokumentiert die Wehr, was sie tut. Acht neue Tabellen, ein öffentlich
|
||||
erreichbares Portal und die Zahlen, nach denen die Gemeinde fragt.
|
||||
|
||||
**Schnellerfassung.** Im Alarmfall fragt FLORIAN drei Dinge: Stichwort, Ort,
|
||||
welche Fahrzeuge raus sind. Alarmzeit ist der Moment des Anlegens. Alles
|
||||
Weitere wird nachgetragen, wenn wieder Ruhe ist.
|
||||
|
||||
**Zeitstempel und Kennzahlen.** Sechs Stempel, je ein Knopf. Daraus rechnet
|
||||
sich Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
|
||||
|
||||
**Stärke nach FwDV 3.** Aus den Funktionen der Mannschaft, in der üblichen
|
||||
Schreibweise, mit Zuordnung zur taktischen Einheit.
|
||||
|
||||
**Einsatzbericht.** Als PDF über Gotenberg, mit der Freigabe festgeschrieben.
|
||||
Dazu der Jahresexport quer gedruckt für die Gemeinde.
|
||||
|
||||
**Statistik.** Kategorien nach Landesstatistik, Verteilung der Hilfsfristen mit
|
||||
dem 80-Prozent-Wert, Beteiligung je Person, Auslastung je Fahrzeug.
|
||||
|
||||
**Portal für Fremdwehren.** Ein signierter Link ohne Anmeldung: Stand sehen,
|
||||
Lagemeldung mit Foto absetzen. Zeitlich begrenzt, mengenbegrenzt, widerrufbar.
|
||||
|
||||
**Übungen.** Thema, Lernziele, Anwesenheit zum Abhaken — die Grundlage für die
|
||||
Teilnahmequote in der Jahreshauptversammlung.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Die Hilfsfrist wird als 80-Prozent-Wert ausgewiesen, nicht als Durchschnitt.**
|
||||
Der Durchschnitt sagt bei Eintreffzeiten nichts: ein einzelner Einsatz auf der
|
||||
Autobahn zieht ihn nach oben, während neunzig Prozent der Fälle im Ort liegen.
|
||||
In Bayern gilt als Ziel, in 80 Prozent der Fälle binnen zehn Minuten da zu
|
||||
sein — also ist genau das die Zahl.
|
||||
|
||||
**Hinweise statt Sperren bei Zeiten und Stärke.** Eine verdrehte Reihenfolge
|
||||
oder eine fehlende Führungskraft wird angezeigt, aber nicht verhindert.
|
||||
Einsätze werden Tage später aus dem Gedächtnis nachgetragen; ein Einsatz mit
|
||||
unsicheren Zeiten ist immer noch besser als einer, den niemand erfasst, weil
|
||||
das Formular ihn nicht angenommen hat.
|
||||
|
||||
**Der freigegebene Bericht ist unveränderbar — Lagemeldungen nicht.** Der
|
||||
Bericht geht an die Gemeinde, die Meldungen sind das Protokoll des Verlaufs.
|
||||
Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
|
||||
geschrieben ist; sie nachträglich abzuweisen wäre falsch.
|
||||
|
||||
**Das Portal zeigt bewusst wenig.** Stichwort, Ort, Zeitstempel und Meldungen —
|
||||
also das, was an der Einsatzstelle ohnehin jeder sieht. Keine Namen von
|
||||
Einsatzkräften, kein Bericht, keine Navigation. Der Link ist ein Fenster auf
|
||||
einen Einsatz, keine Tür in die Anwendung.
|
||||
|
||||
**Koordinaten liegen doppelt.** Als Dezimalgrad, die Prisma lesen kann, und als
|
||||
PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides zusammen —
|
||||
sonst wäre jede Schreiboperation Roh-SQL geworden, und ein über einen anderen
|
||||
Weg angelegter Einsatz fehlte später auf der Karte.
|
||||
|
||||
**Geocoding nur gegen einen eigenen Nominatim.** Einsatzadressen sagen, wo es
|
||||
gebrannt hat. Solche Anfragen gehören nicht an einen fremden Dienst — und die
|
||||
Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin nicht.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Prisma wollte wieder die Suchindizes löschen | wie in Phase 2: gin_trgm und gist kennt das Schema nicht | DROP INDEX-Zeilen aus der erzeugten Migration entfernt, mit Kommentar |
|
||||
| Der Prisma-Client ließ sich nicht neu erzeugen | die Engine-DLL war vom laufenden Entwicklungsserver gesperrt | Server und Worker beenden, dann erzeugen — unter Windows unvermeidlich |
|
||||
| Die Anwesenheitsliste blieb leer | sie forderte 300 Mitglieder an, erlaubt sind 200 je Seite | auf 200 gesetzt; aufgefallen in der Server-Ausgabe während der E2E-Tests |
|
||||
| Zwei E2E-Tests schlugen im Gesamtlauf fehl, einzeln aber nicht | die erste Übersetzung einer Route dauert unter Last länger als das Erwartungslimit | Limit von 15 auf 25 Sekunden — Wartezeit des Entwicklungsservers, kein Fehler der Anwendung |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**Keine.** Alle acht Tabellen tragen `organisationId` und stehen unter Row
|
||||
Level Security, jede schreibende Operation protokolliert, gelöscht wird weich.
|
||||
|
||||
Eine Ergänzung: die Portalprozeduren sind die **ersten öffentlich erreichbaren
|
||||
Endpunkte** in FLORIAN. Sie prüfen den Token selbst, laufen für die Suche des
|
||||
Zugangs bewusst ohne Mandantenfilter (begründet im Code) und arbeiten danach im
|
||||
Mandantenkontext der gefundenen Wehr.
|
||||
|
||||
### Offen und bewusst so gelassen
|
||||
|
||||
Die Zuordnung der Funktionen zur Stärke ist **regional unterschiedlich**. Hier
|
||||
zählt der Maschinist zur Mannschaft und nur der Truppführer als Unterführer;
|
||||
eine Gruppe ergibt damit 1/3/5/9. Andere Wehren rechnen anders. Die Zuordnung
|
||||
steht an einer Stelle in `lib/einsatz/staerke.ts` und ist leicht zu ändern —
|
||||
bestätigt ist sie noch nicht.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npm run typecheck` — ohne Ausgabe
|
||||
- `npm run test` — 207 Tests grün, davon 32 gegen eine echte Datenbank
|
||||
- `npm run test:e2e` — 95 Tests grün, 2 übersprungen
|
||||
- `npm run build` — 38 Routen
|
||||
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 · Phase 2 — Wartungs- und Prüfmanagement
|
||||
|
||||
**09:55 – 12:20 · 2:25 h · Version 0.3.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt. Ab hier
|
||||
ersetzt FLORIAN den Ordner mit den Prüfnachweisen.
|
||||
|
||||
**Datenmodell.** Elf neue Tabellen: Wartungsvorlage und Checklistenpunkt als
|
||||
Regel, Wartungstermin als deren Anwendung auf ein einzelnes Gerät, Prüfung mit
|
||||
Einzelergebnissen und Unterschriften als Nachweis, dazu Mangel, Rückruf mit
|
||||
betroffener Bauart und je Gerät ein Bearbeitungsstand, und schließlich
|
||||
Erinnerung als Nachweis und Sperre gegen Doppelversand in einem.
|
||||
|
||||
**Fristen-Dashboard.** Vier Töpfe nach Dringlichkeit, jede Zahl gleichzeitig
|
||||
ein Filter, darunter die Verteilung nach Fahrzeug, Standort, Gerätetyp oder
|
||||
Zuständigem. Daneben die Lebensdauerüberwachung mit Aussonderungsvorschlag.
|
||||
|
||||
**Prüfwizard.** Ein Punkt je Bildschirm, Foto aus der Kamera, Zeiterfassung im
|
||||
Hintergrund, Unterschrift auf einem Zeichenfeld. Jede Antwort geht sofort zum
|
||||
Server; ein abgebrochener Durchgang wird beim nächsten Öffnen fortgesetzt.
|
||||
|
||||
**Protokoll.** Der Worker rendert über Gotenberg ein PDF mit Kopf, Checkliste,
|
||||
eingebetteten Fotos und Unterschriften und legt es in MinIO ab. Laufende
|
||||
Nummer je Wehr und Jahr.
|
||||
|
||||
**Nächtlicher Lauf.** Termine planen, Gerätestatus nachziehen, erinnern bei
|
||||
T-30, T-7, T-0 und einmal bei Überfälligkeit — als Sammelnachricht je
|
||||
Empfänger.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Die Unveränderbarkeit sitzt in der Datenbank, nicht im Code.** Ein Trigger
|
||||
weist jede Änderung an einer abgeschlossenen Prüfung ab, auch für den
|
||||
Eigentümer-Zugang. Zulässig bleiben drei Dinge, die das Protokoll ergänzen
|
||||
statt es zu verfälschen: der Wechsel auf „ersetzt", das Nachtragen des PDF,
|
||||
solange keines hinterlegt ist, und `geaendertAm`. Dasselbe gilt für die
|
||||
Bestandteile — sonst ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||||
der Einzelergebnisse verändern.
|
||||
|
||||
Der Grund ist derselbe wie beim Audit-Log in Phase 0: ein vergessener
|
||||
Zustandscheck in einem Router reicht sonst, um den Nachweis wertlos zu machen.
|
||||
|
||||
**„Überfällig" wird gerechnet, nicht gespeichert.** Ein gespeicherter Zustand
|
||||
läuft der Uhr hinterher — ein Termin, der heute Nacht fällig geworden ist,
|
||||
stünde bis zum nächsten Lauf als „offen" da. Aus demselben Grund kennt
|
||||
`WartungsterminStatus` nur offen, erledigt und entfallen.
|
||||
|
||||
**Pflichtpunkt heißt: davon hängt die Einsatzbereitschaft ab.** Ein
|
||||
beanstandeter Pflichtpunkt bedeutet „nicht bestanden" und sperrt das Gerät, ein
|
||||
beanstandeter Kann-Punkt „bestanden mit Mängeln". Damit ist das Ergebnis eine
|
||||
fachliche Aussage und keine Zählübung. Übersteuern darf der Prüfer trotzdem —
|
||||
er trägt die Verantwortung, nicht die Software.
|
||||
|
||||
**Bestanden hebt eine bestehende Sperre nicht auf.** Wer ein Gerät gesperrt
|
||||
hat, hatte einen Grund, und der kann außerhalb dieser Prüfung liegen. Von
|
||||
selbst löst sich nur „Prüfung fällig".
|
||||
|
||||
**Melden darf jedes Mitglied.** Der Mangel fällt dem auf, der das Gerät
|
||||
benutzt. Eine Hürde beim Melden führt dazu, dass gar nicht gemeldet wird — und
|
||||
ein nicht gemeldeter Riss im Schlauch ist gefährlicher als eine Fehlmeldung,
|
||||
die jemand wieder verwirft.
|
||||
|
||||
**Die Rückrufsuche ist ein Vorschlag.** Seriennummern werden als Text
|
||||
verglichen; das stimmt bei gleicher Stellenzahl und sonst nicht immer. Eine
|
||||
automatische Sperre auf dieser Grundlage wäre gefährlicher als hilfreich.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Prisma wollte die Suchindizes aus Phase 1 löschen | gin_trgm- und gist-Indizes kennt das Schema nicht | die `DROP INDEX`-Zeilen aus der erzeugten Migration entfernt, mit Kommentar für den nächsten, der sie neu erzeugt |
|
||||
| Der Seed brach beim Beispielprotokoll ab | er legte die Prüfung fertig an, und der Trigger verbot die Ergebnisse dazu | erst Entwurf, dann abschließen — genau wie im Wizard |
|
||||
| Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer | die Nummer kam aus einer Zählung ohne Sperre | Transaktionssperre je Wehr und Jahr, Nummer aus dem Höchstwert statt aus der Anzahl |
|
||||
| Der Worker startete außerhalb des Containers nicht | `.env` wurde nicht geladen, und `server-only` wirft unter Node | `dotenv/config` und `--conditions=react-server` in beiden Startbefehlen |
|
||||
| Der Wizard-Test klickte ins Leere | die Mauskoordinaten beziehen sich auf das Fenster, das Zeichenfeld lag darunter | vor dem Zeichnen ins Bild holen |
|
||||
| Zwei E2E-Projekte teilten sich denselben Prüfentwurf | Desktop und Tablet laufen parallel unter demselben Benutzer | je Projekt ein eigenes Gerät |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**Keine.** Alle elf Tabellen tragen `organisationId` und stehen unter Row Level
|
||||
Security, jede schreibende Operation protokolliert, gelöscht wird weich.
|
||||
|
||||
Eine Ergänzung zur Regel „Prüfdaten werden nie hart gelöscht": ein
|
||||
**Prüfentwurf** darf verschwinden. Er ist noch kein Nachweis, sondern ein
|
||||
abgebrochener Vorgang; ihn aufzuheben würde die Liste der offenen Prüfungen
|
||||
zumüllen. Ab dem Abschluss greift die Sperre.
|
||||
|
||||
### Was aufgefallen ist
|
||||
|
||||
Der Navigationseintrag „Protokoll" (`/protokoll`) zeigt seit Phase 1 auf eine
|
||||
Seite, die es nicht gibt. Das Audit-Log ist über tRPC abrufbar, hat aber keine
|
||||
Ansicht. Gehört in ein eigenes Issue.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npm run typecheck` — ohne Ausgabe
|
||||
- `npm run test` — 161 Tests grün, davon 18 gegen eine echte Datenbank
|
||||
- `npm run test:e2e` — 71 Tests grün, 2 begründet übersprungen (Desktop und Tablet)
|
||||
- `npm run build` — 30 Routen
|
||||
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund; Worker
|
||||
richtet die Zeitpläne ein und erzeugt die Protokoll-PDFs in gut einer Sekunde
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 · Phase 1 — Nachtrag: Geräteformular
|
||||
|
||||
**08:30 – 09:20 · 0:50 h**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Die letzte offene Lücke aus Phase 1: ein Formular zum Anlegen und Bearbeiten
|
||||
eines einzelnen Geräts. Bis dahin konnte man Geräte nur über den Import
|
||||
erfassen — für einen Bestand richtig, für den einen nachbeschafften Gerät
|
||||
umständlich.
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `components/stammdaten/geraet-formular.tsx` | das Formular, geteilt von Anlegen und Bearbeiten |
|
||||
| `components/stammdaten/geraet-formular-werte.ts` | Typ und Vorgabewerte, bewusst ohne `"use client"` |
|
||||
| `components/ui/textarea.tsx` | fehlender Baustein |
|
||||
| `app/(app)/geraete/neu/page.tsx` | Anlegen |
|
||||
| `app/(app)/geraete/[id]/bearbeiten/page.tsx` | Bearbeiten |
|
||||
| `tests/e2e/geraet-formular.spec.ts` | 7 Tests |
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Die Lagerung ist ein Umschalter, keine zwei Auswahlfelder.** Standort und
|
||||
Fahrzeug schließen sich aus. Zwei nebeneinanderstehende Felder laden dazu ein,
|
||||
beide zu füllen; die Datenbank weist das ab, aber eine Fehlermeldung nach dem
|
||||
Absenden ist schlechter als ein Formular, in dem der Fehler gar nicht möglich
|
||||
ist. Beim Wechsel des Fahrzeugs wird der Geräteraum geleert — er gehört zum
|
||||
Fahrzeug.
|
||||
|
||||
**Die Fristen werden schon beim Tippen vorgerechnet.** Wer ein Prüfintervall
|
||||
einträgt, sieht sofort, wann die nächste Prüfung fällig wird. Gerechnet wird
|
||||
mit denselben Funktionen wie auf dem Server; beim Speichern rechnet der Server
|
||||
erneut und behält das letzte Wort. Die Vorschau ist Anzeige, keine Quelle.
|
||||
|
||||
**Bei nur einem Standort wird er vorbelegt.** Das ist bei den meisten Wehren
|
||||
der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Formular stürzte beim ersten Rechnen ab | `LEERES_GERAET` lag im `"use client"`-Modul; eine Server-Komponente bekommt beim Import nur eine Client-Referenz, das Spreaden ergibt `undefined` | Werte in ein eigenes Modul ohne `"use client"` |
|
||||
| E2E fand die Felder nicht | `getByLabel` vergleicht den Text des `<label>`, nicht den zugänglichen Namen — am Pflichtfeld steht dort ein Sternchen | Tests suchen über die Rolle; das Sternchen bekam zusätzlich `aria-hidden`, weil es sonst vorgelesen wird |
|
||||
| `fill()` blieb bei Datumsfeldern wirkungslos | Chromium nimmt bei `input[type=date]` keinen gesetzten Wert über `fill()` an | Datum wird getippt, wie es ein Mensch auch täte |
|
||||
| Zwei Tests scheiterten unter Touch-Emulation | dort öffnet Chromium den Auswähler des Betriebssystems statt Zeichen anzunehmen | begründet übersprungen; die Rechnung selbst deckt der Unit-Test ab |
|
||||
| Anmeldung im Screenshot-Skript lief ins Zeitlimit | bei kaltem `.next` übersetzt `next dev` die Route erst beim ersten Aufruf | Zeitlimit von 20 auf 90 Sekunden |
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npm run typecheck` — ohne Ausgabe
|
||||
- `npm run test` — 104 Tests grün
|
||||
- `npm run test:e2e` — 56 grün, 2 begründet übersprungen
|
||||
- `npm run build` — 19 Routen
|
||||
- Testgeräte aus dem E2E-Lauf wurden aus der Entwicklungsdatenbank entfernt
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 · Phase 1 — Stammdaten
|
||||
|
||||
**07:10 – 08:25 · 1:15 h · Version 0.2.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
41 neue Quelldateien, der Umfang wächst von rund 6.100 auf 15.600 Zeilen.
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|---|---|
|
||||
| Datenmodell | 5 neue Tabellen (Standort, Geraeteraum, Geraetetyp, Qualifikationstyp, Qualifikation), Fahrzeug und Geraet deutlich erweitert, 2 Migrationen |
|
||||
| Fachlogik | `fristen.ts` (Prüftermine, Lebensdauer, Statusableitung), `qr.ts` und `qr-parser.ts`, `etiketten.ts`, `pdf.ts`, `import.ts` und `import-felder.ts` |
|
||||
| Server | 8 tRPC-Router mit Liste, Detail, Anlegen, Ändern, Massenbearbeitung und Verlauf |
|
||||
| Oberfläche | Geräteliste mit Suche und Spaltenauswahl, Gerätedetail mit QR, Fahrzeugliste und -detail mit Beladung, Mitgliederliste, Mitgliedsdetail, Qualifikationen, Import-Assistent, QR-Scanner |
|
||||
| Daten | Seed erweitert: 3 Standorte, 20 Geräteräume, 14 Gerätetypen, 49 Geräte, 14 Qualifikationstypen, 107 Qualifikationen |
|
||||
| Tests | 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10) |
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Der Gerätetyp ist Vorgabe, das Gerät darf abweichen.** Vierzig Pressluftatmer
|
||||
haben dasselbe Prüfintervall — das gehört einmal an den Typ und nicht
|
||||
vierzigmal an das Gerät. Wo der Hersteller für eine Charge etwas anderes
|
||||
vorschreibt, gewinnt der Wert am Gerät. Umgesetzt in `effektiverWert()`, das
|
||||
bewusst zwischen `null` und `0` unterscheidet: ein Intervall von 0 ist eine
|
||||
Angabe, kein fehlender Wert.
|
||||
|
||||
**Ein Gerät liegt entweder an einem Standort oder auf einem Fahrzeug.** Beides
|
||||
gleichzeitig wäre eine Falschangabe, keines von beidem auch. Das steht als
|
||||
Prüfbedingung in der Datenbank, nicht nur im Formular — und ein Trigger prüft
|
||||
zusätzlich, dass ein angegebener Geräteraum wirklich zu dem angegebenen
|
||||
Fahrzeug gehört. Das ließe sich als CHECK nicht ausdrücken.
|
||||
|
||||
**Der QR-Code enthält die Inventarnummer, nicht die UUID.** Eine UUID wäre
|
||||
36 Zeichen lang; der Code bekäme ein feines Muster, und auf einem 38 × 21 mm
|
||||
großen Etikett im staubigen Geräteraum wird das unzuverlässig. Die
|
||||
Inventarnummer steht ohnehin lesbar daneben, und wer den Code mit einem
|
||||
beliebigen Scanner liest, sieht sofort, um welches Gerät es geht.
|
||||
|
||||
**Der Import liest die Datei zweimal.** Die Vorschau zeigt, was passieren
|
||||
würde; beim Übernehmen wird die Datei erneut hochgeladen und serverseitig neu
|
||||
eingelesen. Es wandert kein einziger Wert aus dem Browser in die Datenbank.
|
||||
Der Preis ist eine zweite Übertragung — bei den Größen, um die es geht, kein
|
||||
Thema.
|
||||
|
||||
**Der Etikettenbogen kann an beliebiger Position beginnen.** Wer drei Etiketten
|
||||
braucht, soll nicht einen ganzen Bogen opfern.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Fristen um einen Tag verschoben | `startOfDay` aus date-fns rechnet in der Zeitzone des Servers; ein DATE aus der Datenbank kommt als UTC-Mitternacht und landete dadurch auf dem Vortag | eigene Kalenderarithmetik auf UTC-Bestandteilen, „heute“ über Europe/Berlin bestimmt |
|
||||
| `ctx.sitzung` blieb nullbar | `t.middleware()` erzeugt eine Middleware auf dem Wurzelkontext; die Verengung aus der vorigen Middleware ging verloren | Middleware direkt als Funktion an `.use()` übergeben |
|
||||
| Import-Tests brachen beim Laden ab | `import "server-only"` wirft beim blossen Import, und Vitest ist weder Server noch Browser | im Testlauf durch ein leeres Modul ersetzt (`tests/stubs/server-only.ts`) |
|
||||
| 15 E2E-Tests im Zeitlimit | `next dev` übersetzt jede Route erst beim ersten Aufruf; zu viele parallele Erstaufrufe | Worker auf 4 begrenzt, Zeitlimit angehoben |
|
||||
| 5 weitere E2E-Tests rot | `CardTitle` war ein `div` und damit keine Überschrift | in der Komponente auf ein echtes `h3` umgestellt — behebt zugleich einen Zugänglichkeitsmangel |
|
||||
| Erste Bilder zeigten Ladebalken | „networkidle“ greift zu früh: die Listen holen ihre Daten erst nach, wenn React im Browser gelaufen ist | Screenshot-Skript wartet, bis keine Ladeanzeige mehr im Baum ist |
|
||||
| Demodaten inkonsistent | die 40 Geräte aus Phase 0 hatten keinen Gerätetyp, und der Seed ist idempotent | Geräte-Zeilen der Entwicklungsdatenbank gezielt geleert und neu eingespielt |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**`prisma migrate reset` wurde nicht ausgeführt.** Der naheliegende Weg, die
|
||||
Demodaten sauber neu aufzubauen, verlangt eine ausdrückliche Freigabe — Prisma
|
||||
verweigert den Befehl gegenüber einem KI-Agenten ohne Zustimmung. Statt zu
|
||||
fragen wurde der schmalere Weg gewählt: nur die Tabelle `geraet` der
|
||||
Entwicklungsdatenbank geleert und neu eingespielt.
|
||||
|
||||
**Kein eigenes Formular zum Anlegen von Geräten.** Die Router können anlegen,
|
||||
ändern, umlagern und aussondern; die Oberfläche dazu deckt Liste, Detail,
|
||||
Massenbearbeitung und Import ab. Ein einzelnes Anlegeformular fehlt noch — in
|
||||
der Praxis kommen Bestände als Tabelle herein, und einzelne Neuzugänge lassen
|
||||
sich bis dahin über den Import erfassen. Nachzuholen in Phase 2, wo ohnehin
|
||||
Formulare für Prüfungen entstehen.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npm run typecheck` — ohne Ausgabe
|
||||
- `npm run test` — 104 Tests grün, davon 8 gegen eine echte Datenbank
|
||||
- `npm run test:e2e` — 44 Tests grün (Desktop und Tablet)
|
||||
- `npm run build` — 17 Routen, 103 kB gemeinsames JavaScript
|
||||
- `npm run screenshots` — 18 Bilder, hell und dunkel
|
||||
- Keine Konsolenfehler und keine Hydratationswarnungen im Browser
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
|
||||
|
||||
```
|
||||
Phase 0 ████████████████████ fertig 0.1.0
|
||||
Phase 1 ░░░░░░░░░░░░░░░░░░░░ offen 0.2.0
|
||||
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0
|
||||
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
|
||||
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
|
||||
Phase 1 ████████████████████ fertig 0.2.0
|
||||
Phase 2 ████████████████████ fertig 0.3.0
|
||||
Phase 3 ████████████████████ fertig 0.4.0
|
||||
Phase 4 ████████████████████ fertig 0.5.0
|
||||
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
|
||||
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
||||
@@ -39,7 +39,7 @@ Alles, worauf der Rest aufsetzt.
|
||||
- Basis-Layout mit Seitenleiste und dunkler Darstellung
|
||||
- Seed mit einer vollständigen Beispielwehr
|
||||
|
||||
## Phase 1 — Stammdaten · als Nächstes
|
||||
## Phase 1 — Stammdaten · fertig
|
||||
|
||||
Die Wehr trägt ein, was sie hat.
|
||||
|
||||
@@ -53,27 +53,37 @@ Die Wehr trägt ein, was sie hat.
|
||||
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
|
||||
- Gerätehistorie aus dem Audit-Log
|
||||
- Qualifikationen mit Ablaufwarnung auf der Übersicht
|
||||
- Formular zum Anlegen und Bearbeiten eines einzelnen Geräts, mit
|
||||
Fristenvorschau beim Ausfüllen
|
||||
|
||||
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
|
||||
QR-Code am Regal wiederfinden.
|
||||
|
||||
## Phase 2 — Wartung und Prüfung
|
||||
## Phase 2 — Wartung und Prüfung · fertig
|
||||
|
||||
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
|
||||
|
||||
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
|
||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage
|
||||
- Nächtlicher Job erzeugt Termine, setzt Status, erinnert bei T-30, T-7 und T-0
|
||||
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift
|
||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO
|
||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage,
|
||||
gebündelt nach Fahrzeug, Standort, Gerätetyp oder Zuständigem
|
||||
- Nächtlicher Lauf erzeugt Termine, zieht den Gerätestatus nach und erinnert bei
|
||||
T-30, T-7, T-0 und einmal bei Überfälligkeit
|
||||
- Prüfwizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift auf
|
||||
dem Zeichenfeld
|
||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO — die Sperre setzt
|
||||
ein Datenbanktrigger durch, nicht der Anwendungscode
|
||||
- Korrekturen erzeugen ein neues Protokoll mit Verweis auf das alte
|
||||
- Lebensdauerüberwachung mit Aussonderungsvorschlag
|
||||
- Rückrufe markieren betroffene Geräte automatisch
|
||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben
|
||||
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum
|
||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben, mit
|
||||
Gerätesperre bei kritischen Mängeln
|
||||
- Externe Wartung: Fachfirma, Kosten und Fremdprotokoll als PDF
|
||||
|
||||
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
|
||||
die Unfallkasse standhält.
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
## Phase 3 — Einsätze und Übungen · fertig
|
||||
|
||||
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
|
||||
Einsatzende, eingerückt, einsatzbereit
|
||||
@@ -87,21 +97,26 @@ die Unfallkasse standhält.
|
||||
|
||||
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
|
||||
|
||||
## Phase 4 — Kiosk-PWA
|
||||
## Phase 4 — Kiosk-PWA · fertig
|
||||
|
||||
Für die Wandtablets in der Fahrzeughalle.
|
||||
|
||||
- Eigenes Layout: große Kacheln, mindestens 64px, mit Handschuhen bedienbar
|
||||
- Anmeldung per RFID, PIN oder QR vom Handy, Auto-Logout nach 90 Sekunden
|
||||
- Wizards: Wartung, Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit
|
||||
- Anmeldung per RFID, PIN oder QR vom Handy, Abmeldung nach Untätigkeit
|
||||
(`KIOSK_IDLE_TIMEOUT`, Vorgabe 120 Sekunden)
|
||||
- Wizards: Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit. Die
|
||||
Prüfung bleibt beim Wizard aus Phase 2 — Checkliste, Fotos und Unterschrift
|
||||
lassen sich nicht sinnvoll in eine Warteschlange legen; die Kachel sagt
|
||||
„braucht Netz"
|
||||
- Hallen-Monitor: Vollbild mit Terminen, überfälligen Prüfungen,
|
||||
Fahrzeugstatus, Wetterwarnung
|
||||
- Offline-Betrieb mit IndexedDB-Outbox und Synchronisation bei Reconnect
|
||||
- Offline-Betrieb mit IndexedDB-Outbox, Idempotenzschlüssel und
|
||||
Konfliktliste für alles, was sich beim Nachtragen nicht anwenden lässt
|
||||
|
||||
**Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den
|
||||
Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist.
|
||||
|
||||
## Phase 5 — Atemschutz nach FwDV 7
|
||||
## Phase 5 — Atemschutz nach FwDV 7 · als Nächstes
|
||||
|
||||
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
|
||||
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 733 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 968 KiB |
|
After Width: | Height: | Size: 1013 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 555 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -8,6 +8,9 @@ const nextConfig: NextConfig = {
|
||||
images: { remotePatterns: [] },
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
// Die Entwicklungsanzeige unten links wuerde in den Dokumentationsbildern
|
||||
// mitlaufen. Sie betrifft ohnehin nur "next dev".
|
||||
devIndicators: false,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"engines": {
|
||||
@@ -10,8 +10,8 @@
|
||||
"dev": "next dev -p 3100",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3100",
|
||||
"worker": "tsx src/worker/index.ts",
|
||||
"worker:dev": "tsx watch src/worker/index.ts",
|
||||
"worker": "tsx --conditions=react-server src/worker/index.ts",
|
||||
"worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -23,31 +23,45 @@
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"screenshots": "tsx scripts/screenshots.ts"
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"icons": "tsx scripts/icons.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-table": "^9.2.4",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@zxing/browser": "^0.2.1",
|
||||
"@zxing/library": "^0.23.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.61.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"dexie": "^4.4.5",
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.8.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^15.5.4",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
@@ -61,6 +75,8 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^22.18.6",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
@@ -68,6 +84,7 @@
|
||||
"esbuild": "^0.25.10",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-next": "^15.5.4",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^27.0.0",
|
||||
"prisma": "^6.16.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
|
||||
@@ -13,11 +13,34 @@ export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/**
|
||||
* Ein Wiederholungsversuch auch lokal.
|
||||
*
|
||||
* Der Grund ist derselbe wie bei den großzügigen Zeitlimits unten: "next
|
||||
* dev" übersetzt jede Route beim ersten Aufruf, und mit vier gleichzeitigen
|
||||
* Arbeitern kommt dazu die Anmeldung mit bcrypt (12 Runden, wie in
|
||||
* Produktion). Unter dieser Last läuft gelegentlich ein Test ins Zeitlimit,
|
||||
* der einzeln zuverlässig grün ist — verstreut über alle Dateien, nicht an
|
||||
* einer Stelle. Ein echter Fehler fällt zweimal durch und bleibt sichtbar.
|
||||
*/
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
/**
|
||||
* Bewusst wenige Worker: "next dev" übersetzt jede Route erst beim ersten
|
||||
* Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange
|
||||
* kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann
|
||||
* nach einem Fehler in der Anwendung aus, ist aber keiner.
|
||||
*/
|
||||
workers: process.env.CI ? 1 : 4,
|
||||
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
|
||||
timeout: 90_000,
|
||||
/**
|
||||
* Ab Phase 3 großzügiger: mit fünfzig Routen dauert die erste Übersetzung
|
||||
* einer bisher unbesuchten Seite unter Last auch mal zwanzig Sekunden. Das
|
||||
* sind Wartezeiten des Entwicklungsservers, keine der Anwendung — vorher
|
||||
* schlugen dadurch gelegentlich Tests fehl, die einzeln laufen und grün sind.
|
||||
*/
|
||||
expect: { timeout: 25_000 },
|
||||
|
||||
use: {
|
||||
baseURL: BASIS_URL,
|
||||
@@ -44,6 +67,6 @@ export default defineConfig({
|
||||
command: "npm run dev",
|
||||
url: BASIS_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
timeout: 180_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StandortArt" AS ENUM ('GERAETEHAUS', 'LAGER', 'AUSSENSTELLE', 'WERKSTATT', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GeraeteKategorie" AS ENUM ('ATEMSCHUTZ', 'BELEUCHTUNG', 'BRANDBEKAEMPFUNG', 'ELEKTRO', 'FUNK', 'HYGIENE', 'MEDIZIN', 'MESSTECHNIK', 'PERSOENLICHE_SCHUTZAUSRUESTUNG', 'RETTUNG', 'SCHLAUCHMATERIAL', 'TECHNISCHE_HILFELEISTUNG', 'UMWELTSCHUTZ', 'WASSERFOERDERUNG', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LebensdauerKategorie" AS ENUM ('KEINE', 'HELM', 'SCHUTZKLEIDUNG', 'HANDSCHUHE', 'STIEFEL', 'SEIL_GURT', 'PRESSLUFTATMER', 'ATEMANSCHLUSS', 'DRUCKBEHAELTER', 'SCHLAUCH', 'LEITER', 'SONSTIGE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LebensdauerBezug" AS ENUM ('HERSTELLUNG', 'INDIENSTSTELLUNG');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "QualifikationsArt" AS ENUM ('LEHRGANG', 'BEFAEHIGUNG', 'UNTERSUCHUNG', 'FUEHRERSCHEIN', 'UNTERWEISUNG', 'SONSTIGES');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "fahrzeug" ADD COLUMN "aufbauhersteller" VARCHAR(120),
|
||||
ADD COLUMN "bemerkung" VARCHAR(1000),
|
||||
ADD COLUMN "dinTyp" VARCHAR(40),
|
||||
ADD COLUMN "erstzulassung" DATE,
|
||||
ADD COLUMN "fahrgestellnummer" VARCHAR(30),
|
||||
ADD COLUMN "gesamtmasse" INTEGER,
|
||||
ADD COLUMN "indienststellung" DATE,
|
||||
ADD COLUMN "kilometerstand" INTEGER,
|
||||
ADD COLUMN "naechsteWartung" DATE,
|
||||
ADD COLUMN "sitzplaetze" INTEGER,
|
||||
ADD COLUMN "standortId" UUID,
|
||||
ADD COLUMN "tankinhalt" INTEGER,
|
||||
ADD COLUMN "uvvBis" DATE;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "geraet" ADD COLUMN "anschaffungspreis" DECIMAL(12,2),
|
||||
ADD COLUMN "ausgesondertAm" DATE,
|
||||
ADD COLUMN "ausgesondertGrund" VARCHAR(500),
|
||||
ADD COLUMN "bemerkung" VARCHAR(1000),
|
||||
ADD COLUMN "geraeteraumId" UUID,
|
||||
ADD COLUMN "geraetetypId" UUID,
|
||||
ADD COLUMN "lebensdauerBis" DATE,
|
||||
ADD COLUMN "lebensdauerKategorie" "LebensdauerKategorie",
|
||||
ADD COLUMN "letztePruefung" DATE,
|
||||
ADD COLUMN "modell" VARCHAR(120),
|
||||
ADD COLUMN "standortId" UUID;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "standort" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"kuerzel" VARCHAR(20),
|
||||
"art" "StandortArt" NOT NULL DEFAULT 'GERAETEHAUS',
|
||||
"strasse" VARCHAR(200),
|
||||
"plz" VARCHAR(10),
|
||||
"ort" VARCHAR(120),
|
||||
"koordinaten" geography(Point, 4326),
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "standort_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "geraeteraum" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"fahrzeugId" UUID NOT NULL,
|
||||
"bezeichnung" VARCHAR(40) NOT NULL,
|
||||
"beschreibung" VARCHAR(200),
|
||||
"sortierung" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "geraeteraum_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "geraetetyp" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"kategorie" "GeraeteKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||
"hersteller" VARCHAR(120),
|
||||
"modell" VARCHAR(120),
|
||||
"pruefintervallMonate" INTEGER,
|
||||
"pruefvorschrift" VARCHAR(200),
|
||||
"lebensdauerKategorie" "LebensdauerKategorie" NOT NULL DEFAULT 'KEINE',
|
||||
"lebensdauerJahre" INTEGER,
|
||||
"lebensdauerBezug" "LebensdauerBezug" NOT NULL DEFAULT 'INDIENSTSTELLUNG',
|
||||
"vorwarnzeitMonate" INTEGER NOT NULL DEFAULT 3,
|
||||
"beschreibung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "geraetetyp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "qualifikationstyp" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"kuerzel" VARCHAR(20),
|
||||
"art" "QualifikationsArt" NOT NULL DEFAULT 'LEHRGANG',
|
||||
"gueltigkeitMonate" INTEGER,
|
||||
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 90,
|
||||
"einsatzrelevant" BOOLEAN NOT NULL DEFAULT false,
|
||||
"beschreibung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "qualifikationstyp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "qualifikation" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"personId" UUID NOT NULL,
|
||||
"typId" UUID NOT NULL,
|
||||
"erworbenAm" DATE NOT NULL,
|
||||
"gueltigBis" DATE,
|
||||
"ausstellendeStelle" VARCHAR(200),
|
||||
"nachweisnummer" VARCHAR(80),
|
||||
"urkundeDateiId" UUID,
|
||||
"bestaetigtVonId" UUID,
|
||||
"bestaetigtAm" TIMESTAMPTZ(3),
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "qualifikation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "standort_organisationId_aktiv_idx" ON "standort"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "standort_organisationId_name_key" ON "standort"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraeteraum_organisationId_fahrzeugId_idx" ON "geraeteraum"("organisationId", "fahrzeugId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "geraeteraum_fahrzeugId_bezeichnung_key" ON "geraeteraum"("fahrzeugId", "bezeichnung");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraetetyp_organisationId_kategorie_idx" ON "geraetetyp"("organisationId", "kategorie");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraetetyp_organisationId_aktiv_idx" ON "geraetetyp"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "geraetetyp_organisationId_name_key" ON "geraetetyp"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikationstyp_organisationId_aktiv_idx" ON "qualifikationstyp"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "qualifikationstyp_organisationId_name_key" ON "qualifikationstyp"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikation_organisationId_personId_idx" ON "qualifikation"("organisationId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikation_organisationId_typId_idx" ON "qualifikation"("organisationId", "typId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikation_organisationId_gueltigBis_idx" ON "qualifikation"("organisationId", "gueltigBis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "fahrzeug_organisationId_tuevBis_idx" ON "fahrzeug"("organisationId", "tuevBis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "fahrzeug_organisationId_kennzeichen_key" ON "fahrzeug"("organisationId", "kennzeichen");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_lebensdauerBis_idx" ON "geraet"("organisationId", "lebensdauerBis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_standortId_idx" ON "geraet"("organisationId", "standortId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_geraetetypId_idx" ON "geraet"("organisationId", "geraetetypId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_seriennummer_idx" ON "geraet"("organisationId", "seriennummer");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "standort" ADD CONSTRAINT "standort_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraetetyp" ADD CONSTRAINT "geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraeteraumId_fkey" FOREIGN KEY ("geraeteraumId") REFERENCES "geraeteraum"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikationstyp" ADD CONSTRAINT "qualifikationstyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_typId_fkey" FOREIGN KEY ("typId") REFERENCES "qualifikationstyp"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_bestaetigtVonId_fkey" FOREIGN KEY ("bestaetigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
-- =============================================================================
|
||||
-- Phase 1: Mandantentrennung, Lagerungsregel und Suchindizes.
|
||||
--
|
||||
-- Die vorige Migration hat die Tabellen angelegt. Ohne Row Level Security wären
|
||||
-- sie für die Anwendungsrolle offen — jede neue Tabelle muss deshalb dieselbe
|
||||
-- Behandlung bekommen wie die aus Phase 0.
|
||||
--
|
||||
-- Dazu kommen zwei Dinge, die sich nur in der Datenbank sauber durchsetzen
|
||||
-- lassen: die Regel, wo ein Gerät liegen darf, und schnelle Volltextsuche.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security für die neuen Tabellen
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'standort',
|
||||
'geraeteraum',
|
||||
'geraetetyp',
|
||||
'qualifikationstyp',
|
||||
'qualifikation'
|
||||
]
|
||||
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;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Bestandsdaten auf die neue Lagerungsregel bringen.
|
||||
--
|
||||
-- Vor Phase 1 konnte ein Gerät nur einen Freitext-Lagerort haben. Damit die
|
||||
-- Prüfbedingung weiter unten greift, braucht jedes Gerät ohne Fahrzeug jetzt
|
||||
-- einen echten Standort. Je Wehr wird dafür ein Gerätehaus angelegt, sofern
|
||||
-- noch keines existiert.
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO "standort" ("id", "organisationId", "name", "kuerzel", "art", "erstelltAm", "geaendertAm")
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
o."id",
|
||||
'Gerätehaus',
|
||||
'GH',
|
||||
'GERAETEHAUS',
|
||||
now(),
|
||||
now()
|
||||
FROM "organisation" o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "standort" s WHERE s."organisationId" = o."id"
|
||||
);
|
||||
|
||||
UPDATE "geraet" g
|
||||
SET "standortId" = (
|
||||
SELECT s."id"
|
||||
FROM "standort" s
|
||||
WHERE s."organisationId" = g."organisationId"
|
||||
AND s."art" = 'GERAETEHAUS'
|
||||
ORDER BY s."erstelltAm"
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE g."fahrzeugId" IS NULL
|
||||
AND g."standortId" IS NULL;
|
||||
|
||||
-- Fahrzeuge dem Gerätehaus zuordnen, sofern noch kein Standort gesetzt ist.
|
||||
UPDATE "fahrzeug" f
|
||||
SET "standortId" = (
|
||||
SELECT s."id"
|
||||
FROM "standort" s
|
||||
WHERE s."organisationId" = f."organisationId"
|
||||
AND s."art" = 'GERAETEHAUS'
|
||||
ORDER BY s."erstelltAm"
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE f."standortId" IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Wo ein Gerät liegen darf.
|
||||
--
|
||||
-- Entweder es steht an einem Standort — dann ist kein Fahrzeug gesetzt. Oder es
|
||||
-- ist auf einem Fahrzeug verlastet — dann ist kein Standort gesetzt, und der
|
||||
-- Geräteraum darf angegeben werden, muss aber nicht (manches liegt lose in der
|
||||
-- Kabine). Beides gleichzeitig oder gar nichts ist eine Falschangabe.
|
||||
--
|
||||
-- Dass ein angegebener Geräteraum auch wirklich zum angegebenen Fahrzeug
|
||||
-- gehört, lässt sich als CHECK nicht ausdrücken — das prüft der Trigger
|
||||
-- darunter.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_lagerung_eindeutig" CHECK (
|
||||
("standortId" IS NOT NULL AND "fahrzeugId" IS NULL AND "geraeteraumId" IS NULL)
|
||||
OR ("standortId" IS NULL AND "fahrzeugId" IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION geraeteraum_gehoert_zum_fahrzeug()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
raum_fahrzeug uuid;
|
||||
BEGIN
|
||||
IF NEW."geraeteraumId" IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT "fahrzeugId" INTO raum_fahrzeug
|
||||
FROM "geraeteraum"
|
||||
WHERE "id" = NEW."geraeteraumId";
|
||||
|
||||
IF raum_fahrzeug IS DISTINCT FROM NEW."fahrzeugId" THEN
|
||||
RAISE EXCEPTION
|
||||
'Der Geraeteraum gehoert nicht zu dem angegebenen Fahrzeug.'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER geraet_raum_pruefen
|
||||
BEFORE INSERT OR UPDATE OF "fahrzeugId", "geraeteraumId" ON "geraet"
|
||||
FOR EACH ROW EXECUTE FUNCTION geraeteraum_gehoert_zum_fahrzeug();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Volltextsuche.
|
||||
--
|
||||
-- Die Listen sollen auf Tippen reagieren, auch bei einigen tausend Geräten.
|
||||
-- pg_trgm macht ILIKE '%text%' indexfähig — ohne diese Indizes läuft jede
|
||||
-- Suche als vollständiger Tabellendurchlauf.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "geraet_suche_bezeichnung" ON "geraet" USING gin ("bezeichnung" gin_trgm_ops);
|
||||
CREATE INDEX "geraet_suche_inventarnummer" ON "geraet" USING gin ("inventarnummer" gin_trgm_ops);
|
||||
CREATE INDEX "geraet_suche_seriennummer" ON "geraet" USING gin ("seriennummer" gin_trgm_ops);
|
||||
CREATE INDEX "geraet_suche_hersteller" ON "geraet" USING gin ("hersteller" gin_trgm_ops);
|
||||
|
||||
CREATE INDEX "fahrzeug_suche_funkrufname" ON "fahrzeug" USING gin ("funkrufname" gin_trgm_ops);
|
||||
CREATE INDEX "fahrzeug_suche_bezeichnung" ON "fahrzeug" USING gin ("bezeichnung" gin_trgm_ops);
|
||||
CREATE INDEX "fahrzeug_suche_kennzeichen" ON "fahrzeug" USING gin ("kennzeichen" gin_trgm_ops);
|
||||
|
||||
CREATE INDEX "person_suche_nachname" ON "person" USING gin ("nachname" gin_trgm_ops);
|
||||
CREATE INDEX "person_suche_vorname" ON "person" USING gin ("vorname" gin_trgm_ops);
|
||||
|
||||
CREATE INDEX "geraetetyp_suche_name" ON "geraetetyp" USING gin ("name" gin_trgm_ops);
|
||||
CREATE INDEX "standort_suche_name" ON "standort" USING gin ("name" gin_trgm_ops);
|
||||
|
||||
-- Räumliche Abfragen auf Standorte, ab Phase 6 gebraucht.
|
||||
CREATE INDEX "standort_koordinaten" ON "standort" USING gist ("koordinaten");
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
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 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
|
||||
|
||||
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,469 @@
|
||||
-- =============================================================================
|
||||
-- Phase 2: Tabellen für Wartung und Prüfung.
|
||||
--
|
||||
-- Erzeugt mit `prisma migrate dev --create-only` und danach an einer Stelle von
|
||||
-- Hand bereinigt: Prisma wollte die Volltext- und Geoindizes aus Phase 1
|
||||
-- (gin_trgm_ops, gist auf Koordinaten) löschen, weil es sie im Schema nicht
|
||||
-- kennt — Prisma kann diese Indexarten nicht abbilden. Die DROP INDEX-Zeilen
|
||||
-- sind deshalb entfernt. Wer diese Migration neu erzeugt, muss das wieder tun.
|
||||
--
|
||||
-- Mandantentrennung, Unveränderbarkeit der Protokolle und die Suchindizes für
|
||||
-- die neuen Tabellen stehen in der nachfolgenden Migration.
|
||||
-- =============================================================================
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WartungsterminStatus" AS ENUM ('OFFEN', 'ERLEDIGT', 'ENTFALLEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefungStatus" AS ENUM ('ENTWURF', 'ABGESCHLOSSEN', 'ERSETZT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefErgebnis" AS ENUM ('BESTANDEN', 'BESTANDEN_MIT_MAENGELN', 'NICHT_BESTANDEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefungArt" AS ENUM ('INTERN', 'EXTERN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChecklistenpunktTyp" AS ENUM ('JA_NEIN', 'MESSWERT', 'TEXT', 'FOTO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SignaturRolle" AS ENUM ('PRUEFER', 'GERAETEWART', 'FREMDFIRMA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MangelPrioritaet" AS ENUM ('NIEDRIG', 'MITTEL', 'HOCH', 'KRITISCH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MangelStatus" AS ENUM ('GEMELDET', 'ZUGEWIESEN', 'IN_ARBEIT', 'BEHOBEN', 'VERWORFEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RueckrufStatus" AS ENUM ('OFFEN', 'IN_BEARBEITUNG', 'ERLEDIGT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BetroffenheitStatus" AS ENUM ('OFFEN', 'NICHT_BETROFFEN', 'MASSNAHME_LAEUFT', 'ERLEDIGT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ErinnerungStufe" AS ENUM ('T_30', 'T_7', 'T_0', 'UEBERFAELLIG');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wartungsvorlage" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"geraetetypId" UUID,
|
||||
"intervallMonate" INTEGER NOT NULL,
|
||||
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 30,
|
||||
"pruefvorschrift" VARCHAR(200),
|
||||
"standardDauerMinuten" INTEGER,
|
||||
"standardExtern" BOOLEAN NOT NULL DEFAULT false,
|
||||
"automatisch" BOOLEAN NOT NULL DEFAULT true,
|
||||
"beschreibung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "wartungsvorlage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "checklistenpunkt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"vorlageId" UUID NOT NULL,
|
||||
"text" VARCHAR(300) NOT NULL,
|
||||
"typ" "ChecklistenpunktTyp" NOT NULL DEFAULT 'JA_NEIN',
|
||||
"pflicht" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sollwert" VARCHAR(120),
|
||||
"einheit" VARCHAR(20),
|
||||
"minWert" DECIMAL(12,3),
|
||||
"maxWert" DECIMAL(12,3),
|
||||
"hinweis" VARCHAR(500),
|
||||
"sortierung" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "checklistenpunkt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wartungstermin" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"vorlageId" UUID,
|
||||
"faelligAm" DATE NOT NULL,
|
||||
"status" "WartungsterminStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"automatischErzeugt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"zugewiesenAnId" UUID,
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "wartungstermin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pruefung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"terminId" UUID,
|
||||
"vorlageId" UUID,
|
||||
"art" "PruefungArt" NOT NULL DEFAULT 'INTERN',
|
||||
"status" "PruefungStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||
"prueferId" UUID,
|
||||
"prueferName" VARCHAR(200),
|
||||
"durchgefuehrtAm" DATE NOT NULL,
|
||||
"begonnenUm" TIMESTAMPTZ(3),
|
||||
"dauerMinuten" INTEGER,
|
||||
"ergebnis" "PruefErgebnis",
|
||||
"externeFirma" VARCHAR(200),
|
||||
"externeProtokollDateiId" UUID,
|
||||
"kosten" DECIMAL(10,2),
|
||||
"naechstePruefungAm" DATE,
|
||||
"protokollDateiId" UUID,
|
||||
"protokollNummer" VARCHAR(40),
|
||||
"abgeschlossenAm" TIMESTAMPTZ(3),
|
||||
"ersetztPruefungId" UUID,
|
||||
"korrekturGrund" VARCHAR(500),
|
||||
"bemerkung" VARCHAR(2000),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "pruefung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pruefergebnis" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"pruefungId" UUID NOT NULL,
|
||||
"punktId" UUID NOT NULL,
|
||||
"jaNein" BOOLEAN,
|
||||
"zahlwert" DECIMAL(12,3),
|
||||
"text" VARCHAR(1000),
|
||||
"fotoDateiId" UUID,
|
||||
"inOrdnung" BOOLEAN NOT NULL DEFAULT true,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pruefergebnis_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "signatur" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"pruefungId" UUID NOT NULL,
|
||||
"rolle" "SignaturRolle" NOT NULL DEFAULT 'PRUEFER',
|
||||
"personId" UUID,
|
||||
"name" VARCHAR(200) NOT NULL,
|
||||
"bildDateiId" UUID,
|
||||
"unterzeichnetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "signatur_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mangel" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"pruefungId" UUID,
|
||||
"melderId" UUID,
|
||||
"melderName" VARCHAR(200),
|
||||
"beschreibung" VARCHAR(2000) NOT NULL,
|
||||
"fotoDateiId" UUID,
|
||||
"prioritaet" "MangelPrioritaet" NOT NULL DEFAULT 'MITTEL',
|
||||
"status" "MangelStatus" NOT NULL DEFAULT 'GEMELDET',
|
||||
"zugewiesenAnId" UUID,
|
||||
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"behobenAm" TIMESTAMPTZ(3),
|
||||
"behobenVonId" UUID,
|
||||
"behebung" VARCHAR(2000),
|
||||
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "mangel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"hersteller" VARCHAR(160) NOT NULL,
|
||||
"titel" VARCHAR(200) NOT NULL,
|
||||
"referenz" VARCHAR(80),
|
||||
"beschreibung" VARCHAR(4000),
|
||||
"massnahme" VARCHAR(2000),
|
||||
"veroeffentlichtAm" DATE,
|
||||
"quelle" VARCHAR(500),
|
||||
"sperrtGeraete" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "RueckrufStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "rueckruf_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf_geraetetyp" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"rueckrufId" UUID NOT NULL,
|
||||
"geraetetypId" UUID,
|
||||
"modell" VARCHAR(160),
|
||||
"seriennummerVon" VARCHAR(120),
|
||||
"seriennummerBis" VARCHAR(120),
|
||||
"herstellungVon" DATE,
|
||||
"herstellungBis" DATE,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "rueckruf_geraetetyp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf_betroffenheit" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"rueckrufId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"status" "BetroffenheitStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"festgestelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "rueckruf_betroffenheit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "erinnerung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"terminId" UUID NOT NULL,
|
||||
"stufe" "ErinnerungStufe" NOT NULL,
|
||||
"versendetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"kanal" VARCHAR(40) NOT NULL DEFAULT 'mail',
|
||||
"empfaenger" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
|
||||
"meldung" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "erinnerung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungsvorlage_organisationId_geraetetypId_idx" ON "wartungsvorlage"("organisationId", "geraetetypId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungsvorlage_organisationId_aktiv_idx" ON "wartungsvorlage"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wartungsvorlage_organisationId_name_key" ON "wartungsvorlage"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "checklistenpunkt_organisationId_vorlageId_idx" ON "checklistenpunkt"("organisationId", "vorlageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_faelligAm_idx" ON "wartungstermin"("organisationId", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_status_faelligAm_idx" ON "wartungstermin"("organisationId", "status", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_geraetId_idx" ON "wartungstermin"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_zugewiesenAnId_idx" ON "wartungstermin"("organisationId", "zugewiesenAnId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wartungstermin_geraetId_vorlageId_faelligAm_key" ON "wartungstermin"("geraetId", "vorlageId", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_geraetId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "geraetId", "durchgefuehrtAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_status_idx" ON "pruefung"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "durchgefuehrtAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_prueferId_idx" ON "pruefung"("organisationId", "prueferId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pruefung_organisationId_protokollNummer_key" ON "pruefung"("organisationId", "protokollNummer");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefergebnis_organisationId_pruefungId_idx" ON "pruefergebnis"("organisationId", "pruefungId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pruefergebnis_pruefungId_punktId_key" ON "pruefergebnis"("pruefungId", "punktId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "signatur_organisationId_pruefungId_idx" ON "signatur"("organisationId", "pruefungId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_status_prioritaet_idx" ON "mangel"("organisationId", "status", "prioritaet");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_geraetId_idx" ON "mangel"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_zugewiesenAnId_idx" ON "mangel"("organisationId", "zugewiesenAnId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_organisationId_status_idx" ON "rueckruf"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_organisationId_hersteller_idx" ON "rueckruf"("organisationId", "hersteller");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_geraetetyp_organisationId_rueckrufId_idx" ON "rueckruf_geraetetyp"("organisationId", "rueckrufId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_betroffenheit_organisationId_rueckrufId_idx" ON "rueckruf_betroffenheit"("organisationId", "rueckrufId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_betroffenheit_organisationId_geraetId_idx" ON "rueckruf_betroffenheit"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "rueckruf_betroffenheit_rueckrufId_geraetId_key" ON "rueckruf_betroffenheit"("rueckrufId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "erinnerung_organisationId_versendetAm_idx" ON "erinnerung"("organisationId", "versendetAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "erinnerung_terminId_stufe_key" ON "erinnerung"("terminId", "stufe");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_externeProtokollDateiId_fkey" FOREIGN KEY ("externeProtokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_ersetztPruefungId_fkey" FOREIGN KEY ("ersetztPruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_punktId_fkey" FOREIGN KEY ("punktId") REFERENCES "checklistenpunkt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_bildDateiId_fkey" FOREIGN KEY ("bildDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_melderId_fkey" FOREIGN KEY ("melderId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_behobenVonId_fkey" FOREIGN KEY ("behobenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf" ADD CONSTRAINT "rueckruf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,243 @@
|
||||
-- =============================================================================
|
||||
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
|
||||
--
|
||||
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
|
||||
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
|
||||
--
|
||||
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
|
||||
-- für die Tabellen aus Phase 0 und 1.
|
||||
--
|
||||
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
|
||||
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
|
||||
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
|
||||
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
|
||||
-- der Datenbank, nicht nur in lib/ und server/.
|
||||
--
|
||||
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
|
||||
-- Rückrufe.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'wartungsvorlage',
|
||||
'checklistenpunkt',
|
||||
'wartungstermin',
|
||||
'pruefung',
|
||||
'pruefergebnis',
|
||||
'signatur',
|
||||
'mangel',
|
||||
'rueckruf',
|
||||
'rueckruf_geraetetyp',
|
||||
'rueckruf_betroffenheit',
|
||||
'erinnerung'
|
||||
]
|
||||
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;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Unveränderbarkeit abgeschlossener Prüfungen
|
||||
--
|
||||
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
|
||||
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
|
||||
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
|
||||
--
|
||||
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
|
||||
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
|
||||
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
|
||||
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
|
||||
--
|
||||
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
|
||||
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
vergleich "pruefung"%ROWTYPE;
|
||||
BEGIN
|
||||
-- Entwürfe sind frei änderbar.
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
|
||||
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
|
||||
vergleich := NEW;
|
||||
vergleich."geaendertAm" := OLD."geaendertAm";
|
||||
|
||||
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
|
||||
vergleich."status" := OLD."status";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollDateiId" IS NULL THEN
|
||||
vergleich."protokollDateiId" := OLD."protokollDateiId";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollNummer" IS NULL THEN
|
||||
vergleich."protokollNummer" := OLD."protokollNummer";
|
||||
END IF;
|
||||
|
||||
IF vergleich IS DISTINCT FROM OLD THEN
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
|
||||
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
|
||||
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
|
||||
|
||||
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
|
||||
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
|
||||
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
|
||||
'(nur im Aufbewahrungslauf).',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefung_abschluss_schuetzen
|
||||
BEFORE UPDATE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
|
||||
|
||||
CREATE TRIGGER pruefung_loeschschutz
|
||||
BEFORE DELETE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Dasselbe für die Bestandteile eines Protokolls.
|
||||
--
|
||||
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||||
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
|
||||
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
|
||||
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
betroffene_pruefung uuid;
|
||||
zustand "PruefungStatus";
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
betroffene_pruefung := OLD."pruefungId";
|
||||
ELSE
|
||||
betroffene_pruefung := NEW."pruefungId";
|
||||
END IF;
|
||||
|
||||
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
|
||||
|
||||
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
|
||||
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
|
||||
betroffene_pruefung, TG_OP, TG_TABLE_NAME
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefergebnis_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
CREATE TRIGGER signatur_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
|
||||
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
|
||||
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
|
||||
);
|
||||
|
||||
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
|
||||
-- erzeugen.
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
|
||||
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indizes
|
||||
--
|
||||
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
|
||||
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
|
||||
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
|
||||
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
|
||||
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
|
||||
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
|
||||
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
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';
|
||||
|
||||
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
|
||||
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
|
||||
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
|
||||
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,347 @@
|
||||
-- =============================================================================
|
||||
-- Phase 3: Tabellen für Einsätze und Übungen.
|
||||
--
|
||||
-- Erzeugt mit `prisma migrate dev --create-only` und wie in Phase 2 an einer
|
||||
-- Stelle bereinigt: die DROP INDEX-Zeilen für die Volltext- und Geoindizes
|
||||
-- sind entfernt. Prisma kennt gin_trgm_ops und gist nicht und würde sie bei
|
||||
-- jeder Migration erneut löschen wollen.
|
||||
--
|
||||
-- Mandantentrennung, Koordinaten-Trigger, Unveränderbarkeit freigegebener
|
||||
-- Berichte und die Suchindizes stehen in der nachfolgenden Migration.
|
||||
-- =============================================================================
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EinsatzKategorie" AS ENUM ('BRAND_KLEIN', 'BRAND_MITTEL', 'BRAND_GROSS', 'TECHNISCHE_HILFE_KLEIN', 'TECHNISCHE_HILFE_MITTEL', 'TECHNISCHE_HILFE_GROSS', 'GEFAHRGUT', 'RETTUNGSDIENST', 'BRANDMELDEANLAGE', 'BOESWILLIGER_ALARM', 'SICHERHEITSWACHE', 'UNTERSTUETZUNG', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EinsatzStatus" AS ENUM ('ENTWURF', 'LAUFEND', 'ABGESCHLOSSEN', 'FREIGEGEBEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Alarmierungsart" AS ENUM ('SIRENE', 'MELDEEMPFAENGER', 'TELEFON', 'APP', 'PERSOENLICH', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EinsatzFunktion" AS ENUM ('EINSATZLEITER', 'ZUGFUEHRER', 'GRUPPENFUEHRER', 'STAFFELFUEHRER', 'TRUPPFUEHRER', 'MASCHINIST', 'MELDER', 'TRUPPMANN', 'ATEMSCHUTZTRAEGER', 'SICHERHEITSTRUPP', 'SANITAETER', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UebungsArt" AS ENUM ('PRAXIS', 'THEORIE', 'ATEMSCHUTZ', 'MASCHINIST', 'FUNK', 'ERSTE_HILFE', 'GEMEINSCHAFTSUEBUNG', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LagemeldungQuelle" AS ENUM ('INTERN', 'PORTAL');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "einsatz" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatznummer" VARCHAR(40) NOT NULL,
|
||||
"stichwort" VARCHAR(160) NOT NULL,
|
||||
"kategorie" "EinsatzKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||
"status" "EinsatzStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||
"ilsNummer" VARCHAR(60),
|
||||
"alarmierungsart" "Alarmierungsart",
|
||||
"strasse" VARCHAR(200),
|
||||
"hausnummer" VARCHAR(20),
|
||||
"plz" VARCHAR(10),
|
||||
"ort" VARCHAR(120),
|
||||
"ortsteil" VARCHAR(120),
|
||||
"objekt" VARCHAR(200),
|
||||
"breitengrad" DECIMAL(9,6),
|
||||
"laengengrad" DECIMAL(9,6),
|
||||
"koordinaten" geography(Point, 4326),
|
||||
"koordinatenQuelle" VARCHAR(40),
|
||||
"alarmiertUm" TIMESTAMPTZ(3),
|
||||
"ausgeruecktUm" TIMESTAMPTZ(3),
|
||||
"eingetroffenUm" TIMESTAMPTZ(3),
|
||||
"einsatzendeUm" TIMESTAMPTZ(3),
|
||||
"eingeruecktUm" TIMESTAMPTZ(3),
|
||||
"einsatzbereitUm" TIMESTAMPTZ(3),
|
||||
"einsatzleiterId" UUID,
|
||||
"beschreibung" VARCHAR(4000),
|
||||
"bericht" TEXT,
|
||||
"wetter" VARCHAR(200),
|
||||
"temperatur" INTEGER,
|
||||
"weitereKraefte" VARCHAR(500),
|
||||
"geretteteePersonen" INTEGER NOT NULL DEFAULT 0,
|
||||
"verletztePersonen" INTEGER NOT NULL DEFAULT 0,
|
||||
"totePersonen" INTEGER NOT NULL DEFAULT 0,
|
||||
"sachschaden" DECIMAL(12,2),
|
||||
"kostenpflichtig" BOOLEAN NOT NULL DEFAULT false,
|
||||
"berichtDateiId" UUID,
|
||||
"freigegebenAm" TIMESTAMPTZ(3),
|
||||
"freigegebenVonId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "einsatz_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "einsatzfahrzeug" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatzId" UUID NOT NULL,
|
||||
"fahrzeugId" UUID NOT NULL,
|
||||
"ausgeruecktUm" TIMESTAMPTZ(3),
|
||||
"eingetroffenUm" TIMESTAMPTZ(3),
|
||||
"eingeruecktUm" TIMESTAMPTZ(3),
|
||||
"kilometerAbfahrt" INTEGER,
|
||||
"kilometerRueckkehr" INTEGER,
|
||||
"wasserabgabe" INTEGER,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "einsatzfahrzeug_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "einsatzkraft" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatzId" UUID NOT NULL,
|
||||
"personId" UUID NOT NULL,
|
||||
"funktion" "EinsatzFunktion" NOT NULL DEFAULT 'TRUPPMANN',
|
||||
"fahrzeugId" UUID,
|
||||
"vonUm" TIMESTAMPTZ(3),
|
||||
"bisUm" TIMESTAMPTZ(3),
|
||||
"atemschutz" BOOLEAN NOT NULL DEFAULT false,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "einsatzkraft_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "materialverbrauch" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatzId" UUID NOT NULL,
|
||||
"geraetId" UUID,
|
||||
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||
"menge" DECIMAL(10,2) NOT NULL,
|
||||
"einheit" VARCHAR(20) NOT NULL,
|
||||
"kosten" DECIMAL(10,2),
|
||||
"beschaedigt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "materialverbrauch_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lagemeldung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatzId" UUID NOT NULL,
|
||||
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"text" VARCHAR(2000) NOT NULL,
|
||||
"quelle" "LagemeldungQuelle" NOT NULL DEFAULT 'INTERN',
|
||||
"verfasserId" UUID,
|
||||
"verfasserName" VARCHAR(200),
|
||||
"fotoDateiId" UUID,
|
||||
"wichtig" BOOLEAN NOT NULL DEFAULT false,
|
||||
"portalZugangId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "lagemeldung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "portal_zugang" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatzId" UUID NOT NULL,
|
||||
"bezeichnung" VARCHAR(160) NOT NULL,
|
||||
"tokenHash" VARCHAR(120) NOT NULL,
|
||||
"gueltigBis" TIMESTAMPTZ(3) NOT NULL,
|
||||
"widerrufenAm" TIMESTAMPTZ(3),
|
||||
"aufrufe" INTEGER NOT NULL DEFAULT 0,
|
||||
"letzterAufruf" TIMESTAMPTZ(3),
|
||||
"letzteIp" INET,
|
||||
"erstelltVonId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "portal_zugang_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "uebung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"nummer" VARCHAR(40) NOT NULL,
|
||||
"thema" VARCHAR(200) NOT NULL,
|
||||
"art" "UebungsArt" NOT NULL DEFAULT 'PRAXIS',
|
||||
"datum" DATE NOT NULL,
|
||||
"beginnUm" TIMESTAMPTZ(3),
|
||||
"endeUm" TIMESTAMPTZ(3),
|
||||
"ort" VARCHAR(200),
|
||||
"leiterId" UUID,
|
||||
"lernziele" VARCHAR(2000),
|
||||
"beschreibung" VARCHAR(4000),
|
||||
"bericht" TEXT,
|
||||
"berichtDateiId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "uebung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "uebungsteilnahme" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"uebungId" UUID NOT NULL,
|
||||
"personId" UUID NOT NULL,
|
||||
"funktion" "EinsatzFunktion",
|
||||
"vonUm" TIMESTAMPTZ(3),
|
||||
"bisUm" TIMESTAMPTZ(3),
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "uebungsteilnahme_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "einsatz_organisationId_alarmiertUm_idx" ON "einsatz"("organisationId", "alarmiertUm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "einsatz_organisationId_status_idx" ON "einsatz"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "einsatz_organisationId_kategorie_idx" ON "einsatz"("organisationId", "kategorie");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "einsatz_organisationId_einsatznummer_key" ON "einsatz"("organisationId", "einsatznummer");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "einsatzfahrzeug_organisationId_einsatzId_idx" ON "einsatzfahrzeug"("organisationId", "einsatzId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "einsatzfahrzeug_organisationId_fahrzeugId_idx" ON "einsatzfahrzeug"("organisationId", "fahrzeugId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "einsatzfahrzeug_einsatzId_fahrzeugId_key" ON "einsatzfahrzeug"("einsatzId", "fahrzeugId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "einsatzkraft_organisationId_einsatzId_idx" ON "einsatzkraft"("organisationId", "einsatzId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "einsatzkraft_organisationId_personId_idx" ON "einsatzkraft"("organisationId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "einsatzkraft_einsatzId_personId_key" ON "einsatzkraft"("einsatzId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materialverbrauch_organisationId_einsatzId_idx" ON "materialverbrauch"("organisationId", "einsatzId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "lagemeldung_organisationId_einsatzId_zeitpunkt_idx" ON "lagemeldung"("organisationId", "einsatzId", "zeitpunkt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "portal_zugang_organisationId_einsatzId_idx" ON "portal_zugang"("organisationId", "einsatzId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "portal_zugang_gueltigBis_idx" ON "portal_zugang"("gueltigBis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "uebung_organisationId_datum_idx" ON "uebung"("organisationId", "datum" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "uebung_organisationId_art_idx" ON "uebung"("organisationId", "art");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "uebung_organisationId_nummer_key" ON "uebung"("organisationId", "nummer");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "uebungsteilnahme_organisationId_personId_idx" ON "uebungsteilnahme"("organisationId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "uebungsteilnahme_uebungId_personId_key" ON "uebungsteilnahme"("uebungId", "personId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_einsatzleiterId_fkey" FOREIGN KEY ("einsatzleiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_freigegebenVonId_fkey" FOREIGN KEY ("freigegebenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_verfasserId_fkey" FOREIGN KEY ("verfasserId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_portalZugangId_fkey" FOREIGN KEY ("portalZugangId") REFERENCES "portal_zugang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_leiterId_fkey" FOREIGN KEY ("leiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,243 @@
|
||||
-- =============================================================================
|
||||
-- Phase 3: Mandantentrennung, Koordinaten, Berichtsfreigabe, Indizes.
|
||||
--
|
||||
-- Vier Dinge, die in der Datenbank besser aufgehoben sind als im Code:
|
||||
--
|
||||
-- 1. Row Level Security für die acht neuen Tabellen.
|
||||
--
|
||||
-- 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt. Die Anwendung
|
||||
-- schreibt Breite und Länge als Zahl; den geography-Punkt setzt ein
|
||||
-- Trigger. Sonst müsste jede Schreiboperation Roh-SQL sein, und die
|
||||
-- Karte ab Phase 6 fände Einsätze, die über einen anderen Weg entstanden
|
||||
-- sind, nicht wieder.
|
||||
--
|
||||
-- 3. Die Unveränderbarkeit freigegebener Einsatzberichte. Der Bericht geht
|
||||
-- an die Gemeinde und im Schadensfall an die Versicherung. Dieselbe Logik
|
||||
-- wie beim Prüfprotokoll aus Phase 2.
|
||||
--
|
||||
-- 4. Suchindizes und ein Teilindex auf die laufenden Einsätze.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'einsatz',
|
||||
'einsatzfahrzeug',
|
||||
'einsatzkraft',
|
||||
'materialverbrauch',
|
||||
'lagemeldung',
|
||||
'portal_zugang',
|
||||
'uebung',
|
||||
'uebungsteilnahme'
|
||||
]
|
||||
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
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION einsatz_koordinaten_setzen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW."breitengrad" IS NULL OR NEW."laengengrad" IS NULL THEN
|
||||
NEW."koordinaten" := NULL;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Reihenfolge beachten: ST_MakePoint nimmt erst die Länge, dann die Breite.
|
||||
NEW."koordinaten" := ST_SetSRID(
|
||||
ST_MakePoint(NEW."laengengrad"::double precision, NEW."breitengrad"::double precision),
|
||||
4326
|
||||
)::geography;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION einsatz_koordinaten_setzen() IS
|
||||
'Haelt den PostGIS-Punkt eines Einsatzes mit den Dezimalgraden im Gleichlauf.';
|
||||
|
||||
CREATE TRIGGER einsatz_koordinaten
|
||||
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "einsatz"
|
||||
FOR EACH ROW EXECUTE FUNCTION einsatz_koordinaten_setzen();
|
||||
|
||||
-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler.
|
||||
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_koordinaten_gueltig" CHECK (
|
||||
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Freigegebene Einsatzberichte sind unveränderbar
|
||||
--
|
||||
-- Bis zur Freigabe darf alles nachgetragen werden — ein Einsatz wird tagelang
|
||||
-- ergänzt, und genau dafür ist der Entwurfsstand da. Mit der Freigabe ist
|
||||
-- Schluss: der Bericht ist dann ein Dokument, kein Arbeitsstand.
|
||||
--
|
||||
-- Zwei Ausnahmen bleiben, beide nachtragend statt verändernd: das PDF, solange
|
||||
-- keines hinterlegt ist, und geaendertAm.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION einsatz_freigabe_schuetzen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
vergleich "einsatz"%ROWTYPE;
|
||||
BEGIN
|
||||
IF OLD."status" <> 'FREIGEGEBEN' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
vergleich := NEW;
|
||||
vergleich."geaendertAm" := OLD."geaendertAm";
|
||||
|
||||
IF OLD."berichtDateiId" IS NULL THEN
|
||||
vergleich."berichtDateiId" := OLD."berichtDateiId";
|
||||
END IF;
|
||||
|
||||
IF vergleich IS DISTINCT FROM OLD THEN
|
||||
RAISE EXCEPTION
|
||||
'Der Einsatzbericht % ist freigegeben und damit unveraenderlich.',
|
||||
OLD."einsatznummer"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION einsatz_kein_loeschen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD."status" <> 'FREIGEGEBEN' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Der Einsatzbericht % ist freigegeben und darf nicht geloescht werden '
|
||||
'(nur im Aufbewahrungslauf).',
|
||||
OLD."einsatznummer"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER einsatz_freigabe
|
||||
BEFORE UPDATE ON "einsatz"
|
||||
FOR EACH ROW EXECUTE FUNCTION einsatz_freigabe_schuetzen();
|
||||
|
||||
CREATE TRIGGER einsatz_loeschschutz
|
||||
BEFORE DELETE ON "einsatz"
|
||||
FOR EACH ROW EXECUTE FUNCTION einsatz_kein_loeschen();
|
||||
|
||||
-- Dasselbe für die Bestandteile: sonst ließe sich ein freigegebener Bericht
|
||||
-- über die Einsatzkräfte oder den Materialverbrauch nachträglich verändern.
|
||||
CREATE OR REPLACE FUNCTION einsatzinhalt_gesperrt()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
betroffener_einsatz uuid;
|
||||
zustand "EinsatzStatus";
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
betroffener_einsatz := OLD."einsatzId";
|
||||
ELSE
|
||||
betroffener_einsatz := NEW."einsatzId";
|
||||
END IF;
|
||||
|
||||
SELECT "status" INTO zustand FROM "einsatz" WHERE "id" = betroffener_einsatz;
|
||||
|
||||
IF zustand IS NULL OR zustand <> 'FREIGEGEBEN' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Der Einsatz % ist freigegeben. % auf % ist nicht mehr zulaessig.',
|
||||
betroffener_einsatz, TG_OP, TG_TABLE_NAME
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER einsatzfahrzeug_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzfahrzeug"
|
||||
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
||||
|
||||
CREATE TRIGGER einsatzkraft_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzkraft"
|
||||
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
||||
|
||||
CREATE TRIGGER materialverbrauch_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "materialverbrauch"
|
||||
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
||||
|
||||
-- Lagemeldungen bleiben ausdrücklich ausgenommen: sie sind ein Protokoll des
|
||||
-- Verlaufs, kein Bestandteil des Berichts, und eine Meldung aus dem Portal
|
||||
-- kann eintreffen, während der Bericht schon freigegeben ist.
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Portalzugänge
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Ein Zugang, der nie gültig war, ist ein Fehler in der Erzeugung.
|
||||
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_gueltigkeit" CHECK (
|
||||
"gueltigBis" > "erstelltAm"
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indizes
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Der Alarmmonitor fragt immer dasselbe: was läuft gerade.
|
||||
CREATE INDEX "einsatz_laufend" ON "einsatz" ("organisationId", "alarmiertUm" DESC)
|
||||
WHERE "status" = 'LAUFEND' AND "geloeschtAm" IS NULL;
|
||||
|
||||
-- Räumliche Abfragen ab Phase 6.
|
||||
CREATE INDEX "einsatz_koordinaten_idx" ON "einsatz" USING gist ("koordinaten");
|
||||
|
||||
-- Volltextsuche wie in den Phasen davor.
|
||||
CREATE INDEX "einsatz_suche_stichwort" ON "einsatz" USING gin ("stichwort" gin_trgm_ops);
|
||||
CREATE INDEX "einsatz_suche_ort" ON "einsatz" USING gin ("ort" gin_trgm_ops);
|
||||
CREATE INDEX "einsatz_suche_nummer" ON "einsatz" USING gin ("einsatznummer" gin_trgm_ops);
|
||||
CREATE INDEX "uebung_suche_thema" ON "uebung" USING gin ("thema" gin_trgm_ops);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
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';
|
||||
|
||||
-- Die Einschraenkungen aus den Phasen davor gelten unveraendert weiter.
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,269 @@
|
||||
-- =============================================================================
|
||||
-- Phase 4: Tabellen für das Terminal im Gerätehaus und den Offline-Betrieb.
|
||||
--
|
||||
-- Erzeugt mit `prisma migrate dev --create-only` und wie in den Phasen davor
|
||||
-- um die DROP INDEX-Zeilen bereinigt: Prisma kennt die Trigram- und
|
||||
-- Geoindizes nicht und würde sie sonst bei jeder Migration löschen wollen.
|
||||
--
|
||||
-- Mandantentrennung, die Sperre gegen doppelte Vorgänge und die Indizes für
|
||||
-- den Terminalbetrieb stehen in der nachfolgenden Migration.
|
||||
-- =============================================================================
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FahrtZweck" AS ENUM ('EINSATZ', 'UEBUNG', 'AUSBILDUNG', 'WERKSTATT', 'BESCHAFFUNG', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WaescheStatus" AS ENUM ('ABGEGEBEN', 'IN_REINIGUNG', 'ZURUECK', 'VERSCHOLLEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnwesenheitAnlass" AS ENUM ('DIENST', 'UEBUNG', 'EINSATZ', 'ARBEITSDIENST', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InventurStatus" AS ENUM ('LAEUFT', 'ABGESCHLOSSEN', 'ABGEBROCHEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "KioskVorgangArt" AS ENUM ('MANGEL_MELDEN', 'FAHRT_BEGINNEN', 'FAHRT_BEENDEN', 'ANWESENHEIT_KOMMEN', 'ANWESENHEIT_GEHEN', 'WAESCHE_ABGEBEN', 'INVENTUR_SCANNEN', 'PRUEFUNG_ERFASSEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "KonfliktGrund" AS ENUM ('ZIEL_FEHLT', 'ZIEL_GESPERRT', 'DOPPELT', 'VERALTET', 'SONSTIGES');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "fahrt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"fahrzeugId" UUID NOT NULL,
|
||||
"fahrerId" UUID NOT NULL,
|
||||
"zweck" "FahrtZweck" NOT NULL DEFAULT 'SONSTIGES',
|
||||
"einsatzId" UUID,
|
||||
"uebungId" UUID,
|
||||
"abfahrtUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"rueckkehrUm" TIMESTAMPTZ(3),
|
||||
"kilometerAbfahrt" INTEGER NOT NULL,
|
||||
"kilometerRueckkehr" INTEGER,
|
||||
"ziel" VARCHAR(200),
|
||||
"tankmenge" DECIMAL(8,2),
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fahrt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "anwesenheit" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"personId" UUID NOT NULL,
|
||||
"anlass" "AnwesenheitAnlass" NOT NULL DEFAULT 'DIENST',
|
||||
"angemeldetUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"abgemeldetUm" TIMESTAMPTZ(3),
|
||||
"einsatzId" UUID,
|
||||
"uebungId" UUID,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "anwesenheit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "waescheabgabe" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"personId" UUID NOT NULL,
|
||||
"geraetId" UUID,
|
||||
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||
"menge" INTEGER NOT NULL DEFAULT 1,
|
||||
"status" "WaescheStatus" NOT NULL DEFAULT 'ABGEGEBEN',
|
||||
"grund" VARCHAR(200),
|
||||
"abgegebenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"zurueckAm" TIMESTAMPTZ(3),
|
||||
"angenommenVonId" UUID,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "waescheabgabe_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "inventurgang" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"bezeichnung" VARCHAR(160) NOT NULL,
|
||||
"status" "InventurStatus" NOT NULL DEFAULT 'LAEUFT',
|
||||
"standortId" UUID,
|
||||
"fahrzeugId" UUID,
|
||||
"begonnenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"abgeschlossenAm" TIMESTAMPTZ(3),
|
||||
"begonnenVonId" UUID,
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "inventurgang_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "inventurposten" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"inventurId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"gescanntAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"gefunden" BOOLEAN NOT NULL DEFAULT true,
|
||||
"vorgefundenAn" VARCHAR(200),
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erfasstVonId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "inventurposten_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kiosk_vorgang" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"art" "KioskVorgangArt" NOT NULL,
|
||||
"personId" UUID,
|
||||
"erfasstAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"empfangenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ergebnisId" UUID,
|
||||
"nutzlast" JSONB,
|
||||
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
|
||||
"meldung" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "kiosk_vorgang_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sync_konflikt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"vorgangId" UUID NOT NULL,
|
||||
"art" "KioskVorgangArt" NOT NULL,
|
||||
"grund" "KonfliktGrund" NOT NULL DEFAULT 'SONSTIGES',
|
||||
"beschreibung" VARCHAR(500) NOT NULL,
|
||||
"nutzlast" JSONB,
|
||||
"personId" UUID,
|
||||
"entstandenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"erledigtVonId" UUID,
|
||||
"erledigung" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "sync_konflikt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "fahrt_organisationId_fahrzeugId_abfahrtUm_idx" ON "fahrt"("organisationId", "fahrzeugId", "abfahrtUm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "fahrt_organisationId_fahrerId_idx" ON "fahrt"("organisationId", "fahrerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "anwesenheit_organisationId_personId_angemeldetUm_idx" ON "anwesenheit"("organisationId", "personId", "angemeldetUm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "anwesenheit_organisationId_abgemeldetUm_idx" ON "anwesenheit"("organisationId", "abgemeldetUm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "waescheabgabe_organisationId_status_idx" ON "waescheabgabe"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "waescheabgabe_organisationId_personId_idx" ON "waescheabgabe"("organisationId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "inventurgang_organisationId_status_idx" ON "inventurgang"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "inventurposten_organisationId_inventurId_idx" ON "inventurposten"("organisationId", "inventurId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "inventurposten_inventurId_geraetId_key" ON "inventurposten"("inventurId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "kiosk_vorgang_organisationId_empfangenAm_idx" ON "kiosk_vorgang"("organisationId", "empfangenAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "kiosk_vorgang_organisationId_art_idx" ON "kiosk_vorgang"("organisationId", "art");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sync_konflikt_organisationId_erledigtAm_idx" ON "sync_konflikt"("organisationId", "erledigtAm");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrerId_fkey" FOREIGN KEY ("fahrerId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_angenommenVonId_fkey" FOREIGN KEY ("angenommenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_begonnenVonId_fkey" FOREIGN KEY ("begonnenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_inventurId_fkey" FOREIGN KEY ("inventurId") REFERENCES "inventurgang"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_erledigtVonId_fkey" FOREIGN KEY ("erledigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,110 @@
|
||||
-- =============================================================================
|
||||
-- Phase 4: Mandantentrennung und Regeln für den Terminalbetrieb.
|
||||
--
|
||||
-- Drei Dinge:
|
||||
--
|
||||
-- 1. Row Level Security für die sieben neuen Tabellen.
|
||||
--
|
||||
-- 2. Regeln, die verhindern, dass ein offline erfasster Vorgang beim
|
||||
-- Nachtragen Unsinn anrichtet: kein Kilometerstand, der rückwärts läuft,
|
||||
-- keine Rückkehr vor der Abfahrt, keine Anwesenheit, die vor dem Kommen
|
||||
-- endet.
|
||||
--
|
||||
-- 3. Indizes für die Abfragen, die das Terminal und der Hallenmonitor
|
||||
-- dauernd stellen: wer ist gerade da, welches Fahrzeug ist unterwegs.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'fahrt',
|
||||
'anwesenheit',
|
||||
'waescheabgabe',
|
||||
'inventurgang',
|
||||
'inventurposten',
|
||||
'kiosk_vorgang',
|
||||
'sync_konflikt'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON %I FOR ALL '
|
||||
'USING ("organisationId" = aktuelle_organisation()) '
|
||||
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||
tabelle || '_mandant', tabelle
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Was am Terminal nicht passieren darf
|
||||
--
|
||||
-- Diese Bedingungen fangen ab, was beim Eintippen auf einem Tablet mit
|
||||
-- Handschuhen tatsächlich passiert: eine vertippte Ziffer im Kilometerstand,
|
||||
-- ein Rückkehrzeitpunkt vor der Abfahrt. Im Gerätehaus schaut niemand ein
|
||||
-- zweites Mal hin, und offline erfasste Vorgänge kommen unter Umständen erst
|
||||
-- Stunden später an.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_kilometer_vorwaerts" CHECK (
|
||||
"kilometerRueckkehr" IS NULL OR "kilometerRueckkehr" >= "kilometerAbfahrt"
|
||||
);
|
||||
|
||||
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_rueckkehr_nach_abfahrt" CHECK (
|
||||
"rueckkehrUm" IS NULL OR "rueckkehrUm" >= "abfahrtUm"
|
||||
);
|
||||
|
||||
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_gehen_nach_kommen" CHECK (
|
||||
"abgemeldetUm" IS NULL OR "abgemeldetUm" >= "angemeldetUm"
|
||||
);
|
||||
|
||||
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_menge_positiv" CHECK (
|
||||
"menge" > 0
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indizes für den laufenden Betrieb
|
||||
--
|
||||
-- Der Hallenmonitor fragt im Sekundentakt dasselbe: wer ist da, welches
|
||||
-- Fahrzeug ist draußen. Beides sind Teilmengen, die klein bleiben, während
|
||||
-- die Tabellen über Jahre wachsen — also Teilindizes.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "anwesenheit_anwesend" ON "anwesenheit" ("organisationId", "angemeldetUm" DESC)
|
||||
WHERE "abgemeldetUm" IS NULL;
|
||||
|
||||
CREATE INDEX "fahrt_unterwegs" ON "fahrt" ("organisationId", "fahrzeugId", "abfahrtUm" DESC)
|
||||
WHERE "rueckkehrUm" IS NULL;
|
||||
|
||||
CREATE INDEX "waesche_offen" ON "waescheabgabe" ("organisationId", "personId")
|
||||
WHERE "status" <> 'ZURUECK';
|
||||
|
||||
CREATE INDEX "sync_konflikt_offen" ON "sync_konflikt" ("organisationId", "entstandenAm" DESC)
|
||||
WHERE "erledigtAm" IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
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';
|
||||
|
||||
-- Ein empfangener Terminalvorgang wird nie geaendert oder geloescht:
|
||||
-- er ist der Beleg dafuer, dass etwas schon einmal angekommen ist.
|
||||
-- Ohne diese Sperre koennte ein zweiter Sendeversuch den ersten
|
||||
-- ueberschreiben und derselbe Vorgang doch zweimal wirken.
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Service Worker für das Terminal im Gerätehaus.
|
||||
*
|
||||
* Aufgabe: das Tablet soll auch dann etwas anzeigen, wenn das WLAN in der
|
||||
* Halle gerade weg ist. Drei Strategien, je nachdem worum es geht:
|
||||
*
|
||||
* Navigation (HTML) Netz zuerst, bei Fehlschlag der zwischengespeicherte
|
||||
* Stand, sonst die Offline-Seite. So sieht man immer die
|
||||
* aktuelle Fassung, wenn es geht — und trotzdem etwas,
|
||||
* wenn nicht.
|
||||
*
|
||||
* Abfragen (tRPC GET) Zwischenspeicher zuerst anzeigen, im Hintergrund
|
||||
* erneuern. Für Stammdaten wie Fahrzeuge und Geräte ist
|
||||
* ein paar Minuten alt völlig ausreichend.
|
||||
*
|
||||
* Änderungen (POST) Gar nicht anfassen. Was geschrieben wird, geht über
|
||||
* die Warteschlange in der IndexedDB (siehe
|
||||
* lib/kiosk/outbox.ts) — ein Service Worker, der
|
||||
* Schreibvorgänge zwischenspeichert, führt zu Daten, die
|
||||
* niemand mehr zuordnen kann.
|
||||
*
|
||||
* Bewusst von Hand geschrieben statt über ein Werkzeug erzeugt: es sind
|
||||
* achtzig Zeilen, sie sind lesbar, und sie bringen keine Abhängigkeit mit, die
|
||||
* bei jedem Update mitgezogen werden müsste.
|
||||
*/
|
||||
|
||||
const VERSION = "florian-kiosk-v1";
|
||||
const HUELLE = `${VERSION}-huelle`;
|
||||
const DATEN = `${VERSION}-daten`;
|
||||
|
||||
/** Was beim Einrichten schon einmal geholt wird. */
|
||||
const HUELLE_DATEIEN = ["/kiosk", "/kiosk/offline", "/manifest.webmanifest"];
|
||||
|
||||
self.addEventListener("install", (ereignis) => {
|
||||
ereignis.waitUntil(
|
||||
caches
|
||||
.open(HUELLE)
|
||||
.then((speicher) => speicher.addAll(HUELLE_DATEIEN))
|
||||
// Ein fehlender Eintrag darf die Einrichtung nicht verhindern.
|
||||
.catch(() => undefined)
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (ereignis) => {
|
||||
ereignis.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((namen) =>
|
||||
Promise.all(
|
||||
namen
|
||||
.filter((name) => !name.startsWith(VERSION))
|
||||
.map((name) => caches.delete(name)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (ereignis) => {
|
||||
const anfrage = ereignis.request;
|
||||
const adresse = new URL(anfrage.url);
|
||||
|
||||
// Nur eigene Adressen. Alles andere geht den Service Worker nichts an.
|
||||
if (adresse.origin !== self.location.origin) return;
|
||||
|
||||
// Schreibende Aufrufe niemals abfangen.
|
||||
if (anfrage.method !== "GET") return;
|
||||
|
||||
// Der Anmeldeweg bleibt außen vor: ein zwischengespeicherter Anmeldezustand
|
||||
// wäre eine Sicherheitslücke, keine Bequemlichkeit.
|
||||
if (adresse.pathname.startsWith("/api/auth")) return;
|
||||
|
||||
if (anfrage.mode === "navigate") {
|
||||
ereignis.respondWith(navigationBeantworten(anfrage));
|
||||
return;
|
||||
}
|
||||
|
||||
if (adresse.pathname.startsWith("/api/trpc")) {
|
||||
ereignis.respondWith(abfrageBeantworten(anfrage));
|
||||
return;
|
||||
}
|
||||
|
||||
// Statische Dateien: erst nachsehen, dann holen.
|
||||
if (adresse.pathname.startsWith("/_next/static") || adresse.pathname.startsWith("/icons")) {
|
||||
ereignis.respondWith(zuerstSpeicher(anfrage));
|
||||
}
|
||||
});
|
||||
|
||||
/** Netz zuerst, Zwischenspeicher als Rückfall, Offline-Seite als letzte Rettung. */
|
||||
async function navigationBeantworten(anfrage) {
|
||||
try {
|
||||
const antwort = await fetch(anfrage);
|
||||
const speicher = await caches.open(HUELLE);
|
||||
speicher.put(anfrage, antwort.clone());
|
||||
return antwort;
|
||||
} catch (fehler) {
|
||||
const speicher = await caches.open(HUELLE);
|
||||
const gespeichert = await speicher.match(anfrage);
|
||||
if (gespeichert) return gespeichert;
|
||||
|
||||
const offlineSeite = await speicher.match("/kiosk/offline");
|
||||
if (offlineSeite) return offlineSeite;
|
||||
|
||||
return new Response(
|
||||
"<!doctype html><meta charset=utf-8><title>Offline</title>" +
|
||||
"<p style='font:16px system-ui;padding:2rem'>Kein Netz und nichts im Zwischenspeicher.</p>",
|
||||
{ status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zwischenspeicher zuerst, im Hintergrund erneuern. */
|
||||
async function abfrageBeantworten(anfrage) {
|
||||
const speicher = await caches.open(DATEN);
|
||||
const gespeichert = await speicher.match(anfrage);
|
||||
|
||||
const netz = fetch(anfrage)
|
||||
.then((antwort) => {
|
||||
if (antwort.ok) speicher.put(anfrage, antwort.clone());
|
||||
return antwort;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (gespeichert) {
|
||||
// Die Erneuerung läuft weiter, das Ergebnis interessiert hier nicht mehr.
|
||||
void netz;
|
||||
return gespeichert;
|
||||
}
|
||||
|
||||
const antwort = await netz;
|
||||
if (antwort) return antwort;
|
||||
|
||||
return new Response(JSON.stringify({ fehler: "offline" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function zuerstSpeicher(anfrage) {
|
||||
const speicher = await caches.open(HUELLE);
|
||||
const gespeichert = await speicher.match(anfrage);
|
||||
if (gespeichert) return gespeichert;
|
||||
|
||||
try {
|
||||
const antwort = await fetch(anfrage);
|
||||
if (antwort.ok) speicher.put(anfrage, antwort.clone());
|
||||
return antwort;
|
||||
} catch (fehler) {
|
||||
return new Response("", { status: 504 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Anwendung kann den Zwischenspeicher der Daten leeren lassen — etwa nach
|
||||
* dem Abmelden am Terminal, damit der nächste Anwender keinen fremden Stand
|
||||
* sieht.
|
||||
*/
|
||||
self.addEventListener("message", (ereignis) => {
|
||||
if (ereignis.data === "daten-leeren") {
|
||||
ereignis.waitUntil(caches.delete(DATEN));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Erzeugt die Symbole für das Web-App-Manifest.
|
||||
*
|
||||
* Warum ein Skript und keine gezeichneten Dateien: die Symbole sollen aus
|
||||
* derselben Quelle stammen wie das Zeichen in der Seitenleiste, und sie sollen
|
||||
* sich ändern lassen, ohne dass jemand ein Grafikprogramm öffnen muss.
|
||||
*
|
||||
* Gezeichnet wird die Flamme als SVG auf farbigem Grund, aufgenommen wird mit
|
||||
* demselben Chromium, das ohnehin für die Tests da ist.
|
||||
*
|
||||
* npm run icons
|
||||
*
|
||||
* Die Ergebnisse liegen in public/icons und werden mit eingecheckt — ein Build
|
||||
* soll keinen Browser brauchen.
|
||||
*/
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const ZIEL = path.join(process.cwd(), "public", "icons");
|
||||
|
||||
/** Die Flamme aus lucide-react, als eigenständiges SVG. */
|
||||
const FLAMME = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>
|
||||
</svg>`;
|
||||
|
||||
interface Symbolvorlage {
|
||||
datei: string;
|
||||
groesse: number;
|
||||
/** Sicherheitsabstand für maskierbare Symbole (Android schneidet rund zu). */
|
||||
rand: number;
|
||||
hintergrund: string;
|
||||
farbe: string;
|
||||
eckenRadius: string;
|
||||
}
|
||||
|
||||
const SYMBOLE: Symbolvorlage[] = [
|
||||
{
|
||||
datei: "florian-192.png",
|
||||
groesse: 192,
|
||||
rand: 22,
|
||||
hintergrund: "#b91c1c",
|
||||
farbe: "#ffffff",
|
||||
eckenRadius: "22%",
|
||||
},
|
||||
{
|
||||
datei: "florian-512.png",
|
||||
groesse: 512,
|
||||
rand: 60,
|
||||
hintergrund: "#b91c1c",
|
||||
farbe: "#ffffff",
|
||||
eckenRadius: "22%",
|
||||
},
|
||||
{
|
||||
// Maskierbar: mehr Rand, voller Grund bis zum Rand — Android schneidet
|
||||
// daraus je nach Gerät einen Kreis oder ein Rechteck.
|
||||
datei: "florian-maskable.png",
|
||||
groesse: 512,
|
||||
rand: 110,
|
||||
hintergrund: "#b91c1c",
|
||||
farbe: "#ffffff",
|
||||
eckenRadius: "0",
|
||||
},
|
||||
];
|
||||
|
||||
function seite(vorlage: Symbolvorlage): string {
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
html, body { margin: 0; padding: 0; }
|
||||
.grund {
|
||||
width: ${vorlage.groesse}px;
|
||||
height: ${vorlage.groesse}px;
|
||||
background: ${vorlage.hintergrund};
|
||||
border-radius: ${vorlage.eckenRadius};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${vorlage.farbe};
|
||||
}
|
||||
svg {
|
||||
width: ${vorlage.groesse - vorlage.rand * 2}px;
|
||||
height: ${vorlage.groesse - vorlage.rand * 2}px;
|
||||
}
|
||||
</style></head>
|
||||
<body><div class="grund">${FLAMME}</div></body></html>`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await mkdir(ZIEL, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
for (const vorlage of SYMBOLE) {
|
||||
const seiteImBrowser = await browser.newPage({
|
||||
viewport: { width: vorlage.groesse, height: vorlage.groesse },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
await seiteImBrowser.setContent(seite(vorlage));
|
||||
const ziel = path.join(ZIEL, vorlage.datei);
|
||||
await seiteImBrowser.locator(".grund").screenshot({ path: ziel, omitBackground: false });
|
||||
await seiteImBrowser.close();
|
||||
|
||||
console.info(`[icons] ${vorlage.datei} (${vorlage.groesse}px) -> ${ziel}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.info("[icons] Fertig.");
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error("[icons] Abbruch:", fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
import "dotenv/config";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Page } from "@playwright/test";
|
||||
import { chromium, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
const BASIS = process.env.E2E_BASIS_URL ?? "http://localhost:3100";
|
||||
const ZIEL = path.join(process.cwd(), "docs", "screenshots");
|
||||
@@ -44,21 +44,325 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "Übersicht nach der Anmeldung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "03-geraeteliste",
|
||||
pfad: "/geraete",
|
||||
beschreibung: "Geräteliste mit Suche, Filter und Spaltenauswahl",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "05-fahrzeuge",
|
||||
pfad: "/fahrzeuge",
|
||||
beschreibung: "Fahrzeugübersicht",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "07-mitglieder",
|
||||
pfad: "/mitglieder",
|
||||
beschreibung: "Mitgliederliste nach Abteilung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "08-qualifikationen",
|
||||
pfad: "/qualifikationen",
|
||||
beschreibung: "Qualifikationen mit Ablaufwarnung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "09-import",
|
||||
pfad: "/geraete/importieren",
|
||||
beschreibung: "Import-Assistent",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "10-geraet-neu",
|
||||
pfad: "/geraete/neu",
|
||||
beschreibung: "Formular für ein neues Gerät",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
{
|
||||
name: "11-fristen",
|
||||
pfad: "/pruefungen",
|
||||
beschreibung: "Fristen-Dashboard mit Zeitfenstern und Bündelung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "12-vorlagen",
|
||||
pfad: "/pruefungen/vorlagen",
|
||||
beschreibung: "Wartungsvorlagen mit Intervall und Prüfvorschrift",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "13-maengel",
|
||||
pfad: "/maengel",
|
||||
beschreibung: "Mängel von der Meldung bis zur Behebung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "14-rueckrufe",
|
||||
pfad: "/rueckrufe",
|
||||
beschreibung: "Rückrufe des Herstellers",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "15-lebensdauer",
|
||||
pfad: "/pruefungen/lebensdauer",
|
||||
beschreibung: "Geräte am Ende ihrer Nutzungsdauer",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "16-protokolle",
|
||||
pfad: "/pruefungen/verlauf",
|
||||
beschreibung: "Abgeschlossene Prüfprotokolle",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
{
|
||||
name: "19-einsaetze",
|
||||
pfad: "/einsaetze",
|
||||
beschreibung: "Einsatzliste mit Schnellerfassung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "20-statistik",
|
||||
pfad: "/einsaetze/statistik",
|
||||
beschreibung: "Einsatzstatistik mit Hilfsfristen und Beteiligung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "21-uebungen",
|
||||
pfad: "/uebungen",
|
||||
beschreibung: "Übungen mit Thema und Teilnehmerzahl",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 4 — Terminal im Gerätehaus
|
||||
{
|
||||
name: "25-kiosk-kacheln",
|
||||
pfad: "/kiosk",
|
||||
beschreibung: "Kachelschirm des Terminals",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "26-kiosk-mangel",
|
||||
pfad: "/kiosk/mangel",
|
||||
beschreibung: "Mangel melden in drei Schritten",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "27-kiosk-monitor",
|
||||
pfad: "/kiosk/monitor",
|
||||
beschreibung: "Hallenmonitor im Vollbild",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "28-terminal-uebersicht",
|
||||
pfad: "/terminal",
|
||||
beschreibung: "Was am Terminal zusammenkommt — Sicht der Verwaltung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "29-anmeldemarke",
|
||||
pfad: "/anmeldemarke",
|
||||
beschreibung: "Anmeldemarke fürs Handy",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Seiten, deren Adresse erst zur Laufzeit feststeht — die Kennung eines
|
||||
* Geräts oder Fahrzeugs kennt das Skript vorher nicht.
|
||||
*/
|
||||
const DYNAMISCHE_AUFNAHMEN: Array<{
|
||||
name: string;
|
||||
beschreibung: string;
|
||||
/** Findet den Link auf der Übersichtsseite. */
|
||||
von: string;
|
||||
linkMuster: RegExp;
|
||||
/**
|
||||
* Zusätzliche Bedingung an die Beschriftung des Links. Nötig bei den
|
||||
* Protokollen: dort stehen auch Entwürfe in der Liste, und die zeigen ein
|
||||
* halb leeres Bild.
|
||||
*/
|
||||
textMuster?: RegExp;
|
||||
}> = [
|
||||
{
|
||||
name: "04-geraet",
|
||||
beschreibung: "Gerätedetail mit QR-Code und Verlauf",
|
||||
von: "/geraete",
|
||||
linkMuster: /^\/geraete\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
{
|
||||
name: "06-fahrzeug",
|
||||
beschreibung: "Fahrzeugdetail mit Beladung nach Geräteräumen",
|
||||
von: "/fahrzeuge",
|
||||
linkMuster: /^\/fahrzeuge\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
{
|
||||
name: "17-checkliste",
|
||||
beschreibung: "Checkliste einer Wartungsvorlage",
|
||||
von: "/pruefungen/vorlagen",
|
||||
linkMuster: /^\/pruefungen\/vorlagen\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
{
|
||||
name: "18-pruefprotokoll",
|
||||
beschreibung: "Abgeschlossenes Prüfprotokoll mit Checkliste und Unterschrift",
|
||||
von: "/pruefungen/verlauf",
|
||||
linkMuster: /^\/pruefungen\/[0-9a-f-]{36}$/,
|
||||
// Nur fertige Protokolle: die tragen eine Nummer, Entwürfe nicht.
|
||||
textMuster: /^\d{4}-\d{4}$/,
|
||||
},
|
||||
{
|
||||
name: "22-einsatz",
|
||||
beschreibung: "Einsatzdetail mit Zeitleiste, Stärke und Verlauf",
|
||||
von: "/einsaetze",
|
||||
linkMuster: /^\/einsaetze\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
{
|
||||
name: "23-uebung",
|
||||
beschreibung: "Übung mit Anwesenheitsliste",
|
||||
von: "/uebungen",
|
||||
linkMuster: /^\/uebungen\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Wartet, bis die Seite wirklich fertig ist.
|
||||
*
|
||||
* "networkidle" genügt nicht: Listen holen ihre Daten über tRPC nach, und der
|
||||
* Aufruf startet erst, nachdem React im Browser gelaufen ist. Ohne dieses
|
||||
* Warten zeigen die Bilder Platzhalterbalken statt echter Daten.
|
||||
*/
|
||||
async function fertigGeladen(seite: Page): Promise<void> {
|
||||
await seite.waitForLoadState("networkidle");
|
||||
|
||||
// Die Entwicklungsanzeige von Next.js gehört nicht in eine Anleitung.
|
||||
await seite
|
||||
.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||
.catch(() => undefined);
|
||||
|
||||
try {
|
||||
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} catch {
|
||||
console.warn("[screenshots] Ladeanzeige blieb stehen — Bild wird trotzdem gemacht.");
|
||||
}
|
||||
await seite.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async function anmelden(seite: Page): Promise<void> {
|
||||
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
||||
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await seite.getByRole("button", { name: "Anmelden" }).click();
|
||||
await seite.waitForURL(`${BASIS}/`, { timeout: 20_000 });
|
||||
// Grosszuegig: bei kaltem .next uebersetzt "next dev" die Route erst beim
|
||||
// ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung.
|
||||
await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 });
|
||||
await seite.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Einsatzportal lässt sich nicht über eine feste Adresse aufrufen — der
|
||||
* Link entsteht erst, wenn ihn jemand erzeugt. Also erzeugt ihn dieses Skript
|
||||
* selbst, im ersten Einsatz der Liste, und fotografiert das Ergebnis.
|
||||
*/
|
||||
async function portalAufnehmen(seite: Page, darstellung: string): Promise<void> {
|
||||
try {
|
||||
await seite.goto(`${BASIS}/einsaetze`, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const ziele = await seite
|
||||
.locator("a[href]")
|
||||
.evaluateAll((elemente) =>
|
||||
elemente.map((element) => element.getAttribute("href") ?? "").filter(Boolean),
|
||||
);
|
||||
|
||||
const einsatz = ziele.find((href) => /^\/einsaetze\/[0-9a-f-]{36}$/.test(href));
|
||||
if (!einsatz) {
|
||||
console.warn("[screenshots] Kein Einsatz für das Portal gefunden — übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}${einsatz}`, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
await seite.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen");
|
||||
await seite.getByRole("button", { name: "Erzeugen" }).click();
|
||||
|
||||
const linkfeld = seite.locator("input[readonly]").first();
|
||||
await linkfeld.waitFor({ timeout: 15_000 });
|
||||
const adresse = await linkfeld.inputValue();
|
||||
|
||||
await seite.goto(adresse, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const datei = path.join(ZIEL, `24-portal-${darstellung}.png`);
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
console.info(`[screenshots] Einsatzportal für Fremdwehren (${darstellung}) -> ${datei}`);
|
||||
} catch (fehler) {
|
||||
console.warn("[screenshots] Portalaufnahme fehlgeschlagen:", fehler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Anmeldeschirm des Terminals.
|
||||
*
|
||||
* Braucht das Gegenteil aller anderen Aufnahmen: **keine** Sitzung, dafür ein
|
||||
* eingerichtetes Gerät. Beides zusammen gibt es nur in einem eigenen Kontext,
|
||||
* in dem vor dem ersten Aufruf die Geräteangaben im localStorage stehen.
|
||||
*/
|
||||
async function kioskAnmeldungAufnehmen(
|
||||
browser: Browser,
|
||||
darstellung: "light" | "dark",
|
||||
): Promise<void> {
|
||||
const kontext = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
colorScheme: darstellung,
|
||||
});
|
||||
|
||||
try {
|
||||
await kontext.addInitScript(
|
||||
(angaben: { schluessel: string; wehr: string }) => {
|
||||
// Dieselben Schlüssel wie in lib/kiosk/geraet.ts.
|
||||
window.localStorage.setItem("florian.kiosk.geraeteschluessel", angaben.schluessel);
|
||||
window.localStorage.setItem("florian.kiosk.wehr", angaben.wehr);
|
||||
},
|
||||
{
|
||||
schluessel: process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis",
|
||||
wehr: "ff-musterhausen",
|
||||
},
|
||||
);
|
||||
|
||||
const seite = await kontext.newPage();
|
||||
await seite.goto(`${BASIS}/kiosk`, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const datei = path.join(ZIEL, `30-kiosk-anmeldung-${darstellung}.png`);
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
console.info(`[screenshots] Anmeldung am Terminal (${darstellung}) -> ${datei}`);
|
||||
} catch (fehler) {
|
||||
console.warn("[screenshots] Anmeldeschirm des Terminals fehlgeschlagen:", fehler);
|
||||
} finally {
|
||||
await kontext.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await mkdir(ZIEL, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const browser = await chromium.launch({
|
||||
// Der Mangel-Wizard startet die Kamera von selbst. Ohne Gerät stünde dort
|
||||
// eine Fehlermeldung; mit dem Testbild von Chromium zeigt das Bild, was am
|
||||
// Tablet zu sehen ist.
|
||||
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
|
||||
});
|
||||
|
||||
for (const darstellung of ["light", "dark"] as const) {
|
||||
const kontext = await browser.newContext({
|
||||
@@ -79,15 +383,53 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
|
||||
// Kurz warten, damit Übergänge und Symbole fertig gezeichnet sind.
|
||||
await seite.waitForTimeout(400);
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
||||
}
|
||||
|
||||
// Detailseiten: die erste Kennung von der jeweiligen Übersicht holen.
|
||||
for (const aufnahme of DYNAMISCHE_AUFNAHMEN) {
|
||||
await seite.goto(`${BASIS}${aufnahme.von}`, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const ziele = await seite
|
||||
.locator("a[href]")
|
||||
.evaluateAll((elemente) =>
|
||||
elemente
|
||||
.map((element) => ({
|
||||
href: element.getAttribute("href") ?? "",
|
||||
text: (element.textContent ?? "").trim(),
|
||||
}))
|
||||
.filter((eintrag) => eintrag.href.startsWith("/")),
|
||||
);
|
||||
|
||||
const treffer = ziele.find(
|
||||
(eintrag) =>
|
||||
aufnahme.linkMuster.test(eintrag.href) &&
|
||||
(!aufnahme.textMuster || aufnahme.textMuster.test(eintrag.text)),
|
||||
)?.href;
|
||||
if (!treffer) {
|
||||
console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}${treffer}`, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
||||
}
|
||||
|
||||
await portalAufnehmen(seite, darstellung);
|
||||
|
||||
await kontext.close();
|
||||
|
||||
// Eigener Kontext, weil dieser hier angemeldet ist.
|
||||
await kioskAnmeldungAufnehmen(browser, darstellung);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* Die Anzeige der Anmeldemarke.
|
||||
*
|
||||
* Der Code läuft nach einer Minute ab — deshalb zählt die Seite herunter und
|
||||
* holt sich rechtzeitig einen neuen. Wer davorsteht, soll nie einen abgelaufenen
|
||||
* Code vor die Kamera halten und rätseln, warum nichts passiert.
|
||||
*/
|
||||
export function MarkenAnzeige({
|
||||
bild,
|
||||
name,
|
||||
gueltigSekunden,
|
||||
}: {
|
||||
bild: string;
|
||||
name: string;
|
||||
gueltigSekunden: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [rest, setRest] = React.useState(gueltigSekunden);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRest(gueltigSekunden);
|
||||
|
||||
const zeitgeber = setInterval(() => {
|
||||
setRest((wert) => {
|
||||
if (wert <= 1) {
|
||||
// Ein paar Sekunden vor Ablauf einen neuen holen: die Seite wird
|
||||
// serverseitig gerendert, also genügt ein Aktualisieren.
|
||||
router.refresh();
|
||||
return gueltigSekunden;
|
||||
}
|
||||
return wert - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(zeitgeber);
|
||||
}, [bild, gueltigSekunden, router]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Anmeldemarke</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Am Terminal im Gerätehaus vor die Kamera halten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{name}</CardTitle>
|
||||
<CardDescription>
|
||||
Gültig noch {rest} {rest === 1 ? "Sekunde" : "Sekunden"} — danach erneuert sich der
|
||||
Code von selbst.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<Image
|
||||
src={bild}
|
||||
alt="Anmeldemarke als QR-Code"
|
||||
width={420}
|
||||
height={420}
|
||||
unoptimized
|
||||
className="mx-auto h-auto w-full max-w-xs rounded-lg border border-[var(--border)] bg-white p-3"
|
||||
/>
|
||||
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full bg-[var(--primary)] transition-all duration-1000"
|
||||
style={{ width: `${(rest / gueltigSekunden) * 100}%` }}
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={() => router.refresh()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Neuen Code anzeigen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Der Code enthält keine Daten über dich, sondern nur eine kurzlebige Unterschrift. Ein
|
||||
abfotografierter Code nützt nach einer Minute nichts mehr.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { markeErzeugen, MARKE_GUELTIG_SEKUNDEN } from "@/lib/kiosk/anmeldemarke";
|
||||
import { qrTextAlsDataUri } from "@/lib/stammdaten/qr";
|
||||
import { MarkenAnzeige } from "@/app/(app)/anmeldemarke/anzeige";
|
||||
|
||||
export const metadata = { title: "Anmeldemarke" };
|
||||
|
||||
/**
|
||||
* Die Anmeldemarke fürs Handy.
|
||||
*
|
||||
* Wer den Ausweis vergessen hat, öffnet diese Seite und hält den QR-Code vor
|
||||
* die Kamera des Terminals. Der Code gilt eine Minute; die Seite erneuert ihn
|
||||
* von selbst.
|
||||
*
|
||||
* Erzeugt wird er auf dem Server — die Signatur entsteht mit AUTH_SECRET, und
|
||||
* das hat im Browser nichts verloren.
|
||||
*/
|
||||
export default async function AnmeldemarkeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
|
||||
const bild = await qrTextAlsDataUri(marke, 420);
|
||||
|
||||
return (
|
||||
<MarkenAnzeige
|
||||
bild={bild}
|
||||
name={sitzung.name}
|
||||
gueltigSekunden={MARKE_GUELTIG_SEKUNDEN}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, Copy, FileDown, Link2, Plus, Send, Trash2, Users } from "lucide-react";
|
||||
import { EinsatzFunktion } 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
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 {
|
||||
EinsatzStatusAbzeichen,
|
||||
Hinweisliste,
|
||||
KategorieAbzeichen,
|
||||
StaerkeAnzeige,
|
||||
Zeitkennzahlen,
|
||||
Zeitleiste,
|
||||
} from "@/components/einsatz/anzeige";
|
||||
import { DateiFeld } from "@/components/wartung/datei-feld";
|
||||
import { FUNKTION_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte";
|
||||
import { ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
|
||||
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
|
||||
import { taktischeEinheit } from "@/lib/einsatz/staerke";
|
||||
|
||||
/**
|
||||
* Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz.
|
||||
*
|
||||
* Aufbau in der Reihenfolge, in der gearbeitet wird:
|
||||
*
|
||||
* 1. Zeitstempel — ein Knopf je Stempel, solange es läuft
|
||||
* 2. Fahrzeuge und Kräfte — wer war dabei, in welcher Funktion
|
||||
* 3. Lagemeldungen — der Verlauf, auch aus dem Portal
|
||||
* 4. Material — was verbraucht wurde
|
||||
* 5. Bericht und Freigabe — zum Schluss, in Ruhe
|
||||
*
|
||||
* Nach der Freigabe ist alles gesperrt. Die Oberfläche zeigt das, indem sie
|
||||
* die Bedienelemente weglässt statt sie auszugrauen: was nicht mehr geht,
|
||||
* braucht auch keinen Knopf.
|
||||
*/
|
||||
export function EinsatzDetail({
|
||||
einsatzId,
|
||||
darfBearbeiten,
|
||||
darfFreigeben,
|
||||
}: {
|
||||
einsatzId: string;
|
||||
darfBearbeiten: boolean;
|
||||
darfFreigeben: boolean;
|
||||
}) {
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [neueMeldung, setNeueMeldung] = React.useState("");
|
||||
const [meldungWichtig, setMeldungWichtig] = React.useState(false);
|
||||
const [meldungFoto, setMeldungFoto] = React.useState<string | null>(null);
|
||||
const [bericht, setBericht] = React.useState<string | null>(null);
|
||||
const [portalName, setPortalName] = React.useState("");
|
||||
const [portalLink, setPortalLink] = React.useState<string | null>(null);
|
||||
const [personalOffen, setPersonalOffen] = React.useState(false);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const abfrage = trpc.einsatz.einzeln.useQuery({ id: einsatzId });
|
||||
|
||||
const aktualisieren = () => void werkzeuge.einsatz.einzeln.invalidate({ id: einsatzId });
|
||||
const fehlerZeigen = (fehler: { message: string }) => setMeldung(fehler.message);
|
||||
|
||||
const zeitstempelSetzen = trpc.einsatz.zeitstempelSetzen.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const kraefteSetzen = trpc.einsatz.kraefteSetzen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setMeldung(`${ergebnis.erfasst} Kräfte erfasst — Stärke ${ergebnis.staerkeText}.`);
|
||||
aktualisieren();
|
||||
},
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const kraftEntfernen = trpc.einsatz.kraftEntfernen.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const lagemeldung = trpc.einsatz.lagemeldungErfassen.useMutation({
|
||||
onSuccess: () => {
|
||||
setNeueMeldung("");
|
||||
setMeldungWichtig(false);
|
||||
setMeldungFoto(null);
|
||||
aktualisieren();
|
||||
},
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const aendern = trpc.einsatz.aendern.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung("Gespeichert.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const freigeben = trpc.einsatz.freigeben.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung("Bericht freigegeben. Das PDF entsteht im Hintergrund.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const berichtLink = trpc.einsatz.berichtLink.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
if (ergebnis.bereit && ergebnis.adresse) {
|
||||
window.open(ergebnis.adresse, "_blank", "noopener");
|
||||
} else {
|
||||
setMeldung("Der Bericht wird gerade erzeugt. Gleich noch einmal versuchen.");
|
||||
}
|
||||
},
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const portalErstellen = trpc.einsatz.portalzugangErstellen.useMutation({
|
||||
onSuccess: (zugang) => {
|
||||
setPortalLink(zugang.adresse);
|
||||
setPortalName("");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const portalWiderrufen = trpc.einsatz.portalzugangWiderrufen.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: fehlerZeigen,
|
||||
});
|
||||
|
||||
const vorschlag = trpc.einsatz.personalvorschlag.useQuery(
|
||||
{ einsatzId, anzahl: 60 },
|
||||
{ enabled: personalOffen && darfBearbeiten },
|
||||
);
|
||||
|
||||
if (abfrage.isLoading) {
|
||||
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
|
||||
}
|
||||
if (abfrage.error) return <Fehlermeldung meldung={abfrage.error.message} />;
|
||||
if (!abfrage.data) return null;
|
||||
|
||||
const einsatz = abfrage.data;
|
||||
const freigegeben = einsatz.status === "FREIGEGEBEN";
|
||||
const bearbeitbar = darfBearbeiten && !freigegeben;
|
||||
const einheit = taktischeEinheit(einsatz.staerke);
|
||||
|
||||
const adresse = [
|
||||
[einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" "),
|
||||
[einsatz.plz, einsatz.ort].filter(Boolean).join(" "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Kopf */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{einsatz.stichwort}</h1>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<span className="tabellenzahlen">{einsatz.einsatznummer}</span>
|
||||
<KategorieAbzeichen kategorie={einsatz.kategorie} />
|
||||
<EinsatzStatusAbzeichen status={einsatz.status} />
|
||||
{adresse && <span>· {adresse}</span>}
|
||||
{einsatz.objekt && <span>· {einsatz.objekt}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{freigegeben && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => berichtLink.mutate({ id: einsatzId })}
|
||||
disabled={berichtLink.isPending}
|
||||
>
|
||||
<FileDown aria-hidden />
|
||||
Bericht als PDF
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{darfFreigeben && !freigegeben && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Bericht freigeben? Danach lässt sich am Einsatz nichts mehr ändern.",
|
||||
)
|
||||
) {
|
||||
freigeben.mutate({ id: einsatzId, bericht: bericht ?? undefined });
|
||||
}
|
||||
}}
|
||||
disabled={freigeben.isPending}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Bericht freigeben
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="einsatzdetailmeldung" />}
|
||||
<Hinweisliste hinweise={einsatz.hinweise} />
|
||||
|
||||
{freigegeben && (
|
||||
<div className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 p-3 text-sm">
|
||||
Dieser Bericht ist seit {alsDatumZeit(einsatz.freigegebenAm)} freigegeben
|
||||
{einsatz.freigegebenVon &&
|
||||
` — freigegeben von ${einsatz.freigegebenVon.vorname} ${einsatz.freigegebenVon.nachname}`}
|
||||
. Änderungen sind nicht mehr möglich.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zeiten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zeiten</CardTitle>
|
||||
<CardDescription>
|
||||
Die sechs Stempel des Einsatzes. Ein Klick setzt die aktuelle Uhrzeit.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Zeitleiste
|
||||
stempel={ZEITSTEMPEL.map((stempel) => ({
|
||||
bezeichnung: ZEITSTEMPEL_TEXT[stempel],
|
||||
uhrzeit: einsatz[stempel] ? alsUhrzeit(einsatz[stempel]) : null,
|
||||
gesetzt: Boolean(einsatz[stempel]),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{bearbeitbar && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ZEITSTEMPEL.map((stempel) => (
|
||||
<Button
|
||||
key={stempel}
|
||||
variant={einsatz[stempel] ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
zeitstempelSetzen.mutate({
|
||||
id: einsatzId,
|
||||
stempel: stempel as Zeitstempel,
|
||||
zeitpunkt: einsatz[stempel] ? null : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{einsatz[stempel]
|
||||
? `${ZEITSTEMPEL_TEXT[stempel]} zurücknehmen`
|
||||
: `${ZEITSTEMPEL_TEXT[stempel]} jetzt`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Zeitkennzahlen
|
||||
ausrueckzeit={einsatz.ausrueckzeit}
|
||||
anfahrtszeit={einsatz.anfahrtszeit}
|
||||
hilfsfrist={einsatz.hilfsfrist}
|
||||
dauer={einsatz.dauer}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fahrzeuge und Kräfte */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Eingesetzte Kräfte</CardTitle>
|
||||
<CardDescription>
|
||||
{einsatz.kraefte.length} Personen · {einsatz.fahrzeuge.length} Fahrzeuge
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{bearbeitbar && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPersonalOffen((offen) => !offen)}
|
||||
>
|
||||
<Users aria-hidden />
|
||||
{personalOffen ? "Vorschlag schließen" : "Mannschaft erfassen"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{einsatz.fahrzeuge.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{einsatz.fahrzeuge.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className="rounded-md border border-[var(--border)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
{eintrag.fahrzeug.funkrufname}
|
||||
{eintrag.ausgeruecktUm && (
|
||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||
ab {alsUhrzeit(eintrag.ausgeruecktUm)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{einsatz.kraefte.length === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel="Noch keine Mannschaft erfasst"
|
||||
hinweis="Ohne Kräfte lässt sich kein Bericht freigeben — und die Beteiligung je Person bliebe leer."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Funktion</TableHead>
|
||||
<TableHead>Fahrzeug</TableHead>
|
||||
<TableHead>Atemschutz</TableHead>
|
||||
{bearbeitbar && <TableHead className="w-12" aria-label="Aktion" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{einsatz.kraefte.map((kraft) => (
|
||||
<TableRow key={kraft.id}>
|
||||
<TableCell>
|
||||
{kraft.person.nachname}, {kraft.person.vorname}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{bearbeitbar ? (
|
||||
<Select
|
||||
value={kraft.funktion}
|
||||
onValueChange={(wert) =>
|
||||
kraefteSetzen.mutate({
|
||||
einsatzId,
|
||||
kraefte: [
|
||||
{
|
||||
personId: kraft.personId,
|
||||
funktion: wert as EinsatzFunktion,
|
||||
atemschutz: kraft.atemschutz,
|
||||
fahrzeugId: kraft.fahrzeugId,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-52"
|
||||
aria-label={`Funktion von ${kraft.person.nachname}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(FUNKTION_TEXT) as EinsatzFunktion[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{FUNKTION_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
FUNKTION_TEXT[kraft.funktion]
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{kraft.fahrzeug?.funkrufname ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={kraft.atemschutz}
|
||||
disabled={!bearbeitbar}
|
||||
onCheckedChange={(an) =>
|
||||
kraefteSetzen.mutate({
|
||||
einsatzId,
|
||||
kraefte: [
|
||||
{
|
||||
personId: kraft.personId,
|
||||
funktion: kraft.funktion,
|
||||
atemschutz: an === true,
|
||||
fahrzeugId: kraft.fahrzeugId,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
aria-label={`Atemschutz für ${kraft.person.nachname}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{bearbeitbar && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
kraftEntfernen.mutate({ einsatzId, personId: kraft.personId })
|
||||
}
|
||||
aria-label={`${kraft.person.nachname} entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* Personalvorschlag */}
|
||||
{personalOffen && bearbeitbar && (
|
||||
<div className="rounded-md border border-[var(--border)] p-3">
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
Vorschlag — sortiert nach Einsätzen im letzten Jahr
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-[var(--muted-foreground)]">
|
||||
Eine echte Verfügbarkeitsmeldung gibt es erst mit den Terminen in Phase 7. Bis
|
||||
dahin steht hier, wer erfahrungsgemäß kommt — und ob die Qualifikation noch gilt.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{vorschlag.data?.map((person) => (
|
||||
<button
|
||||
key={person.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
kraefteSetzen.mutate({
|
||||
einsatzId,
|
||||
kraefte: [
|
||||
{
|
||||
personId: person.id,
|
||||
funktion: EinsatzFunktion.TRUPPMANN,
|
||||
atemschutz: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
className="flex items-center justify-between gap-2 rounded border border-[var(--border)] px-3 py-2 text-left text-sm hover:border-[var(--primary)]"
|
||||
>
|
||||
<span>
|
||||
{person.nachname}, {person.vorname}
|
||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||
{person.einsaetzeImJahr} Einsätze
|
||||
</span>
|
||||
{person.hatAbgelaufeneQualifikation && (
|
||||
<span className="ml-2 text-xs font-medium text-[var(--gesperrt)]">
|
||||
Qualifikation abgelaufen
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Plus className="size-4 shrink-0" aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<StaerkeAnzeige staerke={einsatz.staerke} einheit={einheit.einheit} />
|
||||
|
||||
{/* Portalzugänge */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="size-4" aria-hidden />
|
||||
Portal für Fremdwehren
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ein Link ohne Anmeldung: Stand sehen, Lagemeldung mit Foto absetzen. Zeitlich
|
||||
begrenzt und jederzeit widerrufbar.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{bearbeitbar && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={portalName}
|
||||
onChange={(ereignis) => setPortalName(ereignis.target.value)}
|
||||
placeholder="FF Nachbarhausen"
|
||||
aria-label="Für wen ist der Link?"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
portalErstellen.mutate({ einsatzId, bezeichnung: portalName })
|
||||
}
|
||||
disabled={!portalName.trim() || portalErstellen.isPending}
|
||||
>
|
||||
Erzeugen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portalLink && (
|
||||
<div className="space-y-1 rounded-md border border-[var(--primary)] p-2">
|
||||
<p className="text-xs font-medium">
|
||||
Dieser Link wird nur jetzt angezeigt — danach steht in der Datenbank nur noch
|
||||
sein Hash.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={portalLink} className="text-xs" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigator.clipboard.writeText(portalLink)}
|
||||
aria-label="Link kopieren"
|
||||
>
|
||||
<Copy aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2 text-sm">
|
||||
{einsatz.portalzugaenge.map((zugang) => (
|
||||
<li key={zugang.id} className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{zugang.bezeichnung}
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{zugang.widerrufenAm
|
||||
? "widerrufen"
|
||||
: `gültig bis ${alsDatumZeit(zugang.gueltigBis)}`}
|
||||
{" · "}
|
||||
{zugang.aufrufe} Aufrufe
|
||||
</span>
|
||||
</span>
|
||||
{bearbeitbar && !zugang.widerrufenAm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => portalWiderrufen.mutate({ id: zugang.id })}
|
||||
>
|
||||
Widerrufen
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lagemeldungen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verlauf</CardTitle>
|
||||
<CardDescription>
|
||||
Lagemeldungen aus der Wehr und über das Portal. Sie bleiben auch nach der Freigabe
|
||||
möglich — der Verlauf gehört nicht zum festgeschriebenen Bericht.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{darfBearbeiten && (
|
||||
<div className="space-y-2 rounded-md border border-[var(--border)] p-3">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={neueMeldung}
|
||||
onChange={(ereignis) => setNeueMeldung(ereignis.target.value)}
|
||||
placeholder="Was ist der Stand?"
|
||||
aria-label="Neue Lagemeldung"
|
||||
/>
|
||||
|
||||
<DateiFeld
|
||||
entitaet="Lagemeldung"
|
||||
entitaetId={einsatzId}
|
||||
kategorie="lagemeldung"
|
||||
dateiId={meldungFoto}
|
||||
onHochgeladen={setMeldungFoto}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={meldungWichtig}
|
||||
onCheckedChange={(an) => setMeldungWichtig(an === true)}
|
||||
/>
|
||||
wichtig
|
||||
</label>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
disabled={!neueMeldung.trim() || lagemeldung.isPending}
|
||||
onClick={() =>
|
||||
lagemeldung.mutate({
|
||||
einsatzId,
|
||||
text: neueMeldung,
|
||||
wichtig: meldungWichtig,
|
||||
fotoDateiId: meldungFoto,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send aria-hidden />
|
||||
Melden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{einsatz.lagemeldungen.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Noch keine Meldung.</p>
|
||||
) : (
|
||||
<ol className="space-y-3">
|
||||
{einsatz.lagemeldungen.map((meldung) => (
|
||||
<li
|
||||
key={meldung.id}
|
||||
className={`border-l-2 pl-3 text-sm ${
|
||||
meldung.wichtig ? "border-[var(--gesperrt)]" : "border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
|
||||
{alsDatumZeit(meldung.zeitpunkt)}
|
||||
</span>
|
||||
{meldung.wichtig && (
|
||||
<span className="text-xs font-medium text-[var(--gesperrt)]">wichtig</span>
|
||||
)}
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{meldung.verfasserName ??
|
||||
(meldung.verfasser
|
||||
? `${meldung.verfasser.vorname} ${meldung.verfasser.nachname}`
|
||||
: "unbekannt")}{" "}
|
||||
· {QUELLE_TEXT[meldung.quelle]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5">{meldung.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bericht */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bericht</CardTitle>
|
||||
<CardDescription>
|
||||
Der Text, der später an die Gemeinde geht. Mit der Freigabe wird er festgeschrieben.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{bearbeitbar ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bericht">Einsatzbericht</Label>
|
||||
<Textarea
|
||||
id="bericht"
|
||||
rows={8}
|
||||
value={bericht ?? einsatz.bericht ?? ""}
|
||||
onChange={(ereignis) => setBericht(ereignis.target.value)}
|
||||
placeholder="Was war die Lage bei Eintreffen, was wurde getan, wie endete der Einsatz?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => aendern.mutate({ id: einsatzId, bericht: bericht ?? "" })}
|
||||
disabled={aendern.isPending || bericht === null}
|
||||
>
|
||||
Bericht speichern
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="whitespace-pre-line text-sm">
|
||||
{einsatz.bericht ?? "Noch kein Bericht geschrieben."}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
|
||||
|
||||
export const metadata = { title: "Einsatz" };
|
||||
|
||||
/**
|
||||
* Ein Einsatz mit allem, was dazugehört.
|
||||
*
|
||||
* Client-Komponente, weil hier während eines laufenden Einsatzes gearbeitet
|
||||
* wird: Zeitstempel setzen, Kräfte nachtragen, Lagemeldungen schreiben. Jede
|
||||
* dieser Aktionen soll die Seite nicht neu laden.
|
||||
*/
|
||||
export default async function EinsatzSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/einsaetze">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Einsätzen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EinsatzDetail
|
||||
einsatzId={id}
|
||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MapPin, Search, Siren } from "lucide-react";
|
||||
import { EinsatzKategorie, EinsatzStatus } 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { EinsatzStatusAbzeichen, KategorieAbzeichen } from "@/components/einsatz/anzeige";
|
||||
import { KATEGORIE_TEXT } from "@/lib/einsatz/texte";
|
||||
import { alsDauer } from "@/lib/einsatz/zeiten";
|
||||
import { alsDatumZeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Einsatzliste mit Schnellerfassung.
|
||||
*
|
||||
* Die Schnellerfassung ist der wichtigste Knopf der ganzen Phase. Sie fragt
|
||||
* drei Dinge: was, wo, welche Fahrzeuge. Die Alarmzeit ist jetzt. Alles
|
||||
* Weitere — Kräfte, Zeiten, Bericht — wird auf der Detailseite nachgetragen,
|
||||
* wenn wieder Ruhe ist.
|
||||
*/
|
||||
|
||||
const LEER = {
|
||||
stichwort: "",
|
||||
kategorie: EinsatzKategorie.SONSTIGES as EinsatzKategorie,
|
||||
strasse: "",
|
||||
hausnummer: "",
|
||||
plz: "",
|
||||
ort: "",
|
||||
objekt: "",
|
||||
breitengrad: null as number | null,
|
||||
laengengrad: null as number | null,
|
||||
koordinatenQuelle: null as string | null,
|
||||
};
|
||||
|
||||
export function EinsatzListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [suche, setSuche] = React.useState("");
|
||||
const [status, setStatus] = React.useState<EinsatzStatus | "alle">("alle");
|
||||
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
|
||||
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const [formular, setFormular] = React.useState(LEER);
|
||||
const [fahrzeugIds, setFahrzeugIds] = React.useState<Set<string>>(new Set());
|
||||
const [ausgerueckt, setAusgerueckt] = React.useState(true);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const einsaetze = trpc.einsatz.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "desc",
|
||||
suche: suche || undefined,
|
||||
status: status === "alle" ? undefined : [status],
|
||||
jahr: jahr === "alle" ? undefined : jahr,
|
||||
});
|
||||
|
||||
const jahre = trpc.statistik.jahre.useQuery();
|
||||
|
||||
const fahrzeuge = trpc.fahrzeug.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 100, richtung: "asc" },
|
||||
{ enabled: offen },
|
||||
);
|
||||
|
||||
// Adresssuche über den eigenen Nominatim — nur auf Knopfdruck, nicht bei
|
||||
// jedem Tastendruck: der Dienst gehört nicht mit Anfragen überzogen.
|
||||
const [adressuche, setAdressuche] = React.useState<string | null>(null);
|
||||
const adressen = trpc.einsatz.adresseSuchen.useQuery(
|
||||
{ suche: adressuche ?? "" },
|
||||
{ enabled: Boolean(adressuche) },
|
||||
);
|
||||
|
||||
const anlegen = trpc.einsatz.anlegen.useMutation({
|
||||
onSuccess: (einsatz) => {
|
||||
setOffen(false);
|
||||
setFormular(LEER);
|
||||
setFahrzeugIds(new Set());
|
||||
void werkzeuge.einsatz.invalidate();
|
||||
router.push(`/einsaetze/${einsatz.id}`);
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const eintraege = einsaetze.data?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-56 flex-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Stichwort, Einsatznummer, Ort …"
|
||||
className="pl-8"
|
||||
aria-label="Einsätze durchsuchen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(wert) => setStatus(wert as EinsatzStatus | "alle")}
|
||||
>
|
||||
<SelectTrigger className="w-44" aria-label="Nach Stand filtern">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Zustände</SelectItem>
|
||||
<SelectItem value={EinsatzStatus.LAUFEND}>läuft</SelectItem>
|
||||
<SelectItem value={EinsatzStatus.ENTWURF}>Entwurf</SelectItem>
|
||||
<SelectItem value={EinsatzStatus.ABGESCHLOSSEN}>abgeschlossen</SelectItem>
|
||||
<SelectItem value={EinsatzStatus.FREIGEGEBEN}>freigegeben</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={String(jahr)}
|
||||
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
|
||||
>
|
||||
<SelectTrigger className="w-32" aria-label="Jahr wählen">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Jahre</SelectItem>
|
||||
{(jahre.data ?? [new Date().getFullYear()]).map((eintrag) => (
|
||||
<SelectItem key={eintrag} value={String(eintrag)}>
|
||||
{eintrag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Siren aria-hidden />
|
||||
Einsatz erfassen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="einsatzmeldung" />}
|
||||
{einsaetze.error && <Fehlermeldung meldung={einsaetze.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nummer</TableHead>
|
||||
<TableHead>Stichwort</TableHead>
|
||||
<TableHead>Kategorie</TableHead>
|
||||
<TableHead>Ort</TableHead>
|
||||
<TableHead>Fahrzeuge</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead className="text-right">alarmiert</TableHead>
|
||||
<TableHead className="text-right">Hilfsfrist</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{einsaetze.isLoading && <LadeZeilen spalten={8} />}
|
||||
|
||||
{eintraege.map((einsatz) => (
|
||||
<TableRow key={einsatz.id}>
|
||||
<TableCell className="tabellenzahlen font-medium">
|
||||
<Link
|
||||
href={`/einsaetze/${einsatz.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{einsatz.einsatznummer}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{einsatz.stichwort}
|
||||
{einsatz.objekt && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">{einsatz.objekt}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<KategorieAbzeichen kategorie={einsatz.kategorie} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{[einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" ") || "—"}
|
||||
{einsatz.ort && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">{einsatz.ort}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{einsatz.fahrzeuge.length === 0
|
||||
? "—"
|
||||
: einsatz.fahrzeuge
|
||||
.map((eintrag) => eintrag.fahrzeug.funkrufname.split(" ").pop())
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<EinsatzStatusAbzeichen status={einsatz.status} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen whitespace-nowrap">
|
||||
{alsDatumZeit(einsatz.alarmiertUm)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDauer(einsatz.hilfsfrist)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!einsaetze.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel={suche ? "Kein Einsatz gefunden" : "Noch kein Einsatz erfasst"}
|
||||
hinweis={
|
||||
suche
|
||||
? "Vielleicht in einem anderen Jahr?"
|
||||
: "Im Alarmfall genügen Stichwort, Ort und die ausgerückten Fahrzeuge — der Rest wird nachgetragen."
|
||||
}
|
||||
aktion={
|
||||
darfBearbeiten && !suche ? (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Siren aria-hidden />
|
||||
Einsatz erfassen
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schnellerfassung */}
|
||||
<Dialog open={offen} onOpenChange={setOffen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<form
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
setMeldung(null);
|
||||
anlegen.mutate({
|
||||
stichwort: formular.stichwort,
|
||||
kategorie: formular.kategorie,
|
||||
strasse: formular.strasse || null,
|
||||
hausnummer: formular.hausnummer || null,
|
||||
plz: formular.plz || null,
|
||||
ort: formular.ort || null,
|
||||
objekt: formular.objekt || null,
|
||||
breitengrad: formular.breitengrad,
|
||||
laengengrad: formular.laengengrad,
|
||||
koordinatenQuelle: formular.koordinatenQuelle,
|
||||
fahrzeugIds: [...fahrzeugIds],
|
||||
ausgerueckt,
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Einsatz erfassen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Stichwort, Ort, Fahrzeuge — mehr braucht es jetzt nicht. Alarmzeit ist dieser
|
||||
Moment.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stichwort">Einsatzstichwort</Label>
|
||||
<Input
|
||||
id="stichwort"
|
||||
required
|
||||
autoFocus
|
||||
value={formular.stichwort}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, stichwort: ereignis.target.value })
|
||||
}
|
||||
placeholder="B 3 Wohnhausbrand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kategorie">Kategorie</Label>
|
||||
<Select
|
||||
value={formular.kategorie}
|
||||
onValueChange={(wert) =>
|
||||
setFormular({ ...formular, kategorie: wert as EinsatzKategorie })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="kategorie">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(KATEGORIE_TEXT) as EinsatzKategorie[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{KATEGORIE_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="space-y-2 sm:col-span-3">
|
||||
<Label htmlFor="strasse">Straße</Label>
|
||||
<Input
|
||||
id="strasse"
|
||||
value={formular.strasse}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, strasse: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hausnummer">Nummer</Label>
|
||||
<Input
|
||||
id="hausnummer"
|
||||
value={formular.hausnummer}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, hausnummer: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plz">PLZ</Label>
|
||||
<Input
|
||||
id="plz"
|
||||
value={formular.plz}
|
||||
onChange={(ereignis) => setFormular({ ...formular, plz: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-3">
|
||||
<Label htmlFor="ort">Ort</Label>
|
||||
<Input
|
||||
id="ort"
|
||||
value={formular.ort}
|
||||
onChange={(ereignis) => setFormular({ ...formular, ort: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="objekt">Objekt</Label>
|
||||
<Input
|
||||
id="objekt"
|
||||
value={formular.objekt}
|
||||
onChange={(ereignis) => setFormular({ ...formular, objekt: ereignis.target.value })}
|
||||
placeholder="Grundschule, Autobahn A9 km 312 …"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Koordinaten über den eigenen Nominatim */}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setAdressuche(
|
||||
[formular.strasse, formular.hausnummer, formular.plz, formular.ort]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MapPin aria-hidden />
|
||||
Koordinaten suchen
|
||||
</Button>
|
||||
|
||||
{adressen.data && !adressen.data.verfuegbar && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Es ist kein eigener Nominatim eingerichtet — Koordinaten bleiben leer. Das ist
|
||||
in Ordnung; die Karte kommt in Phase 6.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{adressen.data?.treffer.map((treffer) => (
|
||||
<button
|
||||
key={`${treffer.breitengrad},${treffer.laengengrad}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormular({
|
||||
...formular,
|
||||
breitengrad: treffer.breitengrad,
|
||||
laengengrad: treffer.laengengrad,
|
||||
koordinatenQuelle: "nominatim",
|
||||
})
|
||||
}
|
||||
className="block w-full rounded border border-[var(--border)] p-2 text-left text-sm hover:border-[var(--primary)]"
|
||||
>
|
||||
{treffer.anzeige}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{formular.breitengrad !== null && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Koordinaten übernommen: {formular.breitengrad.toFixed(5)},{" "}
|
||||
{formular.laengengrad?.toFixed(5)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Ausgerückte Fahrzeuge</Label>
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{fahrzeuge.data?.eintraege.map((fahrzeug) => (
|
||||
<label
|
||||
key={fahrzeug.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded border border-[var(--border)] px-3 py-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={fahrzeugIds.has(fahrzeug.id)}
|
||||
onCheckedChange={(an) =>
|
||||
setFahrzeugIds((alt) => {
|
||||
const neu = new Set(alt);
|
||||
if (an) neu.add(fahrzeug.id);
|
||||
else neu.delete(fahrzeug.id);
|
||||
return neu;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{fahrzeug.funkrufname}
|
||||
{fahrzeug.dinTyp && (
|
||||
<span className="text-[var(--muted-foreground)]"> · {fahrzeug.dinTyp}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={ausgerueckt}
|
||||
onCheckedChange={(an) => setAusgerueckt(an === true)}
|
||||
/>
|
||||
Fahrzeuge sind jetzt ausgerückt
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={anlegen.isPending}>
|
||||
{anlegen.isPending ? "wird angelegt …" : "Einsatz anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { BarChart3, GraduationCap } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EinsatzListe } from "@/app/(app)/einsaetze/einsatz-liste";
|
||||
|
||||
export const metadata = { title: "Einsätze" };
|
||||
|
||||
/**
|
||||
* Einsatzübersicht.
|
||||
*
|
||||
* Oben die Schnellerfassung: im Alarmfall zählt, dass der Einsatz überhaupt
|
||||
* angelegt wird. Alles andere wird nachgetragen.
|
||||
*/
|
||||
export default async function EinsaetzeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
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">Einsätze</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Vom Alarm bis zum freigegebenen Bericht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/uebungen">
|
||||
<GraduationCap aria-hidden />
|
||||
Übungen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/einsaetze/statistik">
|
||||
<BarChart3 aria-hidden />
|
||||
Statistik
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EinsatzListe darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Einsatzstatistik } from "@/app/(app)/einsaetze/statistik/statistik";
|
||||
|
||||
export const metadata = { title: "Einsatzstatistik" };
|
||||
|
||||
/**
|
||||
* Die Zahlen des Jahres.
|
||||
*
|
||||
* Zwei Adressaten: die Gemeinde fragt nach Anzahl und Hilfsfrist, die Wehr
|
||||
* nach der Beteiligung. Beides steht auf dieser Seite, und der Export darunter
|
||||
* bringt es aufs Papier.
|
||||
*/
|
||||
export default async function StatistikSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/einsaetze">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Einsätzen
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Einsatzstatistik</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Einsätze nach Art und Monat, Hilfsfristen, Beteiligung und Fahrzeugauslastung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Einsatzstatistik />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { FileDown } from "lucide-react";
|
||||
import type { EinsatzKategorie, UebungsArt } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Balkendiagramm } from "@/components/einsatz/anzeige";
|
||||
import { Kennzahl } from "@/components/wartung/anzeige";
|
||||
import { KATEGORIE_TEXT, MONAT_KURZ, UEBUNGSART_TEXT } from "@/lib/einsatz/texte";
|
||||
import { alsDauer } from "@/lib/einsatz/zeiten";
|
||||
|
||||
/**
|
||||
* Einsatzstatistik eines Jahres.
|
||||
*
|
||||
* Die wichtigste Zahl steht oben links und heißt Hilfsfrist: in Bayern soll die
|
||||
* Wehr in 80 Prozent der Fälle innerhalb von zehn Minuten nach Alarmierung an
|
||||
* der Einsatzstelle sein. Genau dieser Wert wird hier ausgewiesen — nicht der
|
||||
* Durchschnitt, denn der verdeckt die Ausreißer, um die es geht.
|
||||
*/
|
||||
export function Einsatzstatistik() {
|
||||
const [jahr, setJahr] = React.useState<number>(new Date().getFullYear());
|
||||
|
||||
const jahre = trpc.statistik.jahre.useQuery();
|
||||
const daten = trpc.statistik.jahr.useQuery({ jahr });
|
||||
|
||||
if (daten.error) return <Fehlermeldung meldung={daten.error.message} />;
|
||||
|
||||
const zahlen = daten.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="jahr">Jahr</Label>
|
||||
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
|
||||
<SelectTrigger id="jahr" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(jahre.data ?? [new Date().getFullYear()]).map((eintrag) => (
|
||||
<SelectItem key={eintrag} value={String(eintrag)}>
|
||||
{eintrag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<a href={`/api/einsaetze/jahresexport?jahr=${jahr}`} target="_blank" rel="noopener">
|
||||
<FileDown aria-hidden />
|
||||
Jahresexport als PDF
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{daten.isLoading && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Wird gerechnet …</p>
|
||||
)}
|
||||
|
||||
{zahlen && zahlen.gesamt === 0 && (
|
||||
<NichtsGefunden
|
||||
titel={`Für ${jahr} ist kein Einsatz erfasst`}
|
||||
hinweis="Ein anderes Jahr zeigt vielleicht mehr."
|
||||
/>
|
||||
)}
|
||||
|
||||
{zahlen && zahlen.gesamt > 0 && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Kennzahl
|
||||
wert={zahlen.gesamt}
|
||||
bezeichnung="Einsätze"
|
||||
hinweis={`${zahlen.freigegeben} Berichte freigegeben`}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
||||
<span className="tabellenzahlen text-3xl font-semibold">
|
||||
{alsDauer(zahlen.zeiten.hilfsfristP80)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">Hilfsfrist (80 %)</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
in 80 % der Fälle war die Wehr schneller da
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
||||
<span className="tabellenzahlen text-3xl font-semibold">
|
||||
{alsDauer(zahlen.zeiten.ausrueckzeitMedian)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">Ausrückzeit (Median)</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">Alarm bis Ausrücken</span>
|
||||
</div>
|
||||
<Kennzahl
|
||||
wert={zahlen.personen.gerettet}
|
||||
bezeichnung="gerettete Personen"
|
||||
hinweis={`${zahlen.personen.verletzt} verletzt, ${zahlen.personen.getoetet} getötet`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nach Kategorie</CardTitle>
|
||||
<CardDescription>Einteilung nach der Landesstatistik.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Balkendiagramm
|
||||
werte={zahlen.nachKategorie.map((eintrag) => ({
|
||||
beschriftung: KATEGORIE_TEXT[eintrag.kategorie as EinsatzKategorie] ?? eintrag.kategorie,
|
||||
wert: eintrag.anzahl,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nach Monat</CardTitle>
|
||||
<CardDescription>Wann im Jahr die Arbeit anfällt.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Balkendiagramm
|
||||
werte={zahlen.nachMonat.map((eintrag) => ({
|
||||
beschriftung: MONAT_KURZ[eintrag.monat - 1] ?? String(eintrag.monat),
|
||||
wert: eintrag.anzahl,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Hilfsfristen</CardTitle>
|
||||
<CardDescription>
|
||||
Verteilung über {zahlen.zeiten.gemessen} Einsätze mit vollständigen Zeiten.
|
||||
Median {alsDauer(zahlen.zeiten.hilfsfristMedian)}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Balkendiagramm
|
||||
werte={zahlen.zeiten.verteilung.map((eintrag) => ({
|
||||
beschriftung: eintrag.text,
|
||||
wert: eintrag.anzahl,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fahrzeuge</CardTitle>
|
||||
<CardDescription>Einsätze, gefahrene Kilometer, Wasserabgabe.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fahrzeug</TableHead>
|
||||
<TableHead className="text-right">Einsätze</TableHead>
|
||||
<TableHead className="text-right">km</TableHead>
|
||||
<TableHead className="text-right">Wasser</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{zahlen.fahrzeuge.map((fahrzeug) => (
|
||||
<TableRow key={fahrzeug.fahrzeugId}>
|
||||
<TableCell>{fahrzeug.name}</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{fahrzeug.einsaetze}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{fahrzeug.kilometer || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{fahrzeug.wasser ? `${fahrzeug.wasser} l` : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Beteiligung</CardTitle>
|
||||
<CardDescription>
|
||||
Wer trägt die Last — Einsätze und Übungen je Person. {zahlen.uebungen.gesamt}{" "}
|
||||
Übungen mit {zahlen.uebungen.teilnahmen} Teilnahmen
|
||||
{zahlen.uebungen.nachArt.length > 0 &&
|
||||
` (${zahlen.uebungen.nachArt
|
||||
.map(
|
||||
(eintrag) =>
|
||||
`${UEBUNGSART_TEXT[eintrag.art as UebungsArt] ?? eintrag.art}: ${eintrag.anzahl}`,
|
||||
)
|
||||
.join(", ")})`}
|
||||
.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="text-right">Einsätze</TableHead>
|
||||
<TableHead className="text-right">davon Atemschutz</TableHead>
|
||||
<TableHead className="text-right">Übungen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{zahlen.beteiligung.map((person) => (
|
||||
<TableRow key={person.personId}>
|
||||
<TableCell>{person.name}</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{person.einsaetze}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{person.atemschutzeinsaetze || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{person.uebungen || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ArrowLeft, Package } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FristAnzeige, StatusAbzeichen } from "@/components/stammdaten/anzeige";
|
||||
|
||||
export const metadata = { title: "Fahrzeug" };
|
||||
|
||||
/**
|
||||
* Fahrzeugdetail mit der Beladung, nach Geräteräumen geordnet.
|
||||
*
|
||||
* Genau so sucht der Gerätewart: nicht "wo ist die Motorsäge", sondern "was
|
||||
* ist in G3". Deshalb steht die Beladeordnung im Mittelpunkt und nicht eine
|
||||
* alphabetische Geräteliste.
|
||||
*/
|
||||
export default async function FahrzeugSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const fahrzeug = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.fahrzeug.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
standort: { select: { id: true, name: true } },
|
||||
geraeteraeume: {
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
|
||||
include: {
|
||||
geraete: {
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: { bezeichnung: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
status: true,
|
||||
naechstePruefung: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
geraete: {
|
||||
where: { geloeschtAm: null, geraeteraumId: null },
|
||||
orderBy: { bezeichnung: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
status: true,
|
||||
naechstePruefung: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!fahrzeug) notFound();
|
||||
|
||||
const angaben: Array<[string, React.ReactNode]> = [
|
||||
["Typ nach DIN", fahrzeug.dinTyp ?? "—"],
|
||||
["Kennzeichen", <span className="tabellenzahlen">{fahrzeug.kennzeichen ?? "—"}</span>],
|
||||
["Hersteller", fahrzeug.hersteller ?? "—"],
|
||||
["Aufbauhersteller", fahrzeug.aufbauhersteller ?? "—"],
|
||||
[
|
||||
"Fahrgestellnummer",
|
||||
<span className="tabellenzahlen">{fahrzeug.fahrgestellnummer ?? "—"}</span>,
|
||||
],
|
||||
["Baujahr", <span className="tabellenzahlen">{fahrzeug.baujahr ?? "—"}</span>],
|
||||
["Erstzulassung", alsDatum(fahrzeug.erstzulassung)],
|
||||
["Indienststellung", alsDatum(fahrzeug.indienststellung)],
|
||||
["Nächster TÜV", <FristAnzeige datum={fahrzeug.tuevBis} vorwarnTage={60} />],
|
||||
["Sicherheitsprüfung (UVV)", <FristAnzeige datum={fahrzeug.uvvBis} vorwarnTage={60} />],
|
||||
["Sitzplätze", <span className="tabellenzahlen">{fahrzeug.sitzplaetze ?? "—"}</span>],
|
||||
[
|
||||
"Löschwasser",
|
||||
fahrzeug.tankinhalt ? (
|
||||
<span className="tabellenzahlen">{fahrzeug.tankinhalt.toLocaleString("de-DE")} l</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
],
|
||||
["Standort", fahrzeug.standort?.name ?? "—"],
|
||||
];
|
||||
|
||||
const alleRaeume = [
|
||||
...fahrzeug.geraeteraeume,
|
||||
// Was keinem Raum zugeordnet ist, bekommt einen eigenen Abschnitt statt
|
||||
// stillschweigend zu verschwinden.
|
||||
...(fahrzeug.geraete.length > 0
|
||||
? [
|
||||
{
|
||||
id: "__ohne__",
|
||||
bezeichnung: "Ohne Raumangabe",
|
||||
beschreibung: "Verlastet, aber keinem Geräteraum zugeordnet",
|
||||
geraete: fahrzeug.geraete,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/fahrzeuge">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Fahrzeugen
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{fahrzeug.funkrufname}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{fahrzeug.dinTyp ? `${fahrzeug.dinTyp} — ` : ""}
|
||||
{fahrzeug.bezeichnung}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{angaben.map(([bezeichnung, wert]) => (
|
||||
<div key={bezeichnung}>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{bezeichnung}
|
||||
</dt>
|
||||
<dd className="text-sm">{wert}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Beladung</CardTitle>
|
||||
<CardDescription>
|
||||
Nach Geräteräumen geordnet, in der Reihenfolge der Beladeordnung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{alleRaeume.length === 0 && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Für dieses Fahrzeug sind noch keine Geräteräume angelegt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{alleRaeume.map((raum) => (
|
||||
<div key={raum.id}>
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<Badge variant="secondary">{raum.bezeichnung}</Badge>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{raum.beschreibung}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
|
||||
{raum.geraete.length}{" "}
|
||||
{raum.geraete.length === 1 ? "Gerät" : "Geräte"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{raum.geraete.length === 0 ? (
|
||||
<p className="pl-1 text-sm text-[var(--muted-foreground)]">leer</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
|
||||
{raum.geraete.map((geraet) => (
|
||||
<li
|
||||
key={geraet.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<Package className="size-4 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<Link
|
||||
href={`/geraete/${geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{geraet.bezeichnung}
|
||||
</Link>
|
||||
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
|
||||
{geraet.inventarnummer}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<FristAnzeige datum={geraet.naechstePruefung} />
|
||||
<StatusAbzeichen status={geraet.status} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Truck } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
|
||||
export const metadata = { title: "Fahrzeuge" };
|
||||
|
||||
const ART_TEXT: Record<string, string> = {
|
||||
LOESCHFAHRZEUG: "Löschfahrzeug",
|
||||
DREHLEITER: "Drehleiter",
|
||||
RUESTWAGEN: "Rüstwagen",
|
||||
GERAETEWAGEN: "Gerätewagen",
|
||||
MANNSCHAFTSTRANSPORT: "Mannschaftstransport",
|
||||
EINSATZLEITWAGEN: "Einsatzleitwagen",
|
||||
WECHSELLADER: "Wechsellader",
|
||||
ANHAENGER: "Anhänger",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fahrzeugübersicht als Kacheln statt als Tabelle.
|
||||
*
|
||||
* Eine Wehr hat drei bis zehn Fahrzeuge, nicht dreihundert. Für diese Menge
|
||||
* ist eine Kachel mit den wichtigen Terminen brauchbarer als eine Zeile: der
|
||||
* TÜV-Termin und die Zahl der verlasteten Geräte sind das, wonach man sucht.
|
||||
*/
|
||||
export default async function FahrzeugeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const fahrzeuge = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.fahrzeug.findMany({
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: { funkrufname: "asc" },
|
||||
include: {
|
||||
standort: { select: { name: true } },
|
||||
_count: { select: { geraete: true, geraeteraeume: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Fahrzeuge</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Einsatzfahrzeuge der Wehr mit Terminen und verlasteten Geräten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fahrzeuge.length === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel="Noch keine Fahrzeuge erfasst"
|
||||
hinweis="Fahrzeuge lassen sich einzeln anlegen oder zusammen mit den Geräten importieren."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{fahrzeuge.map((fahrzeug) => (
|
||||
<Card key={fahrzeug.id} className={!fahrzeug.aktiv ? "opacity-60" : undefined}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate">
|
||||
<Link
|
||||
href={`/fahrzeuge/${fahrzeug.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{fahrzeug.funkrufname}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{fahrzeug.dinTyp ? `${fahrzeug.dinTyp} — ` : ""}
|
||||
{fahrzeug.bezeichnung}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Truck className="size-5 shrink-0 text-[var(--muted-foreground)]" aria-hidden />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{ART_TEXT[fahrzeug.art] ?? fahrzeug.art}</Badge>
|
||||
{fahrzeug.kennzeichen && (
|
||||
<Badge variant="outline" className="tabellenzahlen">
|
||||
{fahrzeug.kennzeichen}
|
||||
</Badge>
|
||||
)}
|
||||
{!fahrzeug.aktiv && <Badge variant="ausgesondert">außer Dienst</Badge>}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Nächster TÜV
|
||||
</dt>
|
||||
<dd>
|
||||
<FristAnzeige datum={fahrzeug.tuevBis} vorwarnTage={60} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Baujahr
|
||||
</dt>
|
||||
<dd className="tabellenzahlen">{fahrzeug.baujahr ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Standort
|
||||
</dt>
|
||||
<dd className="truncate">{fahrzeug.standort?.name ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Indienststellung
|
||||
</dt>
|
||||
<dd className="tabellenzahlen">{alsDatum(fahrzeug.indienststellung)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="border-t border-[var(--border)] pt-2 text-[var(--muted-foreground)]">
|
||||
<Link
|
||||
href={`/geraete?fahrzeug=${fahrzeug.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{fahrzeug._count.geraete}{" "}
|
||||
{fahrzeug._count.geraete === 1 ? "Gerät" : "Geräte"} verlastet
|
||||
</Link>
|
||||
{" · "}
|
||||
{fahrzeug._count.geraeteraeume}{" "}
|
||||
{fahrzeug._count.geraeteraeume === 1 ? "Geräteraum" : "Geräteräume"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GeraetFormular } from "@/components/stammdaten/geraet-formular";
|
||||
import {
|
||||
LEERES_GERAET,
|
||||
alsFeldwert,
|
||||
} from "@/components/stammdaten/geraet-formular-werte";
|
||||
|
||||
export const metadata = { title: "Gerät bearbeiten" };
|
||||
|
||||
/** Ein vorhandenes Gerät bearbeiten. */
|
||||
export default async function GeraetBearbeitenSeite({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect(`/geraete/${id}`);
|
||||
|
||||
const geraet = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.geraet.findUnique({ where: { id } }),
|
||||
);
|
||||
|
||||
if (!geraet) notFound();
|
||||
|
||||
const vorgabe = {
|
||||
...LEERES_GERAET,
|
||||
id: geraet.id,
|
||||
inventarnummer: geraet.inventarnummer,
|
||||
bezeichnung: geraet.bezeichnung,
|
||||
geraetetypId: geraet.geraetetypId,
|
||||
hersteller: geraet.hersteller ?? "",
|
||||
modell: geraet.modell ?? "",
|
||||
seriennummer: geraet.seriennummer ?? "",
|
||||
herstellungsdatum: alsFeldwert(geraet.herstellungsdatum),
|
||||
indienststellung: alsFeldwert(geraet.indienststellung),
|
||||
// Decimal aus Prisma: als deutscher Text ins Feld, damit das Komma bleibt.
|
||||
anschaffungspreis: geraet.anschaffungspreis
|
||||
? String(geraet.anschaffungspreis).replace(".", ",")
|
||||
: "",
|
||||
status: geraet.status,
|
||||
pruefintervallMonate: geraet.pruefintervallMonate?.toString() ?? "",
|
||||
letztePruefung: alsFeldwert(geraet.letztePruefung),
|
||||
lebensdauerJahre: geraet.lebensdauerJahre?.toString() ?? "",
|
||||
lagerungsart: (geraet.fahrzeugId ? "fahrzeug" : "standort") as "fahrzeug" | "standort",
|
||||
standortId: geraet.standortId,
|
||||
fahrzeugId: geraet.fahrzeugId,
|
||||
geraeteraumId: geraet.geraeteraumId,
|
||||
lagerort: geraet.lagerort ?? "",
|
||||
bemerkung: geraet.bemerkung ?? "",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2">
|
||||
<Link href={`/geraete/${geraet.id}`}>
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zum Gerät
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<GeraetFormular
|
||||
vorgabe={vorgabe}
|
||||
titel={geraet.bezeichnung}
|
||||
beschreibung={`Inventarnummer ${geraet.inventarnummer}. Jede Änderung wird protokolliert.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||