Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d692098d | ||
|
|
91954c402b | ||
|
|
bc62a9c1e8 | ||
|
|
e503aafc18 | ||
|
|
7f866ac35c | ||
|
|
1744403620 | ||
|
|
47b8e36a38 | ||
|
|
26b62a3477 |
@@ -213,6 +213,39 @@ LLM_BASE_URL=http://ollama:11434/v1
|
|||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODELL=qwen2.5:14b
|
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)
|
# Datenschutz und Aufbewahrung (DSGVO)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,7 +8,349 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
Noch nichts. Nächster Schritt ist Phase 1 (Stammdatenverwaltung).
|
Nächster Schritt ist Phase 4 (Kiosk-PWA mit Offline-Betrieb).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
## [0.1.0] — 2026-09-03
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
|||||||
| Bereich | Stand |
|
| Bereich | Stand |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Phase 0 — Fundament | fertig |
|
| Phase 0 — Fundament | fertig |
|
||||||
| Phase 1 — Stammdaten | offen |
|
| Phase 1 — Stammdaten | fertig |
|
||||||
| Phase 2 — Wartung und Prüfung | offen |
|
| Phase 2 — Wartung und Prüfung | fertig |
|
||||||
| Phase 3 — Einsätze und Übungen | offen |
|
| Phase 3 — Einsätze und Übungen | fertig |
|
||||||
| Phase 4 — Kiosk-PWA | offen |
|
| Phase 4 — Kiosk-PWA | offen |
|
||||||
| Phase 5 — Atemschutz (FwDV 7) | offen |
|
| Phase 5 — Atemschutz (FwDV 7) | offen |
|
||||||
| Phase 6 — Kartenmodule | offen |
|
| Phase 6 — Kartenmodule | offen |
|
||||||
@@ -152,6 +152,256 @@ ausschließlich über [`src/lib/storage.ts`](src/lib/storage.ts). Objektschlüss
|
|||||||
Downloads laufen über presignte Links mit begrenzter Gültigkeit
|
Downloads laufen über presignte Links mit begrenzter Gültigkeit
|
||||||
(`S3_URL_TTL`, Vorgabe 15 Minuten).
|
(`S3_URL_TTL`, Vorgabe 15 Minuten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ x-app-env: &app-env
|
|||||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||||
LLM_MODELL: ${LLM_MODELL:-}
|
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}
|
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
|
||||||
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
||||||
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
||||||
@@ -298,7 +308,11 @@ services:
|
|||||||
image: florian-app:${IMAGE_TAG:-latest}
|
image: florian-app:${IMAGE_TAG:-latest}
|
||||||
container_name: florian-worker
|
container_name: florian-worker
|
||||||
restart: unless-stopped
|
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:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
ROLLE: worker
|
ROLLE: worker
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
# Anleitung
|
# Anleitung
|
||||||
|
|
||||||
Bedienung von FLORIAN, Stand Phase 0 (Version 0.1.0). Die Bilder zeigen die
|
Bedienung von FLORIAN, Stand Phase 3 (Version 0.4.0). Die Bilder zeigen die
|
||||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||||
|
|
||||||
> Was hier noch fehlt — Mitglieder, Geräte, Prüfungen — kommt in den
|
> Atemschutz, Karten und die Kiosk-Oberfläche kommen in den folgenden Phasen
|
||||||
> folgenden Phasen dazu. Der Stand ist bewusst schmal: Phase 0 baut das
|
> dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
> Fundament, nicht die Fachmodule. Siehe [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
|
|||||||
### Die Seitenleiste
|
### Die Seitenleiste
|
||||||
|
|
||||||
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt
|
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
|
Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
|
||||||
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
|
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
|
||||||
@@ -94,7 +93,692 @@ 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 24). 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. 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.
|
||||||
|
|
||||||
|
## 25. Rollen und was sie dürfen
|
||||||
|
|
||||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||||
@@ -123,7 +807,7 @@ Das Terminal selbst ist nur die Tür.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Anmeldung über authentik einrichten
|
## 26. Anmeldung über authentik einrichten
|
||||||
|
|
||||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||||
|
|
||||||
@@ -176,7 +860,7 @@ ganze Wehr aus.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Terminal-Anmeldung im Gerätehaus
|
## 27. Terminal-Anmeldung im Gerätehaus
|
||||||
|
|
||||||
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
|
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
|
||||||
|
|
||||||
@@ -198,7 +882,7 @@ Huber), PIN `471203`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Was protokolliert wird
|
## 28. Was protokolliert wird
|
||||||
|
|
||||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||||
@@ -218,11 +902,12 @@ einzige Weg, auf dem etwas aus dem Log verschwindet.
|
|||||||
dem Schreiben herausgenommen.
|
dem Schreiben herausgenommen.
|
||||||
|
|
||||||
Einsehbar ist der Log mit dem Recht `system.auditlog`, das Kommandant und
|
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
|
## 29. Wenn etwas nicht geht
|
||||||
|
|
||||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -231,6 +916,11 @@ 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. |
|
| 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` |
|
| 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 |
|
| 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 |
|
| `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben |
|
||||||
|
|
||||||
Zustand aller Randsysteme auf einen Blick:
|
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 |
|
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|
||||||
|---|---|---:|---:|---:|
|
|---|---|---:|---:|---:|
|
||||||
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
|
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
|
||||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 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 | offen |
|
| 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 | offen |
|
| 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 |
|
| 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 |
|
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
||||||
| 6 | Kartenmodule | 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
|
## 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 |
|
| 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
|
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.
|
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
|
## 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 |
|
| 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,
|
Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
|
||||||
warum der Audit-Log schon in Phase 0 append-only ist.
|
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
|
## Phase 3 — Einsätze und Übungen
|
||||||
|
|
||||||
**Vergleichsaufwand: 12–16 PT**
|
**Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 2:10 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -129,6 +166,30 @@ Das externe Portal für Fremdwehren ist sicherheitsrelevant: ein Zugang ohne
|
|||||||
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
|
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
|
||||||
steckt weniger in der Funktion als in der Absicherung.
|
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
|
## Phase 4 — Kiosk-PWA
|
||||||
|
|
||||||
**Vergleichsaufwand: 16–22 PT**
|
**Vergleichsaufwand: 16–22 PT**
|
||||||
|
|||||||
@@ -11,7 +11,360 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
|||||||
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
|
| 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 |
|
||||||
|
| **Summe** | **7:40 h** | **5** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,9 +10,9 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
|
|||||||
|
|
||||||
```
|
```
|
||||||
Phase 0 ████████████████████ fertig 0.1.0
|
Phase 0 ████████████████████ fertig 0.1.0
|
||||||
Phase 1 ░░░░░░░░░░░░░░░░░░░░ offen 0.2.0
|
Phase 1 ████████████████████ fertig 0.2.0
|
||||||
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0
|
Phase 2 ████████████████████ fertig 0.3.0
|
||||||
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
|
Phase 3 ████████████████████ fertig 0.4.0
|
||||||
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
|
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
|
||||||
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
|
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
|
||||||
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
|
||||||
@@ -39,7 +39,7 @@ Alles, worauf der Rest aufsetzt.
|
|||||||
- Basis-Layout mit Seitenleiste und dunkler Darstellung
|
- Basis-Layout mit Seitenleiste und dunkler Darstellung
|
||||||
- Seed mit einer vollständigen Beispielwehr
|
- Seed mit einer vollständigen Beispielwehr
|
||||||
|
|
||||||
## Phase 1 — Stammdaten · als Nächstes
|
## Phase 1 — Stammdaten · fertig
|
||||||
|
|
||||||
Die Wehr trägt ein, was sie hat.
|
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
|
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
|
||||||
- Gerätehistorie aus dem Audit-Log
|
- Gerätehistorie aus dem Audit-Log
|
||||||
- Qualifikationen mit Ablaufwarnung auf der Übersicht
|
- 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
|
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
|
||||||
QR-Code am Regal wiederfinden.
|
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.
|
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
|
||||||
|
|
||||||
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
|
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
|
||||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage
|
- 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
|
gebündelt nach Fahrzeug, Standort, Gerätetyp oder Zuständigem
|
||||||
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift
|
- Nächtlicher Lauf erzeugt Termine, zieht den Gerätestatus nach und erinnert bei
|
||||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO
|
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
|
- Lebensdauerüberwachung mit Aussonderungsvorschlag
|
||||||
- Rückrufe markieren betroffene Geräte automatisch
|
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben
|
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
|
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
|
||||||
die Unfallkasse standhält.
|
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,
|
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
|
||||||
Einsatzende, eingerückt, einsatzbereit
|
Einsatzende, eingerückt, einsatzbereit
|
||||||
@@ -87,7 +97,7 @@ die Unfallkasse standhält.
|
|||||||
|
|
||||||
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
|
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
|
||||||
|
|
||||||
## Phase 4 — Kiosk-PWA
|
## Phase 4 — Kiosk-PWA · als Nächstes
|
||||||
|
|
||||||
Für die Wandtablets in der Fahrzeughalle.
|
Für die Wandtablets in der Fahrzeughalle.
|
||||||
|
|
||||||
|
|||||||
|
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: 203 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 730 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 965 KiB |
|
After Width: | Height: | Size: 1010 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -8,6 +8,9 @@ const nextConfig: NextConfig = {
|
|||||||
images: { remotePatterns: [] },
|
images: { remotePatterns: [] },
|
||||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
|
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
// Die Entwicklungsanzeige unten links wuerde in den Dokumentationsbildern
|
||||||
|
// mitlaufen. Sie betrifft ohnehin nur "next dev".
|
||||||
|
devIndicators: false,
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"dev": "next dev -p 3100",
|
"dev": "next dev -p 3100",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3100",
|
"start": "next start -p 3100",
|
||||||
"worker": "tsx src/worker/index.ts",
|
"worker": "tsx --conditions=react-server src/worker/index.ts",
|
||||||
"worker:dev": "tsx watch src/worker/index.ts",
|
"worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -28,26 +28,38 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@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-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@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-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@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-query": "^5.90.2",
|
||||||
|
"@tanstack/react-table": "^9.2.4",
|
||||||
"@trpc/client": "^11.6.0",
|
"@trpc/client": "^11.6.0",
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
|
"@zxing/browser": "^0.2.1",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"bullmq": "^5.61.0",
|
"bullmq": "^5.61.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.8.1",
|
"ioredis": "^5.8.1",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"papaparse": "^5.7.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
@@ -61,6 +73,8 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "^22.18.6",
|
"@types/node": "^22.18.6",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
|
|||||||
@@ -14,10 +14,23 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
/**
|
||||||
|
* 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"]],
|
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||||
timeout: 30_000,
|
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
|
||||||
expect: { timeout: 10_000 },
|
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: {
|
use: {
|
||||||
baseURL: BASIS_URL,
|
baseURL: BASIS_URL,
|
||||||
@@ -44,6 +57,6 @@ export default defineConfig({
|
|||||||
command: "npm run dev",
|
command: "npm run dev",
|
||||||
url: BASIS_URL,
|
url: BASIS_URL,
|
||||||
reuseExistingServer: !process.env.CI,
|
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,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -44,17 +44,238 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
beschreibung: "Übersicht nach der Anmeldung",
|
beschreibung: "Übersicht nach der Anmeldung",
|
||||||
angemeldet: true,
|
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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
async function anmelden(seite: Page): Promise<void> {
|
||||||
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
||||||
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||||
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||||
await seite.getByRole("button", { name: "Anmelden" }).click();
|
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");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
await mkdir(ZIEL, { recursive: true });
|
await mkdir(ZIEL, { recursive: true });
|
||||||
|
|
||||||
@@ -79,14 +300,49 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
|
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
|
||||||
// Kurz warten, damit Übergänge und Symbole fertig gezeichnet sind.
|
await fertigGeladen(seite);
|
||||||
await seite.waitForTimeout(400);
|
|
||||||
|
|
||||||
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
||||||
await seite.screenshot({ path: datei, fullPage: true });
|
await seite.screenshot({ path: datei, fullPage: true });
|
||||||
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
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();
|
await kontext.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ArrowLeft, ClipboardCheck, Pencil, Printer, TriangleAlert } from "lucide-react";
|
||||||
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { hatRecht } from "@/lib/authz";
|
||||||
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
|
import { qrAlsDataUri, qrZiel } from "@/lib/stammdaten/qr";
|
||||||
|
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { StatusAbzeichen, FristAnzeige } from "@/components/stammdaten/anzeige";
|
||||||
|
import { ErgebnisAbzeichen, MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige";
|
||||||
|
|
||||||
|
export const metadata = { title: "Gerät" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailseite eines Geräts.
|
||||||
|
*
|
||||||
|
* Serverseitig gerendert: hier wird nichts gefiltert und nichts sortiert, also
|
||||||
|
* braucht es auch keinen Zustand im Browser. Der QR-Code wird auf dem Server
|
||||||
|
* erzeugt und als Data-URI eingebettet — so lädt die Seite nichts nach.
|
||||||
|
*/
|
||||||
|
export default async function GeraetSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||||
|
const geraet = await db.geraet.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
geraetetyp: true,
|
||||||
|
standort: { select: { id: true, name: true } },
|
||||||
|
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||||
|
geraeteraum: { select: { id: true, bezeichnung: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!geraet) return null;
|
||||||
|
|
||||||
|
const verlauf = await db.auditLog.findMany({
|
||||||
|
where: { entitaet: "Geraet", entitaetId: id },
|
||||||
|
orderBy: { zeitpunkt: "desc" },
|
||||||
|
take: 30,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zeitpunkt: true,
|
||||||
|
aktion: true,
|
||||||
|
beschreibung: true,
|
||||||
|
akteurName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: was am Gerät geprüft, gemeldet und geplant ist.
|
||||||
|
const [pruefungen, maengel, termine] = await Promise.all([
|
||||||
|
db.pruefung.findMany({
|
||||||
|
where: { geraetId: id, geloeschtAm: null },
|
||||||
|
orderBy: { durchgefuehrtAm: "desc" },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
protokollNummer: true,
|
||||||
|
durchgefuehrtAm: true,
|
||||||
|
ergebnis: true,
|
||||||
|
status: true,
|
||||||
|
art: true,
|
||||||
|
prueferName: true,
|
||||||
|
vorlage: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.mangel.findMany({
|
||||||
|
where: {
|
||||||
|
geraetId: id,
|
||||||
|
geloeschtAm: null,
|
||||||
|
status: { notIn: ["BEHOBEN", "VERWORFEN"] },
|
||||||
|
},
|
||||||
|
orderBy: { gemeldetAm: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
beschreibung: true,
|
||||||
|
prioritaet: true,
|
||||||
|
status: true,
|
||||||
|
gemeldetAm: true,
|
||||||
|
melderName: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.wartungstermin.findMany({
|
||||||
|
where: { geraetId: id, status: "OFFEN", geloeschtAm: null },
|
||||||
|
orderBy: { faelligAm: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
faelligAm: true,
|
||||||
|
vorlage: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { geraet, verlauf, pruefungen, maengel, termine };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!daten) notFound();
|
||||||
|
|
||||||
|
const { geraet, verlauf, pruefungen, maengel, termine } = daten;
|
||||||
|
const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
|
||||||
|
|
||||||
|
const angaben: Array<[string, React.ReactNode]> = [
|
||||||
|
["Inventarnummer", <span className="tabellenzahlen">{geraet.inventarnummer}</span>],
|
||||||
|
["Gerätetyp", geraet.geraetetyp?.name ?? "—"],
|
||||||
|
["Hersteller", geraet.hersteller ?? "—"],
|
||||||
|
["Modell", geraet.modell ?? "—"],
|
||||||
|
["Seriennummer", <span className="tabellenzahlen">{geraet.seriennummer ?? "—"}</span>],
|
||||||
|
["Herstellungsdatum", alsDatum(geraet.herstellungsdatum)],
|
||||||
|
["Indienststellung", alsDatum(geraet.indienststellung)],
|
||||||
|
[
|
||||||
|
"Anschaffungspreis",
|
||||||
|
geraet.anschaffungspreis ? alsEuro(Number(geraet.anschaffungspreis)) : "—",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Prüfintervall",
|
||||||
|
geraet.pruefintervallMonate
|
||||||
|
? `alle ${geraet.pruefintervallMonate} Monate`
|
||||||
|
: (geraet.geraetetyp?.pruefintervallMonate
|
||||||
|
? `alle ${geraet.geraetetyp.pruefintervallMonate} Monate (aus dem Gerätetyp)`
|
||||||
|
: "keines hinterlegt"),
|
||||||
|
],
|
||||||
|
["Letzte Prüfung", alsDatum(geraet.letztePruefung)],
|
||||||
|
["Nächste Prüfung", <FristAnzeige datum={geraet.naechstePruefung} />],
|
||||||
|
["Lebensdauer bis", <FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />],
|
||||||
|
["Prüfvorschrift", geraet.geraetetyp?.pruefvorschrift ?? "—"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||||
|
<Link href="/geraete">
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
Zurück zur Liste
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{geraet.bezeichnung}</h1>
|
||||||
|
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
|
||||||
|
<StatusAbzeichen status={geraet.status} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{hatRecht(sitzung, "pruefung.durchfuehren") && geraet.status !== "AUSGESONDERT" && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link
|
||||||
|
href={`/pruefungen/neu?geraetId=${geraet.id}${
|
||||||
|
termine[0] ? `&terminId=${termine[0].id}` : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ClipboardCheck aria-hidden />
|
||||||
|
Prüfung durchführen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hatRecht(sitzung, "geraet.bearbeiten") && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/geraete/${geraet.id}/bearbeiten`}>
|
||||||
|
<Pencil aria-hidden />
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Angaben</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-[var(--border)] pt-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
Lagerung
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{geraet.fahrzeug ? (
|
||||||
|
<>
|
||||||
|
Verlastet auf{" "}
|
||||||
|
<Link
|
||||||
|
href={`/fahrzeuge/${geraet.fahrzeug.id}`}
|
||||||
|
className="underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{geraet.fahrzeug.funkrufname}
|
||||||
|
</Link>
|
||||||
|
{geraet.geraeteraum && `, ${geraet.geraeteraum.bezeichnung}`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Standort ${geraet.standort?.name ?? "unbekannt"}`
|
||||||
|
)}
|
||||||
|
{geraet.lagerort && ` — ${geraet.lagerort}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{geraet.bemerkung && (
|
||||||
|
<div className="mt-4 border-t border-[var(--border)] pt-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
Bemerkung
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-line text-sm">{geraet.bemerkung}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>QR-Code</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Aufkleber im Geräteraum. Wer ihn scannt, landet direkt auf dieser Seite.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Data-URI: die Grafik kommt aus dem Server-Rendering, nicht aus
|
||||||
|
einem Nachladen. */}
|
||||||
|
<Image
|
||||||
|
src={qr}
|
||||||
|
alt={`QR-Code für ${geraet.inventarnummer}`}
|
||||||
|
width={320}
|
||||||
|
height={320}
|
||||||
|
unoptimized
|
||||||
|
className="mx-auto h-auto w-full max-w-56 rounded border border-[var(--border)] bg-white p-2"
|
||||||
|
/>
|
||||||
|
<p className="break-all text-center text-xs text-[var(--muted-foreground)]">
|
||||||
|
{qrZiel(geraet.inventarnummer)}
|
||||||
|
</p>
|
||||||
|
<form action="/api/geraete/etiketten" method="post">
|
||||||
|
<input type="hidden" name="id" value={geraet.id} />
|
||||||
|
<Button type="submit" variant="outline" className="w-full">
|
||||||
|
<Printer aria-hidden />
|
||||||
|
Etikett drucken
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(termine.length > 0 || maengel.length > 0) && (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Anstehende Prüfungen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Offene Termine aus den Wartungsvorlagen. Die früheste Frist steht oben in den
|
||||||
|
Angaben.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{termine.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Kein offener Termin. Termine entstehen nachts aus den Wartungsvorlagen des
|
||||||
|
Gerätetyps.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{termine.map((termin) => (
|
||||||
|
<li
|
||||||
|
key={termin.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--border)] pb-2 text-sm last:border-0"
|
||||||
|
>
|
||||||
|
<span>{termin.vorlage?.name ?? "Prüfung ohne Vorlage"}</span>
|
||||||
|
<FristAnzeige datum={termin.faelligAm} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TriangleAlert className="size-4" aria-hidden />
|
||||||
|
Offene Mängel
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Was an diesem Gerät noch behoben werden muss.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{maengel.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Kein offener Mangel.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{maengel.map((mangel) => (
|
||||||
|
<li key={mangel.id} className="text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<PrioritaetAbzeichen prioritaet={mangel.prioritaet} />
|
||||||
|
<MangelStatusAbzeichen status={mangel.status} />
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{alsDatum(mangel.gemeldetAm)}
|
||||||
|
{mangel.melderName && ` · ${mangel.melderName}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1">{mangel.beschreibung}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" asChild className="mt-4">
|
||||||
|
<Link href="/maengel">Zur Mängelverwaltung</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Prüfhistorie</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Abgeschlossene Prüfungen mit Ergebnis. Das PDF liegt am jeweiligen Protokoll.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{pruefungen.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Zu diesem Gerät ist noch keine Prüfung dokumentiert.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{pruefungen.map((pruefung) => (
|
||||||
|
<li
|
||||||
|
key={pruefung.id}
|
||||||
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--border)] pb-2 text-sm last:border-0"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/pruefungen/${pruefung.id}`}
|
||||||
|
className="tabellenzahlen font-medium underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{pruefung.protokollNummer ?? "Entwurf"}
|
||||||
|
</Link>
|
||||||
|
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
|
||||||
|
{alsDatum(pruefung.durchgefuehrtAm)}
|
||||||
|
</span>
|
||||||
|
<span>{pruefung.vorlage?.name ?? "—"}</span>
|
||||||
|
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||||
|
{pruefung.art === "EXTERN" && (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">extern</span>
|
||||||
|
)}
|
||||||
|
{pruefung.prueferName && (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
— {pruefung.prueferName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Verlauf</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Gerendert aus dem Audit-Log. Es gibt bewusst keine zweite Historie —
|
||||||
|
zwei Quellen könnten auseinanderlaufen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{verlauf.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Zu diesem Gerät ist noch nichts protokolliert.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{verlauf.map((eintrag) => (
|
||||||
|
<li
|
||||||
|
key={eintrag.id}
|
||||||
|
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-l-2 border-[var(--border)] pl-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
|
||||||
|
{alsDatumZeit(eintrag.zeitpunkt)}
|
||||||
|
</span>
|
||||||
|
<span>{eintrag.beschreibung ?? eintrag.aktion}</span>
|
||||||
|
{eintrag.akteurName && (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
— {eintrag.akteurName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { GeraetStatus } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Columns3,
|
||||||
|
Plus,
|
||||||
|
Printer,
|
||||||
|
QrCode,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
FristAnzeige,
|
||||||
|
Fehlermeldung,
|
||||||
|
GERAET_STATUS_TEXT,
|
||||||
|
LadeZeilen,
|
||||||
|
NichtsGefunden,
|
||||||
|
StatusAbzeichen,
|
||||||
|
} from "@/components/stammdaten/anzeige";
|
||||||
|
import { alsDatum, cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geräteliste.
|
||||||
|
*
|
||||||
|
* Sortierung, Filter und Blättern laufen serverseitig — bei einer Wehr mit
|
||||||
|
* mehreren hundert Geräten will man nicht alles in den Browser laden, nur um
|
||||||
|
* dort zu sortieren. Die Suche wartet bewusst 300 ms, sonst geht bei jedem
|
||||||
|
* Tastendruck eine Abfrage raus.
|
||||||
|
*
|
||||||
|
* Die Spaltenauswahl bleibt im Browser gespeichert: der Gerätewart schaut auf
|
||||||
|
* Prüftermine, der Kassier auf Anschaffungspreise, und beide wollen ihre
|
||||||
|
* Einstellung beim nächsten Mal wiederfinden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Spalte {
|
||||||
|
schluessel: string;
|
||||||
|
titel: string;
|
||||||
|
sortierbar?: boolean;
|
||||||
|
/** Standardmäßig eingeblendet. */
|
||||||
|
vorgabe: boolean;
|
||||||
|
/** Rechtsbündig für Zahlen und Daten. */
|
||||||
|
zahl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPALTEN: Spalte[] = [
|
||||||
|
{ schluessel: "inventarnummer", titel: "Inventarnr.", sortierbar: true, vorgabe: true },
|
||||||
|
{ schluessel: "bezeichnung", titel: "Bezeichnung", sortierbar: true, vorgabe: true },
|
||||||
|
{ schluessel: "geraetetyp", titel: "Gerätetyp", vorgabe: false },
|
||||||
|
{ schluessel: "hersteller", titel: "Hersteller", sortierbar: true, vorgabe: true },
|
||||||
|
{ schluessel: "modell", titel: "Modell", vorgabe: false },
|
||||||
|
{ schluessel: "seriennummer", titel: "Seriennummer", vorgabe: false },
|
||||||
|
{ schluessel: "lagerung", titel: "Lagerung", vorgabe: true },
|
||||||
|
{ schluessel: "status", titel: "Status", sortierbar: true, vorgabe: true },
|
||||||
|
{
|
||||||
|
schluessel: "naechstePruefung",
|
||||||
|
titel: "Nächste Prüfung",
|
||||||
|
sortierbar: true,
|
||||||
|
vorgabe: true,
|
||||||
|
zahl: true,
|
||||||
|
},
|
||||||
|
{ schluessel: "letztePruefung", titel: "Letzte Prüfung", vorgabe: false, zahl: true },
|
||||||
|
{
|
||||||
|
schluessel: "lebensdauerBis",
|
||||||
|
titel: "Lebensdauer bis",
|
||||||
|
sortierbar: true,
|
||||||
|
vorgabe: false,
|
||||||
|
zahl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "indienststellung",
|
||||||
|
titel: "Indienststellung",
|
||||||
|
sortierbar: true,
|
||||||
|
vorgabe: false,
|
||||||
|
zahl: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPEICHERSCHLUESSEL = "florian.geraete.spalten";
|
||||||
|
|
||||||
|
export function GeraeteTabelle({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||||
|
const [suchtext, setSuchtext] = React.useState("");
|
||||||
|
const [suche, setSuche] = React.useState("");
|
||||||
|
const [seite, setSeite] = React.useState(1);
|
||||||
|
const [sortierung, setSortierung] = React.useState<string>("inventarnummer");
|
||||||
|
const [richtung, setRichtung] = React.useState<"asc" | "desc">("asc");
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<GeraetStatus | "alle">("alle");
|
||||||
|
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
|
||||||
|
const [spaltenOffen, setSpaltenOffen] = React.useState(false);
|
||||||
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [sichtbar, setSichtbar] = React.useState<Set<string>>(
|
||||||
|
() => new Set(SPALTEN.filter((spalte) => spalte.vorgabe).map((spalte) => spalte.schluessel)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gespeicherte Spaltenauswahl nachladen — erst nach der Hydratation, sonst
|
||||||
|
// unterscheidet sich der Serverbaum vom Browserbaum.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const gespeichert = window.localStorage.getItem(SPEICHERSCHLUESSEL);
|
||||||
|
if (!gespeichert) return;
|
||||||
|
try {
|
||||||
|
const liste = JSON.parse(gespeichert) as string[];
|
||||||
|
if (Array.isArray(liste) && liste.length > 0) setSichtbar(new Set(liste));
|
||||||
|
} catch {
|
||||||
|
// Kaputter Eintrag: Vorgabe behalten.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.localStorage.setItem(SPEICHERSCHLUESSEL, JSON.stringify([...sichtbar]));
|
||||||
|
}, [sichtbar]);
|
||||||
|
|
||||||
|
// Entprellte Suche.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const zeitgeber = setTimeout(() => {
|
||||||
|
setSuche(suchtext);
|
||||||
|
setSeite(1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(zeitgeber);
|
||||||
|
}, [suchtext]);
|
||||||
|
|
||||||
|
const abfrage = trpc.geraet.liste.useQuery({
|
||||||
|
suche: suche || undefined,
|
||||||
|
seite,
|
||||||
|
seitengroesse: 50,
|
||||||
|
sortierung: sortierung as never,
|
||||||
|
richtung,
|
||||||
|
status: statusFilter === "alle" ? undefined : [statusFilter],
|
||||||
|
});
|
||||||
|
|
||||||
|
const werkzeuge = trpc.useUtils();
|
||||||
|
|
||||||
|
const statusSetzen = trpc.geraet.statusSetzen.useMutation({
|
||||||
|
onSuccess: (ergebnis) => {
|
||||||
|
setMeldung(`${ergebnis.geaendert} Geräte geändert.`);
|
||||||
|
setAusgewaehlt(new Set());
|
||||||
|
void werkzeuge.geraet.liste.invalidate();
|
||||||
|
void werkzeuge.organisation.kennzahlen.invalidate();
|
||||||
|
},
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintraege = abfrage.data?.eintraege ?? [];
|
||||||
|
const alleAusgewaehlt = eintraege.length > 0 && eintraege.every((g) => ausgewaehlt.has(g.id));
|
||||||
|
|
||||||
|
function sortiereNach(spalte: string) {
|
||||||
|
if (sortierung === spalte) {
|
||||||
|
setRichtung((alt) => (alt === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortierung(spalte);
|
||||||
|
setRichtung("asc");
|
||||||
|
}
|
||||||
|
setSeite(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auswahlUmschalten(id: string) {
|
||||||
|
setAusgewaehlt((alt) => {
|
||||||
|
const neu = new Set(alt);
|
||||||
|
if (neu.has(id)) neu.delete(id);
|
||||||
|
else neu.add(id);
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function alleUmschalten() {
|
||||||
|
setAusgewaehlt((alt) => {
|
||||||
|
if (alleAusgewaehlt) {
|
||||||
|
const neu = new Set(alt);
|
||||||
|
for (const geraet of eintraege) neu.delete(geraet.id);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
return new Set([...alt, ...eintraege.map((geraet) => geraet.id)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etikettenbogen für die Auswahl erzeugen und herunterladen. */
|
||||||
|
async function etikettenDrucken() {
|
||||||
|
setMeldung(null);
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("/api/geraete/etiketten", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: [...ausgewaehlt] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
const text = await antwort.text();
|
||||||
|
setMeldung(`Der Etikettenbogen ließ sich nicht erzeugen: ${text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datei = await antwort.blob();
|
||||||
|
const adresse = URL.createObjectURL(datei);
|
||||||
|
window.open(adresse, "_blank", "noopener");
|
||||||
|
// Nach dem Öffnen wieder freigeben, sonst bleibt der Speicher belegt.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(adresse), 60_000);
|
||||||
|
} catch (fehler) {
|
||||||
|
setMeldung(`Der Etikettenbogen ließ sich nicht erzeugen: ${String(fehler)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeigeSpalte = (schluessel: string) => sichtbar.has(schluessel);
|
||||||
|
const spaltenAnzahl = [...sichtbar].length + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Werkzeugleiste */}
|
||||||
|
<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={suchtext}
|
||||||
|
onChange={(ereignis) => setSuchtext(ereignis.target.value)}
|
||||||
|
placeholder="Inventarnummer, Bezeichnung, Hersteller, Seriennummer …"
|
||||||
|
className="pl-8"
|
||||||
|
aria-label="Geräte durchsuchen"
|
||||||
|
/>
|
||||||
|
{suchtext && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSuchtext("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
aria-label="Suche zurücksetzen"
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(wert) => {
|
||||||
|
setStatusFilter(wert as GeraetStatus | "alle");
|
||||||
|
setSeite(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-48" aria-label="Nach Status filtern">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="alle">Alle Zustände</SelectItem>
|
||||||
|
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
|
||||||
|
<SelectItem key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSpaltenOffen((offen) => !offen)}
|
||||||
|
aria-expanded={spaltenOffen}
|
||||||
|
>
|
||||||
|
<Columns3 aria-hidden />
|
||||||
|
Spalten
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{spaltenOffen && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 z-40 cursor-default"
|
||||||
|
aria-label="Spaltenauswahl schließen"
|
||||||
|
onClick={() => setSpaltenOffen(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 z-50 mt-1 w-64 rounded-md border border-[var(--border)] bg-[var(--popover)] p-2 shadow-md">
|
||||||
|
{SPALTEN.map((spalte) => (
|
||||||
|
<label
|
||||||
|
key={spalte.schluessel}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={sichtbar.has(spalte.schluessel)}
|
||||||
|
onCheckedChange={(an) =>
|
||||||
|
setSichtbar((alt) => {
|
||||||
|
const neu = new Set(alt);
|
||||||
|
if (an) neu.add(spalte.schluessel);
|
||||||
|
else neu.delete(spalte.schluessel);
|
||||||
|
// Mindestens eine Spalte muss stehen bleiben.
|
||||||
|
return neu.size === 0 ? alt : neu;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{spalte.titel}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/geraete/scannen">
|
||||||
|
<QrCode aria-hidden />
|
||||||
|
Scannen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{darfBearbeiten && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/geraete/importieren">
|
||||||
|
<Upload aria-hidden />
|
||||||
|
Importieren
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/geraete/neu">
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Neues Gerät
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sammelaktionen — erscheinen erst, wenn etwas ausgewählt ist. */}
|
||||||
|
{ausgewaehlt.size > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--muted)] px-3 py-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{ausgewaehlt.size} {ausgewaehlt.size === 1 ? "Gerät" : "Geräte"} ausgewählt
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={etikettenDrucken}>
|
||||||
|
<Printer aria-hidden />
|
||||||
|
Etiketten drucken
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{darfBearbeiten && (
|
||||||
|
<Select
|
||||||
|
onValueChange={(wert) =>
|
||||||
|
statusSetzen.mutate({
|
||||||
|
ids: [...ausgewaehlt],
|
||||||
|
status: wert as GeraetStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-52" aria-label="Status aller ausgewählten setzen">
|
||||||
|
<SelectValue placeholder="Status setzen auf …" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
|
||||||
|
<SelectItem key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setAusgewaehlt(new Set())}>
|
||||||
|
Auswahl aufheben
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meldung && <Fehlermeldung meldung={meldung} />}
|
||||||
|
{abfrage.error && <Fehlermeldung meldung={abfrage.error.message} />}
|
||||||
|
|
||||||
|
{/* Tabelle */}
|
||||||
|
<div className="rounded-lg border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={alleAusgewaehlt}
|
||||||
|
onCheckedChange={alleUmschalten}
|
||||||
|
aria-label="Alle auf dieser Seite auswählen"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
{SPALTEN.filter((spalte) => zeigeSpalte(spalte.schluessel)).map((spalte) => (
|
||||||
|
<TableHead key={spalte.schluessel} className={cn(spalte.zahl && "text-right")}>
|
||||||
|
{spalte.sortierbar ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => sortiereNach(spalte.schluessel)}
|
||||||
|
className="inline-flex items-center gap-1 uppercase hover:text-[var(--foreground)]"
|
||||||
|
aria-label={`Nach ${spalte.titel} sortieren`}
|
||||||
|
>
|
||||||
|
{spalte.titel}
|
||||||
|
{sortierung === spalte.schluessel &&
|
||||||
|
(richtung === "asc" ? (
|
||||||
|
<ArrowUp className="size-3" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="size-3" aria-hidden />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
spalte.titel
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{abfrage.isLoading && <LadeZeilen spalten={spaltenAnzahl} />}
|
||||||
|
|
||||||
|
{!abfrage.isLoading &&
|
||||||
|
eintraege.map((geraet) => (
|
||||||
|
<TableRow key={geraet.id} data-ausgewaehlt={ausgewaehlt.has(geraet.id)}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={ausgewaehlt.has(geraet.id)}
|
||||||
|
onCheckedChange={() => auswahlUmschalten(geraet.id)}
|
||||||
|
aria-label={`${geraet.inventarnummer} auswählen`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{zeigeSpalte("inventarnummer") && (
|
||||||
|
<TableCell className="font-medium tabellenzahlen">
|
||||||
|
<Link
|
||||||
|
href={`/geraete/${geraet.id}`}
|
||||||
|
className="underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{geraet.inventarnummer}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("bezeichnung") && <TableCell>{geraet.bezeichnung}</TableCell>}
|
||||||
|
|
||||||
|
{zeigeSpalte("geraetetyp") && (
|
||||||
|
<TableCell className="text-[var(--muted-foreground)]">
|
||||||
|
{geraet.geraetetyp?.name ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("hersteller") && <TableCell>{geraet.hersteller ?? "—"}</TableCell>}
|
||||||
|
{zeigeSpalte("modell") && <TableCell>{geraet.modell ?? "—"}</TableCell>}
|
||||||
|
|
||||||
|
{zeigeSpalte("seriennummer") && (
|
||||||
|
<TableCell className="tabellenzahlen">{geraet.seriennummer ?? "—"}</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("lagerung") && (
|
||||||
|
<TableCell className="whitespace-nowrap text-sm">
|
||||||
|
{geraet.fahrzeug ? (
|
||||||
|
<>
|
||||||
|
{geraet.fahrzeug.funkrufname}
|
||||||
|
{geraet.geraeteraum && (
|
||||||
|
<span className="text-[var(--muted-foreground)]">
|
||||||
|
{" "}
|
||||||
|
· {geraet.geraeteraum.bezeichnung}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
(geraet.standort?.name ?? "—")
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("status") && (
|
||||||
|
<TableCell>
|
||||||
|
<StatusAbzeichen status={geraet.status} />
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("naechstePruefung") && (
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<FristAnzeige datum={geraet.naechstePruefung} />
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("letztePruefung") && (
|
||||||
|
<TableCell className="text-right tabellenzahlen">
|
||||||
|
{alsDatum(geraet.letztePruefung)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("lebensdauerBis") && (
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zeigeSpalte("indienststellung") && (
|
||||||
|
<TableCell className="text-right tabellenzahlen">
|
||||||
|
{alsDatum(geraet.indienststellung)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{!abfrage.isLoading && eintraege.length === 0 && (
|
||||||
|
<div className="p-6">
|
||||||
|
<NichtsGefunden
|
||||||
|
titel={suche ? "Kein Gerät gefunden" : "Noch keine Geräte erfasst"}
|
||||||
|
hinweis={
|
||||||
|
suche
|
||||||
|
? `Zu "${suche}" gibt es keinen Treffer. Vielleicht hilft ein Teil der Inventarnummer.`
|
||||||
|
: "Geräte lassen sich einzeln anlegen oder aus einer vorhandenen Tabelle importieren."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
!suche && darfBearbeiten ? (
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/geraete/importieren">
|
||||||
|
<Upload aria-hidden />
|
||||||
|
Aus Datei importieren
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/geraete/neu">
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Einzelnes Gerät anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blättern */}
|
||||||
|
{abfrage.data && abfrage.data.gesamt > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
|
<p className="text-[var(--muted-foreground)]">
|
||||||
|
{abfrage.data.gesamt} {abfrage.data.gesamt === 1 ? "Gerät" : "Geräte"}
|
||||||
|
{abfrage.data.seitenGesamt > 1 &&
|
||||||
|
` · Seite ${abfrage.data.seite} von ${abfrage.data.seitenGesamt}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{abfrage.data.seitenGesamt > 1 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={seite <= 1}
|
||||||
|
onClick={() => setSeite((alt) => Math.max(1, alt - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden />
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={seite >= abfrage.data.seitenGesamt}
|
||||||
|
onClick={() => setSeite((alt) => alt + 1)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
<ChevronRight aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AlertTriangle, CheckCircle2, FileSpreadsheet, Upload } from "lucide-react";
|
||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import-Assistent.
|
||||||
|
*
|
||||||
|
* Drei Schritte, und der mittlere ist der eigentliche Punkt der Übung:
|
||||||
|
*
|
||||||
|
* 1. Datei wählen
|
||||||
|
* 2. Zuordnung prüfen und Vorschau ansehen
|
||||||
|
* 3. Übernehmen
|
||||||
|
*
|
||||||
|
* Zwischen 2 und 3 sitzt ein Mensch. Eine falsch zugeordnete Spalte verteilt
|
||||||
|
* sonst Seriennummern auf Inventarnummern, und das merkt man erst Wochen
|
||||||
|
* später am Regal.
|
||||||
|
*
|
||||||
|
* Die Datei wird beim Übernehmen erneut zum Server geschickt und dort neu
|
||||||
|
* eingelesen. Das kostet eine Übertragung, verhindert aber, dass jemand die
|
||||||
|
* Prüfung umgeht und eigene Werte einschleust.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ImportArt = "geraet" | "person" | "fahrzeug";
|
||||||
|
|
||||||
|
const ART_BEZEICHNUNG: Record<ImportArt, string> = {
|
||||||
|
geraet: "Geräte",
|
||||||
|
person: "Mitglieder",
|
||||||
|
fahrzeug: "Fahrzeuge",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImportAssistent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const werkzeuge = trpc.useUtils();
|
||||||
|
|
||||||
|
const [art, setArt] = React.useState<ImportArt>("geraet");
|
||||||
|
const [datei, setDatei] = React.useState<{ name: string; inhalt: string } | null>(null);
|
||||||
|
const [zuordnung, setZuordnung] = React.useState<Record<string, string | null>>({});
|
||||||
|
const [aktualisieren, setAktualisieren] = React.useState(false);
|
||||||
|
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||||
|
const [ergebnis, setErgebnis] = React.useState<{
|
||||||
|
angelegt: number;
|
||||||
|
aktualisiert: number;
|
||||||
|
uebersprungen: number;
|
||||||
|
nichtUebernommen: number;
|
||||||
|
fehler: string[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const vorschau = trpc.datenImport.vorschau.useMutation({
|
||||||
|
onSuccess: (daten) => {
|
||||||
|
setZuordnung(daten.zuordnung);
|
||||||
|
setFehler(null);
|
||||||
|
},
|
||||||
|
onError: (ausnahme) => setFehler(ausnahme.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uebernehmen = trpc.datenImport.uebernehmen.useMutation({
|
||||||
|
onSuccess: (daten) => {
|
||||||
|
setErgebnis(daten);
|
||||||
|
setFehler(null);
|
||||||
|
void werkzeuge.geraet.liste.invalidate();
|
||||||
|
void werkzeuge.organisation.kennzahlen.invalidate();
|
||||||
|
},
|
||||||
|
onError: (ausnahme) => setFehler(ausnahme.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function dateiWaehlen(ereignis: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const gewaehlt = ereignis.target.files?.[0];
|
||||||
|
if (!gewaehlt) return;
|
||||||
|
|
||||||
|
setErgebnis(null);
|
||||||
|
setFehler(null);
|
||||||
|
|
||||||
|
const puffer = await gewaehlt.arrayBuffer();
|
||||||
|
// Blockweise umwandeln: bei großen Dateien sprengt ein einziger
|
||||||
|
// String.fromCharCode-Aufruf den Aufrufstapel.
|
||||||
|
const bytes = new Uint8Array(puffer);
|
||||||
|
let roh = "";
|
||||||
|
const block = 8192;
|
||||||
|
for (let i = 0; i < bytes.length; i += block) {
|
||||||
|
roh += String.fromCharCode(...bytes.subarray(i, i + block));
|
||||||
|
}
|
||||||
|
const inhalt = btoa(roh);
|
||||||
|
|
||||||
|
setDatei({ name: gewaehlt.name, inhalt });
|
||||||
|
vorschau.mutate({ dateiname: gewaehlt.name, inhalt, art });
|
||||||
|
}
|
||||||
|
|
||||||
|
function zuordnungAendern(spalte: string, feld: string) {
|
||||||
|
const neu = { ...zuordnung, [spalte]: feld === "__keine__" ? null : feld };
|
||||||
|
setZuordnung(neu);
|
||||||
|
if (datei) {
|
||||||
|
vorschau.mutate({ dateiname: datei.name, inhalt: datei.inhalt, art, zuordnung: neu });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const daten = vorschau.data;
|
||||||
|
|
||||||
|
// Schritt 3 geschafft.
|
||||||
|
if (ergebnis) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle2 className="size-6 text-[var(--einsatzbereit)]" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<CardTitle>Import abgeschlossen</CardTitle>
|
||||||
|
<CardDescription>Aus der Datei „{datei?.name}“.</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<dl className="grid gap-4 sm:grid-cols-4">
|
||||||
|
{[
|
||||||
|
["Neu angelegt", ergebnis.angelegt],
|
||||||
|
["Aktualisiert", ergebnis.aktualisiert],
|
||||||
|
["Übersprungen", ergebnis.uebersprungen],
|
||||||
|
["Nicht übernommen", ergebnis.nichtUebernommen],
|
||||||
|
].map(([bezeichnung, wert]) => (
|
||||||
|
<div key={bezeichnung as string}>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
{bezeichnung}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-2xl font-semibold tabellenzahlen">{wert}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{ergebnis.uebersprungen > 0 && !aktualisieren && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Übersprungen wurden Datensätze, die es schon gibt. Mit „Vorhandene
|
||||||
|
aktualisieren“ werden sie stattdessen überschrieben.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis.fehler.length > 0 && (
|
||||||
|
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
|
||||||
|
<p className="mb-1 text-sm font-medium">
|
||||||
|
{ergebnis.fehler.length} Zeilen ließen sich nicht schreiben:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-0.5 pl-5 text-xs">
|
||||||
|
{ergebnis.fehler.slice(0, 20).map((meldung) => (
|
||||||
|
<li key={meldung}>{meldung}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => router.push("/geraete")}>Zur Geräteliste</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setErgebnis(null);
|
||||||
|
setDatei(null);
|
||||||
|
vorschau.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Weitere Datei importieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||||
|
|
||||||
|
{/* Schritt 1 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>1. Datei wählen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
CSV oder Excel (.xlsx). Die erste Zeile muss die Spaltenüberschriften
|
||||||
|
enthalten. Höchstens 10 MB und 5.000 Zeilen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="art">Was wird importiert?</Label>
|
||||||
|
<Select
|
||||||
|
value={art}
|
||||||
|
onValueChange={(wert) => {
|
||||||
|
setArt(wert as ImportArt);
|
||||||
|
setDatei(null);
|
||||||
|
vorschau.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="art">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(ART_BEZEICHNUNG).map(([wert, text]) => (
|
||||||
|
<SelectItem key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="datei">Datei</Label>
|
||||||
|
<input
|
||||||
|
id="datei"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.txt,.xlsx,.xlsm"
|
||||||
|
onChange={dateiWaehlen}
|
||||||
|
className="block w-full cursor-pointer rounded-md border border-[var(--input)] bg-[var(--background)] text-sm file:mr-3 file:cursor-pointer file:rounded-l-md file:border-0 file:bg-[var(--secondary)] file:px-3 file:py-2 file:text-sm file:font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{datei && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<FileSpreadsheet className="size-4" aria-hidden />
|
||||||
|
{datei.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{vorschau.isPending && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Die Datei wird gelesen …</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schritt 2 */}
|
||||||
|
{daten && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>2. Spalten zuordnen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Die Zuordnung wurde nach den Überschriften geraten. Bitte prüfen und
|
||||||
|
nötigenfalls korrigieren — danach passt sich die Vorschau sofort an.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{daten.fehlendePflichtfelder.length > 0 && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
|
||||||
|
<AlertTriangle
|
||||||
|
className="mt-0.5 size-4 shrink-0 text-[var(--destructive)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Diesen Pflichtfeldern ist keine Spalte zugeordnet:{" "}
|
||||||
|
<strong>{daten.fehlendePflichtfelder.join(", ")}</strong>. Ohne sie lässt
|
||||||
|
sich nichts übernehmen.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{daten.spalten.map((spalte) => (
|
||||||
|
<div key={spalte} className="space-y-1">
|
||||||
|
<Label className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Spalte „{spalte}“
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={zuordnung[spalte] ?? "__keine__"}
|
||||||
|
onValueChange={(wert) => zuordnungAendern(spalte, wert)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={`Zielfeld für Spalte ${spalte}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__keine__">— nicht übernehmen —</SelectItem>
|
||||||
|
{daten.felder.map((feld) => (
|
||||||
|
<SelectItem key={feld.schluessel} value={feld.schluessel}>
|
||||||
|
{feld.bezeichnung}
|
||||||
|
{feld.pflicht ? " *" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vorschau</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{daten.anzahlUebernehmbar} von {daten.zeilenGesamt} Zeilen sind
|
||||||
|
übernehmbar
|
||||||
|
{daten.anzahlFehlerhaft > 0 && `, ${daten.anzahlFehlerhaft} haben Fehler`}.
|
||||||
|
{daten.gekuerzt && " Die Datei wurde auf 5.000 Zeilen gekürzt."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="max-h-96 overflow-y-auto rounded-md border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-[var(--card)]">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">Zeile</TableHead>
|
||||||
|
{daten.felder
|
||||||
|
.filter((feld) =>
|
||||||
|
Object.values(zuordnung).includes(feld.schluessel),
|
||||||
|
)
|
||||||
|
.map((feld) => (
|
||||||
|
<TableHead key={feld.schluessel}>{feld.bezeichnung}</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead>Hinweis</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{daten.zeilen.map((zeile) => (
|
||||||
|
<TableRow
|
||||||
|
key={zeile.nummer}
|
||||||
|
className={cn(!zeile.uebernehmbar && "bg-[var(--destructive)]/5")}
|
||||||
|
>
|
||||||
|
<TableCell className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
|
||||||
|
{zeile.nummer}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{daten.felder
|
||||||
|
.filter((feld) =>
|
||||||
|
Object.values(zuordnung).includes(feld.schluessel),
|
||||||
|
)
|
||||||
|
.map((feld) => {
|
||||||
|
const wert = zeile.werte[feld.schluessel];
|
||||||
|
return (
|
||||||
|
<TableCell key={feld.schluessel} className="max-w-48 truncate text-sm">
|
||||||
|
{wert instanceof Date
|
||||||
|
? wert.toLocaleDateString("de-DE")
|
||||||
|
: wert === undefined || wert === null
|
||||||
|
? "—"
|
||||||
|
: String(wert)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{zeile.fehler.length === 0 ? (
|
||||||
|
<span className="text-[var(--einsatzbereit)]">in Ordnung</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--destructive)]">
|
||||||
|
{zeile.fehler.map((f) => f.meldung).join("; ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={aktualisieren}
|
||||||
|
onCheckedChange={(an) => setAktualisieren(Boolean(an))}
|
||||||
|
/>
|
||||||
|
Vorhandene Datensätze aktualisieren statt überspringen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!datei ||
|
||||||
|
daten.anzahlUebernehmbar === 0 ||
|
||||||
|
daten.fehlendePflichtfelder.length > 0 ||
|
||||||
|
uebernehmen.isPending
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
datei &&
|
||||||
|
uebernehmen.mutate({
|
||||||
|
dateiname: datei.name,
|
||||||
|
inhalt: datei.inhalt,
|
||||||
|
art,
|
||||||
|
zuordnung,
|
||||||
|
vorhandeneAktualisieren: aktualisieren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Upload aria-hidden />
|
||||||
|
{uebernehmen.isPending
|
||||||
|
? "Wird übernommen …"
|
||||||
|
: `${daten.anzahlUebernehmbar} Zeilen übernehmen`}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{daten.anzahlFehlerhaft > 0 && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Zeilen mit Fehlern werden übersprungen und bleiben in der Datei.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 { ImportAssistent } from "@/app/(app)/geraete/importieren/import-assistent";
|
||||||
|
|
||||||
|
export const metadata = { title: "Import" };
|
||||||
|
|
||||||
|
export default async function ImportSeite() {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
// Der Import schreibt Stammdaten — ohne dieses Recht gibt es die Seite nicht.
|
||||||
|
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/geraete");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||||
|
<Link href="/geraete">
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
Zurück zur Liste
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Daten importieren</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Aus einer vorhandenen Excel- oder CSV-Tabelle. Vor dem Übernehmen wird
|
||||||
|
angezeigt, was wohin geschrieben würde — geschrieben wird erst danach.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImportAssistent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { 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 } from "@/components/stammdaten/geraet-formular-werte";
|
||||||
|
|
||||||
|
export const metadata = { title: "Neues Gerät" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelnes Gerät anlegen.
|
||||||
|
*
|
||||||
|
* Der übliche Weg für einen Bestand ist der Import; dieses Formular ist für
|
||||||
|
* den Neuzugang zwischendurch — die eine Motorsäge, die nachbeschafft wurde.
|
||||||
|
*/
|
||||||
|
export default async function NeuesGeraetSeite() {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/geraete");
|
||||||
|
|
||||||
|
// Gibt es nur einen Standort, wird er vorbelegt — das ist bei den meisten
|
||||||
|
// Wehren der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
|
||||||
|
const standorte = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||||
|
db.standort.findMany({
|
||||||
|
where: { geloeschtAm: null, aktiv: true },
|
||||||
|
select: { id: true },
|
||||||
|
take: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const vorgabe = {
|
||||||
|
...LEERES_GERAET,
|
||||||
|
standortId: standorte.length === 1 ? (standorte[0]?.id ?? null) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2">
|
||||||
|
<Link href="/geraete">
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
Zurück zur Liste
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<GeraetFormular
|
||||||
|
vorgabe={vorgabe}
|
||||||
|
titel="Neues Gerät"
|
||||||
|
beschreibung="Für einzelne Neuzugänge. Größere Bestände gehen schneller über den Import."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { hatRecht } from "@/lib/authz";
|
||||||
|
import { GeraeteTabelle } from "@/app/(app)/geraete/geraete-tabelle";
|
||||||
|
|
||||||
|
export const metadata = { title: "Geräte" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geräteliste. Die eigentliche Tabelle ist eine Client-Komponente, weil
|
||||||
|
* Sortieren, Filtern und Auswählen ohne Zustand im Browser nicht geht. Die
|
||||||
|
* Rechteprüfung passiert hier auf dem Server — was die Komponente bekommt,
|
||||||
|
* ist nur die Antwort auf "darf diese Person bearbeiten".
|
||||||
|
*/
|
||||||
|
export default async function GeraeteSeite() {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Geräte</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Alle Geräte der Wehr mit Prüfterminen, Lebensdauer und Lagerung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GeraeteTabelle darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Scanner } from "@/app/(app)/geraete/scannen/scanner";
|
||||||
|
|
||||||
|
export const metadata = { title: "Gerät scannen" };
|
||||||
|
|
||||||
|
export default function ScannenSeite() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||||
|
<Link href="/geraete">
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
Zurück zur Liste
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Gerät scannen</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
QR-Code auf dem Geräteetikett vor die Kamera halten. Die Gerätedetailseite
|
||||||
|
öffnet sich dann von selbst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Kamera</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Der Browser fragt beim ersten Mal nach der Freigabe. Das Bild verlässt das
|
||||||
|
Gerät nicht — ausgewertet wird direkt im Browser.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Scanner />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { BrowserQRCodeReader } from "@zxing/browser";
|
||||||
|
import type { Result } from "@zxing/library";
|
||||||
|
import { Camera, CameraOff, KeyboardIcon, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||||
|
import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR-Scanner über die Kamera.
|
||||||
|
*
|
||||||
|
* Bewusst mit @zxing/browser statt der eingebauten BarcodeDetector-API: die
|
||||||
|
* gibt es in Firefox und in Safari auf dem iPad nicht, und im Gerätehaus steht
|
||||||
|
* genau so ein Tablet.
|
||||||
|
*
|
||||||
|
* Zwei Dinge, die in der Praxis zählen:
|
||||||
|
* - Die Kamera wird beim Verlassen der Seite wirklich freigegeben. Sonst
|
||||||
|
* bleibt die Leuchte an und der nächste Aufruf bekommt kein Bild.
|
||||||
|
* - Es gibt immer einen Weg ohne Kamera. Wenn die Freigabe verweigert wurde
|
||||||
|
* oder das Etikett zerkratzt ist, tippt man die Nummer ein.
|
||||||
|
*/
|
||||||
|
export function Scanner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||||
|
const steuerungRef = React.useRef<{ stop: () => void } | null>(null);
|
||||||
|
|
||||||
|
const [laeuft, setLaeuft] = React.useState(false);
|
||||||
|
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||||
|
const [letzterScan, setLetzterScan] = React.useState<string | null>(null);
|
||||||
|
const [eingabe, setEingabe] = React.useState("");
|
||||||
|
|
||||||
|
const stoppen = React.useCallback(() => {
|
||||||
|
steuerungRef.current?.stop();
|
||||||
|
steuerungRef.current = null;
|
||||||
|
setLaeuft(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Kamera in jedem Fall freigeben, auch bei einem Wechsel per Zurück-Taste.
|
||||||
|
React.useEffect(() => stoppen, [stoppen]);
|
||||||
|
|
||||||
|
const oeffnen = React.useCallback(
|
||||||
|
(inventarnummer: string) => {
|
||||||
|
stoppen();
|
||||||
|
router.push(`/g/${encodeURIComponent(inventarnummer)}`);
|
||||||
|
},
|
||||||
|
[router, stoppen],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function starten() {
|
||||||
|
setFehler(null);
|
||||||
|
setLetzterScan(null);
|
||||||
|
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
setFehler(
|
||||||
|
"Dieser Browser gibt keinen Kamerazugriff frei. Bitte die Inventarnummer eintippen.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leser = new BrowserQRCodeReader();
|
||||||
|
|
||||||
|
const steuerung = await leser.decodeFromVideoDevice(
|
||||||
|
undefined,
|
||||||
|
videoRef.current,
|
||||||
|
(ergebnis: Result | undefined) => {
|
||||||
|
if (!ergebnis) return;
|
||||||
|
|
||||||
|
const roh = ergebnis.getText();
|
||||||
|
const nummer = inventarnummerAusScan(roh);
|
||||||
|
|
||||||
|
if (!nummer) {
|
||||||
|
// Fremde Codes kommen ständig vor — der Hersteller-Barcode klebt
|
||||||
|
// oft direkt daneben. Kein Abbruch, nur ein Hinweis.
|
||||||
|
setLetzterScan(roh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
oeffnen(nummer);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
steuerungRef.current = steuerung;
|
||||||
|
setLaeuft(true);
|
||||||
|
} catch (ausnahme) {
|
||||||
|
const name = (ausnahme as { name?: string })?.name;
|
||||||
|
if (name === "NotAllowedError") {
|
||||||
|
setFehler(
|
||||||
|
"Der Kamerazugriff wurde abgelehnt. Er lässt sich in den Browsereinstellungen wieder freigeben — oder Sie tippen die Inventarnummer ein.",
|
||||||
|
);
|
||||||
|
} else if (name === "NotFoundError") {
|
||||||
|
setFehler("An diesem Gerät wurde keine Kamera gefunden.");
|
||||||
|
} else {
|
||||||
|
setFehler(`Die Kamera ließ sich nicht öffnen: ${String(ausnahme)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eingabeAbsenden(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const nummer = inventarnummerAusScan(eingabe);
|
||||||
|
if (!nummer) {
|
||||||
|
setFehler("Das sieht nicht nach einer Inventarnummer aus.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oeffnen(nummer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="aspect-video w-full object-cover"
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
aria-label="Kamerabild zum Scannen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{laeuft ? (
|
||||||
|
<Button variant="outline" onClick={stoppen}>
|
||||||
|
<CameraOff aria-hidden />
|
||||||
|
Kamera anhalten
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={starten}>
|
||||||
|
<Camera aria-hidden />
|
||||||
|
Kamera starten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{laeuft && (
|
||||||
|
<Button variant="ghost" onClick={() => void starten()}>
|
||||||
|
<RefreshCw aria-hidden />
|
||||||
|
Neu starten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{letzterScan && (
|
||||||
|
<p className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
|
||||||
|
Erkannt, aber kein FLORIAN-Etikett:{" "}
|
||||||
|
<span className="break-all font-mono text-xs">{letzterScan}</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-[var(--muted-foreground)]">
|
||||||
|
Vermutlich der Barcode des Herstellers. Bitte den Aufkleber mit dem FLORIAN-Code
|
||||||
|
scannen.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={eingabeAbsenden}
|
||||||
|
className="space-y-2 rounded-lg border border-[var(--border)] p-4"
|
||||||
|
>
|
||||||
|
<Label htmlFor="inventarnummer" className="flex items-center gap-2">
|
||||||
|
<KeyboardIcon className="size-4" aria-hidden />
|
||||||
|
Oder Inventarnummer eintippen
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="inventarnummer"
|
||||||
|
value={eingabe}
|
||||||
|
onChange={(ereignis) => setEingabe(ereignis.target.value)}
|
||||||
|
placeholder="INV-0042"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={!eingabe.trim()}>
|
||||||
|
Öffnen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Funktioniert auch ohne Kamera — praktisch, wenn das Etikett unleserlich geworden ist.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { fuerOrganisation } from "@/lib/db";
|
|||||||
import { rechteVon } from "@/lib/authz";
|
import { rechteVon } from "@/lib/authz";
|
||||||
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
||||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||||
|
import { TrpcProvider } from "@/lib/trpc/provider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
|
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
|
||||||
@@ -23,6 +24,7 @@ export default async function AngemeldetLayout({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TrpcProvider>
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Seitenleiste
|
<Seitenleiste
|
||||||
rechte={[...rechteVon(sitzung.rollen)]}
|
rechte={[...rechteVon(sitzung.rollen)]}
|
||||||
@@ -38,5 +40,6 @@ export default async function AngemeldetLayout({
|
|||||||
<main className="p-6">{children}</main>
|
<main className="p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TrpcProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Check, Play, Plus, Search, X } from "lucide-react";
|
||||||
|
import { MangelPrioritaet, MangelStatus } from "@prisma/client";
|
||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
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 { MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige";
|
||||||
|
import { DateiFeld } from "@/components/wartung/datei-feld";
|
||||||
|
import { MANGEL_STATUS_TEXT, PRIORITAET_TEXT } from "@/lib/wartung/texte";
|
||||||
|
import { alsDatumZeit } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mängelliste mit dem vollständigen Weg: melden → zugewiesen → in Arbeit →
|
||||||
|
* behoben.
|
||||||
|
*
|
||||||
|
* Die Schaltflächen zeigen immer nur den nächsten sinnvollen Schritt. Ein
|
||||||
|
* Formular, in dem man jeden Zustand frei wählen kann, führt dazu, dass Mängel
|
||||||
|
* auf „behoben" springen, ohne dass jemand daran gearbeitet hat.
|
||||||
|
*/
|
||||||
|
export function MaengelListe({
|
||||||
|
darfBearbeiten,
|
||||||
|
darfZuweisen,
|
||||||
|
}: {
|
||||||
|
darfBearbeiten: boolean;
|
||||||
|
darfZuweisen: boolean;
|
||||||
|
}) {
|
||||||
|
const [nurOffene, setNurOffene] = React.useState(true);
|
||||||
|
const [suche, setSuche] = React.useState("");
|
||||||
|
const [meldenOffen, setMeldenOffen] = React.useState(false);
|
||||||
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Formular zum Melden
|
||||||
|
const [geraetSuche, setGeraetSuche] = React.useState("");
|
||||||
|
const [geraetId, setGeraetId] = React.useState("");
|
||||||
|
const [beschreibung, setBeschreibung] = React.useState("");
|
||||||
|
const [prioritaet, setPrioritaet] = React.useState<MangelPrioritaet>(MangelPrioritaet.MITTEL);
|
||||||
|
const [fotoDateiId, setFotoDateiId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const werkzeuge = trpc.useUtils();
|
||||||
|
|
||||||
|
const maengel = trpc.mangel.liste.useQuery({
|
||||||
|
seite: 1,
|
||||||
|
seitengroesse: 100,
|
||||||
|
richtung: "desc",
|
||||||
|
nurOffene,
|
||||||
|
suche: suche || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const kennzahlen = trpc.mangel.kennzahlen.useQuery();
|
||||||
|
|
||||||
|
const geraete = trpc.geraet.liste.useQuery(
|
||||||
|
{ seite: 1, seitengroesse: 25, richtung: "asc", suche: geraetSuche || undefined },
|
||||||
|
{ enabled: meldenOffen },
|
||||||
|
);
|
||||||
|
|
||||||
|
const mitglieder = trpc.person.liste.useQuery(
|
||||||
|
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||||
|
{ enabled: darfZuweisen },
|
||||||
|
);
|
||||||
|
|
||||||
|
const aktualisieren = () => {
|
||||||
|
void werkzeuge.mangel.invalidate();
|
||||||
|
void werkzeuge.geraet.invalidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const melden = trpc.mangel.melden.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setMeldenOffen(false);
|
||||||
|
setGeraetId("");
|
||||||
|
setBeschreibung("");
|
||||||
|
setFotoDateiId(null);
|
||||||
|
setPrioritaet(MangelPrioritaet.MITTEL);
|
||||||
|
aktualisieren();
|
||||||
|
},
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zuweisen = trpc.mangel.zuweisen.useMutation({
|
||||||
|
onSuccess: aktualisieren,
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inArbeit = trpc.mangel.inArbeit.useMutation({
|
||||||
|
onSuccess: aktualisieren,
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const beheben = trpc.mangel.beheben.useMutation({
|
||||||
|
onSuccess: (ergebnis) => {
|
||||||
|
setMeldung(
|
||||||
|
ergebnis.geraetFreigegeben
|
||||||
|
? "Mangel behoben — das Gerät ist wieder einsatzbereit."
|
||||||
|
: "Mangel behoben.",
|
||||||
|
);
|
||||||
|
aktualisieren();
|
||||||
|
},
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const verwerfen = trpc.mangel.verwerfen.useMutation({
|
||||||
|
onSuccess: aktualisieren,
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintraege = maengel.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="Beschreibung oder Inventarnummer …"
|
||||||
|
className="pl-8"
|
||||||
|
aria-label="Mängel durchsuchen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={nurOffene ? "offen" : "alle"}
|
||||||
|
onValueChange={(wert) => setNurOffene(wert === "offen")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-48" aria-label="Nach Stand filtern">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="offen">nur offene</SelectItem>
|
||||||
|
<SelectItem value="alle">alle</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button onClick={() => setMeldenOffen(true)}>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Mangel melden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kennzahlen.data && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{kennzahlen.data.offen} offen
|
||||||
|
{kennzahlen.data.kritisch > 0 && (
|
||||||
|
<span className="font-medium text-[var(--gesperrt)]">
|
||||||
|
{" "}
|
||||||
|
· {kennzahlen.data.kritisch} kritisch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meldung && <Fehlermeldung meldung={meldung} kennung="mangelmeldung" />}
|
||||||
|
{maengel.error && <Fehlermeldung meldung={maengel.error.message} />}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Gerät</TableHead>
|
||||||
|
<TableHead>Mangel</TableHead>
|
||||||
|
<TableHead>Dringlichkeit</TableHead>
|
||||||
|
<TableHead>Stand</TableHead>
|
||||||
|
<TableHead>Zuständig</TableHead>
|
||||||
|
<TableHead className="w-56" aria-label="Aktionen" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{maengel.isLoading && <LadeZeilen spalten={6} />}
|
||||||
|
|
||||||
|
{eintraege.map((mangel) => (
|
||||||
|
<TableRow key={mangel.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/geraete/${mangel.geraet.id}`}
|
||||||
|
className="font-medium underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
<span className="tabellenzahlen">{mangel.geraet.inventarnummer}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{mangel.geraet.bezeichnung}
|
||||||
|
</div>
|
||||||
|
{mangel.geraetGesperrt && (
|
||||||
|
<div className="text-xs font-medium text-[var(--gesperrt)]">
|
||||||
|
wegen dieses Mangels gesperrt
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="max-w-md">
|
||||||
|
<p className="text-sm">{mangel.beschreibung}</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
gemeldet {alsDatumZeit(mangel.gemeldetAm)}
|
||||||
|
{mangel.melderName && ` von ${mangel.melderName}`}
|
||||||
|
{mangel.pruefung?.protokollNummer &&
|
||||||
|
` · aus Protokoll ${mangel.pruefung.protokollNummer}`}
|
||||||
|
</p>
|
||||||
|
{mangel.behebung && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
Behebung: {mangel.behebung}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<PrioritaetAbzeichen prioritaet={mangel.prioritaet} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<MangelStatusAbzeichen status={mangel.status} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{darfZuweisen ? (
|
||||||
|
<Select
|
||||||
|
value={mangel.zugewiesenAn?.id ?? "niemand"}
|
||||||
|
onValueChange={(wert) =>
|
||||||
|
zuweisen.mutate({
|
||||||
|
id: mangel.id,
|
||||||
|
zugewiesenAnId: wert === "niemand" ? null : wert,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-8 w-44"
|
||||||
|
aria-label={`Zuständigkeit für Mangel an ${mangel.geraet.inventarnummer}`}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="niemand">niemand</SelectItem>
|
||||||
|
{mitglieder.data?.eintraege.map((person) => (
|
||||||
|
<SelectItem key={person.id} value={person.id}>
|
||||||
|
{person.nachname}, {person.vorname}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : mangel.zugewiesenAn ? (
|
||||||
|
`${mangel.zugewiesenAn.nachname}, ${mangel.zugewiesenAn.vorname}`
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{darfBearbeiten &&
|
||||||
|
mangel.status !== MangelStatus.BEHOBEN &&
|
||||||
|
mangel.status !== MangelStatus.VERWORFEN && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{mangel.status !== MangelStatus.IN_ARBEIT && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => inArbeit.mutate({ id: mangel.id })}
|
||||||
|
>
|
||||||
|
<Play aria-hidden />
|
||||||
|
In Arbeit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const behebung = window.prompt("Was wurde gemacht?");
|
||||||
|
if (behebung === null) return;
|
||||||
|
beheben.mutate({ id: mangel.id, behebung: behebung || null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check aria-hidden />
|
||||||
|
Behoben
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const grund = window.prompt("Warum ist das kein Mangel?");
|
||||||
|
if (grund === null) return;
|
||||||
|
verwerfen.mutate({ id: mangel.id, grund: grund || undefined });
|
||||||
|
}}
|
||||||
|
aria-label="Meldung verwerfen"
|
||||||
|
>
|
||||||
|
<X aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{!maengel.isLoading && eintraege.length === 0 && (
|
||||||
|
<div className="p-6">
|
||||||
|
<NichtsGefunden
|
||||||
|
titel={nurOffene ? "Kein offener Mangel" : "Noch kein Mangel gemeldet"}
|
||||||
|
hinweis="Mängel entstehen bei einer Prüfung oder werden hier von Hand gemeldet."
|
||||||
|
aktion={
|
||||||
|
<Button onClick={() => setMeldenOffen(true)}>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Mangel melden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={meldenOffen} onOpenChange={setMeldenOffen}>
|
||||||
|
<DialogContent>
|
||||||
|
<form
|
||||||
|
onSubmit={(ereignis) => {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
setMeldung(null);
|
||||||
|
melden.mutate({
|
||||||
|
geraetId,
|
||||||
|
beschreibung,
|
||||||
|
prioritaet,
|
||||||
|
fotoDateiId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mangel melden</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Ein kritischer Mangel sperrt das Gerät sofort — ohne Rückfrage.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="geraetsuche">Gerät suchen</Label>
|
||||||
|
<Input
|
||||||
|
id="geraetsuche"
|
||||||
|
value={geraetSuche}
|
||||||
|
onChange={(ereignis) => setGeraetSuche(ereignis.target.value)}
|
||||||
|
placeholder="Inventarnummer oder Bezeichnung"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select value={geraetId} onValueChange={setGeraetId}>
|
||||||
|
<SelectTrigger aria-label="Gerät auswählen">
|
||||||
|
<SelectValue placeholder="Gerät wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{geraete.data?.eintraege.map((geraet) => (
|
||||||
|
<SelectItem key={geraet.id} value={geraet.id}>
|
||||||
|
{geraet.inventarnummer} — {geraet.bezeichnung}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mangelbeschreibung">Was ist der Mangel?</Label>
|
||||||
|
<Textarea
|
||||||
|
id="mangelbeschreibung"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={beschreibung}
|
||||||
|
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
|
||||||
|
placeholder="Riss im Schlauch, etwa 20 cm von der Kupplung"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mangelprio">Dringlichkeit</Label>
|
||||||
|
<Select
|
||||||
|
value={prioritaet}
|
||||||
|
onValueChange={(wert) => setPrioritaet(wert as MangelPrioritaet)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="mangelprio">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(PRIORITAET_TEXT) as MangelPrioritaet[]).map((wert) => (
|
||||||
|
<SelectItem key={wert} value={wert}>
|
||||||
|
{PRIORITAET_TEXT[wert]}
|
||||||
|
{wert === MangelPrioritaet.KRITISCH && " — sperrt das Gerät"}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Foto</Label>
|
||||||
|
<DateiFeld
|
||||||
|
entitaet="Mangel"
|
||||||
|
kategorie="foto"
|
||||||
|
dateiId={fotoDateiId}
|
||||||
|
onHochgeladen={setFotoDateiId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{melden.error && <Fehlermeldung meldung={melden.error.message} />}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setMeldenOffen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={melden.isPending || !geraetId}>
|
||||||
|
{melden.isPending ? "wird gemeldet …" : "Melden"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Stand der Meldungen:{" "}
|
||||||
|
{Object.entries(kennzahlen.data?.nachStatus ?? {})
|
||||||
|
.map(
|
||||||
|
([status, anzahl]) =>
|
||||||
|
`${MANGEL_STATUS_TEXT[status as MangelStatus] ?? status}: ${anzahl}`,
|
||||||
|
)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { hatRecht } from "@/lib/authz";
|
||||||
|
import { MaengelListe } from "@/app/(app)/maengel/maengel-liste";
|
||||||
|
|
||||||
|
export const metadata = { title: "Mängel" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mängelverwaltung.
|
||||||
|
*
|
||||||
|
* Melden darf jedes Mitglied — der Mangel fällt dem auf, der das Gerät
|
||||||
|
* benutzt. Bearbeiten und beheben bleibt beim Gerätewart.
|
||||||
|
*/
|
||||||
|
export default async function MaengelSeite() {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Mängel</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Von der Meldung bis zur Behebung — mit Foto, Zuständigkeit und Gerätesperre.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MaengelListe
|
||||||
|
darfBearbeiten={hatRecht(sitzung, "pruefung.durchfuehren")}
|
||||||
|
darfZuweisen={hatRecht(sitzung, "pruefung.planen")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { darfPersonSehen } from "@/lib/authz";
|
||||||
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
|
import { alsDatum, personAnrede } 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, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
|
|
||||||
|
export const metadata = { title: "Mitglied" };
|
||||||
|
|
||||||
|
const DIENSTGRAD_TEXT: Record<string, string> = {
|
||||||
|
FEUERWEHRANWAERTER: "Feuerwehranwärter",
|
||||||
|
FEUERWEHRMANN: "Feuerwehrmann",
|
||||||
|
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
|
||||||
|
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
|
||||||
|
LOESCHMEISTER: "Löschmeister",
|
||||||
|
OBERLOESCHMEISTER: "Oberlöschmeister",
|
||||||
|
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
|
||||||
|
BRANDMEISTER: "Brandmeister",
|
||||||
|
OBERBRANDMEISTER: "Oberbrandmeister",
|
||||||
|
HAUPTBRANDMEISTER: "Hauptbrandmeister",
|
||||||
|
KEIN: "—",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mitgliedsdetail.
|
||||||
|
*
|
||||||
|
* Die Zugriffsprüfung läuft über darfPersonSehen(): die eigenen Daten darf
|
||||||
|
* jeder sehen, fremde nur mit dem Recht person.lesen. Adressen und
|
||||||
|
* Geburtsdatum sind personenbezogene Daten — sie stehen deshalb hinter
|
||||||
|
* derselben Schranke wie alles andere.
|
||||||
|
*/
|
||||||
|
export default async function MitgliedSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!darfPersonSehen(sitzung, id)) redirect("/mitglieder");
|
||||||
|
|
||||||
|
const person = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||||
|
db.person.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
vorname: true,
|
||||||
|
nachname: true,
|
||||||
|
mitgliedsnummer: true,
|
||||||
|
dienstgrad: true,
|
||||||
|
abteilung: true,
|
||||||
|
email: true,
|
||||||
|
telefon: true,
|
||||||
|
mobil: true,
|
||||||
|
strasse: true,
|
||||||
|
plz: true,
|
||||||
|
ort: true,
|
||||||
|
geburtsdatum: true,
|
||||||
|
eintrittsdatum: true,
|
||||||
|
austrittsdatum: true,
|
||||||
|
aktiv: true,
|
||||||
|
letzteAnmeldung: true,
|
||||||
|
rollen: { include: { rolle: true } },
|
||||||
|
qualifikationen: {
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
include: { typ: true },
|
||||||
|
orderBy: [{ gueltigBis: "asc" }, { erworbenAm: "desc" }],
|
||||||
|
},
|
||||||
|
ausweisTokens: {
|
||||||
|
where: { geloeschtAm: null, aktiv: true },
|
||||||
|
select: { id: true, typ: true, bezeichnung: true, letzteNutzung: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!person) notFound();
|
||||||
|
|
||||||
|
const angaben: Array<[string, React.ReactNode]> = [
|
||||||
|
["Mitgliedsnummer", <span className="tabellenzahlen">{person.mitgliedsnummer ?? "—"}</span>],
|
||||||
|
["Dienstgrad", DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad],
|
||||||
|
["Abteilung", person.abteilung ?? "—"],
|
||||||
|
["Eintritt", alsDatum(person.eintrittsdatum)],
|
||||||
|
["Geburtsdatum", alsDatum(person.geburtsdatum)],
|
||||||
|
["E-Mail", person.email ?? "—"],
|
||||||
|
["Telefon", person.telefon ?? "—"],
|
||||||
|
["Mobil", person.mobil ?? "—"],
|
||||||
|
[
|
||||||
|
"Anschrift",
|
||||||
|
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||||
|
<Link href="/mitglieder">
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
Zurück zur Mitgliederliste
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{person.rollen.map((zuweisung) => (
|
||||||
|
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
|
||||||
|
{zuweisung.rolle.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{!person.aktiv && <Badge variant="ausgesondert">ausgetreten</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Angaben</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||||
|
{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>Terminal-Zugang</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Anmeldemittel für das Gerätehaus. Gespeichert wird nur ein Hash.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{person.ausweisTokens.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Kein Terminal-Zugang hinterlegt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{person.ausweisTokens.map((token) => (
|
||||||
|
<li key={token.id} className="flex items-center justify-between gap-2">
|
||||||
|
<span>
|
||||||
|
<Badge variant="outline">{token.typ}</Badge>{" "}
|
||||||
|
{token.bezeichnung ?? ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{token.letzteNutzung ? alsDatum(token.letzteNutzung) : "noch nicht benutzt"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Qualifikationen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Lehrgänge, Befähigungen und Untersuchungen samt Gültigkeit.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{person.qualifikationen.length === 0 ? (
|
||||||
|
<NichtsGefunden
|
||||||
|
titel="Keine Qualifikationen erfasst"
|
||||||
|
hinweis="Sobald Lehrgänge oder Untersuchungen eingetragen sind, erscheinen sie hier mit ihrer Gültigkeit."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--border)]">
|
||||||
|
{person.qualifikationen.map((qualifikation) => (
|
||||||
|
<li
|
||||||
|
key={qualifikation.id}
|
||||||
|
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{qualifikation.typ.name}</span>
|
||||||
|
{qualifikation.typ.kuerzel && (
|
||||||
|
<Badge variant="outline">{qualifikation.typ.kuerzel}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--muted-foreground)]">
|
||||||
|
erworben {alsDatum(qualifikation.erworbenAm)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto">
|
||||||
|
{qualifikation.gueltigBis ? (
|
||||||
|
<FristAnzeige
|
||||||
|
datum={qualifikation.gueltigBis}
|
||||||
|
vorwarnTage={qualifikation.typ.vorwarnzeitTage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--muted-foreground)]">unbefristet</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { hatRecht } from "@/lib/authz";
|
||||||
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
|
import { alsDatum, personName } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
|
|
||||||
|
export const metadata = { title: "Mitglieder" };
|
||||||
|
|
||||||
|
const DIENSTGRAD_TEXT: Record<string, string> = {
|
||||||
|
FEUERWEHRANWAERTER: "Feuerwehranwärter",
|
||||||
|
FEUERWEHRMANN: "Feuerwehrmann",
|
||||||
|
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
|
||||||
|
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
|
||||||
|
LOESCHMEISTER: "Löschmeister",
|
||||||
|
OBERLOESCHMEISTER: "Oberlöschmeister",
|
||||||
|
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
|
||||||
|
BRANDMEISTER: "Brandmeister",
|
||||||
|
OBERBRANDMEISTER: "Oberbrandmeister",
|
||||||
|
HAUPTBRANDMEISTER: "Hauptbrandmeister",
|
||||||
|
KEIN: "—",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mitgliederliste, nach Abteilung gruppiert.
|
||||||
|
*
|
||||||
|
* Aktive, Jugendfeuerwehr und Passive werden getrennt geführt — eine
|
||||||
|
* durchlaufende alphabetische Liste vermischt Gruppen, die in der Wehr nichts
|
||||||
|
* miteinander zu tun haben.
|
||||||
|
*/
|
||||||
|
export default async function MitgliederSeite() {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
if (!hatRecht(sitzung, "person.lesen")) redirect("/");
|
||||||
|
|
||||||
|
const personen = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||||
|
db.person.findMany({
|
||||||
|
where: { geloeschtAm: null },
|
||||||
|
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
vorname: true,
|
||||||
|
nachname: true,
|
||||||
|
mitgliedsnummer: true,
|
||||||
|
dienstgrad: true,
|
||||||
|
abteilung: true,
|
||||||
|
email: true,
|
||||||
|
mobil: true,
|
||||||
|
eintrittsdatum: true,
|
||||||
|
aktiv: true,
|
||||||
|
rollen: { select: { rolle: { select: { name: true, schluessel: true } } } },
|
||||||
|
_count: { select: { qualifikationen: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nachAbteilung = new Map<string, typeof personen>();
|
||||||
|
for (const person of personen) {
|
||||||
|
const gruppe = person.abteilung ?? "Ohne Abteilung";
|
||||||
|
if (!nachAbteilung.has(gruppe)) nachAbteilung.set(gruppe, []);
|
||||||
|
nachAbteilung.get(gruppe)!.push(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reihenfolge = ["Aktive", "Jugendfeuerwehr", "Passive", "Verwaltung", "Ohne Abteilung"];
|
||||||
|
const gruppen = [...nachAbteilung.entries()].sort((a, b) => {
|
||||||
|
const ia = reihenfolge.indexOf(a[0]);
|
||||||
|
const ib = reihenfolge.indexOf(b[0]);
|
||||||
|
if (ia === -1 && ib === -1) return a[0].localeCompare(b[0], "de");
|
||||||
|
if (ia === -1) return 1;
|
||||||
|
if (ib === -1) return -1;
|
||||||
|
return ia - ib;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Mitglieder</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{personen.length} {personen.length === 1 ? "Mitglied" : "Mitglieder"}, nach
|
||||||
|
Abteilung geordnet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{personen.length === 0 ? (
|
||||||
|
<NichtsGefunden
|
||||||
|
titel="Noch keine Mitglieder erfasst"
|
||||||
|
hinweis="Mitglieder lassen sich einzeln anlegen oder aus einer vorhandenen Liste importieren."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
gruppen.map(([abteilung, liste]) => (
|
||||||
|
<section key={abteilung} className="space-y-2">
|
||||||
|
<h2 className="text-sm font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
{abteilung} ({liste.length})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Nr.</TableHead>
|
||||||
|
<TableHead>Dienstgrad</TableHead>
|
||||||
|
<TableHead>Funktion</TableHead>
|
||||||
|
<TableHead>Kontakt</TableHead>
|
||||||
|
<TableHead className="text-right">Eintritt</TableHead>
|
||||||
|
<TableHead className="text-right">Qualifikationen</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{liste.map((person) => (
|
||||||
|
<TableRow key={person.id} className={!person.aktiv ? "opacity-60" : undefined}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/mitglieder/${person.id}`}
|
||||||
|
className="underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{personName(person)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabellenzahlen text-[var(--muted-foreground)]">
|
||||||
|
{person.mitgliedsnummer ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{person.rollen
|
||||||
|
.filter((zuweisung) => zuweisung.rolle.schluessel !== "MITGLIED")
|
||||||
|
.map((zuweisung) => (
|
||||||
|
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
|
||||||
|
{zuweisung.rolle.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{person.mobil ?? person.email ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabellenzahlen">
|
||||||
|
{alsDatum(person.eintrittsdatum)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabellenzahlen">
|
||||||
|
{person._count.qualifikationen}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,15 +90,15 @@ export default async function UebersichtSeite() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Stand des Aufbaus</CardTitle>
|
<CardTitle>Stand des Aufbaus</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Phase 0 ist abgeschlossen: Fundament, Mandantentrennung, Anmeldung,
|
Phase 1 ist abgeschlossen: Standorte, Fahrzeuge mit Geräteräumen,
|
||||||
Dateiablage und Protokollierung stehen. Die Fachmodule kommen Phase für
|
Gerätetypen, Geräte mit QR-Etiketten, Qualifikationen und der Import aus
|
||||||
Phase dazu.
|
vorhandenen Tabellen stehen. Als Nächstes kommt das Prüfmanagement.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
|
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
|
||||||
<Badge variant="outline">Phase 1 — Stammdaten</Badge>
|
<Badge variant="einsatzbereit">Phase 1 — Stammdaten</Badge>
|
||||||
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
|
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
|
||||||
<Badge variant="outline">Phase 3 — Einsätze</Badge>
|
<Badge variant="outline">Phase 3 — Einsätze</Badge>
|
||||||
<Badge variant="outline">Phase 4 — Terminal</Badge>
|
<Badge variant="outline">Phase 4 — Terminal</Badge>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { FileDown, PencilLine } from "lucide-react";
|
||||||
|
import type { PruefungStatus } from "@prisma/client";
|
||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die beiden Dinge, die man mit einem fertigen Protokoll tut: herunterladen
|
||||||
|
* oder korrigieren.
|
||||||
|
*
|
||||||
|
* Korrigieren heißt hier nicht ändern. Es entsteht ein neuer Entwurf mit den
|
||||||
|
* bisherigen Antworten und einem Verweis auf dieses Protokoll; erst mit seinem
|
||||||
|
* Abschluss gilt das alte als ersetzt. So bleibt nachvollziehbar, was zuerst
|
||||||
|
* dastand — genau darum geht es bei einem Nachweis.
|
||||||
|
*/
|
||||||
|
export function ProtokollAktionen({
|
||||||
|
pruefungId,
|
||||||
|
status,
|
||||||
|
hatProtokoll,
|
||||||
|
darfKorrigieren,
|
||||||
|
}: {
|
||||||
|
pruefungId: string;
|
||||||
|
status: PruefungStatus;
|
||||||
|
hatProtokoll: boolean;
|
||||||
|
darfKorrigieren: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const link = trpc.pruefung.protokollLink.useMutation({
|
||||||
|
onSuccess: (ergebnis) => {
|
||||||
|
if (ergebnis.bereit && ergebnis.adresse) {
|
||||||
|
window.open(ergebnis.adresse, "_blank", "noopener");
|
||||||
|
setMeldung(null);
|
||||||
|
} else {
|
||||||
|
setMeldung(
|
||||||
|
"Das PDF wird gerade erzeugt. In ein paar Sekunden noch einmal versuchen — Gotenberg braucht einen Moment.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const korrigieren = trpc.pruefung.korrigieren.useMutation({
|
||||||
|
// Der Wizard nimmt den frischen Entwurf auf: er sucht zu Gerät, Vorlage
|
||||||
|
// und Prüfer nach einer angefangenen Prüfung und setzt sie fort — samt der
|
||||||
|
// übernommenen Antworten aus dem alten Protokoll.
|
||||||
|
onSuccess: (neu) =>
|
||||||
|
router.push(
|
||||||
|
`/pruefungen/neu?geraetId=${neu.geraetId}${neu.vorlageId ? `&vorlageId=${neu.vorlageId}` : ""}`,
|
||||||
|
),
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => link.mutate({ id: pruefungId })}
|
||||||
|
disabled={link.isPending || status === "ENTWURF"}
|
||||||
|
>
|
||||||
|
<FileDown aria-hidden />
|
||||||
|
{hatProtokoll ? "Protokoll als PDF" : "PDF erzeugen"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{darfKorrigieren && status !== "ENTWURF" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const grund = window.prompt(
|
||||||
|
"Warum wird korrigiert? Der Grund steht später im neuen Protokoll.",
|
||||||
|
);
|
||||||
|
if (grund?.trim()) korrigieren.mutate({ id: pruefungId, grund: grund.trim() });
|
||||||
|
}}
|
||||||
|
disabled={korrigieren.isPending}
|
||||||
|
>
|
||||||
|
<PencilLine aria-hidden />
|
||||||
|
Korrektur anlegen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meldung && <Fehlermeldung meldung={meldung} kennung="protokollmeldung" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { notFound, 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 { fuerOrganisation } from "@/lib/db";
|
||||||
|
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { ProtokollAktionen } from "@/app/(app)/pruefungen/[id]/aktionen";
|
||||||
|
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||||
|
import { PRIORITAET_TEXT, PUNKTTYP_TEXT } from "@/lib/wartung/texte";
|
||||||
|
|
||||||
|
export const metadata = { title: "Prüfprotokoll" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Prüfprotokoll auf dem Bildschirm.
|
||||||
|
*
|
||||||
|
* Zeigt dasselbe wie das PDF, nur ohne Papierlayout. Das PDF bleibt der
|
||||||
|
* Nachweis; diese Seite ist der schnelle Blick, ohne erst eine Datei öffnen zu
|
||||||
|
* müssen.
|
||||||
|
*/
|
||||||
|
export default async function PruefungSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const pruefung = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||||
|
db.pruefung.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
geraet: {
|
||||||
|
select: { id: true, inventarnummer: true, bezeichnung: true, seriennummer: true },
|
||||||
|
},
|
||||||
|
vorlage: { select: { id: true, name: true, pruefvorschrift: true, intervallMonate: true } },
|
||||||
|
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
|
||||||
|
ergebnisse: { include: { punkt: true } },
|
||||||
|
signaturen: true,
|
||||||
|
maengel: { where: { geloeschtAm: null } },
|
||||||
|
ersetzt: { select: { id: true, protokollNummer: true } },
|
||||||
|
ersetztDurch: { select: { id: true, protokollNummer: true, status: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pruefung) notFound();
|
||||||
|
|
||||||
|
const ergebnisseSortiert = [...pruefung.ergebnisse].sort(
|
||||||
|
(a, b) => a.punkt.sortierung - b.punkt.sortierung,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||||
|
<Link href="/pruefungen/verlauf">
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
Zurück zu den Protokollen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Prüfprotokoll {pruefung.protokollNummer ?? ""}
|
||||||
|
</h1>
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<Link href={`/geraete/${pruefung.geraet.id}`} className="underline underline-offset-4">
|
||||||
|
{pruefung.geraet.inventarnummer} — {pruefung.geraet.bezeichnung}
|
||||||
|
</Link>
|
||||||
|
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||||
|
<PruefungStatusAbzeichen status={pruefung.status} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProtokollAktionen
|
||||||
|
pruefungId={pruefung.id}
|
||||||
|
status={pruefung.status}
|
||||||
|
hatProtokoll={Boolean(pruefung.protokollDateiId)}
|
||||||
|
darfKorrigieren={hatRecht(sitzung, "pruefung.durchfuehren")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pruefung.ersetzt && (
|
||||||
|
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
|
||||||
|
Dieses Protokoll ist eine Korrektur zu{" "}
|
||||||
|
<Link href={`/pruefungen/${pruefung.ersetzt.id}`} className="underline underline-offset-4">
|
||||||
|
{pruefung.ersetzt.protokollNummer ?? "einem früheren Protokoll"}
|
||||||
|
</Link>
|
||||||
|
{pruefung.korrekturGrund && `. Grund: ${pruefung.korrekturGrund}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pruefung.ersetztDurch.length > 0 && (
|
||||||
|
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
|
||||||
|
Dieses Protokoll wurde ersetzt durch{" "}
|
||||||
|
{pruefung.ersetztDurch.map((nachfolger) => (
|
||||||
|
<Link
|
||||||
|
key={nachfolger.id}
|
||||||
|
href={`/pruefungen/${nachfolger.id}`}
|
||||||
|
className="underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{nachfolger.protokollNummer ?? "eine Korrektur"}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Angaben</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["Prüfdatum", alsDatum(pruefung.durchgefuehrtAm)],
|
||||||
|
[
|
||||||
|
"Prüfer",
|
||||||
|
pruefung.pruefer
|
||||||
|
? `${pruefung.pruefer.vorname} ${pruefung.pruefer.nachname}`
|
||||||
|
: (pruefung.prueferName ?? "—"),
|
||||||
|
],
|
||||||
|
["Prüfer-Kennung", pruefung.pruefer?.mitgliedsnummer ?? "—"],
|
||||||
|
["Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern"],
|
||||||
|
["Fachfirma", pruefung.externeFirma ?? "—"],
|
||||||
|
["Kosten", pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"],
|
||||||
|
["Vorlage", pruefung.vorlage?.name ?? "—"],
|
||||||
|
["Prüfvorschrift", pruefung.vorlage?.pruefvorschrift ?? "—"],
|
||||||
|
["Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : "—"],
|
||||||
|
["Nächste Prüfung", alsDatum(pruefung.naechstePruefungAm)],
|
||||||
|
["Abgeschlossen", alsDatumZeit(pruefung.abgeschlossenAm)],
|
||||||
|
] as Array<[string, string]>
|
||||||
|
).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>
|
||||||
|
|
||||||
|
{pruefung.bemerkung && (
|
||||||
|
<div className="mt-4 border-t border-[var(--border)] pt-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
Bemerkung
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-line text-sm">{pruefung.bemerkung}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Unterschriften</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Die Bilder liegen in MinIO und sind im PDF eingebettet.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{pruefung.signaturen.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Keine Unterschrift erfasst.</p>
|
||||||
|
) : (
|
||||||
|
pruefung.signaturen.map((signatur) => (
|
||||||
|
<div key={signatur.id} className="text-sm">
|
||||||
|
<p className="font-medium">{signatur.name}</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{signatur.rolle} · {alsDatumZeit(signatur.unterzeichnetAm)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Checkliste</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{ergebnisseSortiert.filter((eintrag) => !eintrag.inOrdnung).length} von{" "}
|
||||||
|
{ergebnisseSortiert.length} Punkten beanstandet.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Prüfpunkt</TableHead>
|
||||||
|
<TableHead>Art</TableHead>
|
||||||
|
<TableHead>Soll</TableHead>
|
||||||
|
<TableHead>Ist</TableHead>
|
||||||
|
<TableHead>Befund</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ergebnisseSortiert.map((eintrag) => (
|
||||||
|
<TableRow key={eintrag.id}>
|
||||||
|
<TableCell>
|
||||||
|
{eintrag.punkt.text}
|
||||||
|
{eintrag.punkt.pflicht && (
|
||||||
|
<span className="ml-1 text-[var(--gesperrt)]">*</span>
|
||||||
|
)}
|
||||||
|
{eintrag.bemerkung && (
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{eintrag.bemerkung}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{PUNKTTYP_TEXT[eintrag.punkt.typ]}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{eintrag.punkt.sollwert ?? "—"}
|
||||||
|
{eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm tabellenzahlen">
|
||||||
|
{eintrag.punkt.typ === "JA_NEIN"
|
||||||
|
? eintrag.jaNein === true
|
||||||
|
? "ja"
|
||||||
|
: eintrag.jaNein === false
|
||||||
|
? "nein"
|
||||||
|
: "—"
|
||||||
|
: eintrag.punkt.typ === "MESSWERT"
|
||||||
|
? eintrag.zahlwert
|
||||||
|
? `${Number(eintrag.zahlwert).toLocaleString("de-DE")}${
|
||||||
|
eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""
|
||||||
|
}`
|
||||||
|
: "—"
|
||||||
|
: eintrag.punkt.typ === "FOTO"
|
||||||
|
? eintrag.fotoDateiId
|
||||||
|
? "Foto liegt bei"
|
||||||
|
: "—"
|
||||||
|
: (eintrag.text ?? "—")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={
|
||||||
|
eintrag.inOrdnung ? "text-sm" : "text-sm font-medium text-[var(--gesperrt)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{eintrag.inOrdnung ? "i. O." : "nicht i. O."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{pruefung.maengel.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Festgestellte Mängel</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{pruefung.maengel.map((mangel) => (
|
||||||
|
<li key={mangel.id} className="text-sm">
|
||||||
|
<Link href="/maengel" className="underline underline-offset-4">
|
||||||
|
{PRIORITAET_TEXT[mangel.prioritaet]}
|
||||||
|
</Link>
|
||||||
|
: {mangel.beschreibung}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CalendarClock, ClipboardCheck, Hourglass, Users } from "lucide-react";
|
||||||
|
import { trpc } from "@/lib/trpc/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Fehlermeldung,
|
||||||
|
FristAnzeige,
|
||||||
|
LadeZeilen,
|
||||||
|
NichtsGefunden,
|
||||||
|
StatusAbzeichen,
|
||||||
|
} from "@/components/stammdaten/anzeige";
|
||||||
|
import { FensterAbzeichen, Kennzahl } from "@/components/wartung/anzeige";
|
||||||
|
import { alsDatum } from "@/lib/utils";
|
||||||
|
import type { Fristfenster } from "@/lib/wartung/fristen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fristen-Dashboard.
|
||||||
|
*
|
||||||
|
* Aufbau von oben nach unten in der Reihenfolge, in der ein Gerätewart denkt:
|
||||||
|
*
|
||||||
|
* 1. Wie viel ist überfällig? (die vier Zahlen, gleichzeitig Filter)
|
||||||
|
* 2. Wo steckt es? (gebündelt nach Fahrzeug, Standort, Typ oder Zuständigem)
|
||||||
|
* 3. Was genau ist zu tun? (die Terminliste, aus der die Prüfung startet)
|
||||||
|
*
|
||||||
|
* Die Bündelung ist kein Zierrat: wer einen Prüftag ansetzt, arbeitet ein
|
||||||
|
* Fahrzeug ab. Eine alphabetische Gesamtliste hilft dabei nicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GRUPPIERUNGEN = [
|
||||||
|
{ wert: "fahrzeug", text: "nach Fahrzeug" },
|
||||||
|
{ wert: "standort", text: "nach Standort" },
|
||||||
|
{ wert: "geraetetyp", text: "nach Gerätetyp" },
|
||||||
|
{ wert: "zustaendig", text: "nach Zuständigem" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Gruppierung = (typeof GRUPPIERUNGEN)[number]["wert"];
|
||||||
|
type Zeitfenster = "ueberfaellig" | "diese_woche" | "dieser_monat" | "naechste_90_tage";
|
||||||
|
|
||||||
|
export function FristenDashboard({
|
||||||
|
darfPlanen,
|
||||||
|
darfPruefen,
|
||||||
|
}: {
|
||||||
|
darfPlanen: boolean;
|
||||||
|
darfPruefen: boolean;
|
||||||
|
}) {
|
||||||
|
const [gruppierung, setGruppierung] = React.useState<Gruppierung>("fahrzeug");
|
||||||
|
const [fenster, setFenster] = React.useState<Zeitfenster | null>(null);
|
||||||
|
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
|
||||||
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const werkzeuge = trpc.useUtils();
|
||||||
|
|
||||||
|
const dashboard = trpc.wartungstermin.dashboard.useQuery({ gruppierung });
|
||||||
|
|
||||||
|
const termine = trpc.wartungstermin.liste.useQuery({
|
||||||
|
seite: 1,
|
||||||
|
seitengroesse: 100,
|
||||||
|
richtung: "asc",
|
||||||
|
fenster: fenster ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mitglieder = trpc.person.liste.useQuery(
|
||||||
|
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||||
|
{ enabled: darfPlanen },
|
||||||
|
);
|
||||||
|
|
||||||
|
const zuweisen = trpc.wartungstermin.zuweisen.useMutation({
|
||||||
|
onSuccess: (ergebnis) => {
|
||||||
|
setMeldung(`${ergebnis.zugewiesen} Termine zugewiesen.`);
|
||||||
|
setAusgewaehlt(new Set());
|
||||||
|
void werkzeuge.wartungstermin.invalidate();
|
||||||
|
},
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zahlen = dashboard.data?.fenster;
|
||||||
|
const eintraege = termine.data?.eintraege ?? [];
|
||||||
|
|
||||||
|
function auswahlUmschalten(id: string) {
|
||||||
|
setAusgewaehlt((alt) => {
|
||||||
|
const neu = new Set(alt);
|
||||||
|
if (neu.has(id)) neu.delete(id);
|
||||||
|
else neu.add(id);
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Die vier Zahlen. Ein Klick filtert die Liste darunter. */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kennzahl
|
||||||
|
wert={zahlen?.ueberfaellig ?? 0}
|
||||||
|
bezeichnung="überfällig"
|
||||||
|
hinweis="hätte längst geprüft sein müssen"
|
||||||
|
dringend
|
||||||
|
aktiv={fenster === "ueberfaellig"}
|
||||||
|
onClick={() => setFenster(fenster === "ueberfaellig" ? null : "ueberfaellig")}
|
||||||
|
/>
|
||||||
|
<Kennzahl
|
||||||
|
wert={zahlen?.diese_woche ?? 0}
|
||||||
|
bezeichnung="diese Woche"
|
||||||
|
hinweis="in den nächsten sieben Tagen"
|
||||||
|
aktiv={fenster === "diese_woche"}
|
||||||
|
onClick={() => setFenster(fenster === "diese_woche" ? null : "diese_woche")}
|
||||||
|
/>
|
||||||
|
<Kennzahl
|
||||||
|
wert={zahlen?.dieser_monat ?? 0}
|
||||||
|
bezeichnung="diesen Monat"
|
||||||
|
hinweis="in den nächsten 30 Tagen"
|
||||||
|
aktiv={fenster === "dieser_monat"}
|
||||||
|
onClick={() => setFenster(fenster === "dieser_monat" ? null : "dieser_monat")}
|
||||||
|
/>
|
||||||
|
<Kennzahl
|
||||||
|
wert={zahlen?.naechste_90_tage ?? 0}
|
||||||
|
bezeichnung="nächste 90 Tage"
|
||||||
|
hinweis="mittelfristig zu planen"
|
||||||
|
aktiv={fenster === "naechste_90_tage"}
|
||||||
|
onClick={() => setFenster(fenster === "naechste_90_tage" ? null : "naechste_90_tage")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Was sonst noch Aufmerksamkeit braucht. */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Link
|
||||||
|
href="/pruefungen/lebensdauer"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<Hourglass className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Lebensdauer: {dashboard.data?.lebensdauer.abgelaufen ?? 0} abgelaufen
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{dashboard.data?.lebensdauer.bald ?? 0} laufen in den nächsten 90 Tagen ab
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/maengel"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{dashboard.data?.maengelOffen ?? 0} offene Mängel</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">gemeldet, zugewiesen, in Arbeit</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/rueckrufe"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<CalendarClock className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{dashboard.data?.rueckrufeOffen ?? 0} offene Rückrufe
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">Herstellerwarnungen in Arbeit</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meldung && <Fehlermeldung meldung={meldung} kennung="dashboardmeldung" />}
|
||||||
|
{dashboard.error && <Fehlermeldung meldung={dashboard.error.message} />}
|
||||||
|
|
||||||
|
{/* Bündelung */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Verteilung</CardTitle>
|
||||||
|
<CardDescription>Wo die offenen Termine hängen.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={gruppierung}
|
||||||
|
onValueChange={(wert) => setGruppierung(wert as Gruppierung)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-52" aria-label="Bündelung wählen">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{GRUPPIERUNGEN.map((eintrag) => (
|
||||||
|
<SelectItem key={eintrag.wert} value={eintrag.wert}>
|
||||||
|
{eintrag.text}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Bezeichnung</TableHead>
|
||||||
|
<TableHead className="text-right">überfällig</TableHead>
|
||||||
|
<TableHead className="text-right">diese Woche</TableHead>
|
||||||
|
<TableHead className="text-right">diesen Monat</TableHead>
|
||||||
|
<TableHead className="text-right">gesamt offen</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dashboard.isLoading && <LadeZeilen spalten={5} zeilen={4} />}
|
||||||
|
|
||||||
|
{dashboard.data?.gruppen.map((gruppe) => (
|
||||||
|
<TableRow key={gruppe.schluessel}>
|
||||||
|
<TableCell className="font-medium">{gruppe.bezeichnung}</TableCell>
|
||||||
|
<TableCell className="text-right tabellenzahlen">
|
||||||
|
{gruppe.fenster.ueberfaellig > 0 ? (
|
||||||
|
<span className="font-medium text-[var(--gesperrt)]">
|
||||||
|
{gruppe.fenster.ueberfaellig}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabellenzahlen">
|
||||||
|
{gruppe.fenster.diese_woche || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabellenzahlen">
|
||||||
|
{gruppe.fenster.dieser_monat || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabellenzahlen font-medium">
|
||||||
|
{gruppe.gesamt}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{!dashboard.isLoading && (dashboard.data?.gruppen.length ?? 0) === 0 && (
|
||||||
|
<div className="p-4">
|
||||||
|
<NichtsGefunden
|
||||||
|
titel="Keine offenen Prüftermine"
|
||||||
|
hinweis={
|
||||||
|
darfPlanen
|
||||||
|
? "Termine entstehen aus den Wartungsvorlagen — der nächtliche Lauf legt sie an. Ohne Vorlage gibt es keine Termine."
|
||||||
|
: "Sobald Termine anstehen, erscheinen sie hier."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
darfPlanen ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/pruefungen/vorlagen">Vorlagen einrichten</Link>
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Terminliste */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>
|
||||||
|
Offene Termine
|
||||||
|
{fenster && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-[var(--muted-foreground)]">
|
||||||
|
gefiltert auf „{fenster.replace(/_/g, " ")}"
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{termine.data?.gesamt ?? 0} Termine · nach Fälligkeit sortiert
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{fenster && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setFenster(null)}>
|
||||||
|
Filter aufheben
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfPlanen && ausgewaehlt.size > 0 && (
|
||||||
|
<Select
|
||||||
|
onValueChange={(wert) =>
|
||||||
|
zuweisen.mutate({
|
||||||
|
ids: [...ausgewaehlt],
|
||||||
|
zugewiesenAnId: wert === "niemand" ? null : wert,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-64" aria-label="Ausgewählte Termine zuweisen">
|
||||||
|
<SelectValue placeholder={`${ausgewaehlt.size} Termine zuweisen an …`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="niemand">niemandem</SelectItem>
|
||||||
|
{mitglieder.data?.eintraege.map((person) => (
|
||||||
|
<SelectItem key={person.id} value={person.id}>
|
||||||
|
{person.nachname}, {person.vorname}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{darfPlanen && <TableHead className="w-10" aria-label="Auswahl" />}
|
||||||
|
<TableHead>Gerät</TableHead>
|
||||||
|
<TableHead>Prüfung</TableHead>
|
||||||
|
<TableHead>Lagerung</TableHead>
|
||||||
|
<TableHead>Zuständig</TableHead>
|
||||||
|
<TableHead className="text-right">fällig</TableHead>
|
||||||
|
<TableHead className="w-32" aria-label="Aktion" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{termine.isLoading && <LadeZeilen spalten={darfPlanen ? 7 : 6} />}
|
||||||
|
|
||||||
|
{eintraege.map((termin) => (
|
||||||
|
<TableRow key={termin.id}>
|
||||||
|
{darfPlanen && (
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={ausgewaehlt.has(termin.id)}
|
||||||
|
onCheckedChange={() => auswahlUmschalten(termin.id)}
|
||||||
|
aria-label={`Termin für ${termin.geraet.inventarnummer} auswählen`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/geraete/${termin.geraet.id}`}
|
||||||
|
className="font-medium underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
<span className="tabellenzahlen">{termin.geraet.inventarnummer}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{termin.geraet.bezeichnung}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<StatusAbzeichen status={termin.geraet.status} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{termin.vorlage?.name ?? "ohne Vorlage"}
|
||||||
|
{termin.vorlage?.pruefvorschrift && (
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{termin.vorlage.pruefvorschrift}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{termin.geraet.fahrzeug
|
||||||
|
? `${termin.geraet.fahrzeug.funkrufname}${
|
||||||
|
termin.geraet.geraeteraum
|
||||||
|
? ` · ${termin.geraet.geraeteraum.bezeichnung}`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
: (termin.geraet.standort?.name ?? "—")}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{termin.zugewiesenAn ? (
|
||||||
|
`${termin.zugewiesenAn.nachname}, ${termin.zugewiesenAn.vorname}`
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[var(--muted-foreground)]">
|
||||||
|
<Users className="size-3.5" aria-hidden />
|
||||||
|
offen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<FristAnzeige datum={termin.faelligAm} vorwarnTage={30} />
|
||||||
|
<div className="mt-1">
|
||||||
|
<FensterAbzeichen fenster={termin.fenster as Fristfenster} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{darfPruefen && (
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/pruefungen/neu?geraetId=${termin.geraet.id}&terminId=${termin.id}`}
|
||||||
|
>
|
||||||
|
Prüfen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{!termine.isLoading && eintraege.length === 0 && (
|
||||||
|
<div className="p-4">
|
||||||
|
<NichtsGefunden
|
||||||
|
titel={fenster ? "In diesem Zeitraum steht nichts an" : "Keine offenen Termine"}
|
||||||
|
hinweis={
|
||||||
|
fenster
|
||||||
|
? "Ein anderer Zeitraum zeigt vielleicht mehr."
|
||||||
|
: "Alles geprüft — oder es sind noch keine Vorlagen eingerichtet."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-[var(--muted-foreground)]">
|
||||||
|
Stand: {alsDatum(dashboard.data?.stand ?? new Date())}. Termine entstehen nachts aus den
|
||||||
|
Wartungsvorlagen; überfällig wird jeden Tag neu gerechnet, nicht gespeichert.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
|
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 {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Fehlermeldung,
|
||||||
|
FristAnzeige,
|
||||||
|
LadeZeilen,
|
||||||
|
NichtsGefunden,
|
||||||
|
StatusAbzeichen,
|
||||||
|
} from "@/components/stammdaten/anzeige";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geräte am Ende der Nutzungsdauer.
|
||||||
|
*
|
||||||
|
* Der Vorschlag zur Aussonderung ist bewusst ein Knopf und kein Automatismus:
|
||||||
|
* ein abgelaufener Helm wird gesperrt (das macht der nächtliche Lauf), aber
|
||||||
|
* ausgesondert wird er von einem Menschen, der weiß, ob Ersatz da ist.
|
||||||
|
*/
|
||||||
|
export function LebensdauerListe({ darfAussondern }: { darfAussondern: boolean }) {
|
||||||
|
const [vorwarnTage, setVorwarnTage] = React.useState(180);
|
||||||
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const werkzeuge = trpc.useUtils();
|
||||||
|
const geraete = trpc.wartungstermin.lebensdauer.useQuery({ vorwarnTage });
|
||||||
|
|
||||||
|
const aussondern = trpc.geraet.loeschen.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setMeldung("Gerät ausgesondert.");
|
||||||
|
void werkzeuge.wartungstermin.lebensdauer.invalidate();
|
||||||
|
void werkzeuge.geraet.invalidate();
|
||||||
|
},
|
||||||
|
onError: (fehler) => setMeldung(fehler.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintraege = geraete.data ?? [];
|
||||||
|
const abgelaufen = eintraege.filter((geraet) => geraet.abgelaufen).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="vorwarn">Zeitraum</Label>
|
||||||
|
<Select
|
||||||
|
value={String(vorwarnTage)}
|
||||||
|
onValueChange={(wert) => setVorwarnTage(Number(wert))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="vorwarn" className="w-56">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">nur bereits abgelaufen</SelectItem>
|
||||||
|
<SelectItem value="90">nächste 90 Tage</SelectItem>
|
||||||
|
<SelectItem value="180">nächste 6 Monate</SelectItem>
|
||||||
|
<SelectItem value="365">nächstes Jahr</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{abgelaufen} abgelaufen · {eintraege.length - abgelaufen} laufen demnächst ab
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meldung && <Fehlermeldung meldung={meldung} kennung="lebensdauermeldung" />}
|
||||||
|
{geraete.error && <Fehlermeldung meldung={geraete.error.message} />}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[var(--border)]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Gerät</TableHead>
|
||||||
|
<TableHead>Kategorie</TableHead>
|
||||||
|
<TableHead>Lagerung</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">nutzbar bis</TableHead>
|
||||||
|
<TableHead className="w-40" aria-label="Aktion" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{geraete.isLoading && <LadeZeilen spalten={6} />}
|
||||||
|
|
||||||
|
{eintraege.map((geraet) => (
|
||||||
|
<TableRow key={geraet.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/geraete/${geraet.id}`}
|
||||||
|
className="font-medium underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">{geraet.bezeichnung}</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{geraet.lebensdauerKategorie ??
|
||||||
|
geraet.geraetetyp?.lebensdauerKategorie ??
|
||||||
|
"—"}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<StatusAbzeichen status={geraet.status} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={vorwarnTage || 90} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{darfAussondern && geraet.abgelaufen && geraet.status !== "AUSGESONDERT" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`${geraet.inventarnummer} aussondern? Das Gerät bleibt mit allen Prüfungen erhalten, verschwindet aber aus dem Bestand.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
aussondern.mutate({
|
||||||
|
id: geraet.id,
|
||||||
|
grund: "Nutzungsdauer abgelaufen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Archive aria-hidden />
|
||||||
|
Aussondern
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{!geraete.isLoading && eintraege.length === 0 && (
|
||||||
|
<div className="p-6">
|
||||||
|
<NichtsGefunden
|
||||||
|
titel="Nichts läuft ab"
|
||||||
|
hinweis="In diesem Zeitraum erreicht kein Gerät das Ende seiner Nutzungsdauer. Voraussetzung dafür ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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 { LebensdauerListe } from "@/app/(app)/pruefungen/lebensdauer/lebensdauer-liste";
|
||||||
|
|
||||||
|
export const metadata = { title: "Lebensdauer" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lebensdauerüberwachung.
|
||||||
|
*
|
||||||
|
* 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 demnächst abläuft, und schlägt die Aussonderung
|
||||||
|
* vor.
|
||||||
|
*/
|
||||||
|
export default async function LebensdauerSeite() {
|
||||||
|
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="/pruefungen">
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
Zurück zu den Fristen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Lebensdauer</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Geräte am Ende ihrer zulässigen Nutzungsdauer — unabhängig vom Zustand.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LebensdauerListe darfAussondern={hatRecht(sitzung, "geraet.aussondern")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { hatRecht } from "@/lib/authz";
|
||||||
|
import { PruefWizard } from "@/app/(app)/pruefungen/neu/pruef-wizard";
|
||||||
|
|
||||||
|
export const metadata = { title: "Prüfung durchführen" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Prüfwizard.
|
||||||
|
*
|
||||||
|
* Aufgerufen wird er aus dem Fristen-Dashboard (mit Termin) oder von der
|
||||||
|
* Gerätedetailseite (nur mit Gerät). Beides kommt als Suchparameter herein —
|
||||||
|
* so lässt sich der Vorgang auch über einen QR-Scan am Regal starten.
|
||||||
|
*/
|
||||||
|
export default async function NeuePruefungSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ geraetId?: string; terminId?: string; vorlageId?: string }>;
|
||||||
|
}) {
|
||||||
|
const sitzung = await aktuelleSitzung();
|
||||||
|
if (!sitzung) redirect("/anmelden");
|
||||||
|
|
||||||
|
if (!hatRecht(sitzung, "pruefung.durchfuehren")) {
|
||||||
|
redirect("/pruefungen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { geraetId, terminId, vorlageId } = await searchParams;
|
||||||
|
|
||||||
|
if (!geraetId) redirect("/pruefungen");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PruefWizard
|
||||||
|
geraetId={geraetId}
|
||||||
|
terminId={terminId ?? null}
|
||||||
|
vorlageId={vorlageId ?? null}
|
||||||
|
prueferName={sitzung.name}
|
||||||
|
prueferId={sitzung.personId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
TriangleAlert,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ChecklistenpunktTyp, MangelPrioritaet, PruefErgebnis, PruefungArt } 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 { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||||
|
import { DateiFeld } from "@/components/wartung/datei-feld";
|
||||||
|
import { UnterschriftFeld } from "@/components/wartung/unterschrift";
|
||||||
|
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüfwizard — Schritt für Schritt durch die Checkliste.
|
||||||
|
*
|
||||||
|
* Der Ablauf ist auf das Tablet im Gerätehaus zugeschnitten:
|
||||||
|
*
|
||||||
|
* * Ein Punkt je Bildschirm, große Schaltflächen. Wer Handschuhe trägt,
|
||||||
|
* trifft keine 24-Pixel-Kästchen.
|
||||||
|
* * Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens
|
||||||
|
* die letzte Antwort verloren, nicht die ganze Prüfung.
|
||||||
|
* * Die Zeit läuft im Hintergrund mit und landet im Protokoll — sie ist
|
||||||
|
* Bestandteil des Nachweises, wie gründlich geprüft wurde.
|
||||||
|
*
|
||||||
|
* Am Ende steht der Abschluss: Ergebnis, Mängel, Unterschrift. Danach ist das
|
||||||
|
* Protokoll festgeschrieben; Korrekturen erzeugen ein neues.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Antwortstand {
|
||||||
|
jaNein?: boolean | null;
|
||||||
|
zahlwert?: string;
|
||||||
|
text?: string;
|
||||||
|
fotoDateiId?: string | null;
|
||||||
|
bemerkung?: string;
|
||||||
|
inOrdnung?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MangelEntwurf {
|
||||||
|
beschreibung: string;
|
||||||
|
prioritaet: MangelPrioritaet;
|
||||||
|
fotoDateiId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verstricheneZeit(seit: Date | null): string {
|
||||||
|
if (!seit) return "0:00";
|
||||||
|
const sekunden = Math.max(0, Math.floor((Date.now() - seit.getTime()) / 1000));
|
||||||
|
const minuten = Math.floor(sekunden / 60);
|
||||||
|
return `${minuten}:${String(sekunden % 60).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PruefWizard({
|
||||||
|
geraetId,
|
||||||
|
terminId,
|
||||||
|
vorlageId,
|
||||||
|
prueferName,
|
||||||
|
prueferId,
|
||||||
|
}: {
|
||||||
|
geraetId: string;
|
||||||
|
terminId: string | null;
|
||||||
|
vorlageId: string | null;
|
||||||
|
prueferName: string;
|
||||||
|
prueferId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [pruefungId, setPruefungId] = React.useState<string | null>(null);
|
||||||
|
const [schritt, setSchritt] = React.useState(0);
|
||||||
|
const [antworten, setAntworten] = React.useState<Record<string, Antwortstand>>({});
|
||||||
|
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Abschlussangaben
|
||||||
|
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | null>(null);
|
||||||
|
const [bemerkung, setBemerkung] = React.useState("");
|
||||||
|
const [maengel, setMaengel] = React.useState<MangelEntwurf[]>([]);
|
||||||
|
const [signaturDateiId, setSignaturDateiId] = React.useState<string | null>(null);
|
||||||
|
const [art, setArt] = React.useState<PruefungArt>(PruefungArt.INTERN);
|
||||||
|
const [externeFirma, setExterneFirma] = React.useState("");
|
||||||
|
const [kosten, setKosten] = React.useState("");
|
||||||
|
const [fremdprotokollId, setFremdprotokollId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [uhr, setUhr] = React.useState(0);
|
||||||
|
const gestartet = React.useRef(false);
|
||||||
|
|
||||||
|
const werkzeuge = trpc.useUtils();
|
||||||
|
|
||||||
|
const starten = trpc.pruefung.starten.useMutation({
|
||||||
|
onSuccess: (ergebnisDaten) => {
|
||||||
|
const pruefung = ergebnisDaten.pruefung;
|
||||||
|
setPruefungId(pruefung.id);
|
||||||
|
setArt(pruefung.art);
|
||||||
|
|
||||||
|
// Fortgesetzter Entwurf: bereits gegebene Antworten übernehmen.
|
||||||
|
const vorhandene: Record<string, Antwortstand> = {};
|
||||||
|
for (const eintrag of pruefung.ergebnisse) {
|
||||||
|
vorhandene[eintrag.punktId] = {
|
||||||
|
jaNein: eintrag.jaNein,
|
||||||
|
zahlwert: eintrag.zahlwert ? String(eintrag.zahlwert) : "",
|
||||||
|
text: eintrag.text ?? "",
|
||||||
|
fotoDateiId: eintrag.fotoDateiId,
|
||||||
|
bemerkung: eintrag.bemerkung ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setAntworten(vorhandene);
|
||||||
|
},
|
||||||
|
onError: (problem) => setFehler(problem.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Genau einmal starten — React ruft Effekte in der Entwicklung doppelt auf.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (gestartet.current) return;
|
||||||
|
gestartet.current = true;
|
||||||
|
starten.mutate({
|
||||||
|
geraetId,
|
||||||
|
terminId,
|
||||||
|
vorlageId,
|
||||||
|
art: PruefungArt.INTERN,
|
||||||
|
});
|
||||||
|
}, [geraetId, terminId, vorlageId, starten]);
|
||||||
|
|
||||||
|
const pruefung = starten.data?.pruefung;
|
||||||
|
const punkte = React.useMemo(() => pruefung?.vorlage?.punkte ?? [], [pruefung]);
|
||||||
|
const begonnenUm = pruefung?.begonnenUm ? new Date(pruefung.begonnenUm) : null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const zeitgeber = setInterval(() => setUhr((wert) => wert + 1), 1000);
|
||||||
|
return () => clearInterval(zeitgeber);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const speichern = trpc.pruefung.ergebnisSpeichern.useMutation({
|
||||||
|
onError: (problem) => setFehler(problem.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const abschliessen = trpc.pruefung.abschliessen.useMutation({
|
||||||
|
onSuccess: (fertig) => {
|
||||||
|
void werkzeuge.wartungstermin.invalidate();
|
||||||
|
void werkzeuge.geraet.invalidate();
|
||||||
|
router.push(`/pruefungen/${fertig.id}`);
|
||||||
|
},
|
||||||
|
onError: (problem) => setFehler(problem.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function antwortSetzen(punktId: string, teil: Partial<Antwortstand>) {
|
||||||
|
setAntworten((alt) => ({ ...alt, [punktId]: { ...alt[punktId], ...teil } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort zum aktuellen Punkt zum Server schicken. */
|
||||||
|
async function punktSpeichern(punktId: string) {
|
||||||
|
if (!pruefungId) return;
|
||||||
|
const antwort = antworten[punktId];
|
||||||
|
if (!antwort) return;
|
||||||
|
|
||||||
|
await speichern.mutateAsync({
|
||||||
|
pruefungId,
|
||||||
|
punktId,
|
||||||
|
jaNein: antwort.jaNein ?? null,
|
||||||
|
zahlwert:
|
||||||
|
antwort.zahlwert !== undefined && antwort.zahlwert !== ""
|
||||||
|
? Number(antwort.zahlwert)
|
||||||
|
: null,
|
||||||
|
text: antwort.text || null,
|
||||||
|
fotoDateiId: antwort.fotoDateiId ?? null,
|
||||||
|
inOrdnung: antwort.inOrdnung ?? null,
|
||||||
|
bemerkung: antwort.bemerkung || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function weiter() {
|
||||||
|
setFehler(null);
|
||||||
|
const punkt = punkte[schritt];
|
||||||
|
if (punkt) await punktSpeichern(punkt.id);
|
||||||
|
setSchritt((wert) => Math.min(wert + 1, punkte.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function zurueck() {
|
||||||
|
setFehler(null);
|
||||||
|
setSchritt((wert) => Math.max(0, wert - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mängel aus den beanstandeten Punkten vorschlagen. */
|
||||||
|
function maengelVorschlagen() {
|
||||||
|
const vorschlaege: MangelEntwurf[] = [];
|
||||||
|
|
||||||
|
for (const punkt of punkte) {
|
||||||
|
const antwort = antworten[punkt.id];
|
||||||
|
if (!antwort) continue;
|
||||||
|
|
||||||
|
const nichtInOrdnung =
|
||||||
|
antwort.inOrdnung === false ||
|
||||||
|
(punkt.typ === ChecklistenpunktTyp.JA_NEIN && antwort.jaNein === false);
|
||||||
|
|
||||||
|
if (!nichtInOrdnung) continue;
|
||||||
|
|
||||||
|
vorschlaege.push({
|
||||||
|
beschreibung: `${punkt.text}${antwort.bemerkung ? `: ${antwort.bemerkung}` : ""}`,
|
||||||
|
prioritaet: punkt.pflicht ? MangelPrioritaet.KRITISCH : MangelPrioritaet.MITTEL,
|
||||||
|
fotoDateiId: antwort.fotoDateiId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaengel((alt) => [...alt, ...vorschlaege]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amAbschluss = schritt >= punkte.length;
|
||||||
|
|
||||||
|
// Vorschlag für das Gesamtergebnis, sobald der Abschluss erreicht ist.
|
||||||
|
const stand = trpc.pruefung.stand.useQuery(
|
||||||
|
{ id: pruefungId ?? "" },
|
||||||
|
{ enabled: Boolean(pruefungId) && amAbschluss },
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (amAbschluss && stand.data && ergebnis === null) {
|
||||||
|
setErgebnis(stand.data.vorschlag as PruefErgebnis);
|
||||||
|
if (stand.data.vorschlag !== "BESTANDEN" && maengel.length === 0) maengelVorschlagen();
|
||||||
|
}
|
||||||
|
// maengelVorschlagen hängt nur an bereits gesetztem Zustand.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [amAbschluss, stand.data]);
|
||||||
|
|
||||||
|
if (starten.isPending || !pruefung) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Prüfung wird vorbereitet …</h1>
|
||||||
|
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const punkt = punkte[schritt];
|
||||||
|
const antwort = punkt ? (antworten[punkt.id] ?? {}) : {};
|
||||||
|
const fortschritt = punkte.length === 0 ? 100 : Math.round((schritt / punkte.length) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
{/* Kopf: Gerät, Uhr, Fortschritt */}
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
{pruefung.geraet.bezeichnung}
|
||||||
|
</h1>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<Clock className="size-4" aria-hidden />
|
||||||
|
<span className="tabellenzahlen" aria-live="off">
|
||||||
|
{verstricheneZeit(begonnenUm)}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">seit Beginn der Prüfung ({uhr} Sekunden aktualisiert)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
|
||||||
|
{pruefung.vorlage && ` · ${pruefung.vorlage.name}`}
|
||||||
|
{starten.data?.fortgesetzt && " · angefangene Prüfung wird fortgesetzt"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-3 h-2 overflow-hidden rounded-full bg-[var(--muted)]"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={fortschritt}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label="Fortschritt der Prüfung"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--primary)] transition-all"
|
||||||
|
style={{ width: `${fortschritt}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && <Fehlermeldung meldung={fehler} kennung="wizardfehler" />}
|
||||||
|
|
||||||
|
{punkte.length === 0 && (
|
||||||
|
<Fehlermeldung
|
||||||
|
meldung="Zu dieser Prüfung gibt es keine Checkliste. Ohne Prüfpunkte lässt sich nichts dokumentieren — bitte erst eine Wartungsvorlage mit Punkten anlegen."
|
||||||
|
kennung="ohnecheckliste"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ein Prüfpunkt */}
|
||||||
|
{!amAbschluss && punkt && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>
|
||||||
|
Schritt {schritt + 1} von {punkte.length}
|
||||||
|
{punkt.pflicht && " · Pflichtpunkt"}
|
||||||
|
</CardDescription>
|
||||||
|
<CardTitle className="text-xl">{punkt.text}</CardTitle>
|
||||||
|
{punkt.hinweis && (
|
||||||
|
<CardDescription>{punkt.hinweis}</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{punkt.sollwert && (
|
||||||
|
<p className="rounded-md bg-[var(--muted)] px-3 py-2 text-sm">
|
||||||
|
Soll: <strong>{punkt.sollwert}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{punkt.typ === ChecklistenpunktTyp.JA_NEIN && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={antwort.jaNein === true ? "default" : "outline"}
|
||||||
|
className="h-16 text-base"
|
||||||
|
onClick={() => antwortSetzen(punkt.id, { jaNein: true, inOrdnung: null })}
|
||||||
|
>
|
||||||
|
<Check aria-hidden />
|
||||||
|
in Ordnung
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={antwort.jaNein === false ? "destructive" : "outline"}
|
||||||
|
className="h-16 text-base"
|
||||||
|
onClick={() => antwortSetzen(punkt.id, { jaNein: false, inOrdnung: null })}
|
||||||
|
>
|
||||||
|
<X aria-hidden />
|
||||||
|
nicht in Ordnung
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{punkt.typ === ChecklistenpunktTyp.MESSWERT && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="messwert">
|
||||||
|
Messwert{punkt.einheit ? ` in ${punkt.einheit}` : ""}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="messwert"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-14 text-lg"
|
||||||
|
value={antwort.zahlwert ?? ""}
|
||||||
|
onChange={(ereignis) =>
|
||||||
|
antwortSetzen(punkt.id, { zahlwert: ereignis.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{(punkt.minWert !== null || punkt.maxWert !== null) && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
zulässig {punkt.minWert !== null ? String(punkt.minWert) : "…"} bis{" "}
|
||||||
|
{punkt.maxWert !== null ? String(punkt.maxWert) : "…"}
|
||||||
|
{punkt.einheit ? ` ${punkt.einheit}` : ""} — außerhalb gilt der Punkt als
|
||||||
|
beanstandet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{punkt.typ === ChecklistenpunktTyp.TEXT && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="freitext">Befund</Label>
|
||||||
|
<Textarea
|
||||||
|
id="freitext"
|
||||||
|
rows={4}
|
||||||
|
value={antwort.text ?? ""}
|
||||||
|
onChange={(ereignis) => antwortSetzen(punkt.id, { text: ereignis.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{punkt.typ === ChecklistenpunktTyp.FOTO && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Foto</Label>
|
||||||
|
<DateiFeld
|
||||||
|
entitaet="Pruefung"
|
||||||
|
entitaetId={pruefungId ?? undefined}
|
||||||
|
kategorie="foto"
|
||||||
|
dateiId={antwort.fotoDateiId ?? null}
|
||||||
|
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Foto und Bemerkung sind zu jedem Punkt möglich, nicht nur beim Fototyp. */}
|
||||||
|
{punkt.typ !== ChecklistenpunktTyp.FOTO && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Foto (freiwillig)</Label>
|
||||||
|
<DateiFeld
|
||||||
|
entitaet="Pruefung"
|
||||||
|
entitaetId={pruefungId ?? undefined}
|
||||||
|
kategorie="foto"
|
||||||
|
dateiId={antwort.fotoDateiId ?? null}
|
||||||
|
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="punktbemerkung">Bemerkung</Label>
|
||||||
|
<Input
|
||||||
|
id="punktbemerkung"
|
||||||
|
value={antwort.bemerkung ?? ""}
|
||||||
|
onChange={(ereignis) =>
|
||||||
|
antwortSetzen(punkt.id, { bemerkung: ereignis.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Was aufgefallen ist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={antwort.inOrdnung === false}
|
||||||
|
onCheckedChange={(an) =>
|
||||||
|
antwortSetzen(punkt.id, { inOrdnung: an === true ? false : null })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Trotz Wert beanstanden — der Prüfer sieht mehr als die Zahl
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Abschluss */}
|
||||||
|
{amAbschluss && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{stand.data && stand.data.offenePflichtpunkte.length > 0 && (
|
||||||
|
<Fehlermeldung
|
||||||
|
kennung="offenepflichtpunkte"
|
||||||
|
meldung={`Es fehlen noch Pflichtangaben: ${stand.data.offenePflichtpunkte
|
||||||
|
.map((offen) => offen.text)
|
||||||
|
.join(", ")}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ergebnis</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vorgeschlagen ist {stand.data ? ERGEBNIS_TEXT[stand.data.vorschlag] : "…"} —
|
||||||
|
{" "}
|
||||||
|
{stand.data?.beantwortet ?? 0} von {stand.data?.gesamt ?? punkte.length} Punkten
|
||||||
|
beantwortet. Die Entscheidung trifft der Prüfer.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
|
||||||
|
<Button
|
||||||
|
key={wert}
|
||||||
|
type="button"
|
||||||
|
variant={ergebnis === wert ? "default" : "outline"}
|
||||||
|
className="h-14"
|
||||||
|
onClick={() => setErgebnis(wert)}
|
||||||
|
>
|
||||||
|
{ERGEBNIS_TEXT[wert]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="abschlussbemerkung">Bemerkung zum Protokoll</Label>
|
||||||
|
<Textarea
|
||||||
|
id="abschlussbemerkung"
|
||||||
|
rows={3}
|
||||||
|
value={bemerkung}
|
||||||
|
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="art">Durchgeführt</Label>
|
||||||
|
<Select value={art} onValueChange={(wert) => setArt(wert as PruefungArt)}>
|
||||||
|
<SelectTrigger id="art">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={PruefungArt.INTERN}>intern durch die Wehr</SelectItem>
|
||||||
|
<SelectItem value={PruefungArt.EXTERN}>extern durch eine Fachfirma</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{art === PruefungArt.EXTERN && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firma">Fachfirma</Label>
|
||||||
|
<Input
|
||||||
|
id="firma"
|
||||||
|
value={externeFirma}
|
||||||
|
onChange={(ereignis) => setExterneFirma(ereignis.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="kosten">Kosten in Euro</Label>
|
||||||
|
<Input
|
||||||
|
id="kosten"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
value={kosten}
|
||||||
|
onChange={(ereignis) => setKosten(ereignis.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label>Fremdprotokoll als PDF</Label>
|
||||||
|
<DateiFeld
|
||||||
|
entitaet="Pruefung"
|
||||||
|
entitaetId={pruefungId ?? undefined}
|
||||||
|
kategorie="fremdprotokoll"
|
||||||
|
nurBilder={false}
|
||||||
|
beschriftung="Protokoll der Fachfirma anhängen"
|
||||||
|
dateiId={fremdprotokollId}
|
||||||
|
onHochgeladen={setFremdprotokollId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Mängel</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Was aufgefallen ist und noch behoben werden muss. Kritische Mängel sperren das
|
||||||
|
Gerät.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setMaengel((alt) => [
|
||||||
|
...alt,
|
||||||
|
{ beschreibung: "", prioritaet: MangelPrioritaet.MITTEL },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TriangleAlert aria-hidden />
|
||||||
|
Mangel ergänzen
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{maengel.length === 0 && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Kein Mangel erfasst.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{maengel.map((mangel, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="space-y-2 rounded-md border border-[var(--border)] p-3"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
value={mangel.beschreibung}
|
||||||
|
onChange={(ereignis) =>
|
||||||
|
setMaengel((alt) =>
|
||||||
|
alt.map((eintrag, position) =>
|
||||||
|
position === index
|
||||||
|
? { ...eintrag, beschreibung: ereignis.target.value }
|
||||||
|
: eintrag,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Was ist beschädigt, fehlt oder funktioniert nicht?"
|
||||||
|
aria-label={`Beschreibung des Mangels ${index + 1}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={mangel.prioritaet}
|
||||||
|
onValueChange={(wert) =>
|
||||||
|
setMaengel((alt) =>
|
||||||
|
alt.map((eintrag, position) =>
|
||||||
|
position === index
|
||||||
|
? { ...eintrag, prioritaet: wert as MangelPrioritaet }
|
||||||
|
: eintrag,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-44"
|
||||||
|
aria-label={`Dringlichkeit des Mangels ${index + 1}`}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={MangelPrioritaet.NIEDRIG}>niedrig</SelectItem>
|
||||||
|
<SelectItem value={MangelPrioritaet.MITTEL}>mittel</SelectItem>
|
||||||
|
<SelectItem value={MangelPrioritaet.HOCH}>hoch</SelectItem>
|
||||||
|
<SelectItem value={MangelPrioritaet.KRITISCH}>
|
||||||
|
kritisch — sperrt das Gerät
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setMaengel((alt) => alt.filter((_, position) => position !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X aria-hidden />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Unterschrift</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ohne Unterschrift ist ein Protokoll kein Nachweis. Mit dem Abschluss wird es
|
||||||
|
festgeschrieben — Korrekturen erzeugen danach ein neues Protokoll.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UnterschriftFeld
|
||||||
|
name={prueferName}
|
||||||
|
rolle="Prüfer"
|
||||||
|
pruefungId={pruefungId ?? undefined}
|
||||||
|
onFertig={setSignaturDateiId}
|
||||||
|
uebernommen={Boolean(signaturDateiId)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Steuerung */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--border)] pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={zurueck} disabled={schritt === 0}>
|
||||||
|
<ChevronLeft aria-hidden />
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!amAbschluss ? (
|
||||||
|
<Button type="button" onClick={weiter} disabled={speichern.isPending}>
|
||||||
|
{speichern.isPending ? "wird gespeichert …" : "Weiter"}
|
||||||
|
<ChevronRight aria-hidden />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
abschliessen.isPending ||
|
||||||
|
!ergebnis ||
|
||||||
|
!signaturDateiId ||
|
||||||
|
(stand.data?.offenePflichtpunkte.length ?? 0) > 0
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (!pruefungId || !ergebnis || !signaturDateiId) return;
|
||||||
|
setFehler(null);
|
||||||
|
|
||||||
|
abschliessen.mutate({
|
||||||
|
id: pruefungId,
|
||||||
|
ergebnis,
|
||||||
|
bemerkung: bemerkung || null,
|
||||||
|
externeFirma: art === PruefungArt.EXTERN ? externeFirma || null : null,
|
||||||
|
externeProtokollDateiId: art === PruefungArt.EXTERN ? fremdprotokollId : null,
|
||||||
|
kosten: art === PruefungArt.EXTERN && kosten ? Number(kosten) : null,
|
||||||
|
signaturen: [
|
||||||
|
{
|
||||||
|
rolle: "PRUEFER",
|
||||||
|
name: prueferName,
|
||||||
|
personId: prueferId,
|
||||||
|
bildDateiId: signaturDateiId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maengel: maengel
|
||||||
|
.filter((mangel) => mangel.beschreibung.trim().length > 0)
|
||||||
|
.map((mangel) => ({
|
||||||
|
beschreibung: mangel.beschreibung,
|
||||||
|
prioritaet: mangel.prioritaet,
|
||||||
|
fotoDateiId: mangel.fotoDateiId ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check aria-hidden />
|
||||||
|
{abschliessen.isPending ? "wird abgeschlossen …" : "Prüfung abschließen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||