Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc62a9c1e8 | ||
|
|
e503aafc18 | ||
|
|
7f866ac35c | ||
|
|
1744403620 | ||
|
|
47b8e36a38 | ||
|
|
26b62a3477 |
@@ -213,6 +213,19 @@ LLM_BASE_URL=http://ollama:11434/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODELL=qwen2.5:14b
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wartung und Prüfung (Phase 2)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wann der nächtliche Lauf Prüftermine plant. Cron-Syntax, Zeitzone
|
||||
# Europe/Berlin. Der Worker trägt den Zeitplan beim Start je Wehr selbst ein.
|
||||
WARTUNG_NACHTLAUF=15 3 * * *
|
||||
# Wann die Erinnerungen verschickt werden. Bewusst nach der Planung: sonst
|
||||
# erinnert der Lauf an Termine, die es noch gar nicht gibt.
|
||||
WARTUNG_ERINNERUNG_LAUF=45 3 * * *
|
||||
# Wie weit im Voraus Termine angelegt werden. Ohne Horizont stünden Termine für
|
||||
# die nächsten zehn Jahre im Dashboard.
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE=180
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Datenschutz und Aufbewahrung (DSGVO)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,237 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
Noch nichts. Nächster Schritt ist Phase 1 (Stammdatenverwaltung).
|
||||
Nächster Schritt ist Phase 3 (Einsätze und Übungen).
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-09-03
|
||||
|
||||
Phase 2: Wartungs- und Prüfmanagement. Das Modul, wegen dem eine Wehr so ein
|
||||
Programm einführt — Fristen im Blick behalten und im Streitfall nachweisen
|
||||
können, dass geprüft wurde.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Wartungsvorlage` — was an einem Gerätetyp in welchem Takt geprüft wird, mit
|
||||
Intervall, Vorwarnzeit, Prüfvorschrift und Erfahrungswert für die Dauer.
|
||||
- `Checklistenpunkt` in vier Ausprägungen: Ja/Nein, Messwert (mit zulässigem
|
||||
Bereich und Einheit), Text und Foto. Pflichtpunkte sind die, an denen die
|
||||
Einsatzbereitschaft hängt.
|
||||
- `Wartungstermin` — ein fälliger Termin an einem konkreten Gerät. Eindeutig
|
||||
über Gerät, Vorlage und Fälligkeitstag, damit ein zweiter nächtlicher Lauf
|
||||
nichts doppelt anlegt.
|
||||
- `Pruefung`, `Pruefergebnis` und `Signatur` — die durchgeführte Prüfung mit
|
||||
Antworten je Punkt und Unterschrift.
|
||||
- `Mangel` mit dem Weg melden → zugewiesen → in Arbeit → behoben.
|
||||
- `Rueckruf`, `RueckrufGeraetetyp` und `RueckrufBetroffenheit` — eine
|
||||
Herstellerwarnung, die betroffene Bauart und der Stand je Gerät.
|
||||
- `Erinnerung` als Nachweis und gleichzeitig als Sperre gegen Doppelversand.
|
||||
|
||||
**Unveränderbarkeit der Protokolle**
|
||||
|
||||
- Ein Trigger in der Datenbank weist jede Änderung an einer abgeschlossenen
|
||||
Prüfung ab. Zulässig bleiben genau drei Dinge: der Wechsel auf „ersetzt", das
|
||||
Nachtragen des PDF, solange keines hinterlegt ist, und `geaendertAm`.
|
||||
- Dieselbe Sperre gilt für die Bestandteile: an einem abgeschlossenen Protokoll
|
||||
lassen sich weder Einzelergebnisse ändern noch Unterschriften nachtragen.
|
||||
- Korrekturen erzeugen eine **neue** Prüfung mit Verweis auf die alte. Beide
|
||||
bleiben lesbar.
|
||||
- Gelöscht wird ein Protokoll nur im gekennzeichneten Aufbewahrungslauf —
|
||||
derselbe Schalter wie beim Audit-Log.
|
||||
|
||||
**Fristen-Dashboard** (`/pruefungen`)
|
||||
|
||||
- Vier Töpfe nach Dringlichkeit: überfällig, diese Woche, diesen Monat, nächste
|
||||
90 Tage. Jede Zahl ist gleichzeitig ein Filter.
|
||||
- Bündelung nach Fahrzeug, Standort, Gerätetyp oder Zuständigem — wer einen
|
||||
Prüftag ansetzt, arbeitet ein Fahrzeug ab, keine alphabetische Liste.
|
||||
- Sammelzuweisung mehrerer Termine an eine Person.
|
||||
- Daneben die Lebensdauerüberwachung (`/pruefungen/lebensdauer`) mit Vorschlag
|
||||
zur Aussonderung.
|
||||
|
||||
**Prüfwizard** (`/pruefungen/neu`)
|
||||
|
||||
- Ein Prüfpunkt je Bildschirm, große Schaltflächen, Foto direkt aus der Kamera.
|
||||
- Zeiterfassung läuft mit und landet im Protokoll.
|
||||
- Unterschrift auf einem Zeichenfeld, das Finger, Stift und Maus versteht; das
|
||||
PNG landet in MinIO.
|
||||
- Jede Antwort geht sofort zum Server — bricht das WLAN weg, ist höchstens die
|
||||
letzte verloren, und der Entwurf wird beim nächsten Öffnen fortgesetzt.
|
||||
- Mängel werden aus den beanstandeten Punkten vorgeschlagen.
|
||||
- Externe Wartung: Fachfirma, Kosten und das Fremdprotokoll als PDF.
|
||||
|
||||
**Prüfprotokoll als PDF**
|
||||
|
||||
- Erzeugt der Hintergrundprozess über Gotenberg und legt es in MinIO ab: Kopf
|
||||
mit Wehr, Gerät und Prüfer-Kennung, Checkliste mit Soll, Ist und Befund,
|
||||
eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift, Prüfdatum
|
||||
und Seitenzahl.
|
||||
- Laufende Protokollnummer `JJJJ-NNNN` je Wehr und Jahr.
|
||||
|
||||
**Nächtlicher Lauf**
|
||||
|
||||
- `fristen.pruefen` (3:15 Uhr) plant Termine aus den Vorlagen und zieht Frist
|
||||
und Status an jedem Gerät nach.
|
||||
- `erinnerung.senden` (3:45 Uhr) verschickt Vorwarnungen bei T-30, T-7, T-0 und
|
||||
einmal bei Überfälligkeit — als Sammelnachricht je Empfänger.
|
||||
- Beide Läufe sind wiederholbar und tragen sich beim Start des Workers je Wehr
|
||||
selbst ein.
|
||||
|
||||
**Mängel und Rückrufe**
|
||||
|
||||
- Melden darf jedes Mitglied; ein kritischer Mangel sperrt das Gerät sofort.
|
||||
- Beim Beheben wird die Sperre aufgehoben — aber nur, wenn kein anderer offener
|
||||
Mangel dasselbe Gerät sperrt.
|
||||
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum. Das Ergebnis ist ein Vorschlag, keine automatische
|
||||
Sperre.
|
||||
|
||||
**Sonstiges**
|
||||
|
||||
- Datei-Upload unter `/api/dateien` für Fotos, Unterschriften und
|
||||
Fremdprotokolle — als Multipart, nicht Base64 durch eine JSON-Antwort.
|
||||
- Der Seed legt 5 Wartungsvorlagen mit echten Prüfvorgaben an (DGUV 112-190,
|
||||
DIN 14811, DGUV Vorschrift 3, DIN 14920), dazu die fälligen Termine, ein
|
||||
abgeschlossenes Protokoll samt Mangel und einen Beispielrückruf.
|
||||
- 79 neue Tests: 47 Unit-Tests zu Fristen, Bewertung und Planung, 10 gegen eine
|
||||
echte Datenbank zur Unveränderbarkeit und Mandantentrennung, 22 Playwright-
|
||||
Durchläufe über Dashboard, Vorlagen, Wizard, Mängel und Rückrufe.
|
||||
|
||||
**Nachzügler aus Phase 1**
|
||||
|
||||
Mit dieser Version wird auch das Geräteformular ausgeliefert, das nach der
|
||||
0.2.0 entstanden ist:
|
||||
|
||||
- **Formular zum Anlegen und Bearbeiten eines Geräts** (`/geraete/neu` und
|
||||
`/geraete/<id>/bearbeiten`). Damit ist die letzte offene Lücke aus Phase 1
|
||||
geschlossen: Ein Neuzugang lässt sich einzeln erfassen, ohne den Umweg über
|
||||
eine Importdatei.
|
||||
- Die Lagerung wird als Umschalter geführt statt als zwei Auswahlfelder, die
|
||||
man versehentlich beide füllen kann.
|
||||
- Prüftermin und Lebensdauerende werden schon beim Tippen ausgerechnet und
|
||||
angezeigt — mit denselben Funktionen, mit denen der Server anschließend
|
||||
rechnet.
|
||||
- Ist nur ein Standort angelegt, wird er vorbelegt.
|
||||
- 7 weitere Playwright-Tests zum Formular (nun 58 statt 44, zwei davon unter
|
||||
Touch-Emulation begründet übersprungen).
|
||||
|
||||
Dabei behoben:
|
||||
|
||||
- **Das Formular stürzte beim ersten Rechnen ab.** Die Vorgabewerte lagen in
|
||||
einem `"use client"`-Modul und wurden von einer Server-Komponente importiert;
|
||||
dort kommt nur eine Client-Referenz an, kein Objekt. Das Spreaden davon ergab
|
||||
lauter `undefined`. Die Werte liegen jetzt in einem eigenen Modul ohne
|
||||
`"use client"`.
|
||||
- **Das Sternchen an Pflichtfeldern wurde vorgelesen.** Eine Sprachausgabe
|
||||
nannte das Feld „Bezeichnung Stern". Das Sternchen trägt jetzt `aria-hidden`;
|
||||
dass ein Feld ausgefüllt werden muss, sagt das `required`-Attribut.
|
||||
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) —
|
||||
Next.js hängt einen eigenen Routen-Ansager mit derselben Rolle in jede Seite.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Die Datumsarithmetik aus Phase 1 (`utcTag`, `monateAddieren`, `heutigerTag`,
|
||||
`tagesabstand`) ist jetzt exportiert. Zwei Umsetzungen derselben Rechnung
|
||||
wären früher oder später um einen Tag auseinandergelaufen.
|
||||
- Die Seitenleiste zeigt Prüfungen, Mängel und Rückrufe als verfügbar.
|
||||
- Die Gerätedetailseite zeigt Prüfhistorie, offene Mängel und anstehende
|
||||
Termine — und startet die Prüfung.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer.**
|
||||
Die eindeutige Bedingung in der Datenbank fing das ab, aber der zweite Prüfer
|
||||
bekam einen Datenbankfehler statt eines Protokolls. Die Nummer wird jetzt
|
||||
unter einer Transaktionssperre je Wehr und Jahr vergeben — gefunden von den
|
||||
E2E-Tests, weil Desktop- und Tablet-Durchlauf parallel prüften.
|
||||
- **Der Worker ließ sich außerhalb des Containers nicht starten.** Ihm fehlte
|
||||
das Laden der `.env`, und `server-only` warf beim Import unter Node. Beide
|
||||
Startbefehle laufen jetzt mit `--conditions=react-server`, womit der leere
|
||||
Export dieses Pakets greift.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-09-03
|
||||
|
||||
Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit
|
||||
Standorten, Fahrzeugen samt Beladeordnung, Gerätetypen als Vorgabe und
|
||||
Geräten, die per QR-Code am Regal auffindbar sind.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Standort` (Gerätehaus, Lager, Außenstelle, Werkstatt) mit PostGIS-Punkt für
|
||||
die Karte ab Phase 6.
|
||||
- `Geraeteraum` — die Fächer am Fahrzeug (G1 bis G6, Heck, Dach,
|
||||
Mannschaftsraum), in Beladereihenfolge sortierbar.
|
||||
- `Geraetetyp` als Vorgabe für Prüfintervall, Prüfvorschrift und Lebensdauer.
|
||||
Ein einzelnes Gerät darf davon abweichen; sonst gilt der Typ.
|
||||
- `Qualifikationstyp` und `Qualifikation` mit Gültigkeitsdauer und eigener
|
||||
Vorwarnzeit je Typ.
|
||||
- `Fahrzeug` erweitert um Fahrgestellnummer, DIN-Typkurzzeichen, Erstzulassung,
|
||||
Sitzplätze, Tankinhalt, Gesamtmasse und die UVV-Frist.
|
||||
- `Geraet` erweitert um Gerätetyp, Lebensdauerkategorie, errechnetes
|
||||
Lebensdauerende, Anschaffungspreis und die Lagerung.
|
||||
|
||||
**Regeln in der Datenbank**
|
||||
|
||||
- Ein Gerät steht entweder an einem Standort oder ist auf einem Fahrzeug
|
||||
verlastet — eine Prüfbedingung setzt das durch, ein Trigger stellt zusätzlich
|
||||
sicher, dass ein angegebener Geräteraum auch zum angegebenen Fahrzeug gehört.
|
||||
- Row Level Security auf allen neuen Tabellen.
|
||||
- Trigram-Indizes für die Volltextsuche über Geräte, Fahrzeuge und Personen.
|
||||
|
||||
**Funktionen**
|
||||
|
||||
- Geräteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
|
||||
Blättern und Spaltenauswahl. Die Spaltenauswahl bleibt je Browser gespeichert.
|
||||
- Massenbearbeitung: Status mehrerer Geräte auf einmal setzen, Geräte umlagern.
|
||||
Jede Änderung erzeugt einen eigenen Protokolleintrag am jeweiligen Gerät.
|
||||
- QR-Code je Gerät. Der Code zeigt auf `/g/<inventarnummer>` und führt nach der
|
||||
Anmeldung direkt zur Gerätedetailseite.
|
||||
- Etikettenbogen als PDF über Gotenberg: DIN A4 mit 65 Etiketten
|
||||
(38,1 × 21,2 mm). Der Druck kann an einer beliebigen Position beginnen, damit
|
||||
angebrochene Bögen weiterverwendet werden können.
|
||||
- QR-Scan über die Kamera im Browser, mit Eingabefeld als Rückfallweg.
|
||||
- Import aus CSV und Excel: Spalten werden nach den Überschriften automatisch
|
||||
zugeordnet, die Vorschau zeigt Zeile für Zeile, was übernommen würde und was
|
||||
daran nicht stimmt. Geschrieben wird erst nach Bestätigung.
|
||||
- Gerätehistorie und Fahrzeughistorie, gerendert aus dem Audit-Log.
|
||||
- Qualifikationen mit Ablaufwarnung, gerechnet mit der Vorwarnzeit des
|
||||
jeweiligen Typs.
|
||||
- Fahrzeugdetail zeigt die Beladung nach Geräteräumen statt alphabetisch.
|
||||
|
||||
**Daten und Tests**
|
||||
|
||||
- Der Seed legt jetzt zusätzlich 3 Standorte, 20 Geräteräume, 14 Gerätetypen
|
||||
mit echten Prüfvorgaben (DGUV, DIN, vfdb), 49 Geräte und 107 Qualifikationen
|
||||
an. Fristen sind bewusst gestreut, damit die Ampeln etwas zeigen.
|
||||
- 104 Vitest-Tests (vorher 32), davon 40 zum Import und 32 zu QR und Fristen.
|
||||
- 44 Playwright-Tests (vorher 10) über Desktop und Tablet.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Fristenrechnung auf UTC-Kalenderarithmetik umgestellt.** Vorher wurde über
|
||||
`startOfDay` aus date-fns gerechnet, das die Zeitzone des Servers verwendet.
|
||||
Ein Prüfdatum kommt aus der Datenbank als UTC-Mitternacht und wurde dabei auf
|
||||
den Vortag zurückgeschoben — Fristen wären einen Tag zu früh fällig geworden.
|
||||
- `CardTitle` rendert eine echte Überschrift statt eines `div`. Wer mit einer
|
||||
Sprachausgabe arbeitet, springt von Überschrift zu Überschrift; Karten
|
||||
tauchten in dieser Navigation vorher gar nicht auf.
|
||||
- Die Entwicklungsanzeige von Next.js ist abgeschaltet, damit sie nicht in den
|
||||
Bildern der Anleitung mitläuft.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Der Etikettendruck wird protokolliert (`EXPORTIERT`) — damit ist
|
||||
nachvollziehbar, wann welche Aufkleber erzeugt wurden.
|
||||
- Der Import übernimmt keine Werte, die der Browser geschickt hat: die Datei
|
||||
wird beim Übernehmen erneut serverseitig eingelesen und geprüft. Sonst ließe
|
||||
sich die Vorschau umgehen.
|
||||
|
||||
## [0.1.0] — 2026-09-03
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
| Bereich | Stand |
|
||||
|---|---|
|
||||
| Phase 0 — Fundament | fertig |
|
||||
| Phase 1 — Stammdaten | offen |
|
||||
| Phase 2 — Wartung und Prüfung | offen |
|
||||
| Phase 1 — Stammdaten | fertig |
|
||||
| Phase 2 — Wartung und Prüfung | fertig |
|
||||
| Phase 3 — Einsätze und Übungen | offen |
|
||||
| Phase 4 — Kiosk-PWA | offen |
|
||||
| Phase 5 — Atemschutz (FwDV 7) | offen |
|
||||
@@ -152,6 +152,133 @@ ausschließlich über [`src/lib/storage.ts`](src/lib/storage.ts). Objektschlüss
|
||||
Downloads laufen über presignte Links mit begrenzter Gültigkeit
|
||||
(`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 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
@@ -61,6 +61,9 @@ x-app-env: &app-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODELL: ${LLM_MODELL:-}
|
||||
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
|
||||
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
|
||||
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
|
||||
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
||||
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
||||
@@ -298,7 +301,11 @@ services:
|
||||
image: florian-app:${IMAGE_TAG:-latest}
|
||||
container_name: florian-worker
|
||||
restart: unless-stopped
|
||||
command: ["node", "--enable-source-maps", "dist/worker/index.js"]
|
||||
# --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
|
||||
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
|
||||
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
|
||||
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
|
||||
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
|
||||
environment:
|
||||
<<: *app-env
|
||||
ROLLE: worker
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 0 (Version 0.1.0). Die Bilder zeigen die
|
||||
Bedienung von FLORIAN, Stand Phase 2 (Version 0.3.0). Die Bilder zeigen die
|
||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||
|
||||
> Was hier noch fehlt — Mitglieder, Geräte, Prüfungen — kommt in den
|
||||
> folgenden Phasen dazu. Der Stand ist bewusst schmal: Phase 0 baut das
|
||||
> Fundament, nicht die Fachmodule. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
> Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den folgenden Phasen
|
||||
> dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
|
||||
### Die Seitenleiste
|
||||
|
||||
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt
|
||||
rechts sein Phasenkürzel — `P2` heißt „kommt in Phase 2".
|
||||
rechts sein Phasenkürzel — `P3` heißt „kommt in Phase 3".
|
||||
|
||||
Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
|
||||
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
|
||||
@@ -94,7 +93,499 @@ 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. Rollen und was sie dürfen
|
||||
|
||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||
@@ -123,7 +614,7 @@ Das Terminal selbst ist nur die Tür.
|
||||
|
||||
---
|
||||
|
||||
## 5. Anmeldung über authentik einrichten
|
||||
## 19. Anmeldung über authentik einrichten
|
||||
|
||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||
|
||||
@@ -176,7 +667,7 @@ ganze Wehr aus.
|
||||
|
||||
---
|
||||
|
||||
## 6. Terminal-Anmeldung im Gerätehaus
|
||||
## 20. Terminal-Anmeldung im Gerätehaus
|
||||
|
||||
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
|
||||
|
||||
@@ -198,7 +689,7 @@ Huber), PIN `471203`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Was protokolliert wird
|
||||
## 21. Was protokolliert wird
|
||||
|
||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||
@@ -218,11 +709,12 @@ einzige Weg, auf dem etwas aus dem Log verschwindet.
|
||||
dem Schreiben herausgenommen.
|
||||
|
||||
Einsehbar ist der Log mit dem Recht `system.auditlog`, das Kommandant und
|
||||
Administrator haben. Die Ansicht dazu kommt in Phase 1.
|
||||
Administrator haben. Am Gerät und am Fahrzeug ist der Verlauf bereits auf der
|
||||
jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 8. Wenn etwas nicht geht
|
||||
## 22. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
@@ -231,6 +723,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. |
|
||||
| Dateien lassen sich nicht hochladen | MinIO nicht erreichbar oder Bucket fehlt | `http://localhost:9011` öffnen; `docker compose logs minio-init` |
|
||||
| Der Knopf für authentik fehlt | Issuer, Client-ID oder Secret nicht gesetzt | Alle drei sind nötig, sonst wird der Weg ausgeblendet |
|
||||
| Etikettenbogen lässt sich nicht erzeugen | Gotenberg läuft nicht | `docker compose ps`, dann `docker compose up -d gotenberg` |
|
||||
| Kamera bleibt schwarz | Freigabe abgelehnt, oder die Seite läuft nicht über HTTPS | Der Browser fragt nur bei HTTPS oder localhost. Sonst die Inventarnummer eintippen |
|
||||
| Import meldet fehlende Pflichtfelder | Keine Spalte ist Inventarnummer oder Bezeichnung zugeordnet | In Schritt 2 die Zuordnung von Hand setzen |
|
||||
| Umlaute im Import verstümmelt | Die Datei ist weder UTF-8 noch Windows-1252 | In Excel als „CSV UTF-8" speichern |
|
||||
| Gerät lässt sich nicht speichern | Standort **und** Fahrzeug gesetzt, oder keines von beidem | Genau eines von beiden angeben |
|
||||
| `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben |
|
||||
|
||||
Zustand aller Randsysteme auf einen Blick:
|
||||
|
||||
@@ -31,8 +31,8 @@ Faktoren, die in die Schätzung eingehen:
|
||||
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|
||||
|---|---|---:|---:|---:|
|
||||
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
|
||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | offen |
|
||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen |
|
||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
|
||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | 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 |
|
||||
@@ -72,7 +72,7 @@ das nachträglich einbaut, zahlt ein Vielfaches.
|
||||
|
||||
## Phase 1 — Stammdaten
|
||||
|
||||
**Vergleichsaufwand: 10–14 PT**
|
||||
**Vergleichsaufwand: 10–14 PT** · **Tatsächlich: 2:05 h Sitzungszeit** (1:15 h Hauptteil, 0:50 h Nachtrag)
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -89,9 +89,23 @@ Der Import ist der Posten, der erfahrungsgemäß aus dem Ruder läuft. Jede Wehr
|
||||
bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist
|
||||
kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut.
|
||||
|
||||
**Rückblick.** Die Schätzung hat gut getroffen — mit einer Verschiebung: Der
|
||||
Import war teurer als angesetzt (deutsche Datumsformate, Semikolon-CSV,
|
||||
Windows-1252, Excel-Seriennummern, Dubletten in der Datei), die Tabellen
|
||||
dagegen billiger, weil Sortierung und Blättern serverseitig laufen und keine
|
||||
Tabellenbibliothek nötig war. Nicht eingeplant war die Zeitzonen-Falle in der
|
||||
Fristenrechnung; sie hätte in Phase 2 deutlich mehr gekostet, wenn sie erst
|
||||
dort aufgefallen wäre.
|
||||
|
||||
Das zunächst offen gebliebene Formular zum einzelnen Anlegen von Geräten wurde
|
||||
als Nachtrag ergänzt — geschätzt waren 0,5 PT, tatsächlich 0:50 h Sitzungszeit.
|
||||
Die Hälfte davon ging für zwei Dinge drauf, die nichts mit dem Formular zu tun
|
||||
hatten: eine Server/Client-Grenze, die stillschweigend `undefined` liefert, und
|
||||
Datumsfelder, die sich in Chromium nicht automatisiert füllen lassen.
|
||||
|
||||
## Phase 2 — Wartung und Prüfung
|
||||
|
||||
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase
|
||||
**Vergleichsaufwand: 18–24 PT** · **Tatsächlich: 2:25 h Sitzungszeit** — die aufwendigste Phase
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -110,6 +124,29 @@ der Unfallkasse. Das heißt aber auch, dass jeder Fehler teuer ist. Die
|
||||
Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
|
||||
warum der Audit-Log schon in Phase 0 append-only ist.
|
||||
|
||||
**Rückblick.** Deutlich unter der geschätzten Sitzungszeit, und das aus einem
|
||||
Grund, der sich benennen lässt: die Fristenrechnung aus Phase 1 war schon da
|
||||
und musste nur exportiert werden. Wäre sie hier entstanden — samt der
|
||||
Zeitzonen-Falle, über die Phase 1 gestolpert ist —, sähe die Zahl anders aus.
|
||||
|
||||
Teurer als gedacht war das, was zwischen den Bausteinen liegt. Drei Beispiele:
|
||||
|
||||
- Die Reihenfolge beim Abschluss einer Prüfung ist nicht beliebig.
|
||||
Unterschriften und Mängel müssen vor dem Statuswechsel entstehen, denn danach
|
||||
lässt die Datenbank am Protokoll nichts mehr zu. Derselbe Stolperstein traf
|
||||
auch den Seed, der die Beispielprüfung zunächst fertig anlegen wollte.
|
||||
- Die Protokollnummer muss unter einer Sperre vergeben werden. Aufgefallen ist
|
||||
das erst, weil die E2E-Tests Desktop und Tablet gleichzeitig prüfen ließen —
|
||||
zwei Prüfer, die im selben Moment abschließen, sind im Gerätehaus kein
|
||||
konstruierter Fall.
|
||||
- Das PDF entsteht zwischen zwei Datenbanktransaktionen, nicht in einer. Fotos
|
||||
aus MinIO holen und Gotenberg rendern lassen dauert länger, als eine offene
|
||||
Transaktion warten darf.
|
||||
|
||||
Billiger als angesetzt war das Unterschriftenfeld: Pointer-Events decken Finger,
|
||||
Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und
|
||||
`touch-action: none` braucht es nicht.
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
|
||||
**Vergleichsaufwand: 12–16 PT**
|
||||
|
||||
@@ -11,7 +11,258 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
||||
|---|---:|---:|---|
|
||||
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **1:00 h** | **1** | |
|
||||
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
|
||||
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
|
||||
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
|
||||
| **Summe** | **5:30 h** | **4** | |
|
||||
|
||||
---
|
||||
|
||||
## 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,8 +10,8 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
|
||||
|
||||
```
|
||||
Phase 0 ████████████████████ fertig 0.1.0
|
||||
Phase 1 ░░░░░░░░░░░░░░░░░░░░ offen 0.2.0
|
||||
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0
|
||||
Phase 1 ████████████████████ fertig 0.2.0
|
||||
Phase 2 ████████████████████ fertig 0.3.0
|
||||
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
|
||||
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
|
||||
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
|
||||
@@ -39,7 +39,7 @@ Alles, worauf der Rest aufsetzt.
|
||||
- Basis-Layout mit Seitenleiste und dunkler Darstellung
|
||||
- Seed mit einer vollständigen Beispielwehr
|
||||
|
||||
## Phase 1 — Stammdaten · als Nächstes
|
||||
## Phase 1 — Stammdaten · fertig
|
||||
|
||||
Die Wehr trägt ein, was sie hat.
|
||||
|
||||
@@ -53,27 +53,37 @@ Die Wehr trägt ein, was sie hat.
|
||||
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
|
||||
- Gerätehistorie aus dem Audit-Log
|
||||
- Qualifikationen mit Ablaufwarnung auf der Übersicht
|
||||
- Formular zum Anlegen und Bearbeiten eines einzelnen Geräts, mit
|
||||
Fristenvorschau beim Ausfüllen
|
||||
|
||||
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
|
||||
QR-Code am Regal wiederfinden.
|
||||
|
||||
## Phase 2 — Wartung und Prüfung
|
||||
## Phase 2 — Wartung und Prüfung · fertig
|
||||
|
||||
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
|
||||
|
||||
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
|
||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage
|
||||
- Nächtlicher Job erzeugt Termine, setzt Status, erinnert bei T-30, T-7 und T-0
|
||||
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift
|
||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO
|
||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage,
|
||||
gebündelt nach Fahrzeug, Standort, Gerätetyp oder Zuständigem
|
||||
- Nächtlicher Lauf erzeugt Termine, zieht den Gerätestatus nach und erinnert bei
|
||||
T-30, T-7, T-0 und einmal bei Überfälligkeit
|
||||
- Prüfwizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift auf
|
||||
dem Zeichenfeld
|
||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO — die Sperre setzt
|
||||
ein Datenbanktrigger durch, nicht der Anwendungscode
|
||||
- Korrekturen erzeugen ein neues Protokoll mit Verweis auf das alte
|
||||
- Lebensdauerüberwachung mit Aussonderungsvorschlag
|
||||
- Rückrufe markieren betroffene Geräte automatisch
|
||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben
|
||||
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum
|
||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben, mit
|
||||
Gerätesperre bei kritischen Mängeln
|
||||
- Externe Wartung: Fachfirma, Kosten und Fremdprotokoll als PDF
|
||||
|
||||
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
|
||||
die Unfallkasse standhält.
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
## Phase 3 — Einsätze und Übungen · als Nächstes
|
||||
|
||||
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
|
||||
Einsatzende, eingerückt, einsatzbereit
|
||||
|
||||
|
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: 201 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 728 KiB |
|
After Width: | Height: | Size: 751 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 246 KiB |
@@ -8,6 +8,9 @@ const nextConfig: NextConfig = {
|
||||
images: { remotePatterns: [] },
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
// Die Entwicklungsanzeige unten links wuerde in den Dokumentationsbildern
|
||||
// mitlaufen. Sie betrifft ohnehin nur "next dev".
|
||||
devIndicators: false,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"engines": {
|
||||
@@ -10,8 +10,8 @@
|
||||
"dev": "next dev -p 3100",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3100",
|
||||
"worker": "tsx src/worker/index.ts",
|
||||
"worker:dev": "tsx watch src/worker/index.ts",
|
||||
"worker": "tsx --conditions=react-server src/worker/index.ts",
|
||||
"worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -28,26 +28,38 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-table": "^9.2.4",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@zxing/browser": "^0.2.1",
|
||||
"@zxing/library": "^0.23.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.61.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"ioredis": "^5.8.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^15.5.4",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
@@ -61,6 +73,8 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^22.18.6",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
|
||||
@@ -14,10 +14,17 @@ export default defineConfig({
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
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"]],
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
use: {
|
||||
baseURL: BASIS_URL,
|
||||
@@ -44,6 +51,6 @@ export default defineConfig({
|
||||
command: "npm run dev",
|
||||
url: BASIS_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
timeout: 180_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StandortArt" AS ENUM ('GERAETEHAUS', 'LAGER', 'AUSSENSTELLE', 'WERKSTATT', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GeraeteKategorie" AS ENUM ('ATEMSCHUTZ', 'BELEUCHTUNG', 'BRANDBEKAEMPFUNG', 'ELEKTRO', 'FUNK', 'HYGIENE', 'MEDIZIN', 'MESSTECHNIK', 'PERSOENLICHE_SCHUTZAUSRUESTUNG', 'RETTUNG', 'SCHLAUCHMATERIAL', 'TECHNISCHE_HILFELEISTUNG', 'UMWELTSCHUTZ', 'WASSERFOERDERUNG', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LebensdauerKategorie" AS ENUM ('KEINE', 'HELM', 'SCHUTZKLEIDUNG', 'HANDSCHUHE', 'STIEFEL', 'SEIL_GURT', 'PRESSLUFTATMER', 'ATEMANSCHLUSS', 'DRUCKBEHAELTER', 'SCHLAUCH', 'LEITER', 'SONSTIGE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LebensdauerBezug" AS ENUM ('HERSTELLUNG', 'INDIENSTSTELLUNG');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "QualifikationsArt" AS ENUM ('LEHRGANG', 'BEFAEHIGUNG', 'UNTERSUCHUNG', 'FUEHRERSCHEIN', 'UNTERWEISUNG', 'SONSTIGES');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "fahrzeug" ADD COLUMN "aufbauhersteller" VARCHAR(120),
|
||||
ADD COLUMN "bemerkung" VARCHAR(1000),
|
||||
ADD COLUMN "dinTyp" VARCHAR(40),
|
||||
ADD COLUMN "erstzulassung" DATE,
|
||||
ADD COLUMN "fahrgestellnummer" VARCHAR(30),
|
||||
ADD COLUMN "gesamtmasse" INTEGER,
|
||||
ADD COLUMN "indienststellung" DATE,
|
||||
ADD COLUMN "kilometerstand" INTEGER,
|
||||
ADD COLUMN "naechsteWartung" DATE,
|
||||
ADD COLUMN "sitzplaetze" INTEGER,
|
||||
ADD COLUMN "standortId" UUID,
|
||||
ADD COLUMN "tankinhalt" INTEGER,
|
||||
ADD COLUMN "uvvBis" DATE;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "geraet" ADD COLUMN "anschaffungspreis" DECIMAL(12,2),
|
||||
ADD COLUMN "ausgesondertAm" DATE,
|
||||
ADD COLUMN "ausgesondertGrund" VARCHAR(500),
|
||||
ADD COLUMN "bemerkung" VARCHAR(1000),
|
||||
ADD COLUMN "geraeteraumId" UUID,
|
||||
ADD COLUMN "geraetetypId" UUID,
|
||||
ADD COLUMN "lebensdauerBis" DATE,
|
||||
ADD COLUMN "lebensdauerKategorie" "LebensdauerKategorie",
|
||||
ADD COLUMN "letztePruefung" DATE,
|
||||
ADD COLUMN "modell" VARCHAR(120),
|
||||
ADD COLUMN "standortId" UUID;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "standort" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"kuerzel" VARCHAR(20),
|
||||
"art" "StandortArt" NOT NULL DEFAULT 'GERAETEHAUS',
|
||||
"strasse" VARCHAR(200),
|
||||
"plz" VARCHAR(10),
|
||||
"ort" VARCHAR(120),
|
||||
"koordinaten" geography(Point, 4326),
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "standort_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "geraeteraum" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"fahrzeugId" UUID NOT NULL,
|
||||
"bezeichnung" VARCHAR(40) NOT NULL,
|
||||
"beschreibung" VARCHAR(200),
|
||||
"sortierung" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "geraeteraum_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "geraetetyp" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"kategorie" "GeraeteKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||
"hersteller" VARCHAR(120),
|
||||
"modell" VARCHAR(120),
|
||||
"pruefintervallMonate" INTEGER,
|
||||
"pruefvorschrift" VARCHAR(200),
|
||||
"lebensdauerKategorie" "LebensdauerKategorie" NOT NULL DEFAULT 'KEINE',
|
||||
"lebensdauerJahre" INTEGER,
|
||||
"lebensdauerBezug" "LebensdauerBezug" NOT NULL DEFAULT 'INDIENSTSTELLUNG',
|
||||
"vorwarnzeitMonate" INTEGER NOT NULL DEFAULT 3,
|
||||
"beschreibung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "geraetetyp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "qualifikationstyp" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"kuerzel" VARCHAR(20),
|
||||
"art" "QualifikationsArt" NOT NULL DEFAULT 'LEHRGANG',
|
||||
"gueltigkeitMonate" INTEGER,
|
||||
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 90,
|
||||
"einsatzrelevant" BOOLEAN NOT NULL DEFAULT false,
|
||||
"beschreibung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "qualifikationstyp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "qualifikation" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"personId" UUID NOT NULL,
|
||||
"typId" UUID NOT NULL,
|
||||
"erworbenAm" DATE NOT NULL,
|
||||
"gueltigBis" DATE,
|
||||
"ausstellendeStelle" VARCHAR(200),
|
||||
"nachweisnummer" VARCHAR(80),
|
||||
"urkundeDateiId" UUID,
|
||||
"bestaetigtVonId" UUID,
|
||||
"bestaetigtAm" TIMESTAMPTZ(3),
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "qualifikation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "standort_organisationId_aktiv_idx" ON "standort"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "standort_organisationId_name_key" ON "standort"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraeteraum_organisationId_fahrzeugId_idx" ON "geraeteraum"("organisationId", "fahrzeugId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "geraeteraum_fahrzeugId_bezeichnung_key" ON "geraeteraum"("fahrzeugId", "bezeichnung");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraetetyp_organisationId_kategorie_idx" ON "geraetetyp"("organisationId", "kategorie");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraetetyp_organisationId_aktiv_idx" ON "geraetetyp"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "geraetetyp_organisationId_name_key" ON "geraetetyp"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikationstyp_organisationId_aktiv_idx" ON "qualifikationstyp"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "qualifikationstyp_organisationId_name_key" ON "qualifikationstyp"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikation_organisationId_personId_idx" ON "qualifikation"("organisationId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikation_organisationId_typId_idx" ON "qualifikation"("organisationId", "typId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qualifikation_organisationId_gueltigBis_idx" ON "qualifikation"("organisationId", "gueltigBis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "fahrzeug_organisationId_tuevBis_idx" ON "fahrzeug"("organisationId", "tuevBis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "fahrzeug_organisationId_kennzeichen_key" ON "fahrzeug"("organisationId", "kennzeichen");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_lebensdauerBis_idx" ON "geraet"("organisationId", "lebensdauerBis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_standortId_idx" ON "geraet"("organisationId", "standortId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_geraetetypId_idx" ON "geraet"("organisationId", "geraetetypId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "geraet_organisationId_seriennummer_idx" ON "geraet"("organisationId", "seriennummer");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "standort" ADD CONSTRAINT "standort_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraetetyp" ADD CONSTRAINT "geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraeteraumId_fkey" FOREIGN KEY ("geraeteraumId") REFERENCES "geraeteraum"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikationstyp" ADD CONSTRAINT "qualifikationstyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_typId_fkey" FOREIGN KEY ("typId") REFERENCES "qualifikationstyp"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_bestaetigtVonId_fkey" FOREIGN KEY ("bestaetigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
-- =============================================================================
|
||||
-- Phase 1: Mandantentrennung, Lagerungsregel und Suchindizes.
|
||||
--
|
||||
-- Die vorige Migration hat die Tabellen angelegt. Ohne Row Level Security wären
|
||||
-- sie für die Anwendungsrolle offen — jede neue Tabelle muss deshalb dieselbe
|
||||
-- Behandlung bekommen wie die aus Phase 0.
|
||||
--
|
||||
-- Dazu kommen zwei Dinge, die sich nur in der Datenbank sauber durchsetzen
|
||||
-- lassen: die Regel, wo ein Gerät liegen darf, und schnelle Volltextsuche.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security für die neuen Tabellen
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'standort',
|
||||
'geraeteraum',
|
||||
'geraetetyp',
|
||||
'qualifikationstyp',
|
||||
'qualifikation'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON %I FOR ALL '
|
||||
'USING ("organisationId" = aktuelle_organisation()) '
|
||||
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||
tabelle || '_mandant', tabelle
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Bestandsdaten auf die neue Lagerungsregel bringen.
|
||||
--
|
||||
-- Vor Phase 1 konnte ein Gerät nur einen Freitext-Lagerort haben. Damit die
|
||||
-- Prüfbedingung weiter unten greift, braucht jedes Gerät ohne Fahrzeug jetzt
|
||||
-- einen echten Standort. Je Wehr wird dafür ein Gerätehaus angelegt, sofern
|
||||
-- noch keines existiert.
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO "standort" ("id", "organisationId", "name", "kuerzel", "art", "erstelltAm", "geaendertAm")
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
o."id",
|
||||
'Gerätehaus',
|
||||
'GH',
|
||||
'GERAETEHAUS',
|
||||
now(),
|
||||
now()
|
||||
FROM "organisation" o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "standort" s WHERE s."organisationId" = o."id"
|
||||
);
|
||||
|
||||
UPDATE "geraet" g
|
||||
SET "standortId" = (
|
||||
SELECT s."id"
|
||||
FROM "standort" s
|
||||
WHERE s."organisationId" = g."organisationId"
|
||||
AND s."art" = 'GERAETEHAUS'
|
||||
ORDER BY s."erstelltAm"
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE g."fahrzeugId" IS NULL
|
||||
AND g."standortId" IS NULL;
|
||||
|
||||
-- Fahrzeuge dem Gerätehaus zuordnen, sofern noch kein Standort gesetzt ist.
|
||||
UPDATE "fahrzeug" f
|
||||
SET "standortId" = (
|
||||
SELECT s."id"
|
||||
FROM "standort" s
|
||||
WHERE s."organisationId" = f."organisationId"
|
||||
AND s."art" = 'GERAETEHAUS'
|
||||
ORDER BY s."erstelltAm"
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE f."standortId" IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Wo ein Gerät liegen darf.
|
||||
--
|
||||
-- Entweder es steht an einem Standort — dann ist kein Fahrzeug gesetzt. Oder es
|
||||
-- ist auf einem Fahrzeug verlastet — dann ist kein Standort gesetzt, und der
|
||||
-- Geräteraum darf angegeben werden, muss aber nicht (manches liegt lose in der
|
||||
-- Kabine). Beides gleichzeitig oder gar nichts ist eine Falschangabe.
|
||||
--
|
||||
-- Dass ein angegebener Geräteraum auch wirklich zum angegebenen Fahrzeug
|
||||
-- gehört, lässt sich als CHECK nicht ausdrücken — das prüft der Trigger
|
||||
-- darunter.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_lagerung_eindeutig" CHECK (
|
||||
("standortId" IS NOT NULL AND "fahrzeugId" IS NULL AND "geraeteraumId" IS NULL)
|
||||
OR ("standortId" IS NULL AND "fahrzeugId" IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION geraeteraum_gehoert_zum_fahrzeug()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
raum_fahrzeug uuid;
|
||||
BEGIN
|
||||
IF NEW."geraeteraumId" IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT "fahrzeugId" INTO raum_fahrzeug
|
||||
FROM "geraeteraum"
|
||||
WHERE "id" = NEW."geraeteraumId";
|
||||
|
||||
IF raum_fahrzeug IS DISTINCT FROM NEW."fahrzeugId" THEN
|
||||
RAISE EXCEPTION
|
||||
'Der Geraeteraum gehoert nicht zu dem angegebenen Fahrzeug.'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER geraet_raum_pruefen
|
||||
BEFORE INSERT OR UPDATE OF "fahrzeugId", "geraeteraumId" ON "geraet"
|
||||
FOR EACH ROW EXECUTE FUNCTION geraeteraum_gehoert_zum_fahrzeug();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Volltextsuche.
|
||||
--
|
||||
-- Die Listen sollen auf Tippen reagieren, auch bei einigen tausend Geräten.
|
||||
-- pg_trgm macht ILIKE '%text%' indexfähig — ohne diese Indizes läuft jede
|
||||
-- Suche als vollständiger Tabellendurchlauf.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "geraet_suche_bezeichnung" ON "geraet" USING gin ("bezeichnung" gin_trgm_ops);
|
||||
CREATE INDEX "geraet_suche_inventarnummer" ON "geraet" USING gin ("inventarnummer" gin_trgm_ops);
|
||||
CREATE INDEX "geraet_suche_seriennummer" ON "geraet" USING gin ("seriennummer" gin_trgm_ops);
|
||||
CREATE INDEX "geraet_suche_hersteller" ON "geraet" USING gin ("hersteller" gin_trgm_ops);
|
||||
|
||||
CREATE INDEX "fahrzeug_suche_funkrufname" ON "fahrzeug" USING gin ("funkrufname" gin_trgm_ops);
|
||||
CREATE INDEX "fahrzeug_suche_bezeichnung" ON "fahrzeug" USING gin ("bezeichnung" gin_trgm_ops);
|
||||
CREATE INDEX "fahrzeug_suche_kennzeichen" ON "fahrzeug" USING gin ("kennzeichen" gin_trgm_ops);
|
||||
|
||||
CREATE INDEX "person_suche_nachname" ON "person" USING gin ("nachname" gin_trgm_ops);
|
||||
CREATE INDEX "person_suche_vorname" ON "person" USING gin ("vorname" gin_trgm_ops);
|
||||
|
||||
CREATE INDEX "geraetetyp_suche_name" ON "geraetetyp" USING gin ("name" gin_trgm_ops);
|
||||
CREATE INDEX "standort_suche_name" ON "standort" USING gin ("name" gin_trgm_ops);
|
||||
|
||||
-- Räumliche Abfragen auf Standorte, ab Phase 6 gebraucht.
|
||||
CREATE INDEX "standort_koordinaten" ON "standort" USING gist ("koordinaten");
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||
EXECUTE 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
|
||||
|
||||
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,469 @@
|
||||
-- =============================================================================
|
||||
-- Phase 2: Tabellen für Wartung und Prüfung.
|
||||
--
|
||||
-- Erzeugt mit `prisma migrate dev --create-only` und danach an einer Stelle von
|
||||
-- Hand bereinigt: Prisma wollte die Volltext- und Geoindizes aus Phase 1
|
||||
-- (gin_trgm_ops, gist auf Koordinaten) löschen, weil es sie im Schema nicht
|
||||
-- kennt — Prisma kann diese Indexarten nicht abbilden. Die DROP INDEX-Zeilen
|
||||
-- sind deshalb entfernt. Wer diese Migration neu erzeugt, muss das wieder tun.
|
||||
--
|
||||
-- Mandantentrennung, Unveränderbarkeit der Protokolle und die Suchindizes für
|
||||
-- die neuen Tabellen stehen in der nachfolgenden Migration.
|
||||
-- =============================================================================
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WartungsterminStatus" AS ENUM ('OFFEN', 'ERLEDIGT', 'ENTFALLEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefungStatus" AS ENUM ('ENTWURF', 'ABGESCHLOSSEN', 'ERSETZT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefErgebnis" AS ENUM ('BESTANDEN', 'BESTANDEN_MIT_MAENGELN', 'NICHT_BESTANDEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefungArt" AS ENUM ('INTERN', 'EXTERN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChecklistenpunktTyp" AS ENUM ('JA_NEIN', 'MESSWERT', 'TEXT', 'FOTO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SignaturRolle" AS ENUM ('PRUEFER', 'GERAETEWART', 'FREMDFIRMA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MangelPrioritaet" AS ENUM ('NIEDRIG', 'MITTEL', 'HOCH', 'KRITISCH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MangelStatus" AS ENUM ('GEMELDET', 'ZUGEWIESEN', 'IN_ARBEIT', 'BEHOBEN', 'VERWORFEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RueckrufStatus" AS ENUM ('OFFEN', 'IN_BEARBEITUNG', 'ERLEDIGT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BetroffenheitStatus" AS ENUM ('OFFEN', 'NICHT_BETROFFEN', 'MASSNAHME_LAEUFT', 'ERLEDIGT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ErinnerungStufe" AS ENUM ('T_30', 'T_7', 'T_0', 'UEBERFAELLIG');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wartungsvorlage" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"geraetetypId" UUID,
|
||||
"intervallMonate" INTEGER NOT NULL,
|
||||
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 30,
|
||||
"pruefvorschrift" VARCHAR(200),
|
||||
"standardDauerMinuten" INTEGER,
|
||||
"standardExtern" BOOLEAN NOT NULL DEFAULT false,
|
||||
"automatisch" BOOLEAN NOT NULL DEFAULT true,
|
||||
"beschreibung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "wartungsvorlage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "checklistenpunkt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"vorlageId" UUID NOT NULL,
|
||||
"text" VARCHAR(300) NOT NULL,
|
||||
"typ" "ChecklistenpunktTyp" NOT NULL DEFAULT 'JA_NEIN',
|
||||
"pflicht" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sollwert" VARCHAR(120),
|
||||
"einheit" VARCHAR(20),
|
||||
"minWert" DECIMAL(12,3),
|
||||
"maxWert" DECIMAL(12,3),
|
||||
"hinweis" VARCHAR(500),
|
||||
"sortierung" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "checklistenpunkt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wartungstermin" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"vorlageId" UUID,
|
||||
"faelligAm" DATE NOT NULL,
|
||||
"status" "WartungsterminStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"automatischErzeugt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"zugewiesenAnId" UUID,
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "wartungstermin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pruefung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"terminId" UUID,
|
||||
"vorlageId" UUID,
|
||||
"art" "PruefungArt" NOT NULL DEFAULT 'INTERN',
|
||||
"status" "PruefungStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||
"prueferId" UUID,
|
||||
"prueferName" VARCHAR(200),
|
||||
"durchgefuehrtAm" DATE NOT NULL,
|
||||
"begonnenUm" TIMESTAMPTZ(3),
|
||||
"dauerMinuten" INTEGER,
|
||||
"ergebnis" "PruefErgebnis",
|
||||
"externeFirma" VARCHAR(200),
|
||||
"externeProtokollDateiId" UUID,
|
||||
"kosten" DECIMAL(10,2),
|
||||
"naechstePruefungAm" DATE,
|
||||
"protokollDateiId" UUID,
|
||||
"protokollNummer" VARCHAR(40),
|
||||
"abgeschlossenAm" TIMESTAMPTZ(3),
|
||||
"ersetztPruefungId" UUID,
|
||||
"korrekturGrund" VARCHAR(500),
|
||||
"bemerkung" VARCHAR(2000),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "pruefung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pruefergebnis" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"pruefungId" UUID NOT NULL,
|
||||
"punktId" UUID NOT NULL,
|
||||
"jaNein" BOOLEAN,
|
||||
"zahlwert" DECIMAL(12,3),
|
||||
"text" VARCHAR(1000),
|
||||
"fotoDateiId" UUID,
|
||||
"inOrdnung" BOOLEAN NOT NULL DEFAULT true,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pruefergebnis_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "signatur" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"pruefungId" UUID NOT NULL,
|
||||
"rolle" "SignaturRolle" NOT NULL DEFAULT 'PRUEFER',
|
||||
"personId" UUID,
|
||||
"name" VARCHAR(200) NOT NULL,
|
||||
"bildDateiId" UUID,
|
||||
"unterzeichnetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "signatur_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mangel" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"pruefungId" UUID,
|
||||
"melderId" UUID,
|
||||
"melderName" VARCHAR(200),
|
||||
"beschreibung" VARCHAR(2000) NOT NULL,
|
||||
"fotoDateiId" UUID,
|
||||
"prioritaet" "MangelPrioritaet" NOT NULL DEFAULT 'MITTEL',
|
||||
"status" "MangelStatus" NOT NULL DEFAULT 'GEMELDET',
|
||||
"zugewiesenAnId" UUID,
|
||||
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"behobenAm" TIMESTAMPTZ(3),
|
||||
"behobenVonId" UUID,
|
||||
"behebung" VARCHAR(2000),
|
||||
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "mangel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"hersteller" VARCHAR(160) NOT NULL,
|
||||
"titel" VARCHAR(200) NOT NULL,
|
||||
"referenz" VARCHAR(80),
|
||||
"beschreibung" VARCHAR(4000),
|
||||
"massnahme" VARCHAR(2000),
|
||||
"veroeffentlichtAm" DATE,
|
||||
"quelle" VARCHAR(500),
|
||||
"sperrtGeraete" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "RueckrufStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "rueckruf_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf_geraetetyp" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"rueckrufId" UUID NOT NULL,
|
||||
"geraetetypId" UUID,
|
||||
"modell" VARCHAR(160),
|
||||
"seriennummerVon" VARCHAR(120),
|
||||
"seriennummerBis" VARCHAR(120),
|
||||
"herstellungVon" DATE,
|
||||
"herstellungBis" DATE,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "rueckruf_geraetetyp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf_betroffenheit" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"rueckrufId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"status" "BetroffenheitStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"festgestelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "rueckruf_betroffenheit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "erinnerung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"terminId" UUID NOT NULL,
|
||||
"stufe" "ErinnerungStufe" NOT NULL,
|
||||
"versendetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"kanal" VARCHAR(40) NOT NULL DEFAULT 'mail',
|
||||
"empfaenger" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
|
||||
"meldung" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "erinnerung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungsvorlage_organisationId_geraetetypId_idx" ON "wartungsvorlage"("organisationId", "geraetetypId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungsvorlage_organisationId_aktiv_idx" ON "wartungsvorlage"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wartungsvorlage_organisationId_name_key" ON "wartungsvorlage"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "checklistenpunkt_organisationId_vorlageId_idx" ON "checklistenpunkt"("organisationId", "vorlageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_faelligAm_idx" ON "wartungstermin"("organisationId", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_status_faelligAm_idx" ON "wartungstermin"("organisationId", "status", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_geraetId_idx" ON "wartungstermin"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_zugewiesenAnId_idx" ON "wartungstermin"("organisationId", "zugewiesenAnId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wartungstermin_geraetId_vorlageId_faelligAm_key" ON "wartungstermin"("geraetId", "vorlageId", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_geraetId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "geraetId", "durchgefuehrtAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_status_idx" ON "pruefung"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "durchgefuehrtAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_prueferId_idx" ON "pruefung"("organisationId", "prueferId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pruefung_organisationId_protokollNummer_key" ON "pruefung"("organisationId", "protokollNummer");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefergebnis_organisationId_pruefungId_idx" ON "pruefergebnis"("organisationId", "pruefungId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pruefergebnis_pruefungId_punktId_key" ON "pruefergebnis"("pruefungId", "punktId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "signatur_organisationId_pruefungId_idx" ON "signatur"("organisationId", "pruefungId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_status_prioritaet_idx" ON "mangel"("organisationId", "status", "prioritaet");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_geraetId_idx" ON "mangel"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_zugewiesenAnId_idx" ON "mangel"("organisationId", "zugewiesenAnId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_organisationId_status_idx" ON "rueckruf"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_organisationId_hersteller_idx" ON "rueckruf"("organisationId", "hersteller");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_geraetetyp_organisationId_rueckrufId_idx" ON "rueckruf_geraetetyp"("organisationId", "rueckrufId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_betroffenheit_organisationId_rueckrufId_idx" ON "rueckruf_betroffenheit"("organisationId", "rueckrufId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_betroffenheit_organisationId_geraetId_idx" ON "rueckruf_betroffenheit"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "rueckruf_betroffenheit_rueckrufId_geraetId_key" ON "rueckruf_betroffenheit"("rueckrufId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "erinnerung_organisationId_versendetAm_idx" ON "erinnerung"("organisationId", "versendetAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "erinnerung_terminId_stufe_key" ON "erinnerung"("terminId", "stufe");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_externeProtokollDateiId_fkey" FOREIGN KEY ("externeProtokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_ersetztPruefungId_fkey" FOREIGN KEY ("ersetztPruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_punktId_fkey" FOREIGN KEY ("punktId") REFERENCES "checklistenpunkt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_bildDateiId_fkey" FOREIGN KEY ("bildDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_melderId_fkey" FOREIGN KEY ("melderId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_behobenVonId_fkey" FOREIGN KEY ("behobenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf" ADD CONSTRAINT "rueckruf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,243 @@
|
||||
-- =============================================================================
|
||||
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
|
||||
--
|
||||
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
|
||||
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
|
||||
--
|
||||
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
|
||||
-- für die Tabellen aus Phase 0 und 1.
|
||||
--
|
||||
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
|
||||
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
|
||||
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
|
||||
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
|
||||
-- der Datenbank, nicht nur in lib/ und server/.
|
||||
--
|
||||
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
|
||||
-- Rückrufe.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'wartungsvorlage',
|
||||
'checklistenpunkt',
|
||||
'wartungstermin',
|
||||
'pruefung',
|
||||
'pruefergebnis',
|
||||
'signatur',
|
||||
'mangel',
|
||||
'rueckruf',
|
||||
'rueckruf_geraetetyp',
|
||||
'rueckruf_betroffenheit',
|
||||
'erinnerung'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON %I FOR ALL '
|
||||
'USING ("organisationId" = aktuelle_organisation()) '
|
||||
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||
tabelle || '_mandant', tabelle
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Unveränderbarkeit abgeschlossener Prüfungen
|
||||
--
|
||||
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
|
||||
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
|
||||
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
|
||||
--
|
||||
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
|
||||
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
|
||||
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
|
||||
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
|
||||
--
|
||||
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
|
||||
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
vergleich "pruefung"%ROWTYPE;
|
||||
BEGIN
|
||||
-- Entwürfe sind frei änderbar.
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
|
||||
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
|
||||
vergleich := NEW;
|
||||
vergleich."geaendertAm" := OLD."geaendertAm";
|
||||
|
||||
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
|
||||
vergleich."status" := OLD."status";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollDateiId" IS NULL THEN
|
||||
vergleich."protokollDateiId" := OLD."protokollDateiId";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollNummer" IS NULL THEN
|
||||
vergleich."protokollNummer" := OLD."protokollNummer";
|
||||
END IF;
|
||||
|
||||
IF vergleich IS DISTINCT FROM OLD THEN
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
|
||||
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
|
||||
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
|
||||
|
||||
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
|
||||
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
|
||||
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
|
||||
'(nur im Aufbewahrungslauf).',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefung_abschluss_schuetzen
|
||||
BEFORE UPDATE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
|
||||
|
||||
CREATE TRIGGER pruefung_loeschschutz
|
||||
BEFORE DELETE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Dasselbe für die Bestandteile eines Protokolls.
|
||||
--
|
||||
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||||
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
|
||||
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
|
||||
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
betroffene_pruefung uuid;
|
||||
zustand "PruefungStatus";
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
betroffene_pruefung := OLD."pruefungId";
|
||||
ELSE
|
||||
betroffene_pruefung := NEW."pruefungId";
|
||||
END IF;
|
||||
|
||||
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
|
||||
|
||||
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
|
||||
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
|
||||
betroffene_pruefung, TG_OP, TG_TABLE_NAME
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefergebnis_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
CREATE TRIGGER signatur_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
|
||||
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
|
||||
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
|
||||
);
|
||||
|
||||
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
|
||||
-- erzeugen.
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
|
||||
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indizes
|
||||
--
|
||||
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
|
||||
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
|
||||
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
|
||||
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
|
||||
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
|
||||
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
|
||||
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||
|
||||
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
|
||||
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
|
||||
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
|
||||
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -161,6 +161,24 @@ model Organisation {
|
||||
dateien Datei[]
|
||||
fahrzeuge Fahrzeug[]
|
||||
geraete Geraet[]
|
||||
standorte Standort[]
|
||||
geraeteraeume Geraeteraum[]
|
||||
geraetetypen Geraetetyp[]
|
||||
qualifikationstypen Qualifikationstyp[]
|
||||
qualifikationen Qualifikation[]
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
wartungsvorlagen Wartungsvorlage[]
|
||||
checklistenpunkte Checklistenpunkt[]
|
||||
wartungstermine Wartungstermin[]
|
||||
pruefungen Pruefung[]
|
||||
pruefergebnisse Pruefergebnis[]
|
||||
signaturen Signatur[]
|
||||
maengel Mangel[]
|
||||
rueckrufe Rueckruf[]
|
||||
rueckrufTypen RueckrufGeraetetyp[]
|
||||
rueckrufBetroffenheiten RueckrufBetroffenheit[]
|
||||
erinnerungen Erinnerung[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("organisation")
|
||||
@@ -224,6 +242,16 @@ model Person {
|
||||
ausweisTokens AusweisToken[]
|
||||
auditEintraege AuditLog[]
|
||||
hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
|
||||
qualifikationen Qualifikation[] @relation("QualifikationInhaber")
|
||||
bestaetigteQualifikationen Qualifikation[] @relation("QualifikationBestaetiger")
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
zugewieseneTermine Wartungstermin[] @relation("TerminZustaendig")
|
||||
durchgefuehrtePruefungen Pruefung[] @relation("PruefungPruefer")
|
||||
signaturen Signatur[] @relation("SignaturUnterzeichner")
|
||||
gemeldeteMaengel Mangel[] @relation("MangelMelder")
|
||||
zugewieseneMaengel Mangel[] @relation("MangelZustaendig")
|
||||
behobeneMaengel Mangel[] @relation("MangelBehebung")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
@@ -447,6 +475,13 @@ model Datei {
|
||||
hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
|
||||
alsAvatarGenutzt Person[] @relation("PersonAvatar")
|
||||
|
||||
// Phase 2 — woran eine Datei sonst noch hängen kann
|
||||
alsPruefprotokoll Pruefung[] @relation("PruefungProtokoll")
|
||||
alsFremdprotokoll Pruefung[] @relation("PruefungFremdprotokoll")
|
||||
alsErgebnisFoto Pruefergebnis[] @relation("PruefergebnisFoto")
|
||||
alsSignaturbild Signatur[] @relation("SignaturBild")
|
||||
alsMangelFoto Mangel[] @relation("MangelFoto")
|
||||
|
||||
@@unique([bucket, objektSchluessel])
|
||||
@@index([organisationId, entitaet, entitaetId])
|
||||
@@index([organisationId, kategorie])
|
||||
@@ -454,14 +489,119 @@ model Datei {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Vorgriff auf Phase 1
|
||||
// Diese beiden Modelle sind bewusst schmal gehalten. Sie existieren, damit das
|
||||
// Seed-Skript eine realistische Wehr aufbauen kann und die Mandantentrennung
|
||||
// an echten Daten prüfbar ist. Phase 1 erweitert sie um Funkrufname-Systematik,
|
||||
// DIN-Typisierung, Geräteräume, Prüfintervalle und Lebensdauerkategorien.
|
||||
// Phase 1 — Stammdaten
|
||||
//
|
||||
// Ordnungsprinzip:
|
||||
// Standort wo etwas steht (Gerätehaus, Lager, Außenstelle)
|
||||
// Fahrzeug ein Einsatzfahrzeug, es hat Geräteräume
|
||||
// Geraeteraum ein Fach am Fahrzeug (G1 … G6, Dachkasten, Mannschaftsraum)
|
||||
// Geraetetyp die Bauart samt Prüfvorgabe — "Pressluftatmer PA 94"
|
||||
// Geraet das einzelne Stück mit Inventarnummer
|
||||
//
|
||||
// Ein Gerät liegt ENTWEDER an einem Standort ODER verlastet in einem
|
||||
// Geräteraum eines Fahrzeugs. Beides gleichzeitig oder gar nichts wäre eine
|
||||
// Falschangabe; eine Prüfbedingung in der Datenbank verhindert das.
|
||||
// =============================================================================
|
||||
|
||||
/// Ein Einsatzfahrzeug der Wehr.
|
||||
/// Art eines Standorts.
|
||||
enum StandortArt {
|
||||
GERAETEHAUS
|
||||
LAGER
|
||||
AUSSENSTELLE
|
||||
WERKSTATT
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Fachliche Einordnung eines Gerätetyps — bestimmt die Gruppierung in Listen.
|
||||
enum GeraeteKategorie {
|
||||
ATEMSCHUTZ
|
||||
BELEUCHTUNG
|
||||
BRANDBEKAEMPFUNG
|
||||
ELEKTRO
|
||||
FUNK
|
||||
HYGIENE
|
||||
MEDIZIN
|
||||
MESSTECHNIK
|
||||
PERSOENLICHE_SCHUTZAUSRUESTUNG
|
||||
RETTUNG
|
||||
SCHLAUCHMATERIAL
|
||||
TECHNISCHE_HILFELEISTUNG
|
||||
UMWELTSCHUTZ
|
||||
WASSERFOERDERUNG
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Lebensdauerkategorie. Diese Gruppen haben eine maximale Nutzungsdauer ab
|
||||
/// Herstellungs- oder Indienststellungsdatum, unabhängig vom Zustand.
|
||||
enum LebensdauerKategorie {
|
||||
KEINE
|
||||
HELM
|
||||
SCHUTZKLEIDUNG
|
||||
HANDSCHUHE
|
||||
STIEFEL
|
||||
SEIL_GURT
|
||||
PRESSLUFTATMER
|
||||
ATEMANSCHLUSS
|
||||
DRUCKBEHAELTER
|
||||
SCHLAUCH
|
||||
LEITER
|
||||
SONSTIGE
|
||||
}
|
||||
|
||||
/// Woran sich die maximale Nutzungsdauer bemisst.
|
||||
enum LebensdauerBezug {
|
||||
HERSTELLUNG
|
||||
INDIENSTSTELLUNG
|
||||
}
|
||||
|
||||
/// Art einer Qualifikation.
|
||||
enum QualifikationsArt {
|
||||
LEHRGANG
|
||||
BEFAEHIGUNG
|
||||
UNTERSUCHUNG
|
||||
FUEHRERSCHEIN
|
||||
UNTERWEISUNG
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Ein Ort, an dem Fahrzeuge stehen und Geräte lagern.
|
||||
model Standort {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(160)
|
||||
/// Kurzzeichen für Listen und Etiketten, z. B. "GH" oder "LAG".
|
||||
kuerzel String? @db.VarChar(20)
|
||||
art StandortArt @default(GERAETEHAUS)
|
||||
|
||||
strasse String? @db.VarChar(200)
|
||||
plz String? @db.VarChar(10)
|
||||
ort String? @db.VarChar(120)
|
||||
|
||||
/// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht,
|
||||
/// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage.
|
||||
/// Ab Phase 6 zeigt die Karte diese Punkte an.
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
fahrzeuge Fahrzeug[]
|
||||
geraete Geraet[]
|
||||
|
||||
@@unique([organisationId, name])
|
||||
@@index([organisationId, aktiv])
|
||||
@@map("standort")
|
||||
}
|
||||
|
||||
/// Ein Einsatzfahrzeug.
|
||||
model Fahrzeug {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
@@ -470,11 +610,103 @@ model Fahrzeug {
|
||||
funkrufname String @db.VarChar(120)
|
||||
bezeichnung String @db.VarChar(120)
|
||||
art FahrzeugArt @default(LOESCHFAHRZEUG)
|
||||
/// Typkurzzeichen nach DIN, z. B. "LF 20", "TSF-W", "DLA(K) 23/12".
|
||||
dinTyp String? @db.VarChar(40)
|
||||
|
||||
kennzeichen String? @db.VarChar(20)
|
||||
hersteller String? @db.VarChar(120)
|
||||
aufbauhersteller String? @db.VarChar(120)
|
||||
fahrgestellnummer String? @db.VarChar(30)
|
||||
baujahr Int?
|
||||
erstzulassung DateTime? @db.Date
|
||||
indienststellung DateTime? @db.Date
|
||||
|
||||
/// Sitzplätze für die Stärkeberechnung ab Phase 3.
|
||||
sitzplaetze Int?
|
||||
/// Löschwasser in Litern.
|
||||
tankinhalt Int?
|
||||
/// Zulässige Gesamtmasse in kg.
|
||||
gesamtmasse Int?
|
||||
kilometerstand Int?
|
||||
|
||||
/// Nächste Hauptuntersuchung.
|
||||
tuevBis DateTime? @db.Date
|
||||
/// Nächste Sicherheitsprüfung nach UVV.
|
||||
uvvBis DateTime? @db.Date
|
||||
naechsteWartung DateTime? @db.Date
|
||||
|
||||
standortId String? @db.Uuid
|
||||
aktiv Boolean @default(true)
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
|
||||
geraeteraeume Geraeteraum[]
|
||||
geraete Geraet[]
|
||||
|
||||
@@unique([organisationId, funkrufname])
|
||||
@@unique([organisationId, kennzeichen])
|
||||
@@index([organisationId, aktiv])
|
||||
@@index([organisationId, tuevBis])
|
||||
@@map("fahrzeug")
|
||||
}
|
||||
|
||||
/// Ein Fach am Fahrzeug. Die Nummerierung folgt der Beladeordnung: G1 bis G6
|
||||
/// an den Seiten, dazu Dachkasten, Heck und Mannschaftsraum.
|
||||
model Geraeteraum {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
fahrzeugId String @db.Uuid
|
||||
|
||||
/// Kurzzeichen wie "G1", "GR", "Dach".
|
||||
bezeichnung String @db.VarChar(40)
|
||||
beschreibung String? @db.VarChar(200)
|
||||
/// Reihenfolge in der Anzeige — entspricht der Beladeordnung.
|
||||
sortierung Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Cascade)
|
||||
geraete Geraet[]
|
||||
|
||||
@@unique([fahrzeugId, bezeichnung])
|
||||
@@index([organisationId, fahrzeugId])
|
||||
@@map("geraeteraum")
|
||||
}
|
||||
|
||||
/// Die Bauart eines Geräts samt Prüf- und Lebensdauervorgabe. Was hier steht,
|
||||
/// gilt als Vorgabe für jedes Gerät dieses Typs; einzelne Geräte dürfen davon
|
||||
/// abweichen, wenn der Hersteller etwas anderes vorschreibt.
|
||||
model Geraetetyp {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(160)
|
||||
kategorie GeraeteKategorie @default(SONSTIGES)
|
||||
hersteller String? @db.VarChar(120)
|
||||
modell String? @db.VarChar(120)
|
||||
|
||||
/// Vorgabe für das Prüfintervall in Monaten.
|
||||
pruefintervallMonate Int?
|
||||
/// Fundstelle der Prüfvorschrift, z. B. "DGUV Vorschrift 49" oder
|
||||
/// "DIN 14800-16".
|
||||
pruefvorschrift String? @db.VarChar(200)
|
||||
|
||||
lebensdauerKategorie LebensdauerKategorie @default(KEINE)
|
||||
/// Maximale Nutzungsdauer in Jahren.
|
||||
lebensdauerJahre Int?
|
||||
lebensdauerBezug LebensdauerBezug @default(INDIENSTSTELLUNG)
|
||||
/// Wie viele Monate vor Ablauf gewarnt wird.
|
||||
vorwarnzeitMonate Int @default(3)
|
||||
|
||||
beschreibung String? @db.VarChar(1000)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
@@ -484,45 +716,690 @@ model Fahrzeug {
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraete Geraet[]
|
||||
|
||||
@@unique([organisationId, funkrufname])
|
||||
// Phase 2
|
||||
wartungsvorlagen Wartungsvorlage[]
|
||||
rueckrufTypen RueckrufGeraetetyp[]
|
||||
|
||||
@@unique([organisationId, name])
|
||||
@@index([organisationId, kategorie])
|
||||
@@index([organisationId, aktiv])
|
||||
@@map("fahrzeug")
|
||||
@@map("geraetetyp")
|
||||
}
|
||||
|
||||
/// Ein prüfpflichtiges Gerät — Pressluftatmer, Schlauch, Leiter, Motorsäge.
|
||||
/// Ein einzelnes Gerät mit eigener Inventarnummer.
|
||||
model Geraet {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
inventarnummer String @db.VarChar(60)
|
||||
bezeichnung String @db.VarChar(160)
|
||||
geraetetypId String? @db.Uuid
|
||||
|
||||
hersteller String? @db.VarChar(120)
|
||||
modell String? @db.VarChar(120)
|
||||
seriennummer String? @db.VarChar(120)
|
||||
|
||||
herstellungsdatum DateTime? @db.Date
|
||||
indienststellung DateTime? @db.Date
|
||||
anschaffungspreis Decimal? @db.Decimal(12, 2)
|
||||
|
||||
status GeraetStatus @default(EINSATZBEREIT)
|
||||
/// Prüfintervall in Monaten aus Vorschrift oder Herstellerangabe.
|
||||
pruefintervallMonate Int?
|
||||
naechstePruefung DateTime? @db.Date
|
||||
/// Maximale Nutzungsdauer in Jahren (Helme, Seile, Schutzkleidung).
|
||||
lebensdauerJahre Int?
|
||||
|
||||
/// Verlastet auf einem Fahrzeug — oder im Gerätehaus, dann null.
|
||||
/// Prüfintervall in Monaten. Leer bedeutet: Vorgabe des Gerätetyps gilt.
|
||||
pruefintervallMonate Int?
|
||||
letztePruefung DateTime? @db.Date
|
||||
naechstePruefung DateTime? @db.Date
|
||||
|
||||
/// Lebensdauer. Leer bedeutet ebenfalls: Vorgabe des Gerätetyps gilt.
|
||||
lebensdauerKategorie LebensdauerKategorie?
|
||||
lebensdauerJahre Int?
|
||||
/// Errechnetes Ende der Nutzungsdauer, vom Server gesetzt.
|
||||
lebensdauerBis DateTime? @db.Date
|
||||
|
||||
/// ENTWEDER standortId ODER fahrzeugId + geraeteraumId.
|
||||
/// Eine Prüfbedingung in der Datenbank setzt das durch.
|
||||
standortId String? @db.Uuid
|
||||
fahrzeugId String? @db.Uuid
|
||||
geraeteraumId String? @db.Uuid
|
||||
/// Freitext für die genaue Stelle im Raum: "Regal 3, Fach B".
|
||||
lagerort String? @db.VarChar(160)
|
||||
|
||||
ausgesondertAm DateTime? @db.Date
|
||||
ausgesondertGrund String? @db.VarChar(500)
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
|
||||
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
|
||||
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
|
||||
geraeteraum Geraeteraum? @relation(fields: [geraeteraumId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Phase 2 — alles, was am Gerät geprüft, gemeldet und zurückgerufen wird
|
||||
wartungstermine Wartungstermin[]
|
||||
pruefungen Pruefung[]
|
||||
maengel Mangel[]
|
||||
rueckrufBetroffenheiten RueckrufBetroffenheit[]
|
||||
|
||||
@@unique([organisationId, inventarnummer])
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, naechstePruefung])
|
||||
@@index([organisationId, lebensdauerBis])
|
||||
@@index([organisationId, fahrzeugId])
|
||||
@@index([organisationId, standortId])
|
||||
@@index([organisationId, geraetetypId])
|
||||
@@index([organisationId, seriennummer])
|
||||
@@map("geraet")
|
||||
}
|
||||
|
||||
/// Die Art einer Qualifikation — Lehrgang, Untersuchung, Führerschein.
|
||||
model Qualifikationstyp {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(160)
|
||||
/// Kurzzeichen für Listen, z. B. "MA", "AGT", "G26.3", "C1".
|
||||
kuerzel String? @db.VarChar(20)
|
||||
art QualifikationsArt @default(LEHRGANG)
|
||||
|
||||
/// Gültigkeit in Monaten. Leer bedeutet: gilt unbefristet.
|
||||
gueltigkeitMonate Int?
|
||||
/// Wie viele Tage vor Ablauf gewarnt wird.
|
||||
vorwarnzeitTage Int @default(90)
|
||||
/// Voraussetzung für den Einsatzdienst — wird im Dashboard hervorgehoben.
|
||||
einsatzrelevant Boolean @default(false)
|
||||
|
||||
beschreibung String? @db.VarChar(1000)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
qualifikationen Qualifikation[]
|
||||
|
||||
@@unique([organisationId, name])
|
||||
@@index([organisationId, aktiv])
|
||||
@@map("qualifikationstyp")
|
||||
}
|
||||
|
||||
/// Eine erworbene Qualifikation einer Person.
|
||||
model Qualifikation {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
personId String @db.Uuid
|
||||
typId String @db.Uuid
|
||||
|
||||
erworbenAm DateTime @db.Date
|
||||
/// Ablaufdatum. Wird beim Speichern aus gueltigkeitMonate errechnet, kann
|
||||
/// aber überschrieben werden — auf einer Urkunde steht manchmal etwas
|
||||
/// anderes als die Regel hergibt.
|
||||
gueltigBis DateTime? @db.Date
|
||||
|
||||
ausstellendeStelle String? @db.VarChar(200)
|
||||
/// Lehrgangs- oder Urkundennummer.
|
||||
nachweisnummer String? @db.VarChar(80)
|
||||
/// Eingescannte Urkunde, liegt in MinIO.
|
||||
urkundeDateiId String? @db.Uuid
|
||||
|
||||
bestaetigtVonId String? @db.Uuid
|
||||
bestaetigtAm DateTime? @db.Timestamptz(3)
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
person Person @relation("QualifikationInhaber", fields: [personId], references: [id], onDelete: Cascade)
|
||||
typ Qualifikationstyp @relation(fields: [typId], references: [id], onDelete: Restrict)
|
||||
bestaetigtVon Person? @relation("QualifikationBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, personId])
|
||||
@@index([organisationId, typId])
|
||||
@@index([organisationId, gueltigBis])
|
||||
@@map("qualifikation")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 2 — Wartungs- und Prüfmanagement
|
||||
//
|
||||
// Ordnungsprinzip:
|
||||
// Wartungsvorlage was geprüft wird und in welchem Takt — je Gerätetyp
|
||||
// Checklistenpunkt eine einzelne Frage der Prüfung
|
||||
// Wartungstermin ein konkreter Prüftermin an einem konkreten Gerät
|
||||
// Pruefung die durchgeführte Prüfung samt Ergebnis
|
||||
// Pruefergebnis die Antwort auf einen Checklistenpunkt
|
||||
// Signatur die Unterschrift unter das Protokoll
|
||||
// Mangel was dabei auffiel und noch behoben werden muss
|
||||
// Rueckruf eine Herstellerwarnung und die betroffenen Geräte
|
||||
//
|
||||
// Zwei Festlegungen, die den Aufbau erklären:
|
||||
//
|
||||
// 1. Ein abgeschlossenes Prüfprotokoll ist unveränderbar. Ein Trigger in der
|
||||
// Datenbank lässt an einer Prüfung mit Status ABGESCHLOSSEN keine
|
||||
// Änderung mehr zu — außer der Kennzeichnung als ERSETZT. Eine Korrektur
|
||||
// ist eine neue Prüfung, die über ersetztPruefungId auf die alte zeigt.
|
||||
//
|
||||
// 2. "Überfällig" ist kein gespeicherter Zustand, sondern ergibt sich aus
|
||||
// faelligAm und dem heutigen Tag. Gespeicherte Zustände laufen der Uhr
|
||||
// hinterher; berechnete nicht.
|
||||
// =============================================================================
|
||||
|
||||
/// Zustand eines Prüftermins. Überfälligkeit steht bewusst nicht hier drin.
|
||||
enum WartungsterminStatus {
|
||||
OFFEN
|
||||
ERLEDIGT
|
||||
/// Der Termin ist gegenstandslos geworden — Gerät ausgesondert, Vorlage
|
||||
/// abgeschafft, Prüfpflicht entfallen.
|
||||
ENTFALLEN
|
||||
}
|
||||
|
||||
/// Lebenszyklus einer Prüfung.
|
||||
enum PruefungStatus {
|
||||
/// Der Wizard läuft noch. Nur in diesem Zustand ist Ändern erlaubt.
|
||||
ENTWURF
|
||||
/// Unterschrieben und festgeschrieben.
|
||||
ABGESCHLOSSEN
|
||||
/// Durch eine Korrekturprüfung abgelöst. Bleibt erhalten und lesbar.
|
||||
ERSETZT
|
||||
}
|
||||
|
||||
/// Ergebnis einer Prüfung nach DGUV-Logik.
|
||||
enum PruefErgebnis {
|
||||
BESTANDEN
|
||||
BESTANDEN_MIT_MAENGELN
|
||||
NICHT_BESTANDEN
|
||||
}
|
||||
|
||||
/// Wer geprüft hat — die eigene Wehr oder eine Fachfirma.
|
||||
enum PruefungArt {
|
||||
INTERN
|
||||
EXTERN
|
||||
}
|
||||
|
||||
/// Art eines Checklistenpunkts. Bestimmt, welches Eingabefeld der Wizard zeigt.
|
||||
enum ChecklistenpunktTyp {
|
||||
JA_NEIN
|
||||
MESSWERT
|
||||
TEXT
|
||||
FOTO
|
||||
}
|
||||
|
||||
/// In welcher Eigenschaft jemand unterschreibt.
|
||||
enum SignaturRolle {
|
||||
PRUEFER
|
||||
GERAETEWART
|
||||
/// Bei externer Wartung: der Vertreter der Fachfirma.
|
||||
FREMDFIRMA
|
||||
}
|
||||
|
||||
/// Dringlichkeit eines Mangels.
|
||||
enum MangelPrioritaet {
|
||||
NIEDRIG
|
||||
MITTEL
|
||||
HOCH
|
||||
/// Gerät ist nicht mehr einsatzbereit.
|
||||
KRITISCH
|
||||
}
|
||||
|
||||
/// Bearbeitungsstand eines Mangels: melden → zugewiesen → in Arbeit → behoben.
|
||||
enum MangelStatus {
|
||||
GEMELDET
|
||||
ZUGEWIESEN
|
||||
IN_ARBEIT
|
||||
BEHOBEN
|
||||
/// Kein Mangel — Fehlmeldung, bleibt aber nachvollziehbar stehen.
|
||||
VERWORFEN
|
||||
}
|
||||
|
||||
/// Bearbeitungsstand eines Rückrufs.
|
||||
enum RueckrufStatus {
|
||||
OFFEN
|
||||
IN_BEARBEITUNG
|
||||
ERLEDIGT
|
||||
}
|
||||
|
||||
/// Stand je betroffenem Gerät innerhalb eines Rückrufs.
|
||||
enum BetroffenheitStatus {
|
||||
OFFEN
|
||||
/// Geprüft und doch nicht betroffen — falsche Charge, anderes Baujahr.
|
||||
NICHT_BETROFFEN
|
||||
MASSNAHME_LAEUFT
|
||||
ERLEDIGT
|
||||
}
|
||||
|
||||
/// Vorwarnstufe einer Erinnerung. Je Termin und Stufe wird höchstens einmal
|
||||
/// erinnert — der Eintrag ist gleichzeitig die Sperre gegen Doppelversand.
|
||||
enum ErinnerungStufe {
|
||||
T_30
|
||||
T_7
|
||||
T_0
|
||||
UEBERFAELLIG
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Was an einem Gerätetyp regelmäßig zu prüfen ist.
|
||||
///
|
||||
/// Die Vorlage ist die Regel, der Wartungstermin ihre Anwendung auf ein
|
||||
/// einzelnes Gerät. Ändert sich die Vorlage, ändern sich künftige Termine —
|
||||
/// bereits abgeschlossene Prüfungen bleiben unberührt, weil ihre Ergebnisse
|
||||
/// samt Fragestellung erhalten bleiben.
|
||||
model Wartungsvorlage {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(160)
|
||||
/// Für welchen Gerätetyp sie gilt. Leer bedeutet: nur von Hand zuweisbar.
|
||||
geraetetypId String? @db.Uuid
|
||||
|
||||
/// Prüfintervall in Monaten. Zählt ab der letzten durchgeführten Prüfung.
|
||||
intervallMonate Int
|
||||
/// Wie viele Tage vorher gewarnt und erinnert wird.
|
||||
vorwarnzeitTage Int @default(30)
|
||||
/// Fundstelle, z. B. "DGUV Vorschrift 49" oder "DIN 14800-16".
|
||||
pruefvorschrift String? @db.VarChar(200)
|
||||
|
||||
/// Erfahrungswert für die Planung, in Minuten.
|
||||
standardDauerMinuten Int?
|
||||
/// Vorbelegung: diese Prüfung macht üblicherweise eine Fachfirma.
|
||||
standardExtern Boolean @default(false)
|
||||
/// Der nächtliche Lauf erzeugt Termine für alle Geräte dieses Typs.
|
||||
automatisch Boolean @default(true)
|
||||
|
||||
beschreibung String? @db.VarChar(1000)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
|
||||
punkte Checklistenpunkt[]
|
||||
termine Wartungstermin[]
|
||||
pruefungen Pruefung[]
|
||||
|
||||
@@unique([organisationId, name])
|
||||
@@index([organisationId, geraetetypId])
|
||||
@@index([organisationId, aktiv])
|
||||
@@map("wartungsvorlage")
|
||||
}
|
||||
|
||||
/// Eine einzelne Frage der Checkliste.
|
||||
model Checklistenpunkt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
vorlageId String @db.Uuid
|
||||
|
||||
text String @db.VarChar(300)
|
||||
typ ChecklistenpunktTyp @default(JA_NEIN)
|
||||
/// Pflichtpunkte müssen beantwortet sein, bevor sich die Prüfung
|
||||
/// abschließen lässt.
|
||||
pflicht Boolean @default(true)
|
||||
|
||||
/// Sollwert als Klartext für die Anzeige, z. B. "mindestens 270 bar".
|
||||
sollwert String? @db.VarChar(120)
|
||||
/// Einheit eines Messwerts, z. B. "bar", "mbar", "V", "kN".
|
||||
einheit String? @db.VarChar(20)
|
||||
/// Zulässiger Bereich eines Messwerts. Außerhalb davon gilt der Punkt als
|
||||
/// nicht in Ordnung — gerechnet wird das im Server, nicht im Browser.
|
||||
minWert Decimal? @db.Decimal(12, 3)
|
||||
maxWert Decimal? @db.Decimal(12, 3)
|
||||
|
||||
hinweis String? @db.VarChar(500)
|
||||
sortierung Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
vorlage Wartungsvorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade)
|
||||
ergebnisse Pruefergebnis[]
|
||||
|
||||
@@index([organisationId, vorlageId])
|
||||
@@map("checklistenpunkt")
|
||||
}
|
||||
|
||||
/// Ein fälliger Prüftermin an einem einzelnen Gerät.
|
||||
///
|
||||
/// Termine entstehen entweder im nächtlichen Lauf aus den Vorlagen oder von
|
||||
/// Hand. Die Eindeutigkeit über Gerät, Vorlage und Fälligkeitstag verhindert,
|
||||
/// dass ein zweiter Lauf denselben Termin noch einmal anlegt.
|
||||
model Wartungstermin {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
vorlageId String? @db.Uuid
|
||||
|
||||
faelligAm DateTime @db.Date
|
||||
status WartungsterminStatus @default(OFFEN)
|
||||
|
||||
/// true, wenn der nächtliche Lauf den Termin erzeugt hat.
|
||||
automatischErzeugt Boolean @default(false)
|
||||
/// Wer den Termin übernehmen soll.
|
||||
zugewiesenAnId String? @db.Uuid
|
||||
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
erledigtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
||||
zugewiesenAn Person? @relation("TerminZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull)
|
||||
pruefungen Pruefung[]
|
||||
erinnerungen Erinnerung[]
|
||||
|
||||
/// Doppelte Termine aus wiederholten Läufen ausschließen. Für Termine ohne
|
||||
/// Vorlage greift die Bedingung nicht — Postgres wertet NULL als
|
||||
/// unterschiedlich —, die legt aber ohnehin nur ein Mensch an.
|
||||
@@unique([geraetId, vorlageId, faelligAm])
|
||||
@@index([organisationId, faelligAm])
|
||||
@@index([organisationId, status, faelligAm])
|
||||
@@index([organisationId, geraetId])
|
||||
@@index([organisationId, zugewiesenAnId])
|
||||
@@map("wartungstermin")
|
||||
}
|
||||
|
||||
/// Eine durchgeführte Prüfung.
|
||||
///
|
||||
/// Ist der Status ABGESCHLOSSEN, ist der Datensatz festgeschrieben: die
|
||||
/// Datenbank weist jede weitere Änderung ab. Was korrigiert werden muss, wird
|
||||
/// als neue Prüfung erfasst, die über ersetztPruefungId auf die alte zeigt.
|
||||
model Pruefung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
terminId String? @db.Uuid
|
||||
vorlageId String? @db.Uuid
|
||||
|
||||
art PruefungArt @default(INTERN)
|
||||
status PruefungStatus @default(ENTWURF)
|
||||
|
||||
prueferId String? @db.Uuid
|
||||
/// Klarname des Prüfers zum Zeitpunkt der Prüfung. Bleibt lesbar, auch wenn
|
||||
/// die Person später austritt und anonymisiert wird — die Nachweispflicht
|
||||
/// gegenüber der Unfallkasse verlangt einen benennbaren Prüfer.
|
||||
prueferName String? @db.VarChar(200)
|
||||
|
||||
durchgefuehrtAm DateTime @db.Date
|
||||
/// Beginn der Zeiterfassung im Wizard.
|
||||
begonnenUm DateTime? @db.Timestamptz(3)
|
||||
dauerMinuten Int?
|
||||
|
||||
ergebnis PruefErgebnis?
|
||||
|
||||
/// Nur bei art = EXTERN: wer geprüft hat, was es gekostet hat und das
|
||||
/// Protokoll der Fachfirma als PDF.
|
||||
externeFirma String? @db.VarChar(200)
|
||||
externeProtokollDateiId String? @db.Uuid
|
||||
kosten Decimal? @db.Decimal(10, 2)
|
||||
|
||||
/// Nächster Termin laut dieser Prüfung. Kann vom Intervall abweichen, wenn
|
||||
/// der Prüfer eine kürzere Frist ansetzt.
|
||||
naechstePruefungAm DateTime? @db.Date
|
||||
|
||||
/// Das erzeugte Protokoll-PDF in MinIO und seine laufende Nummer.
|
||||
protokollDateiId String? @db.Uuid
|
||||
protokollNummer String? @db.VarChar(40)
|
||||
|
||||
abgeschlossenAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
/// Korrektur: diese Prüfung ersetzt eine frühere.
|
||||
ersetztPruefungId String? @db.Uuid
|
||||
korrekturGrund String? @db.VarChar(500)
|
||||
|
||||
bemerkung String? @db.VarChar(2000)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
termin Wartungstermin? @relation(fields: [terminId], references: [id], onDelete: SetNull)
|
||||
vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
||||
pruefer Person? @relation("PruefungPruefer", fields: [prueferId], references: [id], onDelete: SetNull)
|
||||
protokoll Datei? @relation("PruefungProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull)
|
||||
externesProtokoll Datei? @relation("PruefungFremdprotokoll", fields: [externeProtokollDateiId], references: [id], onDelete: SetNull)
|
||||
ersetzt Pruefung? @relation("PruefungKorrektur", fields: [ersetztPruefungId], references: [id], onDelete: SetNull)
|
||||
ersetztDurch Pruefung[] @relation("PruefungKorrektur")
|
||||
ergebnisse Pruefergebnis[]
|
||||
signaturen Signatur[]
|
||||
maengel Mangel[]
|
||||
|
||||
@@unique([organisationId, protokollNummer])
|
||||
@@index([organisationId, geraetId, durchgefuehrtAm(sort: Desc)])
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, durchgefuehrtAm(sort: Desc)])
|
||||
@@index([organisationId, prueferId])
|
||||
@@map("pruefung")
|
||||
}
|
||||
|
||||
/// Die Antwort auf einen Checklistenpunkt.
|
||||
model Pruefergebnis {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
pruefungId String @db.Uuid
|
||||
punktId String @db.Uuid
|
||||
|
||||
/// Je nach Typ des Punktes ist eines der drei Felder belegt.
|
||||
jaNein Boolean?
|
||||
zahlwert Decimal? @db.Decimal(12, 3)
|
||||
text String? @db.VarChar(1000)
|
||||
|
||||
fotoDateiId String? @db.Uuid
|
||||
|
||||
/// Ob der Punkt in Ordnung ist. Bei Messwerten rechnet der Server das aus
|
||||
/// dem zulässigen Bereich aus, sonst folgt es der Antwort.
|
||||
inOrdnung Boolean @default(true)
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade)
|
||||
punkt Checklistenpunkt @relation(fields: [punktId], references: [id], onDelete: Restrict)
|
||||
foto Datei? @relation("PruefergebnisFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([pruefungId, punktId])
|
||||
@@index([organisationId, pruefungId])
|
||||
@@map("pruefergebnis")
|
||||
}
|
||||
|
||||
/// Unterschrift unter ein Prüfprotokoll. Das Bild ist ein PNG aus dem
|
||||
/// Zeichenfeld und liegt wie alles andere in MinIO.
|
||||
model Signatur {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
pruefungId String @db.Uuid
|
||||
|
||||
rolle SignaturRolle @default(PRUEFER)
|
||||
personId String? @db.Uuid
|
||||
/// Klarname, so wie er im Protokoll steht.
|
||||
name String @db.VarChar(200)
|
||||
|
||||
bildDateiId String? @db.Uuid
|
||||
unterzeichnetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade)
|
||||
person Person? @relation("SignaturUnterzeichner", fields: [personId], references: [id], onDelete: SetNull)
|
||||
bild Datei? @relation("SignaturBild", fields: [bildDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, pruefungId])
|
||||
@@map("signatur")
|
||||
}
|
||||
|
||||
/// Ein Mangel an einem Gerät — gemeldet aus einer Prüfung heraus oder einzeln.
|
||||
model Mangel {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
/// Gesetzt, wenn der Mangel bei einer Prüfung aufgefallen ist.
|
||||
pruefungId String? @db.Uuid
|
||||
|
||||
melderId String? @db.Uuid
|
||||
melderName String? @db.VarChar(200)
|
||||
|
||||
beschreibung String @db.VarChar(2000)
|
||||
fotoDateiId String? @db.Uuid
|
||||
|
||||
prioritaet MangelPrioritaet @default(MITTEL)
|
||||
status MangelStatus @default(GEMELDET)
|
||||
|
||||
zugewiesenAnId String? @db.Uuid
|
||||
gemeldetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
behobenAm DateTime? @db.Timestamptz(3)
|
||||
behobenVonId String? @db.Uuid
|
||||
behebung String? @db.VarChar(2000)
|
||||
|
||||
/// Beim Melden wurde das Gerät gesperrt. Merkt sich, dass die Sperre zu
|
||||
/// diesem Mangel gehört — beim Beheben wird sie wieder aufgehoben.
|
||||
geraetGesperrt Boolean @default(false)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
pruefung Pruefung? @relation(fields: [pruefungId], references: [id], onDelete: SetNull)
|
||||
melder Person? @relation("MangelMelder", fields: [melderId], references: [id], onDelete: SetNull)
|
||||
zugewiesenAn Person? @relation("MangelZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull)
|
||||
behobenVon Person? @relation("MangelBehebung", fields: [behobenVonId], references: [id], onDelete: SetNull)
|
||||
foto Datei? @relation("MangelFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, status, prioritaet])
|
||||
@@index([organisationId, geraetId])
|
||||
@@index([organisationId, zugewiesenAnId])
|
||||
@@map("mangel")
|
||||
}
|
||||
|
||||
/// Eine Herstellerwarnung. Betroffene Geräte werden gesucht, markiert und auf
|
||||
/// Wunsch gesperrt.
|
||||
model Rueckruf {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
hersteller String @db.VarChar(160)
|
||||
titel String @db.VarChar(200)
|
||||
/// Nummer der Bekanntmachung des Herstellers.
|
||||
referenz String? @db.VarChar(80)
|
||||
|
||||
beschreibung String? @db.VarChar(4000)
|
||||
massnahme String? @db.VarChar(2000)
|
||||
|
||||
veroeffentlichtAm DateTime? @db.Date
|
||||
/// Fundstelle, z. B. die Adresse der Herstellermitteilung.
|
||||
quelle String? @db.VarChar(500)
|
||||
|
||||
/// Betroffene Geräte beim Zuordnen automatisch sperren.
|
||||
sperrtGeraete Boolean @default(false)
|
||||
status RueckrufStatus @default(OFFEN)
|
||||
erledigtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
typen RueckrufGeraetetyp[]
|
||||
betroffenheiten RueckrufBetroffenheit[]
|
||||
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, hersteller])
|
||||
@@map("rueckruf")
|
||||
}
|
||||
|
||||
/// Welche Bauart und welche Charge ein Rückruf betrifft.
|
||||
model RueckrufGeraetetyp {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
rueckrufId String @db.Uuid
|
||||
geraetetypId String? @db.Uuid
|
||||
|
||||
/// Freitext, wenn die betroffene Bauart nicht als Gerätetyp erfasst ist.
|
||||
modell String? @db.VarChar(160)
|
||||
|
||||
/// Eingrenzung der betroffenen Stücke. Alle Angaben sind freiwillig; was
|
||||
/// leer bleibt, schränkt nicht ein.
|
||||
seriennummerVon String? @db.VarChar(120)
|
||||
seriennummerBis String? @db.VarChar(120)
|
||||
herstellungVon DateTime? @db.Date
|
||||
herstellungBis DateTime? @db.Date
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade)
|
||||
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, rueckrufId])
|
||||
@@map("rueckruf_geraetetyp")
|
||||
}
|
||||
|
||||
/// Ein einzelnes Gerät innerhalb eines Rückrufs.
|
||||
model RueckrufBetroffenheit {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
rueckrufId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
|
||||
status BetroffenheitStatus @default(OFFEN)
|
||||
festgestelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
erledigtAm DateTime? @db.Timestamptz(3)
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
|
||||
/// Der Rückruf hat dieses Gerät gesperrt — beim Erledigen wird die Sperre
|
||||
/// wieder aufgehoben.
|
||||
geraetGesperrt Boolean @default(false)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([rueckrufId, geraetId])
|
||||
@@index([organisationId, rueckrufId])
|
||||
@@index([organisationId, geraetId])
|
||||
@@map("rueckruf_betroffenheit")
|
||||
}
|
||||
|
||||
/// Nachweis einer versendeten Erinnerung. Der Eintrag ist gleichzeitig die
|
||||
/// Sperre gegen Doppelversand: je Termin und Stufe gibt es ihn nur einmal.
|
||||
model Erinnerung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
terminId String @db.Uuid
|
||||
|
||||
stufe ErinnerungStufe
|
||||
versendetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
/// Kanal laut lib/integrations — "mail", "telegram", "protokoll".
|
||||
kanal String @default("mail") @db.VarChar(40)
|
||||
empfaenger String[] @default([])
|
||||
erfolgreich Boolean @default(true)
|
||||
meldung String? @db.VarChar(500)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
termin Wartungstermin @relation(fields: [terminId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([terminId, stufe])
|
||||
@@index([organisationId, versendetAm(sort: Desc)])
|
||||
@@map("erinnerung")
|
||||
}
|
||||
|
||||
@@ -44,14 +44,160 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "Übersicht nach der Anmeldung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "03-geraeteliste",
|
||||
pfad: "/geraete",
|
||||
beschreibung: "Geräteliste mit Suche, Filter und Spaltenauswahl",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "05-fahrzeuge",
|
||||
pfad: "/fahrzeuge",
|
||||
beschreibung: "Fahrzeugübersicht",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "07-mitglieder",
|
||||
pfad: "/mitglieder",
|
||||
beschreibung: "Mitgliederliste nach Abteilung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "08-qualifikationen",
|
||||
pfad: "/qualifikationen",
|
||||
beschreibung: "Qualifikationen mit Ablaufwarnung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "09-import",
|
||||
pfad: "/geraete/importieren",
|
||||
beschreibung: "Import-Assistent",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "10-geraet-neu",
|
||||
pfad: "/geraete/neu",
|
||||
beschreibung: "Formular für ein neues Gerät",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
{
|
||||
name: "11-fristen",
|
||||
pfad: "/pruefungen",
|
||||
beschreibung: "Fristen-Dashboard mit Zeitfenstern und Bündelung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "12-vorlagen",
|
||||
pfad: "/pruefungen/vorlagen",
|
||||
beschreibung: "Wartungsvorlagen mit Intervall und Prüfvorschrift",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "13-maengel",
|
||||
pfad: "/maengel",
|
||||
beschreibung: "Mängel von der Meldung bis zur Behebung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "14-rueckrufe",
|
||||
pfad: "/rueckrufe",
|
||||
beschreibung: "Rückrufe des Herstellers",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "15-lebensdauer",
|
||||
pfad: "/pruefungen/lebensdauer",
|
||||
beschreibung: "Geräte am Ende ihrer Nutzungsdauer",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "16-protokolle",
|
||||
pfad: "/pruefungen/verlauf",
|
||||
beschreibung: "Abgeschlossene Prüfprotokolle",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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}$/,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Wartet, bis die Seite wirklich fertig ist.
|
||||
*
|
||||
* "networkidle" genügt nicht: Listen holen ihre Daten über tRPC nach, und der
|
||||
* Aufruf startet erst, nachdem React im Browser gelaufen ist. Ohne dieses
|
||||
* Warten zeigen die Bilder Platzhalterbalken statt echter Daten.
|
||||
*/
|
||||
async function fertigGeladen(seite: Page): Promise<void> {
|
||||
await seite.waitForLoadState("networkidle");
|
||||
|
||||
// Die Entwicklungsanzeige von Next.js gehört nicht in eine Anleitung.
|
||||
await seite
|
||||
.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||
.catch(() => undefined);
|
||||
|
||||
try {
|
||||
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} catch {
|
||||
console.warn("[screenshots] Ladeanzeige blieb stehen — Bild wird trotzdem gemacht.");
|
||||
}
|
||||
await seite.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async function anmelden(seite: Page): Promise<void> {
|
||||
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
||||
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await seite.getByRole("button", { name: "Anmelden" }).click();
|
||||
await seite.waitForURL(`${BASIS}/`, { timeout: 20_000 });
|
||||
// Grosszuegig: bei kaltem .next uebersetzt "next dev" die Route erst beim
|
||||
// ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung.
|
||||
await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 });
|
||||
await seite.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
@@ -79,8 +225,41 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
|
||||
// Kurz warten, damit Übergänge und Symbole fertig gezeichnet sind.
|
||||
await seite.waitForTimeout(400);
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
|
||||
}
|
||||
|
||||
// Detailseiten: die erste Kennung von der jeweiligen Übersicht holen.
|
||||
for (const aufnahme of DYNAMISCHE_AUFNAHMEN) {
|
||||
await seite.goto(`${BASIS}${aufnahme.von}`, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const ziele = await seite
|
||||
.locator("a[href]")
|
||||
.evaluateAll((elemente) =>
|
||||
elemente
|
||||
.map((element) => ({
|
||||
href: element.getAttribute("href") ?? "",
|
||||
text: (element.textContent ?? "").trim(),
|
||||
}))
|
||||
.filter((eintrag) => eintrag.href.startsWith("/")),
|
||||
);
|
||||
|
||||
const treffer = ziele.find(
|
||||
(eintrag) =>
|
||||
aufnahme.linkMuster.test(eintrag.href) &&
|
||||
(!aufnahme.textMuster || aufnahme.textMuster.test(eintrag.text)),
|
||||
)?.href;
|
||||
if (!treffer) {
|
||||
console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await seite.goto(`${BASIS}${treffer}`, { waitUntil: "networkidle" });
|
||||
await fertigGeladen(seite);
|
||||
|
||||
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
|
||||
@@ -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 { Seitenleiste } from "@/components/layout/seitenleiste";
|
||||
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,
|
||||
@@ -23,6 +24,7 @@ export default async function AngemeldetLayout({
|
||||
);
|
||||
|
||||
return (
|
||||
<TrpcProvider>
|
||||
<div className="min-h-screen">
|
||||
<Seitenleiste
|
||||
rechte={[...rechteVon(sitzung.rollen)]}
|
||||
@@ -38,5 +40,6 @@ export default async function AngemeldetLayout({
|
||||
<main className="p-6">{children}</main>
|
||||
</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>
|
||||
<CardTitle>Stand des Aufbaus</CardTitle>
|
||||
<CardDescription>
|
||||
Phase 0 ist abgeschlossen: Fundament, Mandantentrennung, Anmeldung,
|
||||
Dateiablage und Protokollierung stehen. Die Fachmodule kommen Phase für
|
||||
Phase dazu.
|
||||
Phase 1 ist abgeschlossen: Standorte, Fahrzeuge mit Geräteräumen,
|
||||
Gerätetypen, Geräte mit QR-Etiketten, Qualifikationen und der Import aus
|
||||
vorhandenen Tabellen stehen. Als Nächstes kommt das Prüfmanagement.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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 3 — Einsätze</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ClipboardList, ListChecks, ShieldAlert, TriangleAlert } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FristenDashboard } from "@/app/(app)/pruefungen/fristen-dashboard";
|
||||
|
||||
export const metadata = { title: "Prüfungen" };
|
||||
|
||||
/**
|
||||
* Das Fristen-Dashboard — die Startseite des Gerätewarts.
|
||||
*
|
||||
* Die Rechteprüfung passiert hier auf dem Server; die Client-Komponente bekommt
|
||||
* nur das Ergebnis. So bleibt die Regel aus CLAUDE.md gewahrt: keine
|
||||
* Rollenprüfung in Komponenten.
|
||||
*/
|
||||
export default async function PruefungenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const darfPlanen = hatRecht(sitzung, "pruefung.planen");
|
||||
const darfPruefen = hatRecht(sitzung, "pruefung.durchfuehren");
|
||||
|
||||
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">Prüfungen und Fristen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Was ansteht, was überfällig ist und was demnächst aus der Nutzungsdauer läuft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/maengel">
|
||||
<TriangleAlert aria-hidden />
|
||||
Mängel
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/rueckrufe">
|
||||
<ShieldAlert aria-hidden />
|
||||
Rückrufe
|
||||
</Link>
|
||||
</Button>
|
||||
{darfPlanen && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/pruefungen/vorlagen">
|
||||
<ListChecks aria-hidden />
|
||||
Vorlagen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{darfPruefen && (
|
||||
<Button asChild>
|
||||
<Link href="/pruefungen/verlauf">
|
||||
<ClipboardList aria-hidden />
|
||||
Protokolle
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FristenDashboard darfPlanen={darfPlanen} darfPruefen={darfPruefen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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 { ProtokollListe } from "@/app/(app)/pruefungen/verlauf/protokoll-liste";
|
||||
|
||||
export const metadata = { title: "Prüfprotokolle" };
|
||||
|
||||
/**
|
||||
* Alle Prüfprotokolle der Wehr.
|
||||
*
|
||||
* Der Nachweis nach außen: wenn die Unfallkasse fragt, wird hier gesucht —
|
||||
* nach Gerät, nach Zeitraum, nach Ergebnis.
|
||||
*/
|
||||
export default async function VerlaufSeite() {
|
||||
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">Prüfprotokolle</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Abgeschlossene Prüfungen mit Ergebnis, Prüfer und PDF.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProtokollListe />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { PruefErgebnis, PruefungStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
|
||||
import { alsDatum, alsEuro } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Liste der Protokolle.
|
||||
*
|
||||
* Entwürfe stehen mit drin, aber gekennzeichnet: eine angefangene Prüfung ist
|
||||
* kein Nachweis, und wer eine sucht, um sie zu Ende zu bringen, findet sie hier.
|
||||
*/
|
||||
export function ProtokollListe() {
|
||||
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | "alle">("alle");
|
||||
const [vonAm, setVonAm] = React.useState("");
|
||||
const [bisAm, setBisAm] = React.useState("");
|
||||
|
||||
const pruefungen = trpc.pruefung.liste.useQuery({
|
||||
anzahl: 200,
|
||||
ergebnis: ergebnis === "alle" ? undefined : [ergebnis],
|
||||
vonAm: vonAm ? new Date(vonAm) : undefined,
|
||||
bisAm: bisAm ? new Date(bisAm) : undefined,
|
||||
});
|
||||
|
||||
const eintraege = pruefungen.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ergebnisfilter">Ergebnis</Label>
|
||||
<Select
|
||||
value={ergebnis}
|
||||
onValueChange={(wert) => setErgebnis(wert as PruefErgebnis | "alle")}
|
||||
>
|
||||
<SelectTrigger id="ergebnisfilter" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Ergebnisse</SelectItem>
|
||||
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{ERGEBNIS_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="von">geprüft ab</Label>
|
||||
<Input
|
||||
id="von"
|
||||
type="date"
|
||||
value={vonAm}
|
||||
onChange={(ereignis) => setVonAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bis">geprüft bis</Label>
|
||||
<Input
|
||||
id="bis"
|
||||
type="date"
|
||||
value={bisAm}
|
||||
onChange={(ereignis) => setBisAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pruefungen.error && <Fehlermeldung meldung={pruefungen.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Protokoll</TableHead>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Prüfung</TableHead>
|
||||
<TableHead>Prüfer</TableHead>
|
||||
<TableHead>Ergebnis</TableHead>
|
||||
<TableHead className="text-right">geprüft am</TableHead>
|
||||
<TableHead className="text-right">Kosten</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pruefungen.isLoading && <LadeZeilen spalten={7} />}
|
||||
|
||||
{eintraege.map((pruefung) => (
|
||||
<TableRow key={pruefung.id}>
|
||||
<TableCell className="tabellenzahlen font-medium">
|
||||
<Link
|
||||
href={`/pruefungen/${pruefung.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{pruefung.protokollNummer ?? "Entwurf"}
|
||||
</Link>
|
||||
{pruefung.status !== PruefungStatus.ABGESCHLOSSEN && (
|
||||
<div className="mt-1">
|
||||
<PruefungStatusAbzeichen status={pruefung.status} />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${pruefung.geraet.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{pruefung.geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{pruefung.vorlage?.name ?? "—"}
|
||||
{pruefung.art === "EXTERN" && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
extern: {pruefung.externeFirma ?? "Fachfirma"}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{pruefung.pruefer
|
||||
? `${pruefung.pruefer.nachname}, ${pruefung.pruefer.vorname}`
|
||||
: (pruefung.prueferName ?? "—")}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||
{pruefung._count.maengel > 0 && (
|
||||
<div className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{pruefung._count.maengel} Mängel
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDatum(pruefung.durchgefuehrtAm)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!pruefungen.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Protokoll"
|
||||
hinweis="Sobald eine Prüfung abgeschlossen ist, steht sie hier — mit PDF, Unterschrift und Ergebnis."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import { ChecklistenpunktTyp } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { PUNKTTYP_TEXT } from "@/lib/wartung/texte";
|
||||
|
||||
/**
|
||||
* Checklisten-Editor.
|
||||
*
|
||||
* Die Reihenfolge ist fachlich wichtig: der Prüfwizard arbeitet die Punkte
|
||||
* genau so ab, wie sie hier stehen. Ein Gerätewart legt sie in der Reihenfolge
|
||||
* an, in der er das Gerät in die Hand nimmt — erst außen, dann innen, zuletzt
|
||||
* die Messwerte.
|
||||
*
|
||||
* Umsortiert wird mit Pfeiltasten statt Ziehen und Fallenlassen: das
|
||||
* funktioniert auch mit Handschuhen auf einem Tablet und mit der Tastatur.
|
||||
*/
|
||||
|
||||
const LEERER_PUNKT = {
|
||||
text: "",
|
||||
typ: ChecklistenpunktTyp.JA_NEIN as ChecklistenpunktTyp,
|
||||
pflicht: true,
|
||||
sollwert: "",
|
||||
einheit: "",
|
||||
minWert: "",
|
||||
maxWert: "",
|
||||
hinweis: "",
|
||||
};
|
||||
|
||||
export function ChecklistenEditor({
|
||||
vorlageId,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
vorlageId: string;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [neu, setNeu] = React.useState(LEERER_PUNKT);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const vorlage = trpc.wartungsvorlage.einzeln.useQuery({ id: vorlageId });
|
||||
|
||||
const aktualisieren = () => void werkzeuge.wartungsvorlage.einzeln.invalidate({ id: vorlageId });
|
||||
|
||||
const punktAnlegen = trpc.wartungsvorlage.punktAnlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setNeu(LEERER_PUNKT);
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const punktLoeschen = trpc.wartungsvorlage.punktLoeschen.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const sortieren = trpc.wartungsvorlage.punkteSortieren.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const punkte = vorlage.data?.punkte ?? [];
|
||||
|
||||
function verschieben(index: number, richtung: -1 | 1) {
|
||||
const ziel = index + richtung;
|
||||
if (ziel < 0 || ziel >= punkte.length) return;
|
||||
|
||||
const reihenfolge = punkte.map((punkt) => punkt.id);
|
||||
[reihenfolge[index], reihenfolge[ziel]] = [reihenfolge[ziel]!, reihenfolge[index]!];
|
||||
|
||||
sortieren.mutate({ vorlageId, reihenfolge });
|
||||
}
|
||||
|
||||
function punktHinzufuegen(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
punktAnlegen.mutate({
|
||||
vorlageId,
|
||||
text: neu.text,
|
||||
typ: neu.typ,
|
||||
pflicht: neu.pflicht,
|
||||
sollwert: neu.sollwert || null,
|
||||
einheit: neu.einheit || null,
|
||||
minWert: neu.minWert === "" ? null : Number(neu.minWert),
|
||||
maxWert: neu.maxWert === "" ? null : Number(neu.maxWert),
|
||||
hinweis: neu.hinweis || null,
|
||||
sortierung: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (vorlage.isLoading) {
|
||||
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
|
||||
}
|
||||
|
||||
if (vorlage.error) return <Fehlermeldung meldung={vorlage.error.message} />;
|
||||
if (!vorlage.data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{vorlage.data.name}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
alle {vorlage.data.intervallMonate} Monate
|
||||
{vorlage.data.geraetetyp && ` · ${vorlage.data.geraetetyp.name}`}
|
||||
{vorlage.data.pruefvorschrift && ` · ${vorlage.data.pruefvorschrift}`}
|
||||
{" · "}
|
||||
{vorlage.data._count.pruefungen} Prüfungen bisher
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Checkliste</CardTitle>
|
||||
<CardDescription>
|
||||
Der Prüfwizard fragt die Punkte in dieser Reihenfolge ab. Pflichtpunkte müssen
|
||||
beantwortet sein, bevor sich eine Prüfung abschließen lässt — und ein durchgefallener
|
||||
Pflichtpunkt bedeutet „nicht bestanden".
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{punkte.length === 0 && (
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Prüfpunkt"
|
||||
hinweis="Eine Vorlage ohne Punkte erzeugt Termine, aber keine sinnvolle Prüfung."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ol className="space-y-2">
|
||||
{punkte.map((punkt, index) => (
|
||||
<li
|
||||
key={punkt.id}
|
||||
className="flex flex-wrap items-start gap-3 rounded-md border border-[var(--border)] p-3"
|
||||
>
|
||||
<span className="tabellenzahlen w-6 shrink-0 pt-1 text-sm text-[var(--muted-foreground)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div className="min-w-48 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{punkt.text}
|
||||
{punkt.pflicht && (
|
||||
<span className="ml-1 text-[var(--gesperrt)]" title="Pflichtpunkt">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{PUNKTTYP_TEXT[punkt.typ]}
|
||||
{punkt.sollwert && ` · Soll: ${punkt.sollwert}`}
|
||||
{(punkt.minWert !== null || punkt.maxWert !== null) &&
|
||||
` · zulässig ${punkt.minWert ?? "…"} bis ${punkt.maxWert ?? "…"}${
|
||||
punkt.einheit ? ` ${punkt.einheit}` : ""
|
||||
}`}
|
||||
</p>
|
||||
{punkt.hinweis && (
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">{punkt.hinweis}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => verschieben(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={`${punkt.text} nach oben`}
|
||||
>
|
||||
<ArrowUp aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => verschieben(index, 1)}
|
||||
disabled={index === punkte.length - 1}
|
||||
aria-label={`${punkt.text} nach unten`}
|
||||
>
|
||||
<ArrowDown aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Prüfpunkt "${punkt.text}" entfernen?`)) {
|
||||
punktLoeschen.mutate({ id: punkt.id });
|
||||
}
|
||||
}}
|
||||
aria-label={`${punkt.text} entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prüfpunkt ergänzen</CardTitle>
|
||||
<CardDescription>
|
||||
Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung" — gerechnet
|
||||
wird das auf dem Server, nicht im Browser.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={punktHinzufuegen} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="punkttext">Frage oder Prüfschritt</Label>
|
||||
<Input
|
||||
id="punkttext"
|
||||
required
|
||||
value={neu.text}
|
||||
onChange={(ereignis) => setNeu({ ...neu, text: ereignis.target.value })}
|
||||
placeholder="Maskenkörper auf Risse und Verformung prüfen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="punkttyp">Art der Antwort</Label>
|
||||
<Select
|
||||
value={neu.typ}
|
||||
onValueChange={(wert) =>
|
||||
setNeu({ ...neu, typ: wert as ChecklistenpunktTyp })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="punkttyp">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PUNKTTYP_TEXT).map(([wert, text]) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sollwert">Sollwert im Klartext</Label>
|
||||
<Input
|
||||
id="sollwert"
|
||||
value={neu.sollwert}
|
||||
onChange={(ereignis) => setNeu({ ...neu, sollwert: ereignis.target.value })}
|
||||
placeholder="mindestens 270 bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{neu.typ === ChecklistenpunktTyp.MESSWERT && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minwert">kleinster zulässiger Wert</Label>
|
||||
<Input
|
||||
id="minwert"
|
||||
type="number"
|
||||
step="any"
|
||||
value={neu.minWert}
|
||||
onChange={(ereignis) => setNeu({ ...neu, minWert: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxwert">größter zulässiger Wert</Label>
|
||||
<Input
|
||||
id="maxwert"
|
||||
type="number"
|
||||
step="any"
|
||||
value={neu.maxWert}
|
||||
onChange={(ereignis) => setNeu({ ...neu, maxWert: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="einheit">Einheit</Label>
|
||||
<Input
|
||||
id="einheit"
|
||||
value={neu.einheit}
|
||||
onChange={(ereignis) => setNeu({ ...neu, einheit: ereignis.target.value })}
|
||||
placeholder="bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hinweis">Hinweis für den Prüfer</Label>
|
||||
<Input
|
||||
id="hinweis"
|
||||
value={neu.hinweis}
|
||||
onChange={(ereignis) => setNeu({ ...neu, hinweis: ereignis.target.value })}
|
||||
placeholder="Sichtprüfung bei gutem Licht, Maske dabei dehnen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={neu.pflicht}
|
||||
onCheckedChange={(an) => setNeu({ ...neu, pflicht: an === true })}
|
||||
/>
|
||||
Pflichtpunkt — davon hängt die Einsatzbereitschaft ab
|
||||
</label>
|
||||
|
||||
{punktAnlegen.error && <Fehlermeldung meldung={punktAnlegen.error.message} />}
|
||||
|
||||
<Button type="submit" disabled={punktAnlegen.isPending}>
|
||||
<Plus aria-hidden />
|
||||
{punktAnlegen.isPending ? "wird ergänzt …" : "Punkt ergänzen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 { ChecklistenEditor } from "@/app/(app)/pruefungen/vorlagen/[id]/checkliste";
|
||||
|
||||
export const metadata = { title: "Checkliste" };
|
||||
|
||||
/**
|
||||
* Eine Vorlage mit ihrer Checkliste.
|
||||
*
|
||||
* Der Editor ist eine Client-Komponente: Punkte werden hinzugefügt, umsortiert
|
||||
* und geändert, ohne dass die Seite dazwischen neu lädt.
|
||||
*/
|
||||
export default async function VorlageSeite({ 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="/pruefungen/vorlagen">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Vorlagen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ChecklistenEditor vorlageId={id} darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
|
||||
</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 { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { VorlagenVerwaltung } from "@/app/(app)/pruefungen/vorlagen/vorlagen-verwaltung";
|
||||
|
||||
export const metadata = { title: "Wartungsvorlagen" };
|
||||
|
||||
/**
|
||||
* Wartungsvorlagen.
|
||||
*
|
||||
* Hier steht, was in welchem Takt geprüft wird. Ohne Vorlage entstehen keine
|
||||
* Termine — deshalb ist das die erste Seite, die eine Wehr in Phase 2 ausfüllt.
|
||||
*/
|
||||
export default async function VorlagenSeite() {
|
||||
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">Wartungsvorlagen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Was an einem Gerätetyp regelmäßig zu prüfen ist — Intervall, Vorschrift und Checkliste.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VorlagenVerwaltung darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Search, Trash2 } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Verwaltung der Wartungsvorlagen.
|
||||
*
|
||||
* Die Liste zeigt, was jede Vorlage kostet und bringt: Intervall, Zahl der
|
||||
* Prüfpunkte, Zahl der offenen Termine. Die Checkliste selbst wird auf der
|
||||
* Detailseite gepflegt — sie ist zu umfangreich für eine Tabellenzeile.
|
||||
*/
|
||||
|
||||
const LEER = {
|
||||
name: "",
|
||||
geraetetypId: "",
|
||||
intervallMonate: 12,
|
||||
vorwarnzeitTage: 30,
|
||||
pruefvorschrift: "",
|
||||
standardDauerMinuten: "",
|
||||
standardExtern: false,
|
||||
automatisch: true,
|
||||
beschreibung: "",
|
||||
};
|
||||
|
||||
export function VorlagenVerwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const [suche, setSuche] = React.useState("");
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const [formular, setFormular] = React.useState(LEER);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const vorlagen = trpc.wartungsvorlage.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "asc",
|
||||
suche: suche || undefined,
|
||||
});
|
||||
|
||||
const geraetetypen = trpc.geraetetyp.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 200,
|
||||
richtung: "asc",
|
||||
});
|
||||
|
||||
const anlegen = trpc.wartungsvorlage.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setOffen(false);
|
||||
setFormular(LEER);
|
||||
void werkzeuge.wartungsvorlage.liste.invalidate();
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const loeschen = trpc.wartungsvorlage.loeschen.useMutation({
|
||||
onSuccess: () => void werkzeuge.wartungsvorlage.liste.invalidate(),
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
function absenden(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
anlegen.mutate({
|
||||
name: formular.name,
|
||||
geraetetypId: formular.geraetetypId || null,
|
||||
intervallMonate: Number(formular.intervallMonate),
|
||||
vorwarnzeitTage: Number(formular.vorwarnzeitTage),
|
||||
pruefvorschrift: formular.pruefvorschrift || null,
|
||||
standardDauerMinuten: formular.standardDauerMinuten
|
||||
? Number(formular.standardDauerMinuten)
|
||||
: null,
|
||||
standardExtern: formular.standardExtern,
|
||||
automatisch: formular.automatisch,
|
||||
beschreibung: formular.beschreibung || null,
|
||||
aktiv: true,
|
||||
});
|
||||
}
|
||||
|
||||
const eintraege = vorlagen.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="Name oder Prüfvorschrift …"
|
||||
className="pl-8"
|
||||
aria-label="Vorlagen durchsuchen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Neue Vorlage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
{vorlagen.error && <Fehlermeldung meldung={vorlagen.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bezeichnung</TableHead>
|
||||
<TableHead>Gerätetyp</TableHead>
|
||||
<TableHead>Vorschrift</TableHead>
|
||||
<TableHead className="text-right">Intervall</TableHead>
|
||||
<TableHead className="text-right">Punkte</TableHead>
|
||||
<TableHead className="text-right">offene Termine</TableHead>
|
||||
<TableHead className="w-24" aria-label="Aktionen" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vorlagen.isLoading && <LadeZeilen spalten={7} />}
|
||||
|
||||
{eintraege.map((vorlage) => (
|
||||
<TableRow key={vorlage.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/pruefungen/vorlagen/${vorlage.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{vorlage.name}
|
||||
</Link>
|
||||
{!vorlage.aktiv && (
|
||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">(inaktiv)</span>
|
||||
)}
|
||||
{!vorlage.automatisch && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
keine automatische Planung
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{vorlage.geraetetyp?.name ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||
{vorlage.pruefvorschrift ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{vorlage.intervallMonate} Mon.
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">{vorlage._count.punkte}</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">{vorlage._count.termine}</TableCell>
|
||||
<TableCell>
|
||||
{darfBearbeiten && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Vorlage "${vorlage.name}" löschen? Offene Termine dazu entfallen.`,
|
||||
)
|
||||
) {
|
||||
loeschen.mutate({ id: vorlage.id });
|
||||
}
|
||||
}}
|
||||
aria-label={`Vorlage ${vorlage.name} löschen`}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!vorlagen.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Noch keine Wartungsvorlage"
|
||||
hinweis="Eine Vorlage sagt, was an einem Gerätetyp in welchem Takt zu prüfen ist. Ohne sie entstehen keine Termine — und ohne Termine kein Fristen-Dashboard."
|
||||
aktion={
|
||||
darfBearbeiten ? (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Erste Vorlage anlegen
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={offen} onOpenChange={setOffen}>
|
||||
<DialogContent>
|
||||
<form onSubmit={absenden} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Wartungsvorlage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die Prüfpunkte kommen im nächsten Schritt dazu — erst die Regel, dann die Fragen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Bezeichnung</Label>
|
||||
<Input
|
||||
id="name"
|
||||
required
|
||||
value={formular.name}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, name: ereignis.target.value })
|
||||
}
|
||||
placeholder="Sichtprüfung Pressluftatmer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geraetetyp">Gilt für Gerätetyp</Label>
|
||||
<Select
|
||||
value={formular.geraetetypId || "keiner"}
|
||||
onValueChange={(wert) =>
|
||||
setFormular({ ...formular, geraetetypId: wert === "keiner" ? "" : wert })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="geraetetyp">
|
||||
<SelectValue placeholder="Gerätetyp wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keiner">keiner — nur von Hand zuweisbar</SelectItem>
|
||||
{geraetetypen.data?.eintraege.map((typ) => (
|
||||
<SelectItem key={typ.id} value={typ.id}>
|
||||
{typ.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Ohne Gerätetyp plant der nächtliche Lauf keine Termine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intervall">Intervall in Monaten</Label>
|
||||
<Input
|
||||
id="intervall"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
required
|
||||
value={formular.intervallMonate}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, intervallMonate: Number(ereignis.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorwarn">Vorwarnzeit in Tagen</Label>
|
||||
<Input
|
||||
id="vorwarn"
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={formular.vorwarnzeitTage}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, vorwarnzeitTage: Number(ereignis.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorschrift">Prüfvorschrift</Label>
|
||||
<Input
|
||||
id="vorschrift"
|
||||
value={formular.pruefvorschrift}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, pruefvorschrift: ereignis.target.value })
|
||||
}
|
||||
placeholder="DGUV Vorschrift 49"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dauer">Übliche Dauer in Minuten</Label>
|
||||
<Input
|
||||
id="dauer"
|
||||
type="number"
|
||||
min={1}
|
||||
value={formular.standardDauerMinuten}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, standardDauerMinuten: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="beschreibung">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="beschreibung"
|
||||
rows={2}
|
||||
value={formular.beschreibung}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, beschreibung: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={formular.automatisch}
|
||||
onCheckedChange={(an) => setFormular({ ...formular, automatisch: an === true })}
|
||||
/>
|
||||
Termine nachts automatisch planen
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={formular.standardExtern}
|
||||
onCheckedChange={(an) =>
|
||||
setFormular({ ...formular, standardExtern: an === true })
|
||||
}
|
||||
/>
|
||||
Wird üblicherweise von einer Fachfirma durchgeführt
|
||||
</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 …" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { alsDatum, personName } from "@/lib/utils";
|
||||
import { qualifikationZustand } from "@/lib/stammdaten/fristen";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
|
||||
export const metadata = { title: "Qualifikationen" };
|
||||
|
||||
const ART_TEXT: Record<string, string> = {
|
||||
LEHRGANG: "Lehrgang",
|
||||
BEFAEHIGUNG: "Befähigung",
|
||||
UNTERSUCHUNG: "Untersuchung",
|
||||
FUEHRERSCHEIN: "Führerschein",
|
||||
UNTERWEISUNG: "Unterweisung",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
/**
|
||||
* Qualifikationen mit Ablaufwarnung.
|
||||
*
|
||||
* Oben steht, was abgelaufen ist oder demnächst abläuft — das ist der Grund,
|
||||
* warum jemand diese Seite öffnet. Der Katalog der Typen steht darunter.
|
||||
*
|
||||
* Gewarnt wird mit der Vorwarnzeit des jeweiligen Typs, nicht mit einer
|
||||
* pauschalen Frist: eine G26.3-Untersuchung braucht mehr Vorlauf als eine
|
||||
* jährliche Unterweisung, weil erst ein Termin beim Arzt gefunden werden muss.
|
||||
*/
|
||||
export default async function QualifikationenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!hatRecht(sitzung, "qualifikation.lesen")) redirect("/");
|
||||
|
||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const in180Tagen = new Date(Date.now() + 180 * 864e5);
|
||||
|
||||
const [ablaufend, typen] = await Promise.all([
|
||||
db.qualifikation.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
gueltigBis: { not: null, lte: in180Tagen },
|
||||
person: { aktiv: true, geloeschtAm: null },
|
||||
},
|
||||
include: {
|
||||
typ: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
kuerzel: true,
|
||||
art: true,
|
||||
vorwarnzeitTage: true,
|
||||
einsatzrelevant: true,
|
||||
},
|
||||
},
|
||||
person: { select: { id: true, vorname: true, nachname: true } },
|
||||
},
|
||||
orderBy: { gueltigBis: "asc" },
|
||||
take: 100,
|
||||
}),
|
||||
db.qualifikationstyp.findMany({
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: [{ art: "asc" }, { name: "asc" }],
|
||||
include: { _count: { select: { qualifikationen: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { ablaufend, typen };
|
||||
});
|
||||
|
||||
const dringend = daten.ablaufend.filter((eintrag) => {
|
||||
const zustand = qualifikationZustand(eintrag.gueltigBis, eintrag.typ.vorwarnzeitTage);
|
||||
return zustand === "ueberfaellig" || zustand === "faellig" || zustand === "bald_faellig";
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Qualifikationen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Lehrgänge, Befähigungen und Untersuchungen der Mannschaft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle
|
||||
className={
|
||||
dringend.length > 0
|
||||
? "mt-0.5 size-5 shrink-0 text-[var(--pruefung-faellig)]"
|
||||
: "mt-0.5 size-5 shrink-0 text-[var(--muted-foreground)]"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<CardTitle>Läuft ab</CardTitle>
|
||||
<CardDescription>
|
||||
{dringend.length === 0
|
||||
? "Derzeit läuft nichts innerhalb der jeweiligen Vorwarnzeit ab."
|
||||
: `${dringend.length} ${dringend.length === 1 ? "Qualifikation ist" : "Qualifikationen sind"} innerhalb der Vorwarnzeit oder bereits abgelaufen.`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{dringend.length > 0 && (
|
||||
<CardContent className="px-0 pb-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">Mitglied</TableHead>
|
||||
<TableHead>Qualifikation</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead className="text-right">Erworben</TableHead>
|
||||
<TableHead className="pr-6 text-right">Gültig bis</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dringend.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell className="pl-6 font-medium">
|
||||
<Link
|
||||
href={`/mitglieder/${eintrag.person.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{personName(eintrag.person)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{eintrag.typ.name}
|
||||
{eintrag.typ.einsatzrelevant && (
|
||||
<Badge variant="gesperrt" className="ml-2">
|
||||
einsatzrelevant
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||
{ART_TEXT[eintrag.typ.art] ?? eintrag.typ.art}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDatum(eintrag.erworbenAm)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right">
|
||||
<FristAnzeige
|
||||
datum={eintrag.gueltigBis}
|
||||
vorwarnTage={eintrag.typ.vorwarnzeitTage}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Katalog</CardTitle>
|
||||
<CardDescription>
|
||||
Welche Qualifikationen die Wehr führt, und wie lange sie jeweils gelten.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
{daten.typen.length === 0 ? (
|
||||
<div className="px-6 pb-6">
|
||||
<NichtsGefunden
|
||||
titel="Noch keine Qualifikationstypen angelegt"
|
||||
hinweis="Erst wenn Typen wie „Maschinist“ oder „G26.3“ angelegt sind, lassen sich Qualifikationen zuweisen."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">Bezeichnung</TableHead>
|
||||
<TableHead>Kürzel</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead className="text-right">Gültigkeit</TableHead>
|
||||
<TableHead className="text-right">Vorwarnzeit</TableHead>
|
||||
<TableHead className="pr-6 text-right">Erfasst</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daten.typen.map((typ) => (
|
||||
<TableRow key={typ.id}>
|
||||
<TableCell className="pl-6 font-medium">
|
||||
{typ.name}
|
||||
{typ.einsatzrelevant && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
einsatzrelevant
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="tabellenzahlen">{typ.kuerzel ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||
{ART_TEXT[typ.art] ?? typ.art}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{typ.gueltigkeitMonate
|
||||
? `${typ.gueltigkeitMonate} Monate`
|
||||
: "unbefristet"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{typ.vorwarnzeitTage} Tage
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right tabellenzahlen">
|
||||
{typ._count.qualifikationen}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { BetroffenheitStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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, StatusAbzeichen } from "@/components/stammdaten/anzeige";
|
||||
import { BetroffenheitAbzeichen, RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { BETROFFENHEIT_TEXT } from "@/lib/wartung/texte";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Rückruf bearbeiten: suchen, zuordnen, abarbeiten.
|
||||
*
|
||||
* Der Ablauf entspricht dem, was eine Wehr wirklich tut, wenn ein
|
||||
* Sicherheitshinweis eintrifft: nachsehen, was man davon hat, es aus dem Dienst
|
||||
* nehmen, die Maßnahme durchführen, wieder freigeben.
|
||||
*/
|
||||
export function RueckrufBearbeitung({
|
||||
rueckrufId,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
rueckrufId: string;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const rueckruf = trpc.rueckruf.einzeln.useQuery({ id: rueckrufId });
|
||||
const treffer = trpc.rueckruf.geraeteSuchen.useQuery({ id: rueckrufId });
|
||||
|
||||
const aktualisieren = () => {
|
||||
void werkzeuge.rueckruf.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
};
|
||||
|
||||
const zuordnen = trpc.rueckruf.zuordnen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setMeldung(
|
||||
`${ergebnis.zugeordnet} Geräte zugeordnet, ${ergebnis.gesperrt} davon gesperrt.`,
|
||||
);
|
||||
setAusgewaehlt(new Set());
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const betroffenheitSetzen = trpc.rueckruf.betroffenheitSetzen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
if (ergebnis.geraetFreigegeben) setMeldung("Gerät wieder einsatzbereit.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const abschliessen = trpc.rueckruf.abschliessen.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung("Rückruf abgeschlossen.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
if (rueckruf.isLoading) {
|
||||
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
|
||||
}
|
||||
if (rueckruf.error) return <Fehlermeldung meldung={rueckruf.error.message} />;
|
||||
if (!rueckruf.data) return null;
|
||||
|
||||
const daten = rueckruf.data;
|
||||
const offeneTreffer = (treffer.data?.treffer ?? []).filter(
|
||||
(geraet) => !geraet.bereitsZugeordnet,
|
||||
);
|
||||
|
||||
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">{daten.titel}</h1>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
{daten.hersteller}
|
||||
{daten.referenz && ` · ${daten.referenz}`}
|
||||
{daten.veroeffentlichtAm && ` · ${alsDatum(daten.veroeffentlichtAm)}`}
|
||||
<RueckrufStatusAbzeichen status={daten.status} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && daten.status !== "ERLEDIGT" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => abschliessen.mutate({ id: rueckrufId })}
|
||||
disabled={abschliessen.isPending}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Rückruf abschließen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="rueckrufmeldung" />}
|
||||
|
||||
{(daten.beschreibung || daten.massnahme) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Warnung des Herstellers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{daten.beschreibung && <p className="whitespace-pre-line">{daten.beschreibung}</p>}
|
||||
{daten.massnahme && (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Maßnahme
|
||||
</p>
|
||||
<p className="whitespace-pre-line">{daten.massnahme}</p>
|
||||
</div>
|
||||
)}
|
||||
{daten.typen.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Betroffene Bauart
|
||||
</p>
|
||||
<ul className="list-inside list-disc">
|
||||
{daten.typen.map((typ) => (
|
||||
<li key={typ.id}>
|
||||
{typ.geraetetyp?.name ?? typ.modell ?? "ohne nähere Eingrenzung"}
|
||||
{(typ.seriennummerVon || typ.seriennummerBis) &&
|
||||
` · Seriennummern ${typ.seriennummerVon ?? "…"} bis ${typ.seriennummerBis ?? "…"}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vorschlag aus dem Bestand */}
|
||||
{darfBearbeiten && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Mögliche Treffer im Bestand</CardTitle>
|
||||
<CardDescription>
|
||||
Vorschlag anhand von Bauart, Modell und Seriennummer. Zugeordnet wird nur, was hier
|
||||
bestätigt wird.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{ausgewaehlt.size > 0 && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
zuordnen.mutate({
|
||||
rueckrufId,
|
||||
geraetIds: [...ausgewaehlt],
|
||||
})
|
||||
}
|
||||
disabled={zuordnen.isPending}
|
||||
>
|
||||
{ausgewaehlt.size} Geräte zuordnen
|
||||
{daten.sperrtGeraete && " und sperren"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{treffer.data?.hinweis && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{treffer.data.hinweis}</p>
|
||||
)}
|
||||
|
||||
{offeneTreffer.length === 0 && !treffer.data?.hinweis && (
|
||||
<NichtsGefunden
|
||||
titel="Kein weiteres Gerät passt auf die Beschreibung"
|
||||
hinweis="Entweder ist der Bestand nicht betroffen, oder alle Treffer sind bereits zugeordnet."
|
||||
/>
|
||||
)}
|
||||
|
||||
{offeneTreffer.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" aria-label="Auswahl" />
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Modell</TableHead>
|
||||
<TableHead>Seriennummer</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{offeneTreffer.map((geraet) => (
|
||||
<TableRow key={geraet.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={ausgewaehlt.has(geraet.id)}
|
||||
onCheckedChange={() =>
|
||||
setAusgewaehlt((alt) => {
|
||||
const neu = new Set(alt);
|
||||
if (neu.has(geraet.id)) neu.delete(geraet.id);
|
||||
else neu.add(geraet.id);
|
||||
return neu;
|
||||
})
|
||||
}
|
||||
aria-label={`${geraet.inventarnummer} auswählen`}
|
||||
/>
|
||||
</TableCell>
|
||||
<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.modell ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{geraet.seriennummer ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={geraet.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bereits zugeordnete Geräte */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Betroffene Geräte</CardTitle>
|
||||
<CardDescription>
|
||||
{daten.betroffenheiten.length} zugeordnet ·{" "}
|
||||
{
|
||||
daten.betroffenheiten.filter(
|
||||
(eintrag) =>
|
||||
eintrag.status === "OFFEN" || eintrag.status === "MASSNAHME_LAEUFT",
|
||||
).length
|
||||
}{" "}
|
||||
noch offen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{daten.betroffenheiten.length === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Gerät zugeordnet"
|
||||
hinweis="Solange kein Gerät zugeordnet ist, hat der Rückruf keine Wirkung auf den Bestand."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Seriennummer</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Gerätestatus</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead className="w-56" aria-label="Aktion" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daten.betroffenheiten.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${eintrag.geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{eintrag.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{eintrag.geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{eintrag.geraet.seriennummer ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{eintrag.geraet.fahrzeug?.funkrufname ??
|
||||
eintrag.geraet.standort?.name ??
|
||||
"—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={eintrag.geraet.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<BetroffenheitAbzeichen status={eintrag.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{darfBearbeiten && (
|
||||
<Select
|
||||
value={eintrag.status}
|
||||
onValueChange={(wert) =>
|
||||
betroffenheitSetzen.mutate({
|
||||
id: eintrag.id,
|
||||
status: wert as BetroffenheitStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-52"
|
||||
aria-label={`Stand für ${eintrag.geraet.inventarnummer}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(BETROFFENHEIT_TEXT) as BetroffenheitStatus[]).map(
|
||||
(wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{BETROFFENHEIT_TEXT[wert]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<Search className="size-3.5" aria-hidden />
|
||||
Die Suche vergleicht Seriennummern als Text. Wo Hersteller reine Zahlen vergeben, stimmt
|
||||
das nur bei gleicher Stellenzahl — deshalb ist das Ergebnis ein Vorschlag und keine
|
||||
automatische Sperre.
|
||||
</p>
|
||||
</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 { RueckrufBearbeitung } from "@/app/(app)/rueckrufe/[id]/bearbeitung";
|
||||
|
||||
export const metadata = { title: "Rückruf" };
|
||||
|
||||
/**
|
||||
* Ein Rückruf mit Gerätesuche und Bearbeitungsstand.
|
||||
*
|
||||
* Die Suche schlägt vor, was auf die Beschreibung passt; zugeordnet wird nur,
|
||||
* was der Gerätewart bestätigt. Eine Chargenangabe des Herstellers passt selten
|
||||
* eins zu eins auf den eigenen Bestand.
|
||||
*/
|
||||
export default async function RueckrufSeite({ 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="/rueckrufe">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Rückrufen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RueckrufBearbeitung
|
||||
rueckrufId={id}
|
||||
darfBearbeiten={hatRecht(sitzung, "pruefung.planen")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { RueckrufListe } from "@/app/(app)/rueckrufe/rueckruf-liste";
|
||||
|
||||
export const metadata = { title: "Rückrufe" };
|
||||
|
||||
/**
|
||||
* Herstellerrückrufe.
|
||||
*
|
||||
* Kommt eine Warnung des Herstellers, muss die Wehr wissen, ob sie betroffene
|
||||
* Stücke im Bestand hat. Genau das ist der Zweck dieser Seite.
|
||||
*/
|
||||
export default async function RueckrufeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Rückrufe</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Herstellerwarnungen, betroffene Geräte und die Maßnahmen dazu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RueckrufListe darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 { RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Liste der Rückrufe.
|
||||
*
|
||||
* Beim Anlegen wird gleich die betroffene Bauart mit erfasst — ohne sie kann
|
||||
* die Suche nach betroffenen Geräten nichts finden, und der Rückruf bliebe ein
|
||||
* Zettel ohne Wirkung.
|
||||
*/
|
||||
export function RueckrufListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const [hersteller, setHersteller] = React.useState("");
|
||||
const [titel, setTitel] = React.useState("");
|
||||
const [referenz, setReferenz] = React.useState("");
|
||||
const [beschreibung, setBeschreibung] = React.useState("");
|
||||
const [massnahme, setMassnahme] = React.useState("");
|
||||
const [veroeffentlichtAm, setVeroeffentlichtAm] = React.useState("");
|
||||
const [sperrtGeraete, setSperrtGeraete] = React.useState(true);
|
||||
const [geraetetypId, setGeraetetypId] = React.useState("");
|
||||
const [modell, setModell] = React.useState("");
|
||||
const [seriennummerVon, setSeriennummerVon] = React.useState("");
|
||||
const [seriennummerBis, setSeriennummerBis] = React.useState("");
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const rueckrufe = trpc.rueckruf.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "desc",
|
||||
});
|
||||
|
||||
const geraetetypen = trpc.geraetetyp.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||
{ enabled: offen },
|
||||
);
|
||||
|
||||
const anlegen = trpc.rueckruf.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setOffen(false);
|
||||
setHersteller("");
|
||||
setTitel("");
|
||||
setReferenz("");
|
||||
setBeschreibung("");
|
||||
setMassnahme("");
|
||||
setGeraetetypId("");
|
||||
setModell("");
|
||||
setSeriennummerVon("");
|
||||
setSeriennummerBis("");
|
||||
void werkzeuge.rueckruf.liste.invalidate();
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const eintraege = rueckrufe.data?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{darfBearbeiten && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Rückruf erfassen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
{rueckrufe.error && <Fehlermeldung meldung={rueckrufe.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Titel</TableHead>
|
||||
<TableHead>Hersteller</TableHead>
|
||||
<TableHead>Referenz</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead className="text-right">betroffene Geräte</TableHead>
|
||||
<TableHead className="text-right">veröffentlicht</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rueckrufe.isLoading && <LadeZeilen spalten={6} />}
|
||||
|
||||
{eintraege.map((rueckruf) => (
|
||||
<TableRow key={rueckruf.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/rueckrufe/${rueckruf.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{rueckruf.titel}
|
||||
</Link>
|
||||
{rueckruf.sperrtGeraete && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sperrt betroffene Geräte
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{rueckruf.hersteller}</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{rueckruf.referenz ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RueckrufStatusAbzeichen status={rueckruf.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{rueckruf._count.betroffenheiten}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDatum(rueckruf.veroeffentlichtAm)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!rueckrufe.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Kein Rückruf erfasst"
|
||||
hinweis="Kommt eine Herstellerwarnung, wird sie hier eingetragen. FLORIAN sucht dann die betroffenen Geräte im Bestand."
|
||||
aktion={
|
||||
darfBearbeiten ? (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Rückruf erfassen
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={offen} onOpenChange={setOffen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<form
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
anlegen.mutate({
|
||||
hersteller,
|
||||
titel,
|
||||
referenz: referenz || null,
|
||||
beschreibung: beschreibung || null,
|
||||
massnahme: massnahme || null,
|
||||
veroeffentlichtAm: veroeffentlichtAm ? new Date(veroeffentlichtAm) : null,
|
||||
quelle: null,
|
||||
sperrtGeraete,
|
||||
typen:
|
||||
geraetetypId || modell || seriennummerVon || seriennummerBis
|
||||
? [
|
||||
{
|
||||
geraetetypId: geraetetypId || null,
|
||||
modell: modell || null,
|
||||
seriennummerVon: seriennummerVon || null,
|
||||
seriennummerBis: seriennummerBis || null,
|
||||
herstellungVon: null,
|
||||
herstellungBis: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rückruf erfassen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die betroffene Bauart gleich mit angeben — danach sucht FLORIAN im Bestand.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hersteller">Hersteller</Label>
|
||||
<Input
|
||||
id="hersteller"
|
||||
required
|
||||
value={hersteller}
|
||||
onChange={(ereignis) => setHersteller(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="referenz">Referenz des Herstellers</Label>
|
||||
<Input
|
||||
id="referenz"
|
||||
value={referenz}
|
||||
onChange={(ereignis) => setReferenz(ereignis.target.value)}
|
||||
placeholder="SI-2026-04"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titel">Titel</Label>
|
||||
<Input
|
||||
id="titel"
|
||||
required
|
||||
value={titel}
|
||||
onChange={(ereignis) => setTitel(ereignis.target.value)}
|
||||
placeholder="Sicherheitshinweis Lungenautomat Baureihe X"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="beschreibung">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="beschreibung"
|
||||
rows={3}
|
||||
value={beschreibung}
|
||||
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="massnahme">Maßnahme</Label>
|
||||
<Textarea
|
||||
id="massnahme"
|
||||
rows={2}
|
||||
value={massnahme}
|
||||
onChange={(ereignis) => setMassnahme(ereignis.target.value)}
|
||||
placeholder="Zur Überprüfung an den Hersteller einsenden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="veroeffentlicht">Veröffentlicht am</Label>
|
||||
<Input
|
||||
id="veroeffentlicht"
|
||||
type="date"
|
||||
value={veroeffentlichtAm}
|
||||
onChange={(ereignis) => setVeroeffentlichtAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="typ">Betroffener Gerätetyp</Label>
|
||||
<Select
|
||||
value={geraetetypId || "keiner"}
|
||||
onValueChange={(wert) => setGeraetetypId(wert === "keiner" ? "" : wert)}
|
||||
>
|
||||
<SelectTrigger id="typ">
|
||||
<SelectValue placeholder="Gerätetyp" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keiner">nicht über den Typ eingrenzen</SelectItem>
|
||||
{geraetetypen.data?.eintraege.map((typ) => (
|
||||
<SelectItem key={typ.id} value={typ.id}>
|
||||
{typ.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modell">Modell enthält</Label>
|
||||
<Input
|
||||
id="modell"
|
||||
value={modell}
|
||||
onChange={(ereignis) => setModell(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serievon">Seriennummer von</Label>
|
||||
<Input
|
||||
id="serievon"
|
||||
value={seriennummerVon}
|
||||
onChange={(ereignis) => setSeriennummerVon(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="seriebis">Seriennummer bis</Label>
|
||||
<Input
|
||||
id="seriebis"
|
||||
value={seriennummerBis}
|
||||
onChange={(ereignis) => setSeriennummerBis(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={sperrtGeraete}
|
||||
onCheckedChange={(an) => setSperrtGeraete(an === true)}
|
||||
/>
|
||||
Betroffene Geräte beim Zuordnen sperren
|
||||
</label>
|
||||
|
||||
{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 erfasst …" : "Erfassen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
|
||||
/**
|
||||
* Datei-Upload.
|
||||
*
|
||||
* Eigener Endpunkt statt einer tRPC-Prozedur, aus demselben Grund wie beim
|
||||
* Etikettenbogen: Dateien sind keine JSON-Daten. Über tRPC müssten sie
|
||||
* Base64-kodiert durch die Leitung — ein Drittel mehr Bytes, alles im
|
||||
* Arbeitsspeicher, und die Fortschrittsanzeige des Browsers wäre weg.
|
||||
*
|
||||
* Verwendet für die Fotos im Prüfwizard, die Unterschrift aus dem Zeichenfeld
|
||||
* und das Fremdprotokoll einer Fachfirma.
|
||||
*
|
||||
* Die Datei geht ausschließlich über lib/storage.ts nach MinIO — Projektregel.
|
||||
* Was hier entsteht, ist der Datei-Datensatz mit der Kennung, die der Aufrufer
|
||||
* anschließend an Prüfergebnis, Signatur oder Mangel hängt.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Was hochgeladen werden darf. Alles andere hat in einem Protokoll nichts verloren. */
|
||||
const ERLAUBTE_TYPEN = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"application/pdf",
|
||||
]);
|
||||
|
||||
/** 20 MB. Ein Handyfoto liegt darunter, ein Fremdprotokoll erst recht. */
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const felder = z.object({
|
||||
/** Woran die Datei hängt: "Pruefung", "Mangel", "Signatur". */
|
||||
entitaet: z.string().trim().min(1).max(80),
|
||||
entitaetId: z.string().uuid().optional(),
|
||||
/** Einordnung: "foto", "signatur", "fremdprotokoll". */
|
||||
kategorie: z.string().trim().max(60).default("foto"),
|
||||
});
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "datei.hochladen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung zum Hochladen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const formular = await anfrage.formData().catch(() => null);
|
||||
if (!formular) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Die Anfrage enthält kein Formular mit einer Datei." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const datei = formular.get("datei");
|
||||
if (!(datei instanceof File)) {
|
||||
return NextResponse.json({ fehler: "Es wurde keine Datei übergeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const geprueft = felder.safeParse({
|
||||
entitaet: formular.get("entitaet") ?? "",
|
||||
entitaetId: formular.get("entitaetId") || undefined,
|
||||
kategorie: formular.get("kategorie") || undefined,
|
||||
});
|
||||
|
||||
if (!geprueft.success) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Es fehlt die Angabe, wozu die Datei gehört." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const mimeTyp = datei.type || "application/octet-stream";
|
||||
|
||||
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler: `Dateien vom Typ ${mimeTyp} werden nicht angenommen. Erlaubt sind Bilder und PDF.`,
|
||||
},
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
if (datei.size > MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ fehler: `Die Datei ist zu groß (${Math.round(datei.size / 1024 / 1024)} MB, erlaubt sind 20 MB).` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = Buffer.from(await datei.arrayBuffer());
|
||||
|
||||
try {
|
||||
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { slug: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: organisation?.slug ?? sitzung.organisationId,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId,
|
||||
dateiname: datei.name || "datei",
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, inhalt, mimeTyp, {
|
||||
kategorie: geprueft.data.kategorie,
|
||||
hochgeladenvon: sitzung.personId,
|
||||
});
|
||||
|
||||
const datensatz = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const angelegt = await db.datei.create({
|
||||
data: {
|
||||
organisationId: sitzung.organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname: datei.name || "datei",
|
||||
mimeTyp,
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId ?? null,
|
||||
kategorie: geprueft.data.kategorie,
|
||||
hochgeladenVonId: sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId ?? null,
|
||||
beschreibung: `Datei "${angelegt.dateiname}" hochgeladen (${geprueft.data.kategorie})`,
|
||||
sitzung,
|
||||
nachher: { dateiId: angelegt.id, mimeTyp, groesseBytes: abgelegt.groesseBytes },
|
||||
});
|
||||
|
||||
return angelegt;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: datensatz.id,
|
||||
dateiname: datensatz.dateiname,
|
||||
mimeTyp: datensatz.mimeTyp,
|
||||
groesseBytes: abgelegt.groesseBytes,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[dateien] Upload fehlgeschlagen:", fehler);
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler:
|
||||
"Die Datei ließ sich nicht ablegen. Läuft der MinIO-Container? (docker compose ps)",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { etikettenPdf, ETIKETTEN_JE_SEITE } from "@/lib/stammdaten/etiketten";
|
||||
import { AuditAktion, protokolliere } from "@/lib/audit";
|
||||
|
||||
/**
|
||||
* Etikettenbogen als PDF.
|
||||
*
|
||||
* Eigener Endpunkt statt einer tRPC-Prozedur: Ein PDF ist eine Datei, keine
|
||||
* JSON-Antwort. Über tRPC müsste es Base64-kodiert durch die Leitung, und der
|
||||
* Browser könnte es nicht einfach in einem neuen Tab öffnen.
|
||||
*
|
||||
* Nimmt zwei Formen entgegen:
|
||||
* - JSON { ids: [...], startPosition?: n } aus der Geräteliste
|
||||
* - Formular mit einem Feld "id" von der Detailseite
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const eingabe = z.object({
|
||||
ids: z.array(z.string().uuid()).min(1).max(500),
|
||||
/** Ab welchem Etikett des Bogens gedruckt wird — für angebrochene Bögen. */
|
||||
startPosition: z.number().int().min(1).max(ETIKETTEN_JE_SEITE).default(1),
|
||||
});
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "geraet.lesen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
|
||||
}
|
||||
|
||||
let daten: z.infer<typeof eingabe>;
|
||||
|
||||
const inhaltstyp = anfrage.headers.get("content-type") ?? "";
|
||||
if (inhaltstyp.includes("application/json")) {
|
||||
const geprueft = eingabe.safeParse(await anfrage.json());
|
||||
if (!geprueft.success) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Es wurde kein gültiges Gerät übergeben." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
daten = geprueft.data;
|
||||
} else {
|
||||
const formular = await anfrage.formData();
|
||||
const id = formular.get("id");
|
||||
const start = Number(formular.get("startPosition") ?? 1);
|
||||
const geprueft = eingabe.safeParse({
|
||||
ids: typeof id === "string" ? [id] : [],
|
||||
startPosition: Number.isFinite(start) ? start : 1,
|
||||
});
|
||||
if (!geprueft.success) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Es wurde kein gültiges Gerät übergeben." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
daten = geprueft.data;
|
||||
}
|
||||
|
||||
const { geraete, wehrname } = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const [liste, organisation] = await Promise.all([
|
||||
db.geraet.findMany({
|
||||
where: { id: { in: daten.ids }, geloeschtAm: null },
|
||||
select: {
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
fahrzeug: { select: { funkrufname: true } },
|
||||
standort: { select: { kuerzel: true, name: true } },
|
||||
},
|
||||
orderBy: { inventarnummer: "asc" },
|
||||
}),
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { name: true, kurzname: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
geraete: liste,
|
||||
wehrname: organisation?.kurzname ?? organisation?.name ?? "Feuerwehr",
|
||||
};
|
||||
});
|
||||
|
||||
if (geraete.length === 0) {
|
||||
return NextResponse.json({ fehler: "Keines dieser Geräte gibt es." }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const pdf = await etikettenPdf(
|
||||
geraete.map((geraet) => ({
|
||||
inventarnummer: geraet.inventarnummer,
|
||||
bezeichnung: geraet.bezeichnung,
|
||||
zusatz: geraet.fahrzeug?.funkrufname ?? geraet.standort?.kuerzel ?? geraet.standort?.name,
|
||||
})),
|
||||
wehrname,
|
||||
daten.startPosition,
|
||||
);
|
||||
|
||||
// Der Abruf von Etiketten ist protokollpflichtig: daran lässt sich später
|
||||
// nachvollziehen, wann welche Aufkleber gedruckt wurden.
|
||||
await protokolliere({
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.EXPORTIERT,
|
||||
entitaet: "Geraet",
|
||||
beschreibung: `Etikettenbogen für ${geraete.length} Geräte erzeugt`,
|
||||
sitzung,
|
||||
kontext: { anzahl: geraete.length, startPosition: daten.startPosition },
|
||||
});
|
||||
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="etiketten-${geraete.length}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[etiketten] PDF fehlgeschlagen:", fehler);
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler:
|
||||
"Der Etikettenbogen ließ sich nicht erzeugen. Läuft der Gotenberg-Container? (docker compose ps)",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Ziel der QR-Codes auf den Geräteetiketten: /g/<inventarnummer>
|
||||
*
|
||||
* Die Route leitet nur weiter. Sie ist bewusst kurz, damit der Code auf einem
|
||||
* 38 × 21 mm großen Etikett mit dicken Modulen auskommt — je länger die URL,
|
||||
* desto feiner das Muster und desto unzuverlässiger der Scan im Geräteraum.
|
||||
*
|
||||
* Wichtig: Die Adresse ist keine Berechtigung. Wer nicht angemeldet ist, landet
|
||||
* auf der Anmeldeseite und kommt danach hierher zurück.
|
||||
*/
|
||||
export const metadata = { title: "Gerät suchen" };
|
||||
|
||||
export default async function QrZielSeite({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ nummer: string }>;
|
||||
}) {
|
||||
const { nummer } = await params;
|
||||
const inventarnummer = decodeURIComponent(nummer);
|
||||
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
redirect(`/anmelden?callbackUrl=${encodeURIComponent(`/g/${nummer}`)}`);
|
||||
}
|
||||
|
||||
const geraet = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.geraet.findFirst({
|
||||
where: { inventarnummer, geloeschtAm: null },
|
||||
select: { id: true },
|
||||
}),
|
||||
);
|
||||
|
||||
if (geraet) redirect(`/geraete/${geraet.id}`);
|
||||
|
||||
// Kein Treffer: das kann ein Etikett einer anderen Wehr sein, ein Gerät, das
|
||||
// ausgesondert wurde, oder ein Tippfehler. Alle drei Fälle sehen für den
|
||||
// Scanner gleich aus — deshalb eine Meldung statt einer 404-Seite.
|
||||
return (
|
||||
<div className="mx-auto max-w-md">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<QrCode className="size-6 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<CardTitle>Kein Gerät gefunden</CardTitle>
|
||||
<CardDescription>
|
||||
Zur Inventarnummer{" "}
|
||||
<span className="tabellenzahlen font-medium">{inventarnummer}</span> gibt es in
|
||||
dieser Wehr kein Gerät.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-[var(--muted-foreground)]">
|
||||
<li>Das Etikett gehört zu einer anderen Wehr.</li>
|
||||
<li>Das Gerät wurde ausgesondert und ist nicht mehr in der Liste.</li>
|
||||
<li>Die Inventarnummer wurde beim Anlegen anders geschrieben.</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link href={`/geraete?suche=${encodeURIComponent(inventarnummer)}`}>
|
||||
In der Liste suchen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/geraete/scannen">Nochmal scannen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
ShieldAlert,
|
||||
Siren,
|
||||
TriangleAlert,
|
||||
Truck,
|
||||
Wallet,
|
||||
Users,
|
||||
@@ -64,6 +66,20 @@ const EINTRAEGE: Eintrag[] = [
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{
|
||||
titel: "Mängel",
|
||||
pfad: "/maengel",
|
||||
symbol: TriangleAlert,
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{
|
||||
titel: "Rückrufe",
|
||||
pfad: "/rueckrufe",
|
||||
symbol: ShieldAlert,
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
|
||||
{
|
||||
titel: "Terminal",
|
||||
@@ -97,8 +113,9 @@ const EINTRAEGE: Eintrag[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** In Phase 0 gibt es nur die Übersicht — alles andere ist noch Ausblick. */
|
||||
const AKTUELLE_PHASE = 0;
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 2;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { GeraetStatus } from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
import { fristzustand, tageBis, type Fristzustand } from "@/lib/stammdaten/fristen";
|
||||
|
||||
/**
|
||||
* Anzeigebausteine für Stammdaten.
|
||||
*
|
||||
* Farbe bedeutet überall dasselbe: grün einsatzbereit, gelb läuft ab, rot
|
||||
* gesperrt oder überfällig, grau ausgesondert. Wer die Farbe einmal gelernt
|
||||
* hat, muss den Text nicht mehr lesen — und genau darum geht es bei einer
|
||||
* Liste mit dreihundert Zeilen.
|
||||
*
|
||||
* Farbe allein reicht aber nicht: jede Angabe trägt zusätzlich Klartext.
|
||||
* Rot-Grün-Sehschwäche ist häufig, und im Gerätehaus arbeitet niemand mit
|
||||
* kalibriertem Bildschirm.
|
||||
*/
|
||||
|
||||
const STATUS_TEXT: Record<GeraetStatus, string> = {
|
||||
EINSATZBEREIT: "einsatzbereit",
|
||||
PRUEFUNG_FAELLIG: "Prüfung fällig",
|
||||
GESPERRT: "gesperrt",
|
||||
AUSGESONDERT: "ausgesondert",
|
||||
IN_REPARATUR: "in Reparatur",
|
||||
};
|
||||
|
||||
const STATUS_VARIANTE: Record<
|
||||
GeraetStatus,
|
||||
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "ausgesondert" | "secondary"
|
||||
> = {
|
||||
EINSATZBEREIT: "einsatzbereit",
|
||||
PRUEFUNG_FAELLIG: "pruefungFaellig",
|
||||
GESPERRT: "gesperrt",
|
||||
AUSGESONDERT: "ausgesondert",
|
||||
IN_REPARATUR: "secondary",
|
||||
};
|
||||
|
||||
export function StatusAbzeichen({ status }: { status: GeraetStatus }) {
|
||||
return <Badge variant={STATUS_VARIANTE[status]}>{STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
export const GERAET_STATUS_TEXT = STATUS_TEXT;
|
||||
|
||||
/**
|
||||
* Ein Datum mit Ampel. Zeigt zusätzlich, wie viele Tage noch bleiben —
|
||||
* "in 12 Tagen" sagt mehr als ein Datum, das man erst im Kopf ausrechnen muss.
|
||||
*/
|
||||
export function FristAnzeige({
|
||||
datum,
|
||||
vorwarnTage = 30,
|
||||
ohneAngabe = "—",
|
||||
}: {
|
||||
datum: Date | string | null | undefined;
|
||||
vorwarnTage?: number;
|
||||
ohneAngabe?: string;
|
||||
}) {
|
||||
if (!datum) {
|
||||
return <span className="text-[var(--muted-foreground)]">{ohneAngabe}</span>;
|
||||
}
|
||||
|
||||
const alsDate = datum instanceof Date ? datum : new Date(datum);
|
||||
const zustand = fristzustand(alsDate, vorwarnTage);
|
||||
const tage = tageBis(alsDate);
|
||||
|
||||
const farbe: Record<Fristzustand, string> = {
|
||||
ueberfaellig: "text-[var(--gesperrt)] font-medium",
|
||||
faellig: "text-[var(--gesperrt)] font-medium",
|
||||
bald_faellig: "text-[var(--pruefung-faellig)]",
|
||||
in_ordnung: "",
|
||||
ohne_frist: "text-[var(--muted-foreground)]",
|
||||
};
|
||||
|
||||
const zusatz =
|
||||
tage === null
|
||||
? ""
|
||||
: tage < 0
|
||||
? `seit ${Math.abs(tage)} ${Math.abs(tage) === 1 ? "Tag" : "Tagen"}`
|
||||
: tage === 0
|
||||
? "heute"
|
||||
: `in ${tage} ${tage === 1 ? "Tag" : "Tagen"}`;
|
||||
|
||||
return (
|
||||
<span className={cn("tabellenzahlen whitespace-nowrap", farbe[zustand])}>
|
||||
{alsDatum(alsDate)}
|
||||
{zusatz && (
|
||||
<span className="ml-1.5 text-xs text-[var(--muted-foreground)]">({zusatz})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Leerer Zustand einer Liste — mit einem Hinweis, was als Nächstes zu tun ist. */
|
||||
export function NichtsGefunden({
|
||||
titel,
|
||||
hinweis,
|
||||
aktion,
|
||||
}: {
|
||||
titel: string;
|
||||
hinweis?: string;
|
||||
aktion?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-[var(--border)] px-6 py-16 text-center">
|
||||
<p className="font-medium">{titel}</p>
|
||||
{hinweis && (
|
||||
<p className="max-w-md text-sm text-[var(--muted-foreground)]">{hinweis}</p>
|
||||
)}
|
||||
{aktion}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Platzhalterzeilen, solange geladen wird. */
|
||||
export function LadeZeilen({ zeilen = 8, spalten = 6 }: { zeilen?: number; spalten?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: zeilen }).map((_, zeile) => (
|
||||
<tr key={zeile} className="border-b border-[var(--border)]">
|
||||
{Array.from({ length: spalten }).map((__, spalte) => (
|
||||
<td key={spalte} className="px-3 py-3">
|
||||
<div className="h-4 animate-pulse rounded bg-[var(--muted)]" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlermeldung, die sagt, was schiefging — nicht nur, dass etwas schiefging.
|
||||
*
|
||||
* Die Kennung ist nötig, weil Next.js einen eigenen Routen-Ansager mit der
|
||||
* Rolle "alert" in jede Seite hängt. Ohne sie ist nicht eindeutig, welches der
|
||||
* beiden gemeint ist.
|
||||
*/
|
||||
export function Fehlermeldung({
|
||||
meldung,
|
||||
kennung = "formularfehler",
|
||||
}: {
|
||||
meldung: string;
|
||||
kennung?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid={kennung}
|
||||
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
|
||||
>
|
||||
{meldung}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { GeraetStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Form und Vorgabewerte des Geräteformulars.
|
||||
*
|
||||
* Bewusst ein eigenes Modul ohne "use client": Die Seiten, die das Formular
|
||||
* einbinden, sind Server-Komponenten und bauen die Vorgabewerte zusammen.
|
||||
* Lägen diese Werte im Formularmodul selbst, bekäme der Server beim Import
|
||||
* keine echten Daten, sondern nur eine Client-Referenz — das Spreaden davon
|
||||
* ergibt lauter undefined, und das Formular stürzt beim ersten Rechnen ab.
|
||||
*/
|
||||
|
||||
export interface GeraetFormularWerte {
|
||||
id?: string;
|
||||
inventarnummer: string;
|
||||
bezeichnung: string;
|
||||
geraetetypId: string | null;
|
||||
hersteller: string;
|
||||
modell: string;
|
||||
seriennummer: string;
|
||||
herstellungsdatum: string;
|
||||
indienststellung: string;
|
||||
anschaffungspreis: string;
|
||||
status: GeraetStatus;
|
||||
pruefintervallMonate: string;
|
||||
letztePruefung: string;
|
||||
lebensdauerJahre: string;
|
||||
lagerungsart: "standort" | "fahrzeug";
|
||||
standortId: string | null;
|
||||
fahrzeugId: string | null;
|
||||
geraeteraumId: string | null;
|
||||
lagerort: string;
|
||||
bemerkung: string;
|
||||
}
|
||||
|
||||
export const LEERES_GERAET: GeraetFormularWerte = {
|
||||
inventarnummer: "",
|
||||
bezeichnung: "",
|
||||
geraetetypId: null,
|
||||
hersteller: "",
|
||||
modell: "",
|
||||
seriennummer: "",
|
||||
herstellungsdatum: "",
|
||||
indienststellung: "",
|
||||
anschaffungspreis: "",
|
||||
status: "EINSATZBEREIT",
|
||||
pruefintervallMonate: "",
|
||||
letztePruefung: "",
|
||||
lebensdauerJahre: "",
|
||||
lagerungsart: "standort",
|
||||
standortId: null,
|
||||
fahrzeugId: null,
|
||||
geraeteraumId: null,
|
||||
lagerort: "",
|
||||
bemerkung: "",
|
||||
};
|
||||
|
||||
/** Wandelt ein Datum aus der Datenbank in den Wert eines date-Feldes. */
|
||||
export function alsFeldwert(datum: Date | string | null | undefined): string {
|
||||
if (!datum) return "";
|
||||
const wert = datum instanceof Date ? datum : new Date(datum);
|
||||
return wert.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { GeraetStatus } from "@prisma/client";
|
||||
import { Info, Save, X } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung, GERAET_STATUS_TEXT } from "@/components/stammdaten/anzeige";
|
||||
import { alsDatum, cn } from "@/lib/utils";
|
||||
import {
|
||||
effektiverWert,
|
||||
lebensdauerEnde,
|
||||
naechstePruefung,
|
||||
} from "@/lib/stammdaten/fristen";
|
||||
import type { GeraetFormularWerte } from "@/components/stammdaten/geraet-formular-werte";
|
||||
|
||||
/**
|
||||
* Formular zum Anlegen und Bearbeiten eines Geräts.
|
||||
*
|
||||
* Zwei Dinge sind hier wichtiger als das Formular selbst:
|
||||
*
|
||||
* 1. Die Lagerung ist eine Entweder-oder-Entscheidung. Das Formular führt
|
||||
* sie als Umschalter statt als zwei Auswahlfelder, die man versehentlich
|
||||
* beide füllen kann — die Datenbank würde das ohnehin abweisen, aber eine
|
||||
* Fehlermeldung nach dem Absenden ist schlechter als ein Formular, in dem
|
||||
* der Fehler gar nicht erst möglich ist.
|
||||
*
|
||||
* 2. Prüftermin und Lebensdauerende werden schon beim Tippen ausgerechnet und
|
||||
* angezeigt. Gerechnet wird mit denselben Funktionen wie auf dem Server
|
||||
* (lib/stammdaten/fristen.ts) — beim Speichern rechnet der Server erneut
|
||||
* und behält das letzte Wort.
|
||||
*
|
||||
* Das Sternchen an Pflichtfeldern trägt aria-hidden: Es ist eine optische
|
||||
* Markierung. Ohne das läse eine Sprachausgabe „Bezeichnung Stern" vor. Dass
|
||||
* ein Feld ausgefüllt werden muss, sagt bereits das required-Attribut.
|
||||
*/
|
||||
|
||||
/** Und zurück. Ein leeres Feld bedeutet "nicht gesetzt", nicht "1970". */
|
||||
function alsDatumOderNull(wert: string): Date | null {
|
||||
if (!wert) return null;
|
||||
const [jahr, monat, tag] = wert.split("-").map(Number);
|
||||
if (!jahr || !monat || !tag) return null;
|
||||
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
|
||||
}
|
||||
|
||||
function alsZahlOderNull(wert: string): number | null {
|
||||
const bereinigt = wert.trim().replace(",", ".");
|
||||
if (!bereinigt) return null;
|
||||
const zahl = Number(bereinigt);
|
||||
return Number.isFinite(zahl) ? zahl : null;
|
||||
}
|
||||
|
||||
const KEINE = "__keine__";
|
||||
|
||||
export function GeraetFormular({
|
||||
vorgabe,
|
||||
titel,
|
||||
beschreibung,
|
||||
}: {
|
||||
vorgabe: GeraetFormularWerte;
|
||||
titel: string;
|
||||
beschreibung: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const [werte, setWerte] = React.useState<GeraetFormularWerte>(vorgabe);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const standorte = trpc.standort.auswahl.useQuery();
|
||||
const fahrzeuge = trpc.fahrzeug.auswahl.useQuery();
|
||||
const geraetetypen = trpc.geraetetyp.auswahl.useQuery();
|
||||
const raeume = trpc.geraeteraum.zuFahrzeug.useQuery(
|
||||
{ fahrzeugId: werte.fahrzeugId ?? "" },
|
||||
{ enabled: werte.lagerungsart === "fahrzeug" && Boolean(werte.fahrzeugId) },
|
||||
);
|
||||
|
||||
const bearbeiten = Boolean(werte.id);
|
||||
|
||||
function setzen<K extends keyof GeraetFormularWerte>(feld: K, wert: GeraetFormularWerte[K]) {
|
||||
setWerte((alt) => ({ ...alt, [feld]: wert }));
|
||||
}
|
||||
|
||||
const gewaehlterTyp = geraetetypen.data?.find((typ) => typ.id === werte.geraetetypId);
|
||||
|
||||
/**
|
||||
* Vorschau der Fristen. Rechnet mit denselben Funktionen wie der Server, damit
|
||||
* beim Speichern nichts Überraschendes herauskommt.
|
||||
*/
|
||||
const vorschau = React.useMemo(() => {
|
||||
const intervall = effektiverWert(
|
||||
alsZahlOderNull(werte.pruefintervallMonate),
|
||||
gewaehlterTyp?.pruefintervallMonate ?? null,
|
||||
);
|
||||
const jahre = effektiverWert(
|
||||
alsZahlOderNull(werte.lebensdauerJahre),
|
||||
gewaehlterTyp?.lebensdauerJahre ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
intervall,
|
||||
naechstePruefung: naechstePruefung({
|
||||
letztePruefung: alsDatumOderNull(werte.letztePruefung),
|
||||
indienststellung: alsDatumOderNull(werte.indienststellung),
|
||||
intervallMonate: intervall,
|
||||
}),
|
||||
lebensdauerBis: lebensdauerEnde({
|
||||
herstellungsdatum: alsDatumOderNull(werte.herstellungsdatum),
|
||||
indienststellung: alsDatumOderNull(werte.indienststellung),
|
||||
lebensdauerJahre: jahre,
|
||||
bezug: gewaehlterTyp?.lebensdauerBezug ?? "INDIENSTSTELLUNG",
|
||||
}),
|
||||
jahre,
|
||||
};
|
||||
}, [werte, gewaehlterTyp]);
|
||||
|
||||
const gemeinsam = {
|
||||
onSuccess: (geraet: { id: string }) => {
|
||||
void werkzeuge.geraet.liste.invalidate();
|
||||
void werkzeuge.organisation.kennzahlen.invalidate();
|
||||
router.push(`/geraete/${geraet.id}`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (ausnahme: { message: string }) => setFehler(ausnahme.message),
|
||||
};
|
||||
|
||||
const anlegen = trpc.geraet.anlegen.useMutation(gemeinsam);
|
||||
const aendern = trpc.geraet.aendern.useMutation(gemeinsam);
|
||||
const laeuft = anlegen.isPending || aendern.isPending;
|
||||
|
||||
function absenden(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
const lagerung =
|
||||
werte.lagerungsart === "fahrzeug"
|
||||
? {
|
||||
standortId: null,
|
||||
fahrzeugId: werte.fahrzeugId,
|
||||
geraeteraumId: werte.geraeteraumId,
|
||||
}
|
||||
: { standortId: werte.standortId, fahrzeugId: null, geraeteraumId: null };
|
||||
|
||||
if (werte.lagerungsart === "fahrzeug" && !werte.fahrzeugId) {
|
||||
setFehler("Bitte ein Fahrzeug auswählen — oder auf „Standort“ umschalten.");
|
||||
return;
|
||||
}
|
||||
if (werte.lagerungsart === "standort" && !werte.standortId) {
|
||||
setFehler("Bitte einen Standort auswählen — oder auf „Fahrzeug“ umschalten.");
|
||||
return;
|
||||
}
|
||||
|
||||
const daten = {
|
||||
inventarnummer: werte.inventarnummer.trim(),
|
||||
bezeichnung: werte.bezeichnung.trim(),
|
||||
geraetetypId: werte.geraetetypId,
|
||||
hersteller: werte.hersteller.trim() || null,
|
||||
modell: werte.modell.trim() || null,
|
||||
seriennummer: werte.seriennummer.trim() || null,
|
||||
herstellungsdatum: alsDatumOderNull(werte.herstellungsdatum),
|
||||
indienststellung: alsDatumOderNull(werte.indienststellung),
|
||||
anschaffungspreis: alsZahlOderNull(werte.anschaffungspreis),
|
||||
status: werte.status,
|
||||
pruefintervallMonate: alsZahlOderNull(werte.pruefintervallMonate),
|
||||
letztePruefung: alsDatumOderNull(werte.letztePruefung),
|
||||
lebensdauerJahre: alsZahlOderNull(werte.lebensdauerJahre),
|
||||
lagerort: werte.lagerort.trim() || null,
|
||||
bemerkung: werte.bemerkung.trim() || null,
|
||||
...lagerung,
|
||||
};
|
||||
|
||||
if (werte.id) {
|
||||
aendern.mutate({ id: werte.id, ...daten });
|
||||
} else {
|
||||
anlegen.mutate(daten);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={absenden} className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{titel}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{beschreibung}</p>
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Was ist es?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inventarnummer">
|
||||
Inventarnummer <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="inventarnummer"
|
||||
value={werte.inventarnummer}
|
||||
onChange={(e) => setzen("inventarnummer", e.target.value)}
|
||||
placeholder="INV-0050"
|
||||
required
|
||||
maxLength={60}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Muss innerhalb der Wehr eindeutig sein. Sie steht später auf dem Etikett und
|
||||
im QR-Code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bezeichnung">
|
||||
Bezeichnung <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="bezeichnung"
|
||||
value={werte.bezeichnung}
|
||||
onChange={(e) => setzen("bezeichnung", e.target.value)}
|
||||
placeholder="Pressluftatmer PA 94"
|
||||
required
|
||||
maxLength={160}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="geraetetyp">Gerätetyp</Label>
|
||||
<Select
|
||||
value={werte.geraetetypId ?? KEINE}
|
||||
onValueChange={(wert) => {
|
||||
const typId = wert === KEINE ? null : wert;
|
||||
const typ = geraetetypen.data?.find((eintrag) => eintrag.id === typId);
|
||||
setWerte((alt) => ({
|
||||
...alt,
|
||||
geraetetypId: typId,
|
||||
// Leere Felder aus dem Typ vorbelegen — Ausgefülltes bleibt.
|
||||
bezeichnung: alt.bezeichnung || (typ?.name ?? ""),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="geraetetyp">
|
||||
<SelectValue placeholder="— keiner —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={KEINE}>— keiner —</SelectItem>
|
||||
{geraetetypen.data?.map((typ) => (
|
||||
<SelectItem key={typ.id} value={typ.id}>
|
||||
{typ.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Der Typ gibt Prüfintervall, Prüfvorschrift und Lebensdauer vor. Wer unten
|
||||
etwas einträgt, überschreibt die Vorgabe für dieses eine Gerät.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hersteller">Hersteller</Label>
|
||||
<Input
|
||||
id="hersteller"
|
||||
value={werte.hersteller}
|
||||
onChange={(e) => setzen("hersteller", e.target.value)}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modell">Modell</Label>
|
||||
<Input
|
||||
id="modell"
|
||||
value={werte.modell}
|
||||
onChange={(e) => setzen("modell", e.target.value)}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="seriennummer">Seriennummer</Label>
|
||||
<Input
|
||||
id="seriennummer"
|
||||
value={werte.seriennummer}
|
||||
onChange={(e) => setzen("seriennummer", e.target.value)}
|
||||
maxLength={120}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={werte.status}
|
||||
onValueChange={(wert) => setzen("status", wert as GeraetStatus)}
|
||||
>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Aus einer überfälligen Prüfung folgt „Prüfung fällig“ von selbst. Gesperrt,
|
||||
ausgesondert und in Reparatur bleiben so stehen, wie sie gesetzt wurden.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alter, Prüfung und Lebensdauer</CardTitle>
|
||||
<CardDescription>
|
||||
Leer gelassene Felder übernehmen die Vorgabe des Gerätetyps.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="herstellungsdatum">Herstellungsdatum</Label>
|
||||
<Input
|
||||
id="herstellungsdatum"
|
||||
type="date"
|
||||
value={werte.herstellungsdatum}
|
||||
onChange={(e) => setzen("herstellungsdatum", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="indienststellung">Indienststellung</Label>
|
||||
<Input
|
||||
id="indienststellung"
|
||||
type="date"
|
||||
value={werte.indienststellung}
|
||||
onChange={(e) => setzen("indienststellung", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="anschaffungspreis">Anschaffungspreis (Euro)</Label>
|
||||
<Input
|
||||
id="anschaffungspreis"
|
||||
inputMode="decimal"
|
||||
value={werte.anschaffungspreis}
|
||||
onChange={(e) => setzen("anschaffungspreis", e.target.value)}
|
||||
placeholder="1899,00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pruefintervall">Prüfintervall (Monate)</Label>
|
||||
<Input
|
||||
id="pruefintervall"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={werte.pruefintervallMonate}
|
||||
onChange={(e) => setzen("pruefintervallMonate", e.target.value)}
|
||||
placeholder={
|
||||
gewaehlterTyp?.pruefintervallMonate
|
||||
? String(gewaehlterTyp.pruefintervallMonate)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="letztePruefung">Letzte Prüfung</Label>
|
||||
<Input
|
||||
id="letztePruefung"
|
||||
type="date"
|
||||
value={werte.letztePruefung}
|
||||
onChange={(e) => setzen("letztePruefung", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lebensdauer">Lebensdauer (Jahre)</Label>
|
||||
<Input
|
||||
id="lebensdauer"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={werte.lebensdauerJahre}
|
||||
onChange={(e) => setzen("lebensdauerJahre", e.target.value)}
|
||||
placeholder={
|
||||
gewaehlterTyp?.lebensdauerJahre ? String(gewaehlterTyp.lebensdauerJahre) : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vorschau: was der Server daraus rechnen wird. */}
|
||||
<div className="flex items-start gap-3 rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Daraus ergibt sich</p>
|
||||
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-[var(--muted-foreground)]">Nächste Prüfung:</dt>
|
||||
<dd className="tabellenzahlen">
|
||||
{vorschau.naechstePruefung ? alsDatum(vorschau.naechstePruefung) : "keine"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-[var(--muted-foreground)]">Lebensdauer bis:</dt>
|
||||
<dd className="tabellenzahlen">
|
||||
{vorschau.lebensdauerBis ? alsDatum(vorschau.lebensdauerBis) : "unbegrenzt"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{vorschau.intervall !== null &&
|
||||
!werte.pruefintervallMonate &&
|
||||
gewaehlterTyp?.pruefintervallMonate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Prüfintervall {vorschau.intervall} Monate — aus dem Gerätetyp
|
||||
„{gewaehlterTyp.name}“.
|
||||
</p>
|
||||
)}
|
||||
{!vorschau.naechstePruefung && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Ohne Prüfintervall und ohne Indienststellung gibt es keinen Termin. Das ist
|
||||
der Normalfall für Geräte ohne Prüfpflicht.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wo liegt es?</CardTitle>
|
||||
<CardDescription>
|
||||
Entweder an einem Standort oder auf einem Fahrzeug — beides zugleich geht nicht.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Umschalter statt zwei Feldern, die man beide füllen kann. */}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Art der Lagerung"
|
||||
className="inline-flex rounded-md border border-[var(--border)] p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["standort", "An einem Standort"],
|
||||
["fahrzeug", "Auf einem Fahrzeug"],
|
||||
] as const
|
||||
).map(([wert, text]) => (
|
||||
<button
|
||||
key={wert}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={werte.lagerungsart === wert}
|
||||
onClick={() =>
|
||||
setWerte((alt) => ({
|
||||
...alt,
|
||||
lagerungsart: wert,
|
||||
...(wert === "standort"
|
||||
? { fahrzeugId: null, geraeteraumId: null }
|
||||
: { standortId: null }),
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors",
|
||||
werte.lagerungsart === wert
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)] font-medium"
|
||||
: "hover:bg-[var(--accent)]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{werte.lagerungsart === "standort" ? (
|
||||
<div className="space-y-2 sm:max-w-md">
|
||||
<Label htmlFor="standort">
|
||||
Standort <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={werte.standortId ?? ""}
|
||||
onValueChange={(wert) => setzen("standortId", wert)}
|
||||
>
|
||||
<SelectTrigger id="standort">
|
||||
<SelectValue placeholder="Bitte auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{standorte.data?.map((standort) => (
|
||||
<SelectItem key={standort.id} value={standort.id}>
|
||||
{standort.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fahrzeug">
|
||||
Fahrzeug <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={werte.fahrzeugId ?? ""}
|
||||
onValueChange={(wert) =>
|
||||
// Der Raum gehört zum Fahrzeug — beim Wechsel muss er weg.
|
||||
setWerte((alt) => ({ ...alt, fahrzeugId: wert, geraeteraumId: null }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="fahrzeug">
|
||||
<SelectValue placeholder="Bitte auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fahrzeuge.data?.map((fahrzeug) => (
|
||||
<SelectItem key={fahrzeug.id} value={fahrzeug.id}>
|
||||
{fahrzeug.funkrufname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geraeteraum">Geräteraum</Label>
|
||||
<Select
|
||||
value={werte.geraeteraumId ?? KEINE}
|
||||
onValueChange={(wert) =>
|
||||
setzen("geraeteraumId", wert === KEINE ? null : wert)
|
||||
}
|
||||
disabled={!werte.fahrzeugId}
|
||||
>
|
||||
<SelectTrigger id="geraeteraum">
|
||||
<SelectValue placeholder="— ohne Raumangabe —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={KEINE}>— ohne Raumangabe —</SelectItem>
|
||||
{raeume.data?.map((raum) => (
|
||||
<SelectItem key={raum.id} value={raum.id}>
|
||||
{raum.bezeichnung}
|
||||
{raum.beschreibung ? ` — ${raum.beschreibung}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Darf leer bleiben: manches liegt lose in der Kabine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 sm:max-w-md">
|
||||
<Label htmlFor="lagerort">Genaue Stelle</Label>
|
||||
<Input
|
||||
id="lagerort"
|
||||
value={werte.lagerort}
|
||||
onChange={(e) => setzen("lagerort", e.target.value)}
|
||||
placeholder="Regal 3, Fach B"
|
||||
maxLength={160}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bemerkung">Bemerkung</Label>
|
||||
<Textarea
|
||||
id="bemerkung"
|
||||
value={werte.bemerkung}
|
||||
onChange={(e) => setzen("bemerkung", e.target.value)}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" disabled={laeuft}>
|
||||
<Save aria-hidden />
|
||||
{laeuft ? "Wird gespeichert …" : bearbeiten ? "Änderungen speichern" : "Gerät anlegen"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link href={werte.id ? `/geraete/${werte.id}` : "/geraete"}>
|
||||
<X aria-hidden />
|
||||
Abbrechen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -22,26 +22,35 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
/**
|
||||
* Kartenüberschrift.
|
||||
*
|
||||
* Bewusst ein echtes h3 und kein div: Wer mit einer Sprachausgabe arbeitet,
|
||||
* springt von Überschrift zu Überschrift. Eine Karte, deren Titel technisch
|
||||
* keine Überschrift ist, taucht in dieser Navigation nicht auf — die Seite
|
||||
* wirkt dann leer, obwohl sie voll ist.
|
||||
*
|
||||
* Über "as" lässt sich die Ebene anpassen, wenn die Karte im Dokument tiefer
|
||||
* oder höher steht.
|
||||
*/
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement> & { as?: "h2" | "h3" | "h4" }
|
||||
>(({ className, as: Element = "h3", ...props }, ref) => (
|
||||
<Element
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-[var(--muted-foreground)]", className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
|
||||