Phase 2: Wartungs- und Prüfmanagement #3

Merged
florian.wilde merged 1 commits from feat/phase-2-wartung into main 2026-09-03 10:35:45 +00:00
97 changed files with 13367 additions and 133 deletions
+13
View File
@@ -213,6 +213,19 @@ LLM_BASE_URL=http://ollama:11434/v1
LLM_API_KEY= LLM_API_KEY=
LLM_MODELL=qwen2.5:14b 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) # Datenschutz und Aufbewahrung (DSGVO)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+125 -2
View File
@@ -8,10 +8,110 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht] ## [Unveröffentlicht]
Nächster Schritt ist Phase 2 (Wartungs- und Prüfmanagement). 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 ### 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 - **Formular zum Anlegen und Bearbeiten eines Geräts** (`/geraete/neu` und
`/geraete/<id>/bearbeiten`). Damit ist die letzte offene Lücke aus Phase 1 `/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 geschlossen: Ein Neuzugang lässt sich einzeln erfassen, ohne den Umweg über
@@ -25,7 +125,7 @@ Nächster Schritt ist Phase 2 (Wartungs- und Prüfmanagement).
- 7 weitere Playwright-Tests zum Formular (nun 58 statt 44, zwei davon unter - 7 weitere Playwright-Tests zum Formular (nun 58 statt 44, zwei davon unter
Touch-Emulation begründet übersprungen). Touch-Emulation begründet übersprungen).
### Behoben Dabei behoben:
- **Das Formular stürzte beim ersten Rechnen ab.** Die Vorgabewerte lagen in - **Das Formular stürzte beim ersten Rechnen ab.** Die Vorgabewerte lagen in
einem `"use client"`-Modul und wurden von einer Server-Komponente importiert; einem `"use client"`-Modul und wurden von einer Server-Komponente importiert;
@@ -38,6 +138,29 @@ Nächster Schritt ist Phase 2 (Wartungs- und Prüfmanagement).
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) — - Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) —
Next.js hängt einen eigenen Routen-Ansager mit derselben Rolle in jede Seite. 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 ## [0.2.0] — 2026-09-03
Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit
+128 -1
View File
@@ -25,7 +25,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|---|---| |---|---|
| Phase 0 — Fundament | fertig | | Phase 0 — Fundament | fertig |
| Phase 1 — Stammdaten | fertig | | Phase 1 — Stammdaten | fertig |
| Phase 2 — Wartung und Prüfung | offen | | Phase 2 — Wartung und Prüfung | fertig |
| Phase 3 — Einsätze und Übungen | offen | | Phase 3 — Einsätze und Übungen | offen |
| Phase 4 — Kiosk-PWA | offen | | Phase 4 — Kiosk-PWA | offen |
| Phase 5 — Atemschutz (FwDV 7) | 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 Downloads laufen über presignte Links mit begrenzter Gültigkeit
(`S3_URL_TTL`, Vorgabe 15 Minuten). (`S3_URL_TTL`, Vorgabe 15 Minuten).
---
## Phase 2 — Wartung und Prüfung
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt: Prüffristen
im Blick behalten und im Streitfall nachweisen können, dass geprüft wurde.
### Der Weg durch das Modul
```
Wartungsvorlage was an einem Gerätetyp in welchem Takt geprüft wird
└─ Checklistenpunkt die einzelne Frage: ja/nein, Messwert, Text, Foto
↓ nächtlicher Lauf
Wartungstermin ein fälliger Termin an einem konkreten Gerät
↓ Prüfwizard
Pruefung durchgeführte Prüfung, unterschrieben, festgeschrieben
├─ Pruefergebnis je Checklistenpunkt eine Antwort
├─ Signatur Unterschrift aus dem Zeichenfeld, PNG in MinIO
└─ Mangel was aufgefallen ist und behoben werden muss
```
Ohne Vorlage gibt es keine Termine, ohne Termine kein Fristen-Dashboard. Die
erste Seite, die eine Wehr in Phase 2 ausfüllt, ist deshalb
`/pruefungen/vorlagen`.
### Das Protokoll ist unveränderbar
Ein abgeschlossenes Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse.
Wäre es nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
deshalb in der Datenbank (Trigger `pruefung_unveraenderlich`, Migration
`20260903000300_wartung_rls`) und nicht nur im Anwendungscode — ein vergessener
Zustandscheck in einem Router würde sonst reichen, um sie auszuhebeln.
Nach dem Abschluss sind genau drei Änderungen zulässig, und alle drei ergänzen
das Protokoll, statt es zu verfälschen:
| Änderung | Warum |
|---|---|
| `ABGESCHLOSSEN` → `ERSETZT` | eine Korrekturprüfung ist nachgefolgt |
| `protokollDateiId`, solange leer | das PDF entsteht im Hintergrund und wird nachgetragen |
| `geaendertAm` | setzt Prisma bei jedem Schreibvorgang mit |
Dasselbe gilt für die Bestandteile: an einem abgeschlossenen Protokoll lassen
sich weder Einzelergebnisse ändern noch Unterschriften nachtragen. **Korrekturen
erzeugen eine neue Prüfung** mit Verweis auf die alte; beide bleiben lesbar.
Gelöscht wird ein Protokoll nie — außer im ausdrücklich gekennzeichneten
Aufbewahrungslauf, demselben Schalter wie beim Audit-Log
(`SET LOCAL app.audit_bereinigung = 'an'`).
### Der nächtliche Lauf
Der Worker trägt sich beim Start je Wehr zwei wiederkehrende Aufträge ein:
| Auftrag | Vorgabe | Was er tut |
|---|---|---|
| `fristen.pruefen` | 03:15 Uhr | Termine aus den Vorlagen planen, Frist und Status an jedem Gerät nachziehen |
| `erinnerung.senden` | 03:45 Uhr | Vorwarnungen zu offenen Terminen verschicken |
Beide sind wiederholbar: doppelte Termine verhindert die Eindeutigkeit über
Gerät, Vorlage und Fälligkeitstag, doppelte Mails ein Erinnerungseintrag je
Termin und Stufe. Erinnert wird bei T-30 (bzw. der Vorwarnzeit der Vorlage),
T-7, T-0 und einmal bei Überfälligkeit — als **eine** Sammelnachricht je
Empfänger, nicht als eine Mail je Gerät.
„Überfällig" ist bewusst kein gespeicherter Zustand, sondern wird aus dem
Fälligkeitsdatum und dem heutigen Tag gerechnet. Gespeicherte Zustände laufen
der Uhr hinterher; berechnete nicht.
### Prüfwizard
Zugeschnitten auf das Tablet im Gerätehaus: ein Prüfpunkt je Bildschirm, große
Schaltflächen, Foto direkt aus der Kamera, Unterschrift auf einem Zeichenfeld,
das Finger, Stift und Maus gleichermaßen versteht.
Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens die
letzte Antwort verloren, nicht die ganze Prüfung — beim erneuten Öffnen wird der
Entwurf fortgesetzt.
Die Bewertung rechnet der Server, nicht der Browser
([`src/lib/wartung/bewertung.ts`](src/lib/wartung/bewertung.ts)):
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
- ein beanstandeter **Pflichtpunkt** bedeutet „nicht bestanden" und sperrt das
Gerät,
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er
trägt die Verantwortung.
### Prüfprotokoll als PDF
Erzeugt der Worker über Gotenberg
([`src/lib/wartung/protokoll.ts`](src/lib/wartung/protokoll.ts)) und legt es in
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
Prüfdatum und Seitenzahl.
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder.
Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
Die laufende Protokollnummer (`JJJJ-NNNN`) wird je Wehr und Jahr unter einer
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
verschiedene Nummern.
### Mängel und Rückrufe
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben.
**Melden darf jedes Mitglied** — der Mangel fällt dem auf, der das Gerät
benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das
Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn
kein anderer offener Mangel dasselbe Gerät sperrt.
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich
und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein
**Vorschlag**, keine automatische Sperre: eine Chargenangabe des Herstellers
passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `WARTUNG_NACHTLAUF` | `15 3 * * *` | wann Termine geplant werden (cron, Europe/Berlin) |
| `WARTUNG_ERINNERUNG_LAUF` | `45 3 * * *` | wann Erinnerungen verschickt werden |
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
--- ---
## Befehle ## Befehle
+8 -1
View File
@@ -61,6 +61,9 @@ x-app-env: &app-env
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-} LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODELL: ${LLM_MODELL:-} LLM_MODELL: ${LLM_MODELL:-}
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10} AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650} AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365} PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
@@ -298,7 +301,11 @@ services:
image: florian-app:${IMAGE_TAG:-latest} image: florian-app:${IMAGE_TAG:-latest}
container_name: florian-worker container_name: florian-worker
restart: unless-stopped restart: unless-stopped
command: ["node", "--enable-source-maps", "dist/worker/index.js"] # --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
environment: environment:
<<: *app-env <<: *app-env
ROLLE: worker ROLLE: worker
+228 -9
View File
@@ -1,10 +1,10 @@
# Anleitung # Anleitung
Bedienung von FLORIAN, Stand Phase 1 (Version 0.2.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. Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Prüfungen, Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den > Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den folgenden Phasen
> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md). > dazu. Siehe [ROADMAP.md](ROADMAP.md).
--- ---
@@ -70,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
### Die Seitenleiste ### Die Seitenleiste
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt
rechts sein Phasenkürzel — `P2` heißt „kommt in Phase 2". rechts sein Phasenkürzel — `P3` heißt „kommt in Phase 3".
Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis: sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
@@ -366,7 +366,226 @@ Zum Schluss steht da, wie viele Datensätze angelegt, aktualisiert und
--- ---
## 11. Rollen und was sie dürfen ---
## 11. Prüfungen und Fristen
![Fristen-Dashboard](screenshots/11-fristen-light.png)
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
![Wartungsvorlagen](screenshots/12-vorlagen-light.png)
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
![Checkliste einer Vorlage](screenshots/17-checkliste-light.png)
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
![Prüfprotokoll](screenshots/18-pruefprotokoll-light.png)
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
![Mängel](screenshots/13-maengel-light.png)
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
![Rückrufe](screenshots/14-rueckrufe-light.png)
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
![Lebensdauer](screenshots/15-lebensdauer-light.png)
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 Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr `src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
@@ -395,7 +614,7 @@ Das Terminal selbst ist nur die Tür.
--- ---
## 12. Anmeldung über authentik einrichten ## 19. Anmeldung über authentik einrichten
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
@@ -448,7 +667,7 @@ ganze Wehr aus.
--- ---
## 13. Terminal-Anmeldung im Gerätehaus ## 20. Terminal-Anmeldung im Gerätehaus
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits. Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
@@ -470,7 +689,7 @@ Huber), PIN `471203`.
--- ---
## 14. Was protokolliert wird ## 21. Was protokolliert wird
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
@@ -495,7 +714,7 @@ jeweiligen Detailseite sichtbar.
--- ---
## 15. Wenn etwas nicht geht ## 22. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft | | Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---| |---|---|---|
+25 -2
View File
@@ -32,7 +32,7 @@ Faktoren, die in die Schätzung eingehen:
|---|---|---:|---:|---:| |---|---|---:|---:|---:|
| 0 | Fundament | 12–16 PT | — | **1:00 h** | | 0 | Fundament | 12–16 PT | — | **1:00 h** |
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** | | 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen | | 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen | | 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen | | 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen | | 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
@@ -105,7 +105,7 @@ Datumsfelder, die sich in Chromium nicht automatisiert füllen lassen.
## Phase 2 — Wartung und Prüfung ## Phase 2 — Wartung und Prüfung
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase **Vergleichsaufwand: 18–24 PT** · **Tatsächlich: 2:25 h Sitzungszeit** — die aufwendigste Phase
| Baustein | PT | | Baustein | PT |
|---|---:| |---|---:|
@@ -124,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, Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
warum der Audit-Log schon in Phase 0 append-only ist. warum der Audit-Log schon in Phase 0 append-only ist.
**Rückblick.** Deutlich unter der geschätzten Sitzungszeit, und das aus einem
Grund, der sich benennen lässt: die Fristenrechnung aus Phase 1 war schon da
und musste nur exportiert werden. Wäre sie hier entstanden — samt der
Zeitzonen-Falle, über die Phase 1 gestolpert ist —, sähe die Zahl anders aus.
Teurer als gedacht war das, was zwischen den Bausteinen liegt. Drei Beispiele:
- Die Reihenfolge beim Abschluss einer Prüfung ist nicht beliebig.
Unterschriften und Mängel müssen vor dem Statuswechsel entstehen, denn danach
lässt die Datenbank am Protokoll nichts mehr zu. Derselbe Stolperstein traf
auch den Seed, der die Beispielprüfung zunächst fertig anlegen wollte.
- Die Protokollnummer muss unter einer Sperre vergeben werden. Aufgefallen ist
das erst, weil die E2E-Tests Desktop und Tablet gleichzeitig prüfen ließen —
zwei Prüfer, die im selben Moment abschließen, sind im Gerätehaus kein
konstruierter Fall.
- Das PDF entsteht zwischen zwei Datenbanktransaktionen, nicht in einer. Fotos
aus MinIO holen und Gotenberg rendern lassen dauert länger, als eine offene
Transaktion warten darf.
Billiger als angesetzt war das Unterschriftenfeld: Pointer-Events decken Finger,
Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und
`touch-action: none` braucht es nicht.
## Phase 3 — Einsätze und Übungen ## Phase 3 — Einsätze und Übungen
**Vergleichsaufwand: 12–16 PT** **Vergleichsaufwand: 12–16 PT**
+108 -1
View File
@@ -13,7 +13,114 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 | | Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 | | Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 | | Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
| **Summe** | **3:05 h** | **3** | | | 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
--- ---
+17 -9
View File
@@ -11,7 +11,7 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
``` ```
Phase 0 ████████████████████ fertig 0.1.0 Phase 0 ████████████████████ fertig 0.1.0
Phase 1 ████████████████████ fertig 0.2.0 Phase 1 ████████████████████ fertig 0.2.0
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0 Phase 2 ████████████████████ fertig 0.3.0
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0 Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0 Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0 Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
@@ -59,23 +59,31 @@ Die Wehr trägt ein, was sie hat.
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per **Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
QR-Code am Regal wiederfinden. QR-Code am Regal wiederfinden.
## Phase 2 — Wartung und Prüfung · als Nächstes ## Phase 2 — Wartung und Prüfung · fertig
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner. Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit - Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage - Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage,
- Nächtlicher Job erzeugt Termine, setzt Status, erinnert bei T-30, T-7 und T-0 gebündelt nach Fahrzeug, Standort, Gerätetyp oder Zuständigem
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift - Nächtlicher Lauf erzeugt Termine, zieht den Gerätestatus nach und erinnert bei
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO T-30, T-7, T-0 und einmal bei Überfälligkeit
- Prüfwizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift auf
dem Zeichenfeld
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO — die Sperre setzt
ein Datenbanktrigger durch, nicht der Anwendungscode
- Korrekturen erzeugen ein neues Protokoll mit Verweis auf das alte
- Lebensdauerüberwachung mit Aussonderungsvorschlag - Lebensdauerüberwachung mit Aussonderungsvorschlag
- Rückrufe markieren betroffene Geräte automatisch - Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben und Herstellungszeitraum
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben, mit
Gerätesperre bei kritischen Mängeln
- Externe Wartung: Fachfirma, Kosten und Fremdprotokoll als PDF
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch **Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
die Unfallkasse standhält. die Unfallkasse standhält.
## Phase 3 — Einsätze und Übungen ## Phase 3 — Einsätze und Übungen · als Nächstes
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen, - Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
Einsatzende, eingerückt, einsatzbereit Einsatzende, eingerückt, einsatzbereit
Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "florian", "name": "florian",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": { "engines": {
@@ -10,8 +10,8 @@
"dev": "next dev -p 3100", "dev": "next dev -p 3100",
"build": "next build", "build": "next build",
"start": "next start -p 3100", "start": "next start -p 3100",
"worker": "tsx src/worker/index.ts", "worker": "tsx --conditions=react-server src/worker/index.ts",
"worker:dev": "tsx watch src/worker/index.ts", "worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -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;
$$;
+3
View File
@@ -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"
+577
View File
@@ -167,6 +167,19 @@ model Organisation {
qualifikationstypen Qualifikationstyp[] qualifikationstypen Qualifikationstyp[]
qualifikationen Qualifikation[] 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]) @@index([aktiv])
@@map("organisation") @@map("organisation")
} }
@@ -232,6 +245,14 @@ model Person {
qualifikationen Qualifikation[] @relation("QualifikationInhaber") qualifikationen Qualifikation[] @relation("QualifikationInhaber")
bestaetigteQualifikationen Qualifikation[] @relation("QualifikationBestaetiger") 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 /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein. /// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email]) @@unique([organisationId, email])
@@ -454,6 +475,13 @@ model Datei {
hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull) hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
alsAvatarGenutzt Person[] @relation("PersonAvatar") 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]) @@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId]) @@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie]) @@index([organisationId, kategorie])
@@ -688,6 +716,10 @@ model Geraetetyp {
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraete Geraet[] geraete Geraet[]
// Phase 2
wartungsvorlagen Wartungsvorlage[]
rueckrufTypen RueckrufGeraetetyp[]
@@unique([organisationId, name]) @@unique([organisationId, name])
@@index([organisationId, kategorie]) @@index([organisationId, kategorie])
@@index([organisationId, aktiv]) @@index([organisationId, aktiv])
@@ -746,6 +778,12 @@ model Geraet {
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
geraeteraum Geraeteraum? @relation(fields: [geraeteraumId], 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]) @@unique([organisationId, inventarnummer])
@@index([organisationId, status]) @@index([organisationId, status])
@@index([organisationId, naechstePruefung]) @@index([organisationId, naechstePruefung])
@@ -826,3 +864,542 @@ model Qualifikation {
@@index([organisationId, gueltigBis]) @@index([organisationId, gueltigBis])
@@map("qualifikation") @@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")
}
+427 -1
View File
@@ -5,7 +5,9 @@
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen, * Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen * 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der * Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
* Mannschaft. * Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
* Beispielrückruf.
* *
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang, * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -19,15 +21,22 @@
import "dotenv/config"; import "dotenv/config";
import { import {
AusweisTokenTyp, AusweisTokenTyp,
ChecklistenpunktTyp,
Dienstgrad, Dienstgrad,
FahrzeugArt, FahrzeugArt,
GeraeteKategorie, GeraeteKategorie,
GeraetStatus, GeraetStatus,
LebensdauerBezug, LebensdauerBezug,
LebensdauerKategorie, LebensdauerKategorie,
MangelPrioritaet,
MangelStatus,
PrismaClient, PrismaClient,
PruefErgebnis,
PruefungArt,
PruefungStatus,
QualifikationsArt, QualifikationsArt,
RolleSchluessel, RolleSchluessel,
SignaturRolle,
StandortArt, StandortArt,
} from "@prisma/client"; } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -1092,6 +1101,417 @@ async function qualifikationenAnlegen(organisationId: string, typen: Map<string,
log(`Qualifikationen: ${angelegt}`); log(`Qualifikationen: ${angelegt}`);
} }
// -----------------------------------------------------------------------------
// Phase 2 — Wartungsvorlagen, Termine, ein Beispielprotokoll
//
// Die Vorlagen sind bewusst echt: Intervalle und Vorschriften stammen aus den
// gängigen Regelwerken. Eine Beispielwehr, in der jede Prüfung "Prüfung 1"
// heißt, zeigt nicht, worum es geht.
// -----------------------------------------------------------------------------
interface PunktVorlage {
text: string;
typ: ChecklistenpunktTyp;
pflicht?: boolean;
sollwert?: string;
einheit?: string;
minWert?: number;
maxWert?: number;
hinweis?: string;
}
interface WartungsVorlage {
name: string;
geraetetyp: string;
intervallMonate: number;
vorwarnzeitTage?: number;
pruefvorschrift?: string;
standardDauerMinuten?: number;
punkte: PunktVorlage[];
}
const WARTUNGSVORLAGEN: WartungsVorlage[] = [
{
name: "Pressluftatmer — Jahresprüfung",
geraetetyp: "Pressluftatmer PA 94",
intervallMonate: 12,
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
standardDauerMinuten: 25,
punkte: [
{ text: "Trageeinrichtung auf Beschädigung und Vollständigkeit prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Bänderung auf Risse, Schnitte und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Hochdruckdichtheit: Druckabfall in einer Minute",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 10 bar in einer Minute",
einheit: "bar",
minWert: 0,
maxWert: 10,
},
{
text: "Ansprechdruck der Warneinrichtung",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "50 bis 60 bar",
einheit: "bar",
minWert: 50,
maxWert: 60,
},
{
text: "Lungenautomat: Öffnungsdruck",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "bis 3,5 mbar",
einheit: "mbar",
maxWert: 3.5,
},
{ text: "Auffälligkeiten", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
],
},
{
name: "Atemanschluss — Halbjahresprüfung",
geraetetyp: "Atemschutzmaske FPS 7000",
intervallMonate: 6,
pruefvorschrift: "DGUV Regel 112-190",
standardDauerMinuten: 15,
punkte: [
{ text: "Maskenkörper auf Risse, Verformung und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Sichtscheibe frei von Kratzern im Sichtfeld", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Ausatemventil auf Dichtheit und Sitz prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Dichtprüfung: Druckabfall in einer Minute",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 0,5 mbar",
einheit: "mbar",
minWert: 0,
maxWert: 0.5,
},
{ text: "Foto der Maske nach der Reinigung", typ: ChecklistenpunktTyp.FOTO, pflicht: false },
],
},
{
name: "Feuerwehrleine — Jahresprüfung",
geraetetyp: "Feuerwehrleine 30 m",
intervallMonate: 12,
pruefvorschrift: "DGUV Information 205-014, DIN 14920",
standardDauerMinuten: 10,
punkte: [
{ text: "Leine über die gesamte Länge durch die Hand ziehen, Beschädigungen prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Kennzeichnung und Herstellungsjahr lesbar", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Leinenbeutel unbeschädigt und trocken", typ: ChecklistenpunktTyp.JA_NEIN, pflicht: false },
{ text: "Befund", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
],
},
{
name: "Druckschlauch — Druckprüfung",
geraetetyp: "B-Druckschlauch 20 m",
intervallMonate: 12,
pruefvorschrift: "DIN 14811",
standardDauerMinuten: 8,
punkte: [
{
text: "Prüfdruck halten",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "mindestens 8 bar über 5 Minuten",
einheit: "bar",
minWert: 8,
},
{ text: "Kupplungen fest, Dichtringe vorhanden", typ: ChecklistenpunktTyp.JA_NEIN },
{ text: "Schlauchgewebe ohne Scheuerstellen und Löcher", typ: ChecklistenpunktTyp.JA_NEIN },
],
},
{
name: "Elektrische Betriebsmittel — DGUV-V3-Prüfung",
geraetetyp: "Stromerzeuger 8 kVA",
intervallMonate: 12,
pruefvorschrift: "DGUV Vorschrift 3, DIN VDE 0701-0702",
standardDauerMinuten: 30,
punkte: [
{ text: "Sichtprüfung Gehäuse, Leitungen und Steckvorrichtungen", typ: ChecklistenpunktTyp.JA_NEIN },
{
text: "Schutzleiterwiderstand",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "höchstens 0,3 Ohm",
einheit: "Ohm",
minWert: 0,
maxWert: 0.3,
},
{
text: "Isolationswiderstand",
typ: ChecklistenpunktTyp.MESSWERT,
sollwert: "mindestens 1 Megaohm",
einheit: "MOhm",
minWert: 1,
},
{ text: "Funktionsprüfung unter Last", typ: ChecklistenpunktTyp.JA_NEIN },
],
},
];
async function wartungsvorlagenAnlegen(organisationId: string, typen: Map<string, string>) {
const karte = new Map<string, { id: string; intervallMonate: number; punkte: string[] }>();
for (const vorlage of WARTUNGSVORLAGEN) {
const geraetetypId = typen.get(vorlage.geraetetyp) ?? null;
const angelegt = await prisma.wartungsvorlage.upsert({
where: { organisationId_name: { organisationId, name: vorlage.name } },
update: {},
create: {
organisationId,
name: vorlage.name,
geraetetypId,
intervallMonate: vorlage.intervallMonate,
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 30,
pruefvorschrift: vorlage.pruefvorschrift ?? null,
standardDauerMinuten: vorlage.standardDauerMinuten ?? null,
},
});
const punktIds: string[] = [];
const vorhandene = await prisma.checklistenpunkt.count({ where: { vorlageId: angelegt.id } });
if (vorhandene === 0) {
for (const [index, punkt] of vorlage.punkte.entries()) {
const erzeugt = await prisma.checklistenpunkt.create({
data: {
organisationId,
vorlageId: angelegt.id,
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht ?? true,
sollwert: punkt.sollwert ?? null,
einheit: punkt.einheit ?? null,
minWert: punkt.minWert ?? null,
maxWert: punkt.maxWert ?? null,
hinweis: punkt.hinweis ?? null,
sortierung: (index + 1) * 10,
},
});
punktIds.push(erzeugt.id);
}
} else {
const bestand = await prisma.checklistenpunkt.findMany({
where: { vorlageId: angelegt.id },
orderBy: { sortierung: "asc" },
select: { id: true },
});
punktIds.push(...bestand.map((punkt) => punkt.id));
}
karte.set(vorlage.name, {
id: angelegt.id,
intervallMonate: vorlage.intervallMonate,
punkte: punktIds,
});
}
log(`Wartungsvorlagen: ${karte.size}`);
return karte;
}
/**
* Termine für alle Geräte der jeweiligen Bauart.
*
* Das ist dieselbe Rechnung, die der nächtliche Lauf anstellt — hier nur ohne
* Worker, damit eine frisch aufgesetzte Wehr sofort ein gefülltes
* Fristen-Dashboard hat.
*/
async function wartungstermineAnlegen(
organisationId: string,
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
) {
let angelegt = 0;
for (const vorlage of WARTUNGSVORLAGEN) {
const eintrag = vorlagen.get(vorlage.name);
if (!eintrag) continue;
const geraete = await prisma.geraet.findMany({
where: {
organisationId,
geloeschtAm: null,
geraetetyp: { name: vorlage.geraetetyp },
},
select: { id: true, letztePruefung: true, indienststellung: true },
});
for (const geraet of geraete) {
const basis = geraet.letztePruefung ?? geraet.indienststellung;
if (!basis) continue;
const faelligAm = plusMonate(basis, vorlage.intervallMonate);
const ergebnis = await prisma.wartungstermin.createMany({
data: [
{
organisationId,
geraetId: geraet.id,
vorlageId: eintrag.id,
faelligAm,
automatischErzeugt: true,
},
],
skipDuplicates: true,
});
angelegt += ergebnis.count;
}
}
log(`Wartungstermine: ${angelegt}`);
}
/**
* Ein abgeschlossenes Protokoll samt Mangel.
*
* Ohne ein Beispiel bleibt die Protokollansicht leer, und niemand sieht, wie
* ein fertiger Nachweis aussieht. Das PDF fehlt bewusst: es entsteht im
* Worker, sobald einer läuft.
*/
async function beispielPruefungAnlegen(
organisationId: string,
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
) {
const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung");
if (!vorlage) return;
const vorhanden = await prisma.pruefung.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Beispielprüfung: bereits vorhanden");
return;
}
const geraet = await prisma.geraet.findFirst({
where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null },
select: { id: true, inventarnummer: true },
});
if (!geraet) return;
const pruefer = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } },
},
select: { id: true, vorname: true, nachname: true },
});
const geprueftAm = plusMonate(new Date(), -2);
const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart";
const punkte = await prisma.checklistenpunkt.findMany({
where: { vorlageId: vorlage.id },
orderBy: { sortierung: "asc" },
});
// Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der
// Trigger aus Migration 20260903000300 lässt an einer bereits
// abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und
// das ist auch für den Seed richtig so.
const pruefung = await prisma.pruefung.create({
data: {
organisationId,
geraetId: geraet.id,
vorlageId: vorlage.id,
art: PruefungArt.INTERN,
status: PruefungStatus.ENTWURF,
ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN,
prueferId: pruefer?.id ?? null,
prueferName,
durchgefuehrtAm: geprueftAm,
begonnenUm: geprueftAm,
dauerMinuten: 12,
naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate),
protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`,
abgeschlossenAm: geprueftAm,
bemerkung:
"Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.",
ergebnisse: {
create: punkte.map((punkt, index) => ({
organisationId,
punktId: punkt.id,
jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null,
text:
punkt.typ === ChecklistenpunktTyp.TEXT
? "Leine selbst ohne Befund, Beutel beschädigt."
: null,
inOrdnung: index !== 2,
bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null,
})),
},
signaturen: {
create: [
{
organisationId,
rolle: SignaturRolle.PRUEFER,
personId: pruefer?.id ?? null,
name: prueferName,
unterzeichnetAm: geprueftAm,
},
],
},
},
});
await prisma.pruefung.update({
where: { id: pruefung.id },
data: { status: PruefungStatus.ABGESCHLOSSEN },
});
await prisma.mangel.create({
data: {
organisationId,
geraetId: geraet.id,
pruefungId: pruefung.id,
melderId: pruefer?.id ?? null,
melderName: prueferName,
beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.",
prioritaet: MangelPrioritaet.NIEDRIG,
status: MangelStatus.ZUGEWIESEN,
zugewiesenAnId: pruefer?.id ?? null,
},
});
log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`);
}
/** Ein Rückruf, damit die Ansicht nicht leer ist. */
async function rueckrufAnlegen(organisationId: string) {
const vorhanden = await prisma.rueckruf.count({ where: { organisationId } });
if (vorhanden > 0) return;
const typ = await prisma.geraetetyp.findFirst({
where: { organisationId, name: "Atemschutzmaske FPS 7000" },
select: { id: true },
});
await prisma.rueckruf.create({
data: {
organisationId,
hersteller: "Dräger",
titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000",
referenz: "SI-2026-03",
beschreibung:
"Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. "
+ "Betroffen sind Masken mit Herstellung vor 2020.",
massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.",
veroeffentlichtAm: plusMonate(new Date(), -1),
sperrtGeraete: false,
typen: typ
? {
create: [
{
organisationId,
geraetetypId: typ.id,
herstellungBis: datum(2019, 12, 31),
},
],
}
: undefined,
},
});
log("Rückruf: 1 Beispiel");
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Ablauf // Ablauf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -1113,6 +1533,12 @@ async function main(): Promise<void> {
const qualiTypen = await qualifikationstypenAnlegen(organisation.id); const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
await qualifikationenAnlegen(organisation.id, qualiTypen); await qualifikationenAnlegen(organisation.id, qualiTypen);
// Phase 2 — Wartung und Prüfung
const wartungsvorlagen = await wartungsvorlagenAnlegen(organisation.id, typen);
await wartungstermineAnlegen(organisation.id, wartungsvorlagen);
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
await rueckrufAnlegen(organisation.id);
log("Fertig."); log("Fertig.");
log(""); log("");
log("Anmeldung für den Erststart:"); log("Anmeldung für den Erststart:");
+74 -3
View File
@@ -80,6 +80,44 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Formular für ein neues Gerät", beschreibung: "Formular für ein neues Gerät",
angemeldet: true, 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,
},
]; ];
/** /**
@@ -92,6 +130,12 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
/** Findet den Link auf der Übersichtsseite. */ /** Findet den Link auf der Übersichtsseite. */
von: string; von: string;
linkMuster: RegExp; 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", name: "04-geraet",
@@ -105,6 +149,20 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
von: "/fahrzeuge", von: "/fahrzeuge",
linkMuster: /^\/fahrzeuge\/[0-9a-f-]{36}$/, 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}$/,
},
]; ];
/** /**
@@ -116,6 +174,12 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
*/ */
async function fertigGeladen(seite: Page): Promise<void> { async function fertigGeladen(seite: Page): Promise<void> {
await seite.waitForLoadState("networkidle"); 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 { try {
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, { await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
timeout: 15_000, timeout: 15_000,
@@ -177,11 +241,18 @@ async function main(): Promise<void> {
.locator("a[href]") .locator("a[href]")
.evaluateAll((elemente) => .evaluateAll((elemente) =>
elemente elemente
.map((element) => element.getAttribute("href") ?? "") .map((element) => ({
.filter((href) => href.startsWith("/")), href: element.getAttribute("href") ?? "",
text: (element.textContent ?? "").trim(),
}))
.filter((eintrag) => eintrag.href.startsWith("/")),
); );
const treffer = ziele.find((href) => aufnahme.linkMuster.test(href)); const treffer = ziele.find(
(eintrag) =>
aufnahme.linkMuster.test(eintrag.href) &&
(!aufnahme.textMuster || aufnahme.textMuster.test(eintrag.text)),
)?.href;
if (!treffer) { if (!treffer) {
console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`); console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`);
continue; continue;
+179 -4
View File
@@ -1,7 +1,7 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Pencil, Printer } from "lucide-react"; import { ArrowLeft, ClipboardCheck, Pencil, Printer, TriangleAlert } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth"; import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz"; import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db"; import { fuerOrganisation } from "@/lib/db";
@@ -10,6 +10,7 @@ import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { StatusAbzeichen, FristAnzeige } from "@/components/stammdaten/anzeige"; import { StatusAbzeichen, FristAnzeige } from "@/components/stammdaten/anzeige";
import { ErgebnisAbzeichen, MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige";
export const metadata = { title: "Gerät" }; export const metadata = { title: "Gerät" };
@@ -51,12 +52,56 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
}, },
}); });
return { geraet, verlauf }; // 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(); if (!daten) notFound();
const { geraet, verlauf } = daten; const { geraet, verlauf, pruefungen, maengel, termine } = daten;
const qr = await qrAlsDataUri(geraet.inventarnummer, 320); const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
const angaben: Array<[string, React.ReactNode]> = [ const angaben: Array<[string, React.ReactNode]> = [
@@ -102,8 +147,22 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
</p> </p>
</div> </div>
{hatRecht(sitzung, "geraet.bearbeiten") && ( <div className="flex flex-wrap gap-2">
{hatRecht(sitzung, "pruefung.durchfuehren") && geraet.status !== "AUSGESONDERT" && (
<Button asChild> <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`}> <Link href={`/geraete/${geraet.id}/bearbeiten`}>
<Pencil aria-hidden /> <Pencil aria-hidden />
Bearbeiten Bearbeiten
@@ -111,6 +170,7 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
</Button> </Button>
)} )}
</div> </div>
</div>
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2"> <Card className="lg:col-span-2">
@@ -195,6 +255,121 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
</Card> </Card>
</div> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Verlauf</CardTitle> <CardTitle>Verlauf</CardTitle>
+453
View File
@@ -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>
);
}
+33
View File
@@ -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,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>
);
}
+282
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+38
View File
@@ -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,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>
);
}
+41
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+349
View File
@@ -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>
);
}
+168
View File
@@ -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 },
);
}
}
+17 -1
View File
@@ -13,7 +13,9 @@ import {
Map, Map,
Menu, Menu,
MonitorSmartphone, MonitorSmartphone,
ShieldAlert,
Siren, Siren,
TriangleAlert,
Truck, Truck,
Wallet, Wallet,
Users, Users,
@@ -64,6 +66,20 @@ const EINTRAEGE: Eintrag[] = [
rechte: ["pruefung.lesen"], rechte: ["pruefung.lesen"],
abPhase: 2, 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: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
{ {
titel: "Terminal", titel: "Terminal",
@@ -99,7 +115,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist /** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */ * sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 1; const AKTUELLE_PHASE = 2;
export interface SeitenleisteProps { export interface SeitenleisteProps {
rechte: Recht[]; rechte: Recht[];
+167
View File
@@ -0,0 +1,167 @@
"use client";
import type {
BetroffenheitStatus,
MangelPrioritaet,
MangelStatus,
PruefErgebnis,
PruefungStatus,
RueckrufStatus,
WartungsterminStatus,
} from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import type { Fristfenster } from "@/lib/wartung/fristen";
import {
BETROFFENHEIT_TEXT,
ERGEBNIS_TEXT,
FENSTER_TEXT,
MANGEL_STATUS_TEXT,
PRIORITAET_TEXT,
PRUEFUNG_STATUS_TEXT,
RUECKRUF_STATUS_TEXT,
TERMIN_STATUS_TEXT,
} from "@/lib/wartung/texte";
/**
* Anzeigebausteine für Wartung und Prüfung.
*
* Dieselbe Farblehre wie bei den Stammdaten: grün in Ordnung, gelb es eilt,
* rot es steht still, grau erledigt. Und wie dort trägt jede Farbe zusätzlich
* Klartext — im Gerätehaus steht kein kalibrierter Bildschirm, und
* Rot-Grün-Sehschwäche ist häufig.
*
* Die Beschriftungen selbst stehen in lib/wartung/texte.ts, damit auch
* Server-Komponenten sie verwenden können.
*/
export function ErgebnisAbzeichen({ ergebnis }: { ergebnis: PruefErgebnis | null }) {
if (!ergebnis) return <Badge variant="secondary">offen</Badge>;
const variante =
ergebnis === "BESTANDEN"
? "einsatzbereit"
: ergebnis === "BESTANDEN_MIT_MAENGELN"
? "pruefungFaellig"
: "gesperrt";
return <Badge variant={variante}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
}
export function PruefungStatusAbzeichen({ status }: { status: PruefungStatus }) {
const variante =
status === "ABGESCHLOSSEN" ? "outline" : status === "ENTWURF" ? "pruefungFaellig" : "ausgesondert";
return <Badge variant={variante}>{PRUEFUNG_STATUS_TEXT[status]}</Badge>;
}
export function TerminStatusAbzeichen({ status }: { status: WartungsterminStatus }) {
const variante =
status === "OFFEN" ? "outline" : status === "ERLEDIGT" ? "einsatzbereit" : "ausgesondert";
return <Badge variant={variante}>{TERMIN_STATUS_TEXT[status]}</Badge>;
}
export function FensterAbzeichen({ fenster }: { fenster: Fristfenster }) {
const variante =
fenster === "ueberfaellig"
? "gesperrt"
: fenster === "diese_woche"
? "pruefungFaellig"
: "outline";
return <Badge variant={variante}>{FENSTER_TEXT[fenster]}</Badge>;
}
export function MangelStatusAbzeichen({ status }: { status: MangelStatus }) {
const variante =
status === "BEHOBEN"
? "einsatzbereit"
: status === "VERWORFEN"
? "ausgesondert"
: status === "IN_ARBEIT"
? "pruefungFaellig"
: "outline";
return <Badge variant={variante}>{MANGEL_STATUS_TEXT[status]}</Badge>;
}
export function PrioritaetAbzeichen({ prioritaet }: { prioritaet: MangelPrioritaet }) {
const variante =
prioritaet === "KRITISCH"
? "gesperrt"
: prioritaet === "HOCH"
? "pruefungFaellig"
: prioritaet === "NIEDRIG"
? "ausgesondert"
: "secondary";
return <Badge variant={variante}>{PRIORITAET_TEXT[prioritaet]}</Badge>;
}
export function RueckrufStatusAbzeichen({ status }: { status: RueckrufStatus }) {
const variante =
status === "OFFEN" ? "gesperrt" : status === "IN_BEARBEITUNG" ? "pruefungFaellig" : "einsatzbereit";
return <Badge variant={variante}>{RUECKRUF_STATUS_TEXT[status]}</Badge>;
}
export function BetroffenheitAbzeichen({ status }: { status: BetroffenheitStatus }) {
const variante =
status === "ERLEDIGT"
? "einsatzbereit"
: status === "NICHT_BETROFFEN"
? "ausgesondert"
: status === "MASSNAHME_LAEUFT"
? "pruefungFaellig"
: "gesperrt";
return <Badge variant={variante}>{BETROFFENHEIT_TEXT[status]}</Badge>;
}
/**
* Kennzahl mit Klartext darunter. Die Zahl ist groß, weil sie aus zwei Metern
* Abstand lesbar sein soll — das Tablet liegt beim Prüfen auf der Werkbank.
*/
export function Kennzahl({
wert,
bezeichnung,
hinweis,
dringend,
aktiv,
onClick,
}: {
wert: number;
bezeichnung: string;
hinweis?: string;
dringend?: boolean;
aktiv?: boolean;
onClick?: () => void;
}) {
const inhalt = (
<>
<span
className={`tabellenzahlen text-3xl font-semibold ${
dringend && wert > 0 ? "text-[var(--gesperrt)]" : ""
}`}
>
{wert}
</span>
<span className="text-sm font-medium">{bezeichnung}</span>
{hinweis && <span className="text-xs text-[var(--muted-foreground)]">{hinweis}</span>}
</>
);
const klassen = `flex flex-col gap-0.5 rounded-lg border p-4 text-left transition-colors ${
aktiv
? "border-[var(--primary)] bg-[var(--accent)]"
: "border-[var(--border)] bg-[var(--card)]"
} ${onClick ? "hover:border-[var(--primary)]" : ""}`;
if (!onClick) return <div className={klassen}>{inhalt}</div>;
return (
<button type="button" onClick={onClick} className={klassen} aria-pressed={aktiv}>
{inhalt}
</button>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import * as React from "react";
import { Camera, FileUp, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { dateiHochladen } from "@/lib/hochladen";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
/**
* Foto- und Dateifeld.
*
* Auf dem Tablet öffnet es unmittelbar die Kamera (capture="environment"),
* denn genau dafür ist es da: der Prüfer fotografiert den Riss im Schlauch,
* während er davorsteht. Am Rechner ist es ein gewöhnliches Dateifeld.
*
* Hochgeladen wird sofort — nicht erst beim Speichern des Formulars. Wer eine
* halbe Stunde an einer Prüfung sitzt, soll seine Fotos nicht verlieren, weil
* am Ende etwas schiefgeht.
*/
export interface DateiFeldProps {
entitaet: string;
entitaetId?: string | null;
kategorie?: string;
/** Nur Bilder annehmen und die Kamera anbieten. */
nurBilder?: boolean;
beschriftung?: string;
dateiId?: string | null;
onHochgeladen: (dateiId: string | null, dateiname?: string) => void;
}
export function DateiFeld({
entitaet,
entitaetId,
kategorie = "foto",
nurBilder = true,
beschriftung,
dateiId,
onHochgeladen,
}: DateiFeldProps) {
const eingabeRef = React.useRef<HTMLInputElement | null>(null);
const [laeuft, setLaeuft] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const [name, setName] = React.useState<string | null>(null);
const [vorschau, setVorschau] = React.useState<string | null>(null);
// Die Vorschau ist eine Objekt-URL auf die lokale Datei — sie wird beim
// Austausch wieder freigegeben, sonst hält der Browser sie bis zum Neuladen.
React.useEffect(() => {
return () => {
if (vorschau) URL.revokeObjectURL(vorschau);
};
}, [vorschau]);
async function auswaehlen(ereignis: React.ChangeEvent<HTMLInputElement>) {
const datei = ereignis.target.files?.[0];
if (!datei) return;
setLaeuft(true);
setFehler(null);
try {
const hochgeladen = await dateiHochladen(datei, entitaet, entitaetId, kategorie);
setName(hochgeladen.dateiname);
if (datei.type.startsWith("image/")) {
setVorschau(URL.createObjectURL(datei));
}
onHochgeladen(hochgeladen.id, hochgeladen.dateiname);
} catch (problem) {
setFehler(problem instanceof Error ? problem.message : String(problem));
} finally {
setLaeuft(false);
// Damit dieselbe Datei erneut gewählt werden kann.
if (eingabeRef.current) eingabeRef.current.value = "";
}
}
function entfernen() {
setName(null);
if (vorschau) URL.revokeObjectURL(vorschau);
setVorschau(null);
onHochgeladen(null);
}
return (
<div className="space-y-2">
<input
ref={eingabeRef}
type="file"
accept={nurBilder ? "image/*" : "image/*,application/pdf"}
{...(nurBilder ? { capture: "environment" as const } : {})}
onChange={auswaehlen}
className="hidden"
aria-label={beschriftung ?? "Datei auswählen"}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => eingabeRef.current?.click()}
disabled={laeuft}
>
{nurBilder ? <Camera aria-hidden /> : <FileUp aria-hidden />}
{laeuft
? "wird hochgeladen …"
: dateiId
? "Ersetzen"
: (beschriftung ?? (nurBilder ? "Foto aufnehmen" : "Datei wählen"))}
</Button>
{dateiId && (
<>
<span className="text-sm text-[var(--muted-foreground)]">{name ?? "Datei liegt bei"}</span>
<Button type="button" variant="ghost" size="sm" onClick={entfernen}>
<X aria-hidden />
Entfernen
</Button>
</>
)}
</div>
{vorschau && (
// Bewusst kein next/image: die Quelle ist eine lokale Objekt-URL, die
// der Bildoptimierer weder kennt noch abrufen kann.
// eslint-disable-next-line @next/next/no-img-element
<img
src={vorschau}
alt="Vorschau der ausgewählten Datei"
className="max-h-40 rounded border border-[var(--border)]"
/>
)}
{fehler && <Fehlermeldung meldung={fehler} kennung="dateifehler" />}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { Check, Eraser } from "lucide-react";
import { Button } from "@/components/ui/button";
import { canvasAlsPng, dateiHochladen } from "@/lib/hochladen";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
/**
* Unterschriftenfeld.
*
* Ein Zeichenfeld, das mit Finger, Stift und Maus funktioniert — deshalb
* Pointer-Events und nicht getrennt Maus und Touch: die Browser fassen beides
* darunter zusammen, und ein Tablet mit Stift liefert je nach Gerät mal das
* eine, mal das andere.
*
* Zwei Feinheiten, die im Gerätehaus den Unterschied machen:
*
* * Die Zeichenfläche wird auf die tatsächliche Pixeldichte skaliert. Ohne
* das ist die Unterschrift auf einem Tablet ein verwaschener Klotz.
* * touch-action: none verhindert, dass das Wischen beim Unterschreiben die
* Seite scrollt.
*
* Das Ergebnis ist ein PNG in MinIO; zurück kommt dessen Kennung, die an die
* Signatur der Prüfung gehängt wird.
*/
export interface UnterschriftFeldProps {
/** Wer unterschreibt — steht unter dem Feld und später im Protokoll. */
name: string;
/** Rolle als Klartext, nur zur Beschriftung. */
rolle?: string;
/** Prüfung, an der die Datei hängt. */
pruefungId?: string;
onFertig: (dateiId: string) => void;
/** Bereits übernommen — dann ist das Feld gesperrt. */
uebernommen?: boolean;
}
export function UnterschriftFeld({
name,
rolle,
pruefungId,
onFertig,
uebernommen = false,
}: UnterschriftFeldProps) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const zeichnet = React.useRef(false);
const [leer, setLeer] = React.useState(true);
const [laeuft, setLaeuft] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
// Zeichenfläche an die Anzeigegröße und die Pixeldichte anpassen.
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const anpassen = () => {
const dichte = window.devicePixelRatio || 1;
const breite = canvas.clientWidth;
const hoehe = canvas.clientHeight;
canvas.width = Math.round(breite * dichte);
canvas.height = Math.round(hoehe * dichte);
const stift = canvas.getContext("2d");
if (!stift) return;
stift.scale(dichte, dichte);
stift.lineWidth = 2;
stift.lineCap = "round";
stift.lineJoin = "round";
stift.strokeStyle = "#111111";
// Weißer Grund: das PNG landet später auf einem weißen Blatt Papier.
stift.fillStyle = "#ffffff";
stift.fillRect(0, 0, breite, hoehe);
};
anpassen();
window.addEventListener("resize", anpassen);
return () => window.removeEventListener("resize", anpassen);
}, []);
function position(ereignis: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current!;
const rahmen = canvas.getBoundingClientRect();
return { x: ereignis.clientX - rahmen.left, y: ereignis.clientY - rahmen.top };
}
function beginnen(ereignis: React.PointerEvent<HTMLCanvasElement>) {
if (uebernommen) return;
const stift = canvasRef.current?.getContext("2d");
if (!stift) return;
canvasRef.current?.setPointerCapture(ereignis.pointerId);
zeichnet.current = true;
const { x, y } = position(ereignis);
stift.beginPath();
stift.moveTo(x, y);
setLeer(false);
}
function ziehen(ereignis: React.PointerEvent<HTMLCanvasElement>) {
if (!zeichnet.current || uebernommen) return;
const stift = canvasRef.current?.getContext("2d");
if (!stift) return;
const { x, y } = position(ereignis);
stift.lineTo(x, y);
stift.stroke();
}
function beenden(ereignis: React.PointerEvent<HTMLCanvasElement>) {
if (!zeichnet.current) return;
zeichnet.current = false;
canvasRef.current?.releasePointerCapture(ereignis.pointerId);
}
function leeren() {
const canvas = canvasRef.current;
const stift = canvas?.getContext("2d");
if (!canvas || !stift) return;
stift.fillStyle = "#ffffff";
stift.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
setLeer(true);
}
async function uebernehmen() {
const canvas = canvasRef.current;
if (!canvas || leer) return;
setLaeuft(true);
setFehler(null);
try {
const png = await canvasAlsPng(canvas);
const datei = await dateiHochladen(
png,
"Pruefung",
pruefungId,
"signatur",
`unterschrift-${Date.now()}.png`,
);
onFertig(datei.id);
} catch (problem) {
setFehler(problem instanceof Error ? problem.message : String(problem));
} finally {
setLaeuft(false);
}
}
return (
<div className="space-y-2">
<canvas
ref={canvasRef}
onPointerDown={beginnen}
onPointerMove={ziehen}
onPointerUp={beenden}
onPointerLeave={beenden}
className="h-40 w-full touch-none rounded-md border border-[var(--border)] bg-white"
aria-label={`Unterschriftenfeld für ${name}`}
style={{ cursor: uebernommen ? "not-allowed" : "crosshair" }}
/>
<div className="flex flex-wrap items-center gap-2">
<div className="mr-auto text-sm">
<span className="font-medium">{name}</span>
{rolle && <span className="text-[var(--muted-foreground)]"> · {rolle}</span>}
</div>
<Button type="button" variant="ghost" size="sm" onClick={leeren} disabled={uebernommen || leer}>
<Eraser aria-hidden />
Löschen
</Button>
<Button
type="button"
size="sm"
onClick={uebernehmen}
disabled={uebernommen || leer || laeuft}
>
<Check aria-hidden />
{uebernommen ? "übernommen" : laeuft ? "wird gespeichert …" : "Übernehmen"}
</Button>
</div>
{fehler && <Fehlermeldung meldung={fehler} kennung="unterschriftfehler" />}
</div>
);
}
+8
View File
@@ -106,6 +106,14 @@ const schema = z.object({
LLM_API_KEY: z.string().optional(), LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(), LLM_MODELL: z.string().optional(),
// Wartung und Prüfung (Phase 2)
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
/** Wann Erinnerungen verschickt werden — nach der Planung, nicht davor. */
WARTUNG_ERINNERUNG_LAUF: z.string().default("45 3 * * *"),
/** Wie weit im Voraus Termine angelegt werden. */
WARTUNG_PLANUNGSHORIZONT_TAGE: zahl(180),
// Aufbewahrung // Aufbewahrung
AUFBEWAHRUNG_JAHRE: zahl(10), AUFBEWAHRUNG_JAHRE: zahl(10),
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650), AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
+63
View File
@@ -0,0 +1,63 @@
/**
* Datei-Upload aus dem Browser.
*
* Gegenstück zu src/app/api/dateien/route.ts. Bewusst kein tRPC: Dateien
* gehören als Multipart durch die Leitung, nicht Base64-kodiert durch eine
* JSON-Antwort.
*
* Diese Datei läuft im Browser — hier darf nichts aus lib/storage oder lib/db
* hineingeraten.
*/
export interface HochgeladeneDatei {
id: string;
dateiname: string;
mimeTyp: string;
groesseBytes: number;
}
export class UploadFehler extends Error {}
/**
* Lädt eine Datei hoch und gibt den angelegten Datensatz zurück.
*
* @param entitaet woran die Datei hängt, z. B. "Pruefung"
* @param entitaetId der zugehörige Datensatz, sofern schon vorhanden
* @param kategorie "foto", "signatur" oder "fremdprotokoll"
*/
export async function dateiHochladen(
datei: File | Blob,
entitaet: string,
entitaetId?: string | null,
kategorie = "foto",
dateiname?: string,
): Promise<HochgeladeneDatei> {
const formular = new FormData();
formular.append(
"datei",
datei,
dateiname ?? (datei instanceof File ? datei.name : "datei.png"),
);
formular.append("entitaet", entitaet);
if (entitaetId) formular.append("entitaetId", entitaetId);
formular.append("kategorie", kategorie);
const antwort = await fetch("/api/dateien", { method: "POST", body: formular });
if (!antwort.ok) {
const inhalt = (await antwort.json().catch(() => null)) as { fehler?: string } | null;
throw new UploadFehler(inhalt?.fehler ?? `Der Upload schlug fehl (${antwort.status}).`);
}
return (await antwort.json()) as HochgeladeneDatei;
}
/** Wandelt den Inhalt eines Zeichenfelds in eine PNG-Datei um. */
export async function canvasAlsPng(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((auflösen, ablehnen) => {
canvas.toBlob((blob) => {
if (blob) auflösen(blob);
else ablehnen(new UploadFehler("Die Unterschrift ließ sich nicht in ein Bild umwandeln."));
}, "image/png");
});
}
+8 -4
View File
@@ -23,10 +23,14 @@ import { formatInTimeZone } from "date-fns-tz";
* *
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist * Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
* unabhängig davon, wo der Server steht. * unabhängig davon, wo der Server steht.
*
* Die vier Grundfunktionen sind ab Phase 2 exportiert: die Wartungsplanung
* rechnet mit denselben Kalendertagen, und zwei Umsetzungen derselben
* Datumsarithmetik würden früher oder später um einen Tag auseinanderlaufen.
*/ */
/** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */ /** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */
function utcTag(wert: Date): Date { export function utcTag(wert: Date): Date {
return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate())); return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate()));
} }
@@ -35,7 +39,7 @@ function utcTag(wert: Date): Date {
* geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der * geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der
* 3. März. * 3. März.
*/ */
function monateAddieren(basis: Date, monate: number): Date { export function monateAddieren(basis: Date, monate: number): Date {
const jahr = basis.getUTCFullYear(); const jahr = basis.getUTCFullYear();
const monat = basis.getUTCMonth() + monate; const monat = basis.getUTCMonth() + monate;
const tag = basis.getUTCDate(); const tag = basis.getUTCDate();
@@ -56,7 +60,7 @@ function jahreAddieren(basis: Date, jahre: number): Date {
* nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht * nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht
* abhängen. * abhängen.
*/ */
function heutigerTag(jetzt: Date): Date { export function heutigerTag(jetzt: Date): Date {
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd") const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
.split("-") .split("-")
.map(Number); .map(Number);
@@ -64,7 +68,7 @@ function heutigerTag(jetzt: Date): Date {
} }
/** Kalendertage zwischen zwei UTC-Tagen. */ /** Kalendertage zwischen zwei UTC-Tagen. */
function tagesabstand(von: Date, bis: Date): number { export function tagesabstand(von: Date, bis: Date): number {
return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000); return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000);
} }
+162
View File
@@ -0,0 +1,162 @@
/**
* Bewertung einer Prüfung.
*
* Die Frage, ob ein Gerät bestanden hat, wird hier beantwortet und nirgends
* sonst. Weder der Browser noch die Datenbank entscheiden das mit: der Browser
* nicht, weil man ihm nicht trauen kann, und die Datenbank nicht, weil die
* Regel fachlich ist und sich ändern kann.
*
* Die Regel in einem Satz: ein nicht erfüllter Pflichtpunkt bedeutet "nicht
* bestanden", ein nicht erfüllter Kann-Punkt "bestanden mit Mängeln".
*
* Dahinter steckt die Festlegung, dass Pflichtpunkte genau die sind, an denen
* die Einsatzbereitschaft hängt — deshalb heißen sie so. Wer einen Punkt als
* Pflicht anlegt, sagt damit: fällt der durch, geht das Gerät aus dem Dienst.
*/
export type PunktTyp = "JA_NEIN" | "MESSWERT" | "TEXT" | "FOTO";
export type Ergebnis = "BESTANDEN" | "BESTANDEN_MIT_MAENGELN" | "NICHT_BESTANDEN";
export const ERGEBNIS_BEZEICHNUNG: Record<Ergebnis, string> = {
BESTANDEN: "bestanden",
BESTANDEN_MIT_MAENGELN: "bestanden mit Mängeln",
NICHT_BESTANDEN: "nicht bestanden",
};
/** Ein Checklistenpunkt, so weit die Bewertung ihn braucht. */
export interface Punkt {
id: string;
text: string;
typ: PunktTyp;
pflicht: boolean;
minWert?: number | null;
maxWert?: number | null;
}
/** Die Antwort darauf. */
export interface Antwort {
punktId: string;
jaNein?: boolean | null;
zahlwert?: number | null;
text?: string | null;
/** Kennung der hochgeladenen Datei, wenn ein Foto verlangt war. */
fotoDateiId?: string | null;
/** Vom Prüfer übersteuert. Fehlt die Angabe, rechnet der Server sie aus. */
inOrdnung?: boolean | null;
}
/**
* Liegt ein Messwert im zulässigen Bereich?
*
* Fehlt eine Grenze, schränkt sie nicht ein — "mindestens 270 bar" ist ein
* Bereich mit Untergrenze und ohne Obergrenze. Fehlt der Wert selbst, ist die
* Frage unbeantwortet; das fängt vollstaendig() ab, nicht diese Funktion.
*/
export function messwertInOrdnung(
wert: number | null | undefined,
minWert?: number | null,
maxWert?: number | null,
): boolean {
if (wert === null || wert === undefined || Number.isNaN(wert)) return false;
if (minWert !== null && minWert !== undefined && wert < minWert) return false;
if (maxWert !== null && maxWert !== undefined && wert > maxWert) return false;
return true;
}
/** Ist der Punkt überhaupt beantwortet? */
export function beantwortet(punkt: Punkt, antwort: Antwort | undefined): boolean {
if (!antwort) return false;
switch (punkt.typ) {
case "JA_NEIN":
return antwort.jaNein === true || antwort.jaNein === false;
case "MESSWERT":
return (
antwort.zahlwert !== null &&
antwort.zahlwert !== undefined &&
!Number.isNaN(antwort.zahlwert)
);
case "TEXT":
return Boolean(antwort.text?.trim());
case "FOTO":
return Boolean(antwort.fotoDateiId);
}
}
/**
* Ob ein einzelner Punkt in Ordnung ist.
*
* Die Angabe des Prüfers hat Vorrang: bei einem Messwert innerhalb der Toleranz
* kann trotzdem etwas nicht stimmen, und wer davorsteht, sieht mehr als die
* Zahl. Ohne ausdrückliche Angabe entscheidet die Art des Punktes:
*
* ja_nein die Antwort selbst
* messwert der zulässige Bereich
* text/foto beantwortet heißt in Ordnung — hier gibt es nichts zu rechnen
*/
export function punktInOrdnung(punkt: Punkt, antwort: Antwort | undefined): boolean {
if (!antwort) return false;
if (antwort.inOrdnung === true || antwort.inOrdnung === false) return antwort.inOrdnung;
switch (punkt.typ) {
case "JA_NEIN":
return antwort.jaNein === true;
case "MESSWERT":
return messwertInOrdnung(antwort.zahlwert, punkt.minWert, punkt.maxWert);
case "TEXT":
case "FOTO":
return beantwortet(punkt, antwort);
}
}
/** Pflichtpunkte, die noch offen sind. Solange welche übrig sind, ist die
* Prüfung nicht abschließbar. */
export function offenePflichtpunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
return punkte.filter((punkt) => punkt.pflicht && !beantwortet(punkt, nachId.get(punkt.id)));
}
/** Punkte, die nicht in Ordnung sind — die Vorlage für die Mängelmeldung. */
export function beanstandetePunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
return punkte.filter((punkt) => {
const antwort = nachId.get(punkt.id);
if (!beantwortet(punkt, antwort)) return false;
return !punktInOrdnung(punkt, antwort);
});
}
/**
* Das Gesamtergebnis.
*
* Es ist ein Vorschlag, kein Urteil: der Prüfer darf es übersteuern, denn er
* trägt die Verantwortung. Was er wählt, steht im Protokoll — was hier
* herauskommt, ist die Vorbelegung und der Abgleich dazu.
*/
export function ergebnisVorschlag(punkte: Punkt[], antworten: Antwort[]): Ergebnis {
const beanstandet = beanstandetePunkte(punkte, antworten);
if (beanstandet.length === 0) return "BESTANDEN";
if (beanstandet.some((punkt) => punkt.pflicht)) return "NICHT_BESTANDEN";
return "BESTANDEN_MIT_MAENGELN";
}
/**
* Welchen Status das Gerät nach dieser Prüfung haben soll.
*
* Nicht bestanden heißt gesperrt — ein Gerät, das die Prüfung nicht besteht,
* gehört nicht auf das Fahrzeug. Mit Mängeln bestanden bleibt einsatzbereit;
* der Mangel wird verfolgt, aber er hindert den Einsatz nicht.
*/
export function geraetStatusNachPruefung(
ergebnis: Ergebnis,
): "EINSATZBEREIT" | "GESPERRT" {
return ergebnis === "NICHT_BESTANDEN" ? "GESPERRT" : "EINSATZBEREIT";
}
/** Wie lange die Prüfung gedauert hat, in vollen Minuten. */
export function dauerInMinuten(begonnenUm: Date | null | undefined, beendetUm: Date): number | null {
if (!begonnenUm) return null;
const minuten = Math.round((beendetUm.getTime() - begonnenUm.getTime()) / 60_000);
return minuten >= 0 ? minuten : null;
}
+166
View File
@@ -0,0 +1,166 @@
import {
heutigerTag,
monateAddieren,
tagesabstand,
utcTag,
} from "@/lib/stammdaten/fristen";
/**
* Fristenlogik der Wartung.
*
* Wie in Phase 1: reine Funktionen ohne Datenbankzugriff. Was hier entschieden
* wird — wann ein Termin fällig ist, in welchen Topf er im Dashboard fällt, ob
* heute erinnert wird — muss ohne laufenden Stack durchtestbar sein.
*
* Alle Angaben sind Kalendertage in Europe/Berlin, gerechnet auf UTC-Basis.
* Die Uhrzeit spielt bei Prüffristen keine Rolle.
*/
/** Die Töpfe des Fristen-Dashboards. */
export type Fristfenster =
| "ueberfaellig"
| "diese_woche"
| "dieser_monat"
| "naechste_90_tage"
| "spaeter";
export const FRISTFENSTER_BEZEICHNUNG: Record<Fristfenster, string> = {
ueberfaellig: "überfällig",
diese_woche: "diese Woche",
dieser_monat: "diesen Monat",
naechste_90_tage: "nächste 90 Tage",
spaeter: "später",
};
/** Reihenfolge der Anzeige — dringend zuerst. */
export const FRISTFENSTER_REIHENFOLGE: Fristfenster[] = [
"ueberfaellig",
"diese_woche",
"dieser_monat",
"naechste_90_tage",
"spaeter",
];
/**
* Die Grenzen der Töpfe, als UTC-Kalendertage.
*
* "Diese Woche" heißt bewusst "innerhalb von sieben Tagen" und nicht "bis
* Sonntag": am Freitag interessiert den Gerätewart, was bis nächsten Freitag
* ansteht, nicht was das Kalenderblatt sagt. Dasselbe gilt für den Monat.
*/
export function fensterGrenzen(heute: Date = new Date()) {
const start = heutigerTag(heute);
const plusTage = (tage: number) => new Date(start.getTime() + tage * 86_400_000);
return {
heute: start,
inSiebenTagen: plusTage(7),
inDreissigTagen: plusTage(30),
inNeunzigTagen: plusTage(90),
};
}
/** In welchen Topf ein Fälligkeitsdatum fällt. */
export function terminFenster(faelligAm: Date, heute: Date = new Date()): Fristfenster {
const tage = tagesabstand(heutigerTag(heute), faelligAm);
if (tage < 0) return "ueberfaellig";
if (tage <= 7) return "diese_woche";
if (tage <= 30) return "dieser_monat";
if (tage <= 90) return "naechste_90_tage";
return "spaeter";
}
/**
* Nächste Fälligkeit nach einer Prüfung.
*
* Basis ist das Datum der letzten durchgeführten Prüfung. Gibt es keine, zählt
* die Indienststellung des Geräts — eine Wehr, die ein Gerät vor drei Jahren in
* Dienst gestellt und nie geprüft hat, soll das im Dashboard sehen und nicht
* durch einen fehlenden Termin verdeckt bekommen.
*
* Ohne beides gibt es keinen Termin. Das ist kein Fehler: bei einem Gerät ohne
* jede Datumsangabe wäre jedes errechnete Datum geraten.
*/
export function naechsteFaelligkeit(angaben: {
letztePruefung?: Date | null;
indienststellung?: Date | null;
intervallMonate: number;
}): Date | null {
const { letztePruefung, indienststellung, intervallMonate } = angaben;
if (!intervallMonate || intervallMonate <= 0) return null;
const basis = letztePruefung ?? indienststellung;
if (!basis) return null;
return monateAddieren(utcTag(basis), intervallMonate);
}
/** Vorwarnstufen der Erinnerung, wie sie im Schema stehen. */
export type Erinnerungsstufe = "T_30" | "T_7" | "T_0" | "UEBERFAELLIG";
/**
* Welche Erinnerung heute ansteht — oder keine.
*
* Der nächtliche Lauf ruft das für jeden offenen Termin auf. Dass eine Stufe
* nicht zweimal verschickt wird, entscheidet nicht diese Funktion, sondern der
* Erinnerungs-Eintrag in der Datenbank: je Termin und Stufe gibt es ihn nur
* einmal.
*
* Deshalb steht hier "kleiner oder gleich" und nicht "genau": ein Termin, der
* erst 20 Tage vor Fälligkeit angelegt wird, soll seine erste Erinnerung sofort
* bekommen und nicht durchrutschen, weil der Tag T-30 nie eingetreten ist.
*
* @param vorwarnzeitTage Vorwarnzeit der Vorlage. Sie ersetzt die 30 Tage der
* ersten Stufe; die Stufen T-7 und T-0 bleiben fest.
*/
export function erinnerungsstufe(
faelligAm: Date,
vorwarnzeitTage = 30,
heute: Date = new Date(),
): Erinnerungsstufe | null {
const tage = tagesabstand(heutigerTag(heute), faelligAm);
if (tage < 0) return "UEBERFAELLIG";
if (tage === 0) return "T_0";
if (tage <= 7) return "T_7";
if (tage <= Math.max(vorwarnzeitTage, 7)) return "T_30";
return null;
}
/**
* Bis wann im Voraus geplant wird.
*
* Der nächtliche Lauf legt Termine nicht bis in alle Ewigkeit an, sondern nur
* innerhalb dieses Horizonts. Sonst stünden im Dashboard Termine für 2031, und
* jede Änderung an einer Vorlage müsste hunderte Zukunftstermine nachziehen.
*/
export function innerhalbPlanungshorizont(
faelligAm: Date,
horizontTage: number,
heute: Date = new Date(),
): boolean {
return tagesabstand(heutigerTag(heute), faelligAm) <= horizontTage;
}
/**
* Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042".
*
* Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz,
* sprechbar und ohne Bindestrichwüste.
*/
export function protokollNummer(jahr: number, laufend: number): string {
return `${jahr}-${String(laufend).padStart(4, "0")}`;
}
/** Zerlegt eine Protokollnummer wieder. Gibt null zurück, wenn sie nicht passt. */
export function protokollNummerLesen(
nummer: string | null | undefined,
): { jahr: number; laufend: number } | null {
if (!nummer) return null;
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
if (!treffer) return null;
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
}
+119
View File
@@ -0,0 +1,119 @@
import { heutigerTag } from "@/lib/stammdaten/fristen";
import { innerhalbPlanungshorizont, naechsteFaelligkeit } from "@/lib/wartung/fristen";
/**
* Terminplanung.
*
* Der nächtliche Lauf beantwortet für jedes Gerät und jede Vorlage dieselbe
* Frage: steht ein Termin an, den es noch nicht gibt? Die Antwort steht hier
* als reine Funktion — der Job in src/worker holt die Daten und schreibt das
* Ergebnis, entscheiden tut er nichts.
*
* Das ist Absicht: die Planung ist die Stelle, an der eine Wehr merkt, dass ein
* Gerät seit vier Jahren nicht geprüft wurde. Solche Logik gehört testbar, ohne
* dass eine Datenbank, ein Redis und eine Uhr richtig stehen müssen.
*/
/** Ein Gerät, so weit die Planung es braucht. */
export interface PlanungsGeraet {
id: string;
inventarnummer: string;
status: string;
indienststellung?: Date | null;
/** Datum der letzten abgeschlossenen Prüfung nach dieser Vorlage. */
letztePruefungZurVorlage?: Date | null;
/** Ersatzweise das Prüfdatum am Gerät selbst (Phase 1, aus dem Import). */
letztePruefung?: Date | null;
/** Es gibt bereits einen offenen Termin für diese Vorlage. */
hatOffenenTermin: boolean;
}
export interface Planungsvorlage {
id: string;
intervallMonate: number;
}
export interface GeplanterTermin {
geraetId: string;
vorlageId: string;
faelligAm: Date;
/** Es gab keinen Anhaltspunkt für ein Datum — der Termin ist sofort fällig. */
ohneBasisdatum: boolean;
}
/** Geräte, für die grundsätzlich nicht geplant wird. */
const NICHT_PLANEN = new Set(["AUSGESONDERT"]);
/**
* Welche Termine anzulegen sind.
*
* Vier Fälle, in dieser Reihenfolge:
*
* 1. Ausgesonderte Geräte fallen raus. Ein ausgesondertes Gerät wird nicht
* mehr geprüft, und ein Termin darauf wäre nur Rauschen im Dashboard.
* 2. Gibt es schon einen offenen Termin, kommt kein zweiter dazu. Erst wenn
* der erledigt ist, wird der nächste geplant.
* 3. Fehlt jeder Anhaltspunkt — keine frühere Prüfung, keine Indienststellung
* —, wird der Termin auf heute gesetzt. Ein Gerät ohne jedes Datum ist
* nicht "in Ordnung", sondern ungeklärt, und genau so soll es auffallen.
* 4. Sonst: letzte Prüfung plus Intervall. Liegt das Ergebnis jenseits des
* Planungshorizonts, wird noch nicht geplant.
*/
export function zuPlanendeTermine(
vorlage: Planungsvorlage,
geraete: PlanungsGeraet[],
optionen: { horizontTage: number; heute?: Date },
): GeplanterTermin[] {
const heute = optionen.heute ?? new Date();
const heutigerStichtag = heutigerTag(heute);
const geplant: GeplanterTermin[] = [];
for (const geraet of geraete) {
if (NICHT_PLANEN.has(geraet.status)) continue;
if (geraet.hatOffenenTermin) continue;
const basis = geraet.letztePruefungZurVorlage ?? geraet.letztePruefung ?? null;
const faellig = naechsteFaelligkeit({
letztePruefung: basis,
indienststellung: geraet.indienststellung,
intervallMonate: vorlage.intervallMonate,
});
if (!faellig) {
geplant.push({
geraetId: geraet.id,
vorlageId: vorlage.id,
faelligAm: heutigerStichtag,
ohneBasisdatum: true,
});
continue;
}
if (!innerhalbPlanungshorizont(faellig, optionen.horizontTage, heute)) continue;
geplant.push({
geraetId: geraet.id,
vorlageId: vorlage.id,
faelligAm: faellig,
ohneBasisdatum: false,
});
}
return geplant;
}
/**
* Der Prüftermin, der am Gerät steht.
*
* Ein Gerät kann mehreren Vorlagen unterliegen — ein Pressluftatmer wird
* jährlich sichtgeprüft und alle sechs Jahre grundüberholt. Am Gerät selbst
* steht die nächste Frist, und das ist immer die früheste offene.
*/
export function fruehesteFaelligkeit(termine: Array<{ faelligAm: Date }>): Date | null {
if (termine.length === 0) return null;
return termine.reduce(
(frueheste, termin) => (termin.faelligAm < frueheste ? termin.faelligAm : frueheste),
termine[0]!.faelligAm,
);
}
+308
View File
@@ -0,0 +1,308 @@
import "server-only";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { herunterladen, hochladen, objektSchluessel } from "@/lib/storage";
import {
alsDataUri,
protokollPdf,
type ProtokollDaten,
type ProtokollPunkt,
} from "@/lib/wartung/protokoll";
import type { Ergebnis, PunktTyp } from "@/lib/wartung/bewertung";
/**
* Vom Datensatz zum abgelegten PDF.
*
* Läuft im Hintergrundprozess, nicht in der Anfrage: Gotenberg braucht ein paar
* Sekunden, und die Fotos wollen aus MinIO geholt und eingebettet werden. Wer
* gerade eine Prüfung abgeschlossen hat, soll dabei nicht warten.
*
* Der Ablauf ist bewusst dreigeteilt — lesen, rendern, schreiben. Zwischen
* Lesen und Schreiben liegen Netzaufrufe zu MinIO und Gotenberg; die dürfen
* dauern. Eine offene Datenbanktransaktion dürfte das nicht, sie liefe in ihr
* Zeitlimit und gäbe eine Verbindung nicht frei.
*
* Der Vorgang ist wiederholbar: gibt es das Protokoll schon, passiert nichts.
* So darf der Auftrag zweimal laufen, ohne dass zwei PDFs entstehen.
*/
/** Mehr Fotos passen nicht sinnvoll in ein Protokoll — und nicht in den Speicher. */
const MAX_FOTOS = 24;
/** Ein einzelnes Bild größer als das wird nicht eingebettet. */
const MAX_BILD_BYTES = 8 * 1024 * 1024;
const ROLLE_TEXT: Record<string, string> = {
PRUEFER: "Prüfer",
GERAETEWART: "Gerätewart",
FREMDFIRMA: "Fachfirma",
};
const PRIORITAET_TEXT: Record<string, string> = {
NIEDRIG: "niedrig",
MITTEL: "mittel",
HOCH: "hoch",
KRITISCH: "kritisch",
};
/** Antwort in Klartext für die Protokollspalte "Ist". */
function istwert(
typ: PunktTyp,
ergebnis: {
jaNein: boolean | null;
zahlwert: { toNumber(): number } | null;
text: string | null;
fotoDateiId: string | null;
} | null,
einheit?: string | null,
): string {
if (!ergebnis) return "nicht geprüft";
switch (typ) {
case "JA_NEIN":
return ergebnis.jaNein === true ? "ja" : ergebnis.jaNein === false ? "nein" : "—";
case "MESSWERT": {
if (!ergebnis.zahlwert) return "—";
const zahl = ergebnis.zahlwert.toNumber();
return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: 3 })}${
einheit ? ` ${einheit}` : ""
}`;
}
case "TEXT":
return ergebnis.text ?? "—";
case "FOTO":
return ergebnis.fotoDateiId ? "Foto liegt bei" : "—";
}
}
/**
* Erzeugt das Protokoll-PDF und legt es in MinIO ab.
*
* @returns die Kennung der Datei, oder null wenn es zu dieser Prüfung kein
* Protokoll geben kann (unbekannt oder noch Entwurf)
*/
export async function protokollErzeugen(
organisationId: string,
pruefungId: string,
): Promise<{ dateiId: string; neu: boolean } | null> {
// ---------------------------------------------------------------- lesen
const geladen = await fuerOrganisation(organisationId, async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: pruefungId },
include: {
organisation: { select: { name: true, slug: true, strasse: true, plz: true, ort: true } },
geraet: {
include: {
geraetetyp: { select: { name: true } },
fahrzeug: { select: { funkrufname: true } },
standort: { select: { name: true } },
geraeteraum: { select: { bezeichnung: true } },
},
},
vorlage: {
select: {
name: true,
pruefvorschrift: true,
intervallMonate: true,
punkte: { orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }] },
},
},
pruefer: { select: { mitgliedsnummer: true } },
ergebnisse: true,
signaturen: { orderBy: { unterzeichnetAm: "asc" } },
maengel: { where: { geloeschtAm: null } },
ersetzt: { select: { protokollNummer: true } },
},
});
if (!pruefung) return null;
if (pruefung.status === "ENTWURF") return null;
const bildIds = [
...pruefung.ergebnisse.map((ergebnis) => ergebnis.fotoDateiId),
...pruefung.signaturen.map((signatur) => signatur.bildDateiId),
].filter((id): id is string => Boolean(id));
const dateien = bildIds.length
? await db.datei.findMany({
where: { id: { in: bildIds } },
select: { id: true, objektSchluessel: true, mimeTyp: true, groesseBytes: true },
})
: [];
return { pruefung, dateien };
});
if (!geladen) return null;
const { pruefung, dateien } = geladen;
if (pruefung.protokollDateiId) {
return { dateiId: pruefung.protokollDateiId, neu: false };
}
// --------------------------------------------------------------- rendern
const bilder = new Map<string, string>();
let eingebettet = 0;
for (const datei of dateien) {
if (eingebettet >= MAX_FOTOS) break;
if (Number(datei.groesseBytes) > MAX_BILD_BYTES) continue;
try {
const inhalt = await herunterladen(datei.objektSchluessel);
bilder.set(datei.id, alsDataUri(inhalt, datei.mimeTyp));
eingebettet += 1;
} catch (fehler) {
// Ein fehlendes Bild darf das Protokoll nicht verhindern — der Nachweis
// steckt in den Werten, nicht im Foto.
console.error(`[protokoll] Bild ${datei.id} nicht ladbar:`, fehler);
}
}
const ergebnisNachPunkt = new Map(
pruefung.ergebnisse.map((ergebnis) => [ergebnis.punktId, ergebnis]),
);
const punkte: ProtokollPunkt[] = (pruefung.vorlage?.punkte ?? []).map((punkt) => {
const ergebnis = ergebnisNachPunkt.get(punkt.id) ?? null;
return {
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht,
sollwert: punkt.sollwert,
einheit: punkt.einheit,
istwert: istwert(punkt.typ, ergebnis, punkt.einheit),
inOrdnung: ergebnis?.inOrdnung ?? false,
bemerkung: ergebnis?.bemerkung ?? null,
bild: ergebnis?.fotoDateiId ? (bilder.get(ergebnis.fotoDateiId) ?? null) : null,
};
});
const lagerung = pruefung.geraet.fahrzeug
? [pruefung.geraet.fahrzeug.funkrufname, pruefung.geraet.geraeteraum?.bezeichnung]
.filter(Boolean)
.join(" · ")
: (pruefung.geraet.standort?.name ?? null);
const daten: ProtokollDaten = {
organisation: {
name: pruefung.organisation.name,
strasse: pruefung.organisation.strasse,
plz: pruefung.organisation.plz,
ort: pruefung.organisation.ort,
},
geraet: {
inventarnummer: pruefung.geraet.inventarnummer,
bezeichnung: pruefung.geraet.bezeichnung,
hersteller: pruefung.geraet.hersteller,
modell: pruefung.geraet.modell,
seriennummer: pruefung.geraet.seriennummer,
typName: pruefung.geraet.geraetetyp?.name ?? null,
lagerung,
baujahr: pruefung.geraet.herstellungsdatum,
indienststellung: pruefung.geraet.indienststellung,
},
pruefung: {
protokollNummer: pruefung.protokollNummer,
durchgefuehrtAm: pruefung.durchgefuehrtAm,
art: pruefung.art,
ergebnis: (pruefung.ergebnis ?? "BESTANDEN") as Ergebnis,
dauerMinuten: pruefung.dauerMinuten,
prueferName: pruefung.prueferName,
prueferKennung: pruefung.pruefer?.mitgliedsnummer ?? null,
externeFirma: pruefung.externeFirma,
kosten: pruefung.kosten ? pruefung.kosten.toNumber() : null,
naechstePruefungAm: pruefung.naechstePruefungAm,
bemerkung: pruefung.bemerkung,
ersetztProtokollNummer: pruefung.ersetzt?.protokollNummer ?? null,
korrekturGrund: pruefung.korrekturGrund,
},
vorlage: pruefung.vorlage
? {
name: pruefung.vorlage.name,
pruefvorschrift: pruefung.vorlage.pruefvorschrift,
intervallMonate: pruefung.vorlage.intervallMonate,
}
: null,
punkte,
maengel: pruefung.maengel.map((mangel) => ({
beschreibung: mangel.beschreibung,
prioritaet: PRIORITAET_TEXT[mangel.prioritaet] ?? mangel.prioritaet,
})),
signaturen: pruefung.signaturen.map((signatur) => ({
name: signatur.name,
rolle: ROLLE_TEXT[signatur.rolle] ?? signatur.rolle,
unterzeichnetAm: signatur.unterzeichnetAm,
bild: signatur.bildDateiId ? (bilder.get(signatur.bildDateiId) ?? null) : null,
})),
erzeugtAm: new Date(),
};
const pdf = await protokollPdf(daten);
const dateiname = `Pruefprotokoll-${pruefung.protokollNummer ?? pruefung.id}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: pruefung.organisation.slug,
entitaet: "Pruefung",
entitaetId: pruefung.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
pruefung: pruefung.id,
protokoll: pruefung.protokollNummer ?? "",
});
// -------------------------------------------------------------- schreiben
return fuerOrganisation(organisationId, async (db) => {
// Zwischenzeitlich könnte ein zweiter Lauf schneller gewesen sein.
const stand = await db.pruefung.findUnique({
where: { id: pruefung.id },
select: { protokollDateiId: true },
});
if (stand?.protokollDateiId) {
return { dateiId: stand.protokollDateiId, neu: false };
}
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: abgelegt.objektSchluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: BigInt(abgelegt.groesseBytes),
pruefsumme: abgelegt.pruefsumme,
entitaet: "Pruefung",
entitaetId: pruefung.id,
kategorie: "pruefprotokoll",
hochgeladenVonId: pruefung.prueferId,
},
});
// Erlaubt trotz Änderungssperre: das Protokollfeld war leer (siehe Trigger
// pruefung_unveraenderlich in Migration 20260903000300).
await db.pruefung.update({
where: { id: pruefung.id },
data: { protokollDateiId: datei.id },
});
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfprotokoll ${pruefung.protokollNummer ?? ""} erzeugt (${Math.round(
abgelegt.groesseBytes / 1024,
)} kB)`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
});
return { dateiId: datei.id, neu: true };
});
}
+479
View File
@@ -0,0 +1,479 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
import { ERGEBNIS_BEZEICHNUNG, type Ergebnis, type PunktTyp } from "@/lib/wartung/bewertung";
/**
* Das Prüfprotokoll als PDF.
*
* Es ist der Nachweis, den die Wehr bei einer Prüfung durch die Unfallkasse
* vorlegt. Danach richtet sich der Aufbau:
*
* Kopf wer geprüft hat, welche Wehr, welches Gerät, welche Vorschrift
* Mitte jeder Checklistenpunkt mit Soll, Ist und Befund
* Fotos eingebettet, nicht verlinkt — ein Link ins Nichts ist kein Beleg
* Mängel was aufgefallen ist
* Fuß Unterschriften, Prüfvorschrift, Prüfdatum, Seitenzahl
*
* Alles ist eingebettet: keine externen Schriften, keine nachgeladenen Bilder.
* Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute, auch wenn
* es dann keinen Server mehr gibt, der irgendetwas nachliefert.
*/
/** Bild als data-URI, damit es fest im PDF steckt. */
export function alsDataUri(inhalt: Buffer, mimeTyp: string): string {
return `data:${mimeTyp};base64,${inhalt.toString("base64")}`;
}
export interface ProtokollPunkt {
text: string;
typ: PunktTyp;
pflicht: boolean;
sollwert?: string | null;
einheit?: string | null;
/** Die Antwort, schon für die Anzeige aufbereitet. */
istwert: string;
inOrdnung: boolean;
bemerkung?: string | null;
/** Foto zum Punkt, bereits als data-URI. */
bild?: string | null;
}
export interface ProtokollSignatur {
name: string;
rolle: string;
unterzeichnetAm: Date;
/** PNG des Zeichenfelds als data-URI. */
bild?: string | null;
}
export interface ProtokollDaten {
organisation: {
name: string;
strasse?: string | null;
plz?: string | null;
ort?: string | null;
};
geraet: {
inventarnummer: string;
bezeichnung: string;
hersteller?: string | null;
modell?: string | null;
seriennummer?: string | null;
typName?: string | null;
lagerung?: string | null;
baujahr?: Date | null;
indienststellung?: Date | null;
};
pruefung: {
protokollNummer?: string | null;
durchgefuehrtAm: Date;
art: "INTERN" | "EXTERN";
ergebnis: Ergebnis;
dauerMinuten?: number | null;
prueferName?: string | null;
/** Mitgliedsnummer oder Kennung des Prüfers — die "Prüfer-ID" im Kopf. */
prueferKennung?: string | null;
externeFirma?: string | null;
kosten?: number | null;
naechstePruefungAm?: Date | null;
bemerkung?: string | null;
/** Bei einer Korrektur: Nummer des ersetzten Protokolls. */
ersetztProtokollNummer?: string | null;
korrekturGrund?: string | null;
};
vorlage?: {
name: string;
pruefvorschrift?: string | null;
intervallMonate: number;
} | null;
punkte: ProtokollPunkt[];
maengel: Array<{ beschreibung: string; prioritaet: string }>;
signaturen: ProtokollSignatur[];
erzeugtAm: Date;
}
const ERGEBNIS_FARBE: Record<Ergebnis, string> = {
BESTANDEN: "#15803d",
BESTANDEN_MIT_MAENGELN: "#b45309",
NICHT_BESTANDEN: "#b91c1c",
};
function zeile(bezeichnung: string, wert: string | null | undefined): string {
return `
<div class="feld">
<div class="beschriftung">${html(bezeichnung)}</div>
<div class="wert">${html(wert && wert !== "" ? wert : "—")}</div>
</div>`;
}
function punktZeile(punkt: ProtokollPunkt, nummer: number): string {
const soll = [punkt.sollwert, punkt.einheit].filter(Boolean).join(" ");
return `
<tr class="${punkt.inOrdnung ? "" : "beanstandet"}">
<td class="nummer">${nummer}</td>
<td>
${html(punkt.text)}
${punkt.pflicht ? '<span class="pflicht" title="Pflichtpunkt">*</span>' : ""}
${punkt.bemerkung ? `<div class="bemerkung">${html(punkt.bemerkung)}</div>` : ""}
</td>
<td class="soll">${html(soll || "—")}</td>
<td class="ist">${html(punkt.istwert || "—")}</td>
<td class="befund">${punkt.inOrdnung ? "i. O." : "nicht i. O."}</td>
</tr>`;
}
function fotoBlock(punkte: ProtokollPunkt[]): string {
const mitBild = punkte.filter((punkt) => punkt.bild);
if (mitBild.length === 0) return "";
return `
<section>
<h2>Fotos</h2>
<div class="fotos">
${mitBild
.map(
(punkt) => `
<figure>
<img src="${punkt.bild}" alt="">
<figcaption>${html(punkt.text)}</figcaption>
</figure>`,
)
.join("")}
</div>
</section>`;
}
/** Baut das eigenständige HTML des Protokolls. */
export function protokollHtml(daten: ProtokollDaten): string {
const { organisation, geraet, pruefung, vorlage, punkte, maengel, signaturen } = daten;
const anschrift = [
organisation.strasse,
[organisation.plz, organisation.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(", ");
const geraetetext = [geraet.hersteller, geraet.modell].filter(Boolean).join(" ");
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Prüfprotokoll ${html(pruefung.protokollNummer ?? geraet.inventarnummer)}</title>
<style>
/* Ränder kommen von Gotenberg (siehe protokollPdf) — hier keine setzen,
sonst bleibt kein Platz für Kopf- und Fußzeile. */
@page { size: A4; }
* { box-sizing: border-box; }
html, body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.5pt;
color: #111;
-webkit-print-color-adjust: exact;
}
h1 { font-size: 15pt; margin: 0 0 1mm; }
h2 {
font-size: 10.5pt;
margin: 6mm 0 2mm;
padding-bottom: 1mm;
border-bottom: 0.4mm solid #111;
}
.kopf {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8mm;
padding-bottom: 3mm;
border-bottom: 0.6mm solid #111;
}
.kopf .wehr { font-size: 8.5pt; color: #333; }
.ergebnis {
text-align: right;
white-space: nowrap;
}
.ergebnis .marke {
display: inline-block;
padding: 1.5mm 3mm;
border: 0.5mm solid currentColor;
border-radius: 1.5mm;
font-weight: 700;
font-size: 11pt;
}
.ergebnis .nummer { font-size: 8.5pt; color: #333; margin-top: 1.5mm; }
.felder {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2mm 6mm;
margin-top: 3mm;
}
.feld .beschriftung {
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
}
.feld .wert { font-size: 9.5pt; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
border-bottom: 0.3mm solid #999;
padding: 1.5mm 2mm;
}
td {
padding: 1.8mm 2mm;
border-bottom: 0.2mm solid #ddd;
vertical-align: top;
}
tr { break-inside: avoid; }
td.nummer { width: 8mm; color: #666; }
td.soll, td.ist { width: 28mm; }
td.befund { width: 24mm; font-weight: 600; }
tr.beanstandet td.befund { color: #b91c1c; }
tr.beanstandet td { background: #fdf2f2; }
.pflicht { color: #b91c1c; font-weight: 700; }
.bemerkung { font-size: 8pt; color: #444; margin-top: 0.8mm; }
.fotos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}
.fotos figure { margin: 0; break-inside: avoid; }
.fotos img {
width: 100%;
height: 45mm;
object-fit: cover;
border: 0.2mm solid #bbb;
}
.fotos figcaption { font-size: 7.5pt; color: #444; margin-top: 1mm; }
.maengel li { margin-bottom: 1.5mm; }
.unterschriften {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8mm;
margin-top: 4mm;
}
.unterschrift { break-inside: avoid; }
.unterschrift .bild {
height: 22mm;
border-bottom: 0.3mm solid #111;
display: flex;
align-items: flex-end;
}
.unterschrift .bild img { max-height: 21mm; max-width: 100%; }
.unterschrift .name { font-size: 8.5pt; margin-top: 1.5mm; }
.unterschrift .rolle { font-size: 7.5pt; color: #555; }
.hinweis {
margin-top: 6mm;
padding: 2.5mm 3mm;
background: #f4f4f5;
border-left: 1mm solid #999;
font-size: 8pt;
color: #333;
}
.korrektur {
margin-top: 3mm;
padding: 2.5mm 3mm;
border: 0.4mm solid #b45309;
color: #7c2d12;
font-size: 8.5pt;
}
</style>
</head>
<body>
<div class="kopf">
<div>
<h1>Prüfprotokoll</h1>
<div class="wehr">
${html(organisation.name)}${anschrift ? ` · ${html(anschrift)}` : ""}
</div>
</div>
<div class="ergebnis" style="color: ${ERGEBNIS_FARBE[pruefung.ergebnis]}">
<div class="marke">${html(ERGEBNIS_BEZEICHNUNG[pruefung.ergebnis])}</div>
<div class="nummer">
${pruefung.protokollNummer ? `Protokoll ${html(pruefung.protokollNummer)}` : "ohne Nummer"}
</div>
</div>
</div>
${
pruefung.ersetztProtokollNummer
? `<div class="korrektur">
<strong>Korrekturprotokoll.</strong> Dieses Protokoll ersetzt
${html(pruefung.ersetztProtokollNummer)}.
${pruefung.korrekturGrund ? ` Grund: ${html(pruefung.korrekturGrund)}` : ""}
</div>`
: ""
}
<section>
<h2>Gerät</h2>
<div class="felder">
${zeile("Inventarnummer", geraet.inventarnummer)}
${zeile("Bezeichnung", geraet.bezeichnung)}
${zeile("Gerätetyp", geraet.typName)}
${zeile("Hersteller / Modell", geraetetext)}
${zeile("Seriennummer", geraet.seriennummer)}
${zeile("Lagerung", geraet.lagerung)}
${zeile("Baujahr", geraet.baujahr ? alsDatum(geraet.baujahr) : null)}
${zeile("Indienststellung", geraet.indienststellung ? alsDatum(geraet.indienststellung) : null)}
</div>
</section>
<section>
<h2>Prüfung</h2>
<div class="felder">
${zeile("Prüfdatum", alsDatum(pruefung.durchgefuehrtAm))}
${zeile("Prüfer", pruefung.prueferName)}
${zeile("Prüfer-Kennung", pruefung.prueferKennung)}
${zeile("Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern")}
${zeile("Fachfirma", pruefung.externeFirma)}
${zeile("Kosten", pruefung.kosten !== null && pruefung.kosten !== undefined ? alsEuro(pruefung.kosten) : null)}
${zeile("Vorlage", vorlage?.name)}
${zeile("Prüfvorschrift", vorlage?.pruefvorschrift)}
${zeile("Intervall", vorlage ? `${vorlage.intervallMonate} Monate` : null)}
${zeile("Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : null)}
${zeile("Nächste Prüfung", pruefung.naechstePruefungAm ? alsDatum(pruefung.naechstePruefungAm) : null)}
</div>
${pruefung.bemerkung ? `<p style="margin-top:3mm">${html(pruefung.bemerkung)}</p>` : ""}
</section>
<section>
<h2>Checkliste</h2>
<table>
<thead>
<tr>
<th>Nr.</th>
<th>Prüfpunkt</th>
<th>Soll</th>
<th>Ist</th>
<th>Befund</th>
</tr>
</thead>
<tbody>
${punkte.map((punkt, index) => punktZeile(punkt, index + 1)).join("")}
</tbody>
</table>
<p style="font-size:7.5pt;color:#555;margin-top:2mm">
<span class="pflicht">*</span> Pflichtpunkt — davon hängt die
Einsatzbereitschaft ab.
</p>
</section>
${fotoBlock(punkte)}
${
maengel.length > 0
? `<section>
<h2>Festgestellte Mängel</h2>
<ul class="maengel">
${maengel
.map(
(mangel) =>
`<li><strong>${html(mangel.prioritaet)}:</strong> ${html(mangel.beschreibung)}</li>`,
)
.join("")}
</ul>
</section>`
: ""
}
<section>
<h2>Unterschriften</h2>
<div class="unterschriften">
${signaturen
.map(
(signatur) => `
<div class="unterschrift">
<div class="bild">${signatur.bild ? `<img src="${signatur.bild}" alt="">` : ""}</div>
<div class="name">${html(signatur.name)}</div>
<div class="rolle">${html(signatur.rolle)} · ${html(alsDatumZeit(signatur.unterzeichnetAm))}</div>
</div>`,
)
.join("")}
</div>
</section>
<div class="hinweis">
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Dieses
Protokoll ist nach dem Abschluss unveränderbar gespeichert. Korrekturen
erscheinen als eigenes Protokoll mit Verweis auf dieses.
</div>
</body>
</html>`;
}
/** Kopfzeile des PDF — sie wiederholt sich auf jeder Seite. */
export function kopfzeileHtml(daten: ProtokollDaten): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>${html(daten.organisation.name)}</span>
<span>Prüfprotokoll ${html(daten.pruefung.protokollNummer ?? "")} · ${html(daten.geraet.inventarnummer)}</span>
</div>
</body></html>`;
}
/** Fußzeile mit Prüfvorschrift, Prüfdatum und Seitenzahl. */
export function fusszeileHtml(daten: ProtokollDaten): string {
const vorschrift = daten.vorlage?.pruefvorschrift ?? "ohne Angabe der Vorschrift";
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>${html(vorschrift)} · geprüft am ${html(alsDatum(daten.pruefung.durchgefuehrtAm))}</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>
</body></html>`;
}
/** Erzeugt das Protokoll als PDF über Gotenberg. */
export async function protokollPdf(daten: ProtokollDaten): Promise<Buffer> {
return htmlZuPdf(protokollHtml(daten), {
randOben: 0.55,
randUnten: 0.55,
randLinks: 0.5,
randRechts: 0.5,
hintergrundDrucken: true,
kopfzeileHtml: kopfzeileHtml(daten),
fusszeileHtml: fusszeileHtml(daten),
});
}
+81
View File
@@ -0,0 +1,81 @@
import type {
BetroffenheitStatus,
ChecklistenpunktTyp,
MangelPrioritaet,
MangelStatus,
PruefErgebnis,
PruefungStatus,
RueckrufStatus,
WartungsterminStatus,
} from "@prisma/client";
import type { Fristfenster } from "@/lib/wartung/fristen";
/**
* Klartext zu den Aufzählungen der Wartung.
*
* Steht bewusst in einer Datei ohne "use client": Server-Komponenten und
* Client-Komponenten brauchen dieselben Beschriftungen. Läge das in einem
* Client-Modul, bekäme der Server statt der Texte nur Platzhalter — Next.js
* ersetzt alle Exporte eines "use client"-Moduls durch Verweise.
*/
export const ERGEBNIS_TEXT: Record<PruefErgebnis, string> = {
BESTANDEN: "bestanden",
BESTANDEN_MIT_MAENGELN: "mit Mängeln bestanden",
NICHT_BESTANDEN: "nicht bestanden",
};
export const PRUEFUNG_STATUS_TEXT: Record<PruefungStatus, string> = {
ENTWURF: "Entwurf",
ABGESCHLOSSEN: "abgeschlossen",
ERSETZT: "ersetzt",
};
export const TERMIN_STATUS_TEXT: Record<WartungsterminStatus, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
ENTFALLEN: "entfallen",
};
export const MANGEL_STATUS_TEXT: Record<MangelStatus, string> = {
GEMELDET: "gemeldet",
ZUGEWIESEN: "zugewiesen",
IN_ARBEIT: "in Arbeit",
BEHOBEN: "behoben",
VERWORFEN: "verworfen",
};
export const PRIORITAET_TEXT: Record<MangelPrioritaet, string> = {
NIEDRIG: "niedrig",
MITTEL: "mittel",
HOCH: "hoch",
KRITISCH: "kritisch",
};
export const RUECKRUF_STATUS_TEXT: Record<RueckrufStatus, string> = {
OFFEN: "offen",
IN_BEARBEITUNG: "in Bearbeitung",
ERLEDIGT: "erledigt",
};
export const BETROFFENHEIT_TEXT: Record<BetroffenheitStatus, string> = {
OFFEN: "offen",
NICHT_BETROFFEN: "nicht betroffen",
MASSNAHME_LAEUFT: "Maßnahme läuft",
ERLEDIGT: "erledigt",
};
export const PUNKTTYP_TEXT: Record<ChecklistenpunktTyp, string> = {
JA_NEIN: "Ja / Nein",
MESSWERT: "Messwert",
TEXT: "Text",
FOTO: "Foto",
};
export const FENSTER_TEXT: Record<Fristfenster, string> = {
ueberfaellig: "überfällig",
diese_woche: "diese Woche",
dieser_monat: "diesen Monat",
naechste_90_tage: "nächste 90 Tage",
spaeter: "später",
};
+12
View File
@@ -9,6 +9,11 @@ import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/o
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog"; import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person"; import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
import { importRouter } from "@/server/routers/stammdaten/import"; import { importRouter } from "@/server/routers/stammdaten/import";
import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage";
import { wartungsterminRouter } from "@/server/routers/wartung/termin";
import { pruefungRouter } from "@/server/routers/wartung/pruefung";
import { mangelRouter } from "@/server/routers/wartung/mangel";
import { rueckrufRouter } from "@/server/routers/wartung/rueckruf";
/** /**
* Wurzel-Router. * Wurzel-Router.
@@ -139,6 +144,13 @@ export const appRouter = router({
qualifikationstyp: qualifikationstypRouter, qualifikationstyp: qualifikationstypRouter,
qualifikation: qualifikationRouter, qualifikation: qualifikationRouter,
datenImport: importRouter, datenImport: importRouter,
// Phase 2 — Wartung und Prüfung
wartungsvorlage: wartungsvorlageRouter,
wartungstermin: wartungsterminRouter,
pruefung: pruefungRouter,
mangel: mangelRouter,
rueckruf: rueckrufRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
+476
View File
@@ -0,0 +1,476 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { MangelPrioritaet, MangelStatus, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
} from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
/**
* Mängel.
*
* Der Weg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben. Jeder
* Schritt landet im Audit-Log, und wer zuständig ist, bekommt eine Nachricht.
*
* Melden darf jedes Mitglied. Das ist Absicht: der Mangel fällt demjenigen auf,
* der das Gerät benutzt, nicht dem Gerätewart am Schreibtisch. 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.
*
* Bearbeiten, zuweisen und beheben bleibt beim Gerätewart.
*/
const SUCHFELDER = ["beschreibung", "behebung"];
const LISTEN_AUSWAHL = {
id: true,
beschreibung: true,
prioritaet: true,
status: true,
gemeldetAm: true,
behobenAm: true,
behebung: true,
geraetGesperrt: true,
melderName: true,
fotoDateiId: true,
geloeschtAm: true,
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
},
melder: { select: { id: true, vorname: true, nachname: true } },
zugewiesenAn: { select: { id: true, vorname: true, nachname: true } },
behobenVon: { select: { id: true, vorname: true, nachname: true } },
pruefung: { select: { id: true, protokollNummer: true, durchgefuehrtAm: true } },
} satisfies Prisma.MangelSelect;
/** Zustände, die als erledigt gelten. */
const ERLEDIGT: MangelStatus[] = [MangelStatus.BEHOBEN, MangelStatus.VERWORFEN];
export const mangelRouter = router({
liste: mitRecht("pruefung.lesen")
.input(
listenEingabe(["gemeldetAm", "prioritaet", "status"] as const).extend({
status: z.array(z.nativeEnum(MangelStatus)).optional(),
prioritaet: z.array(z.nativeEnum(MangelPrioritaet)).optional(),
geraetId: z.string().uuid().optional(),
zugewiesenAnId: z.string().uuid().optional(),
nurOffene: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.MangelWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(input.nurOffene ? { status: { notIn: ERLEDIGT } } : {}),
...(input.prioritaet?.length ? { prioritaet: { in: input.prioritaet } } : {}),
...(input.geraetId ? { geraetId: input.geraetId } : {}),
...(input.zugewiesenAnId ? { zugewiesenAnId: input.zugewiesenAnId } : {}),
...(input.suche
? {
OR: [
...SUCHFELDER.map((feld) => ({
[feld]: { contains: input.suche, mode: "insensitive" as const },
})),
{ geraet: { inventarnummer: { contains: input.suche, mode: "insensitive" } } },
],
}
: {}),
};
const [eintraege, gesamt] = await Promise.all([
db.mangel.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy:
input.sortierung === "prioritaet"
? [{ prioritaet: input.richtung }, { gemeldetAm: "desc" }]
: input.sortierung === "status"
? [{ status: input.richtung }, { gemeldetAm: "desc" }]
: { gemeldetAm: input.sortierung ? input.richtung : "desc" },
...blaettern(input),
}),
db.mangel.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const mangel = await db.mangel.findUnique({
where: { id: input.id },
select: LISTEN_AUSWAHL,
});
if (!mangel) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
return mangel;
}),
),
/** Zahlen für Übersicht und Filterleiste. */
kennzahlen: mitRecht("pruefung.lesen").query(async ({ ctx }) =>
ctx.db(async (db) => {
const nachStatus = await db.mangel.groupBy({
by: ["status"],
where: { geloeschtAm: null },
_count: { _all: true },
});
const [offen, kritisch] = await Promise.all([
db.mangel.count({ where: { geloeschtAm: null, status: { notIn: ERLEDIGT } } }),
db.mangel.count({
where: {
geloeschtAm: null,
status: { notIn: ERLEDIGT },
prioritaet: MangelPrioritaet.KRITISCH,
},
}),
]);
return {
offen,
kritisch,
nachStatus: Object.fromEntries(
nachStatus.map((eintrag) => [eintrag.status, eintrag._count._all]),
) as Record<MangelStatus, number>,
};
}),
),
/**
* Mangel melden.
*
* Ein kritischer Mangel sperrt das Gerät sofort — ohne Rückfrage. Wer die
* höchste Stufe wählt, sagt damit: das Ding darf nicht mehr aufs Fahrzeug.
* Dass die Sperre zu diesem Mangel gehört, wird vermerkt; beim Beheben wird
* sie wieder aufgehoben.
*/
melden: mitRecht("pruefung.lesen")
.input(
z.object({
geraetId: z.string().uuid(),
beschreibung: z.string().trim().min(1, "Ohne Beschreibung nützt die Meldung nichts.").max(2000),
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
fotoDateiId: z.string().uuid().nullish(),
geraetSperren: z.boolean().optional(),
zugewiesenAnId: z.string().uuid().nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.geraetId },
select: { id: true, inventarnummer: true, bezeichnung: true, status: true },
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const sperren =
input.geraetSperren ?? input.prioritaet === MangelPrioritaet.KRITISCH;
const wirdGesperrt = sperren && geraet.status !== "GESPERRT";
const mangel = await db.mangel.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: input.geraetId,
beschreibung: input.beschreibung,
prioritaet: input.prioritaet,
fotoDateiId: input.fotoDateiId ?? null,
melderId: ctx.sitzung.personId,
melderName: ctx.sitzung.name,
zugewiesenAnId: input.zugewiesenAnId ?? null,
status: input.zugewiesenAnId ? MangelStatus.ZUGEWIESEN : MangelStatus.GEMELDET,
geraetGesperrt: wirdGesperrt,
},
});
if (wirdGesperrt) {
await db.geraet.update({
where: { id: input.geraetId },
data: { status: "GESPERRT" },
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Mangel",
entitaetId: mangel.id,
beschreibung: `Mangel an ${geraet.inventarnummer} gemeldet (${input.prioritaet})${
wirdGesperrt ? ", Gerät gesperrt" : ""
}`,
sitzung: ctx.sitzung,
nachher: mangel,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
organisationId: ctx.sitzung.organisationId,
anlass: "mangel_gemeldet",
mangelId: mangel.id,
}).catch((fehler) => {
console.error("[mangel] Benachrichtigung ließ sich nicht einstellen:", fehler);
});
return mangel;
}),
),
/** Zuständigkeit setzen. Der Status springt dabei auf "zugewiesen". */
zuweisen: mitRecht("pruefung.planen")
.input(idEingabe.extend({ zugewiesenAnId: z.string().uuid().nullish() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
zugewiesenAnId: input.zugewiesenAnId ?? null,
status: input.zugewiesenAnId
? vorher.status === MangelStatus.GEMELDET
? MangelStatus.ZUGEWIESEN
: vorher.status
: MangelStatus.GEMELDET,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} zugewiesen`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
organisationId: ctx.sitzung.organisationId,
anlass: "mangel_zugewiesen",
mangelId: input.id,
}).catch(() => undefined);
return nachher;
}),
),
/** In Arbeit nehmen. */
inArbeit: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
if (ERLEDIGT.includes(vorher.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Mangel ist bereits abgeschlossen.",
});
}
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
status: MangelStatus.IN_ARBEIT,
zugewiesenAnId: vorher.zugewiesenAnId ?? ctx.sitzung.personId,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} in Arbeit`,
sitzung: ctx.sitzung,
vorher: { status: vorher.status },
nachher: { status: nachher.status },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Mangel beheben.
*
* Hat dieser Mangel das Gerät gesperrt, wird die Sperre hier aufgehoben —
* aber nur, wenn kein anderer offener Mangel das Gerät ebenfalls sperrt.
* Sonst gäbe ein behobener Kratzer ein Gerät frei, das wegen eines Risses
* gesperrt ist.
*/
beheben: mitRecht("pruefung.durchfuehren")
.input(
idEingabe.extend({
behebung: z.string().trim().max(2000).nullish(),
geraetFreigeben: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { id: true, inventarnummer: true, status: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
const jetzt = new Date();
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
status: MangelStatus.BEHOBEN,
behobenAm: jetzt,
behobenVonId: ctx.sitzung.personId,
behebung: input.behebung ?? null,
},
});
let freigegeben = false;
if (vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
const weitereSperren = await db.mangel.count({
where: {
geraetId: vorher.geraetId,
id: { not: input.id },
geraetGesperrt: true,
status: { notIn: ERLEDIGT },
geloeschtAm: null,
},
});
if (weitereSperren === 0 && (input.geraetFreigeben ?? true)) {
await db.geraet.update({
where: { id: vorher.geraetId },
data: { status: "EINSATZBEREIT" },
});
freigegeben = true;
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} behoben${
freigegeben ? ", Gerät wieder einsatzbereit" : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
kontext: { geraetFreigegeben: freigegeben },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
organisationId: ctx.sitzung.organisationId,
anlass: "mangel_behoben",
mangelId: input.id,
}).catch(() => undefined);
return { ...nachher, geraetFreigegeben: freigegeben };
}),
),
/** Fehlmeldung. Bleibt stehen, damit nachvollziehbar ist, was gemeldet war. */
verwerfen: mitRecht("pruefung.durchfuehren")
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { id: true, inventarnummer: true, status: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
status: MangelStatus.VERWORFEN,
behebung: input.grund ?? null,
behobenAm: new Date(),
behobenVonId: ctx.sitzung.personId,
},
});
if (vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
const weitereSperren = await db.mangel.count({
where: {
geraetId: vorher.geraetId,
id: { not: input.id },
geraetGesperrt: true,
status: { notIn: ERLEDIGT },
geloeschtAm: null,
},
});
if (weitereSperren === 0) {
await db.geraet.update({
where: { id: vorher.geraetId },
data: { status: "EINSATZBEREIT" },
});
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangelmeldung zu ${vorher.geraet.inventarnummer} verworfen${
input.grund ? `: ${input.grund}` : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+883
View File
@@ -0,0 +1,883 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
ChecklistenpunktTyp,
MangelPrioritaet,
Prisma,
PruefErgebnis,
PruefungArt,
PruefungStatus,
SignaturRolle,
WartungsterminStatus,
} from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
import { downloadLink } from "@/lib/storage";
import {
dauerInMinuten,
ergebnisVorschlag,
offenePflichtpunkte,
punktInOrdnung,
type Antwort,
type Punkt,
} from "@/lib/wartung/bewertung";
import {
naechsteFaelligkeit,
protokollNummer,
protokollNummerLesen,
} from "@/lib/wartung/fristen";
import { utcTag } from "@/lib/stammdaten/fristen";
/**
* Prüfungen — der Wizard von der ersten Frage bis zum unterschriebenen PDF.
*
* Der Ablauf in vier Schritten:
*
* starten legt einen Entwurf an und liefert die Checkliste
* ergebnisSpeichern je Punkt, so oft wie nötig — der Wizard speichert laufend
* abschliessen prüft auf Vollständigkeit, unterschreibt, schreibt fest
* (Hintergrund) das PDF entsteht im Worker und wird nachgetragen
*
* Warum laufend gespeichert wird: eine Atemschutzprüfung dauert eine
* Dreiviertelstunde, und ein Tablet im Gerätehaus verliert schon mal das WLAN.
* Wer dann von vorn anfangen muss, macht die Prüfung beim nächsten Mal wieder
* auf Papier.
*
* Nach dem Abschluss ist Schluss: die Datenbank lässt keine Änderung mehr zu
* (siehe Migration 20260903000300). Korrekturen laufen über korrigieren() und
* erzeugen ein neues Protokoll mit Verweis auf das alte.
*/
/** Was die Detailansicht und der Wizard brauchen. */
const PRUEFUNG_EINSCHLUSS = {
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
seriennummer: true,
hersteller: true,
modell: true,
herstellungsdatum: true,
indienststellung: true,
geraetetyp: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
},
vorlage: {
select: {
id: true,
name: true,
intervallMonate: true,
pruefvorschrift: true,
standardDauerMinuten: true,
punkte: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
},
},
},
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
ergebnisse: { include: { punkt: true } },
signaturen: true,
maengel: { where: { geloeschtAm: null } },
termin: { select: { id: true, faelligAm: true, status: true } },
ersetzt: { select: { id: true, protokollNummer: true, durchgefuehrtAm: true } },
} satisfies Prisma.PruefungInclude;
/** Prisma-Punkt in die Form, mit der lib/wartung/bewertung rechnet. */
function alsPunkt(punkt: {
id: string;
text: string;
typ: ChecklistenpunktTyp;
pflicht: boolean;
minWert: Prisma.Decimal | null;
maxWert: Prisma.Decimal | null;
}): Punkt {
return {
id: punkt.id,
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht,
minWert: punkt.minWert ? punkt.minWert.toNumber() : null,
maxWert: punkt.maxWert ? punkt.maxWert.toNumber() : null,
};
}
function alsAntwort(ergebnis: {
punktId: string;
jaNein: boolean | null;
zahlwert: Prisma.Decimal | null;
text: string | null;
fotoDateiId: string | null;
inOrdnung: boolean;
}): Antwort {
return {
punktId: ergebnis.punktId,
jaNein: ergebnis.jaNein,
zahlwert: ergebnis.zahlwert ? ergebnis.zahlwert.toNumber() : null,
text: ergebnis.text,
fotoDateiId: ergebnis.fotoDateiId,
inOrdnung: ergebnis.inOrdnung,
};
}
export const pruefungRouter = router({
/** Liste der Prüfungen, für die Historie am Gerät und die Gesamtübersicht. */
liste: mitRecht("pruefung.lesen")
.input(
z.object({
geraetId: z.string().uuid().optional(),
prueferId: z.string().uuid().optional(),
status: z.array(z.nativeEnum(PruefungStatus)).optional(),
ergebnis: z.array(z.nativeEnum(PruefErgebnis)).optional(),
vonAm: z.coerce.date().optional(),
bisAm: z.coerce.date().optional(),
anzahl: z.number().int().min(1).max(200).default(50),
}),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.pruefung.findMany({
where: {
geloeschtAm: null,
...(input.geraetId ? { geraetId: input.geraetId } : {}),
...(input.prueferId ? { prueferId: input.prueferId } : {}),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(input.ergebnis?.length ? { ergebnis: { in: input.ergebnis } } : {}),
...(input.vonAm || input.bisAm
? {
durchgefuehrtAm: {
...(input.vonAm ? { gte: input.vonAm } : {}),
...(input.bisAm ? { lte: input.bisAm } : {}),
},
}
: {}),
},
orderBy: { durchgefuehrtAm: "desc" },
take: input.anzahl,
select: {
id: true,
protokollNummer: true,
durchgefuehrtAm: true,
art: true,
status: true,
ergebnis: true,
dauerMinuten: true,
externeFirma: true,
kosten: true,
naechstePruefungAm: true,
protokollDateiId: true,
prueferName: true,
pruefer: { select: { id: true, vorname: true, nachname: true } },
vorlage: { select: { id: true, name: true } },
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
_count: { select: { maengel: true } },
},
}),
),
),
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: PRUEFUNG_EINSCHLUSS,
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
return pruefung;
}),
),
/** Eigene Entwürfe — angefangene Prüfungen, die noch offen liegen. */
entwuerfe: mitRecht("pruefung.durchfuehren").query(async ({ ctx }) =>
ctx.db((db) =>
db.pruefung.findMany({
where: {
status: PruefungStatus.ENTWURF,
geloeschtAm: null,
prueferId: ctx.sitzung.personId,
},
orderBy: { erstelltAm: "desc" },
select: {
id: true,
begonnenUm: true,
durchgefuehrtAm: true,
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
vorlage: { select: { id: true, name: true } },
_count: { select: { ergebnisse: true } },
},
}),
),
),
/**
* Prüfung beginnen.
*
* Gibt es zu diesem Gerät und dieser Vorlage bereits einen eigenen Entwurf,
* wird der fortgesetzt statt ein zweiter angelegt. Sonst sammeln sich nach
* jedem verlorenen WLAN halbe Prüfungen an.
*/
starten: mitRecht("pruefung.durchfuehren")
.input(
z.object({
geraetId: z.string().uuid(),
terminId: z.string().uuid().nullish(),
vorlageId: z.string().uuid().nullish(),
art: z.nativeEnum(PruefungArt).default(PruefungArt.INTERN),
externeFirma: z.string().trim().max(200).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.geraetId },
select: { id: true, inventarnummer: true, bezeichnung: true, geraetetypId: true },
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
// Vorlage: ausdrücklich gewählt, sonst über den Termin, sonst die des
// Gerätetyps — in dieser Reihenfolge.
let vorlageId = input.vorlageId ?? null;
if (!vorlageId && input.terminId) {
const termin = await db.wartungstermin.findUnique({
where: { id: input.terminId },
select: { vorlageId: true },
});
vorlageId = termin?.vorlageId ?? null;
}
if (!vorlageId && geraet.geraetetypId) {
const passende = await db.wartungsvorlage.findFirst({
where: { geraetetypId: geraet.geraetetypId, aktiv: true, geloeschtAm: null },
orderBy: { intervallMonate: "asc" },
select: { id: true },
});
vorlageId = passende?.id ?? null;
}
const laufender = await db.pruefung.findFirst({
where: {
geraetId: input.geraetId,
vorlageId,
status: PruefungStatus.ENTWURF,
prueferId: ctx.sitzung.personId,
geloeschtAm: null,
},
include: PRUEFUNG_EINSCHLUSS,
});
if (laufender) return { pruefung: laufender, fortgesetzt: true };
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: { vorname: true, nachname: true },
});
const pruefung = await db.pruefung.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: input.geraetId,
terminId: input.terminId ?? null,
vorlageId,
art: input.art,
externeFirma: input.externeFirma ?? null,
prueferId: ctx.sitzung.personId,
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
durchgefuehrtAm: utcTag(new Date()),
begonnenUm: new Date(),
},
include: PRUEFUNG_EINSCHLUSS,
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfung an ${geraet.inventarnummer} begonnen`,
sitzung: ctx.sitzung,
nachher: { geraetId: geraet.id, vorlageId, art: input.art },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { pruefung, fortgesetzt: false };
}),
),
/**
* Eine Antwort speichern.
*
* Ob der Punkt in Ordnung ist, rechnet der Server — der Browser schickt nur,
* was gemessen oder angekreuzt wurde. Eine ausdrückliche Angabe des Prüfers
* hat Vorrang: er steht davor und sieht mehr als die Zahl.
*/
ergebnisSpeichern: mitRecht("pruefung.durchfuehren")
.input(
z.object({
pruefungId: z.string().uuid(),
punktId: z.string().uuid(),
jaNein: z.boolean().nullish(),
zahlwert: z.number().nullish(),
text: z.string().trim().max(1000).nullish(),
fotoDateiId: z.string().uuid().nullish(),
inOrdnung: z.boolean().nullish(),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.pruefungId },
select: { id: true, status: true, vorlageId: true },
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Prüfung ist abgeschlossen und lässt sich nicht mehr ändern. Für eine Korrektur eine neue Prüfung anlegen.",
});
}
const punkt = await db.checklistenpunkt.findUnique({ where: { id: input.punktId } });
if (!punkt) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
const bewertet = punktInOrdnung(alsPunkt(punkt), {
punktId: input.punktId,
jaNein: input.jaNein,
zahlwert: input.zahlwert,
text: input.text,
fotoDateiId: input.fotoDateiId,
inOrdnung: input.inOrdnung,
});
const daten = {
jaNein: input.jaNein ?? null,
zahlwert: input.zahlwert ?? null,
text: input.text ?? null,
fotoDateiId: input.fotoDateiId ?? null,
bemerkung: input.bemerkung ?? null,
inOrdnung: bewertet,
};
return db.pruefergebnis.upsert({
where: { pruefungId_punktId: { pruefungId: input.pruefungId, punktId: input.punktId } },
create: {
organisationId: ctx.sitzung.organisationId,
pruefungId: input.pruefungId,
punktId: input.punktId,
...daten,
},
update: daten,
});
}),
),
/** Zwischenstand: was fehlt noch, was ist beanstandet, was käme heraus. */
stand: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: {
vorlage: {
select: { punkte: { where: { geloeschtAm: null }, orderBy: { sortierung: "asc" } } },
},
ergebnisse: true,
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
const antworten = pruefung.ergebnisse.map(alsAntwort);
return {
offenePflichtpunkte: offenePflichtpunkte(punkte, antworten).map((punkt) => ({
id: punkt.id,
text: punkt.text,
})),
beantwortet: antworten.length,
gesamt: punkte.length,
vorschlag: ergebnisVorschlag(punkte, antworten),
};
}),
),
/**
* Prüfung abschließen.
*
* Hier passiert alles, was zusammengehört, in einer Transaktion:
* Unterschriften anlegen, Mängel melden, Protokollnummer ziehen, Prüfung
* festschreiben, Termin erledigen, nächsten Termin planen und den Status des
* Geräts nachziehen. Ginge davon etwas schief, darf nichts davon gelten.
*
* Die Reihenfolge ist nicht beliebig: Unterschriften und Mängel müssen vor
* dem Statuswechsel entstehen, denn danach lässt die Datenbank am Protokoll
* nichts mehr zu.
*/
abschliessen: mitRecht("pruefung.durchfuehren")
.input(
z.object({
id: z.string().uuid(),
ergebnis: z.nativeEnum(PruefErgebnis).optional(),
durchgefuehrtAm: z.coerce.date().optional(),
bemerkung: z.string().trim().max(2000).nullish(),
naechstePruefungAm: z.coerce.date().nullish(),
externeFirma: z.string().trim().max(200).nullish(),
externeProtokollDateiId: z.string().uuid().nullish(),
kosten: z.number().nonnegative().max(9_999_999).nullish(),
signaturen: z
.array(
z.object({
rolle: z.nativeEnum(SignaturRolle).default(SignaturRolle.PRUEFER),
name: z.string().trim().min(1).max(200),
personId: z.string().uuid().nullish(),
bildDateiId: z.string().uuid().nullish(),
}),
)
.min(1, "Ohne Unterschrift ist ein Protokoll kein Nachweis.")
.max(4),
maengel: z
.array(
z.object({
beschreibung: z.string().trim().min(1).max(2000),
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
fotoDateiId: z.string().uuid().nullish(),
}),
)
.max(50)
.default([]),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: {
geraet: {
select: {
id: true,
inventarnummer: true,
status: true,
indienststellung: true,
pruefintervallMonate: true,
},
},
vorlage: {
select: {
id: true,
intervallMonate: true,
punkte: { where: { geloeschtAm: null } },
},
},
ergebnisse: true,
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Prüfung ist bereits abgeschlossen.",
});
}
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
const antworten = pruefung.ergebnisse.map(alsAntwort);
const offene = offenePflichtpunkte(punkte, antworten);
if (offene.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Es fehlen noch ${offene.length} Pflichtangaben: ${offene
.slice(0, 3)
.map((punkt) => punkt.text)
.join(", ")}${offene.length > 3 ? " …" : ""}`,
});
}
const ergebnis = input.ergebnis ?? ergebnisVorschlag(punkte, antworten);
const jetzt = new Date();
const durchgefuehrtAm = utcTag(input.durchgefuehrtAm ?? pruefung.durchgefuehrtAm);
// Nächster Termin: was der Prüfer angibt, sonst das Intervall der
// Vorlage, sonst das am Gerät hinterlegte.
const intervall = pruefung.vorlage?.intervallMonate ?? pruefung.geraet.pruefintervallMonate;
const naechste =
input.naechstePruefungAm ??
(intervall
? naechsteFaelligkeit({ letztePruefung: durchgefuehrtAm, intervallMonate: intervall })
: null);
// Unterschriften und Mängel zuerst — danach ist das Protokoll dicht.
for (const signatur of input.signaturen) {
await db.signatur.create({
data: {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
rolle: signatur.rolle,
name: signatur.name,
personId: signatur.personId ?? null,
bildDateiId: signatur.bildDateiId ?? null,
unterzeichnetAm: jetzt,
},
});
}
for (const mangel of input.maengel) {
await db.mangel.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: pruefung.geraetId,
pruefungId: pruefung.id,
melderId: ctx.sitzung.personId,
melderName: ctx.sitzung.name,
beschreibung: mangel.beschreibung,
prioritaet: mangel.prioritaet,
fotoDateiId: mangel.fotoDateiId ?? null,
geraetGesperrt: ergebnis === PruefErgebnis.NICHT_BESTANDEN,
},
});
}
// Fortlaufende Protokollnummer je Wehr und Jahr.
//
// Zwei Prüfer, die gleichzeitig abschließen, würden ohne Sperre
// dieselbe Nummer ziehen — die eindeutige Bedingung in der Datenbank
// fängt das ab, aber der zweite bekäme einen Datenbankfehler statt
// eines Protokolls. Die Sperre gilt nur bis zum Ende dieser
// Transaktion und nur für diese Wehr und dieses Jahr.
const jahr = durchgefuehrtAm.getUTCFullYear();
const sperrschluessel = `pruefung:${ctx.sitzung.organisationId}:${jahr}`;
await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${sperrschluessel}))`;
// Höchste vergebene Nummer plus eins — nicht die Anzahl der Zeilen.
// Nummern werden nie wiederverwendet, auch wenn eine Prüfung im
// Aufbewahrungslauf verschwindet.
const letzte = await db.pruefung.findFirst({
where: { protokollNummer: { startsWith: `${jahr}-` } },
orderBy: { protokollNummer: "desc" },
select: { protokollNummer: true },
});
const nummer = protokollNummer(
jahr,
(protokollNummerLesen(letzte?.protokollNummer)?.laufend ?? 0) + 1,
);
const abgeschlossen = await db.pruefung.update({
where: { id: pruefung.id },
data: {
status: PruefungStatus.ABGESCHLOSSEN,
ergebnis,
durchgefuehrtAm,
bemerkung: input.bemerkung ?? pruefung.bemerkung,
naechstePruefungAm: naechste,
externeFirma: input.externeFirma ?? pruefung.externeFirma,
externeProtokollDateiId:
input.externeProtokollDateiId ?? pruefung.externeProtokollDateiId,
kosten: input.kosten ?? pruefung.kosten,
dauerMinuten: dauerInMinuten(pruefung.begonnenUm, jetzt),
protokollNummer: nummer,
abgeschlossenAm: jetzt,
},
});
// Eine Korrektur löst das alte Protokoll ab. Es bleibt lesbar.
if (pruefung.ersetztPruefungId) {
await db.pruefung.update({
where: { id: pruefung.ersetztPruefungId },
data: { status: PruefungStatus.ERSETZT },
});
}
// Termin erledigen und den nächsten planen.
if (pruefung.terminId) {
await db.wartungstermin.update({
where: { id: pruefung.terminId },
data: { status: WartungsterminStatus.ERLEDIGT, erledigtAm: jetzt },
});
}
if (naechste && pruefung.vorlageId) {
await db.wartungstermin.upsert({
where: {
geraetId_vorlageId_faelligAm: {
geraetId: pruefung.geraetId,
vorlageId: pruefung.vorlageId,
faelligAm: naechste,
},
},
create: {
organisationId: ctx.sitzung.organisationId,
geraetId: pruefung.geraetId,
vorlageId: pruefung.vorlageId,
faelligAm: naechste,
automatischErzeugt: true,
},
update: {},
});
}
// Der Status des Geräts folgt dem Ergebnis: nicht bestanden heißt
// gesperrt. Bestanden hebt eine frühere Sperre bewusst NICHT auf —
// wer gesperrt hat, hatte einen Grund, und der kann außerhalb dieser
// Prüfung liegen. Nur "Prüfung fällig" löst sich von selbst.
const neuerStatus =
ergebnis === PruefErgebnis.NICHT_BESTANDEN
? "GESPERRT"
: pruefung.geraet.status === "PRUEFUNG_FAELLIG"
? "EINSATZBEREIT"
: pruefung.geraet.status;
await db.geraet.update({
where: { id: pruefung.geraetId },
data: {
letztePruefung: durchgefuehrtAm,
naechstePruefung: naechste,
status: neuerStatus,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfung ${nummer} an ${pruefung.geraet.inventarnummer} abgeschlossen: ${ergebnis}`,
sitzung: ctx.sitzung,
vorher: { status: pruefung.status },
nachher: {
status: PruefungStatus.ABGESCHLOSSEN,
ergebnis,
protokollNummer: nummer,
naechstePruefungAm: naechste,
},
kontext: {
maengel: input.maengel.length,
unterschriften: input.signaturen.length,
geraetStatus: neuerStatus,
},
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
// Das PDF entsteht im Hintergrund: Gotenberg braucht ein paar Sekunden,
// und der Prüfer soll währenddessen nicht auf einen Ladebalken sehen.
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
}).catch((fehler) => {
console.error("[pruefung] PDF-Auftrag ließ sich nicht einstellen:", fehler);
});
return abgeschlossen;
}),
),
/**
* Korrektur eines abgeschlossenen Protokolls.
*
* Es wird nichts überschrieben. Stattdessen entsteht ein neuer Entwurf mit
* denselben Antworten und einem Verweis auf das alte Protokoll. Erst mit
* seinem Abschluss gilt das alte als ersetzt.
*/
korrigieren: mitRecht("pruefung.durchfuehren")
.input(idEingabe.extend({ grund: z.string().trim().min(1, "Ein Grund gehört dazu.").max(500) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const alt = await db.pruefung.findUnique({
where: { id: input.id },
include: { ergebnisse: true, geraet: { select: { inventarnummer: true } } },
});
if (!alt) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (alt.status === PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein Entwurf lässt sich unmittelbar ändern — dafür braucht es keine Korrektur.",
});
}
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: { vorname: true, nachname: true },
});
const neu = await db.pruefung.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: alt.geraetId,
terminId: alt.terminId,
vorlageId: alt.vorlageId,
art: alt.art,
externeFirma: alt.externeFirma,
kosten: alt.kosten,
prueferId: ctx.sitzung.personId,
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
durchgefuehrtAm: alt.durchgefuehrtAm,
begonnenUm: new Date(),
bemerkung: alt.bemerkung,
ersetztPruefungId: alt.id,
korrekturGrund: input.grund,
},
});
// Die bisherigen Antworten übernehmen, damit nur das Falsche geändert
// werden muss und nicht alles noch einmal einzutippen ist.
for (const ergebnis of alt.ergebnisse) {
await db.pruefergebnis.create({
data: {
organisationId: ctx.sitzung.organisationId,
pruefungId: neu.id,
punktId: ergebnis.punktId,
jaNein: ergebnis.jaNein,
zahlwert: ergebnis.zahlwert,
text: ergebnis.text,
fotoDateiId: ergebnis.fotoDateiId,
inOrdnung: ergebnis.inOrdnung,
bemerkung: ergebnis.bemerkung,
},
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Pruefung",
entitaetId: neu.id,
beschreibung: `Korrektur zu Protokoll ${alt.protokollNummer ?? alt.id} an ${alt.geraet.inventarnummer} begonnen: ${input.grund}`,
sitzung: ctx.sitzung,
nachher: { ersetztPruefungId: alt.id, korrekturGrund: input.grund },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return neu;
}),
),
/** Entwurf verwerfen. Abgeschlossene Prüfungen sind davon ausgenommen. */
entwurfVerwerfen: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
select: { id: true, status: true, prueferId: true, geraetId: true },
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Abgeschlossene Protokolle lassen sich nicht verwerfen.",
});
}
await db.pruefung.delete({ where: { id: input.id } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Pruefung",
entitaetId: input.id,
beschreibung: "Angefangene Prüfung verworfen",
sitzung: ctx.sitzung,
vorher: pruefung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/**
* Zeitlich begrenzter Link auf das Protokoll-PDF.
*
* Fehlt das PDF noch, wird die Erzeugung angestoßen und der Aufrufer
* gebeten, es gleich noch einmal zu versuchen — statt ihn mit einem Fehler
* stehen zu lassen.
*/
protokollLink: mitRecht("pruefung.lesen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
select: {
id: true,
protokollNummer: true,
status: true,
protokoll: { select: { objektSchluessel: true, dateiname: true } },
geraet: { select: { inventarnummer: true } },
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (!pruefung.protokoll) {
if (pruefung.status === PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein Protokoll entsteht erst mit dem Abschluss der Prüfung.",
});
}
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
});
return { bereit: false as const, adresse: null };
}
const adresse = await downloadLink(
pruefung.protokoll.objektSchluessel,
pruefung.protokoll.dateiname,
);
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.DATEI_ABGERUFEN,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Protokoll ${pruefung.protokollNummer ?? ""} abgerufen`,
sitzung: ctx.sitzung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { bereit: true as const, adresse };
}),
),
});
+524
View File
@@ -0,0 +1,524 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BetroffenheitStatus, Prisma, RueckrufStatus } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Rückrufe.
*
* Ein Hersteller meldet einen Fehler an einer Baureihe. Die Wehr muss dann
* genau zwei Dinge tun: herausfinden, ob sie betroffene Stücke hat, und sie aus
* dem Verkehr ziehen, bis die Maßnahme des Herstellers durch ist.
*
* Genau das bildet dieser Router ab:
* geraeteSuchen zeigt, welche Geräte auf die Beschreibung passen
* zuordnen hält fest, welche betroffen sind, und sperrt sie auf Wunsch
* erledigen hebt die Sperre wieder auf, wenn die Maßnahme durch ist
*
* Die Suche ist bewusst ein Vorschlag und keine automatische Sperre. Eine
* Chargenangabe des Herstellers passt selten eins zu eins auf die
* Seriennummern im eigenen Bestand — das letzte Wort hat der Gerätewart.
*/
const LISTEN_AUSWAHL = {
id: true,
hersteller: true,
titel: true,
referenz: true,
veroeffentlichtAm: true,
status: true,
sperrtGeraete: true,
erledigtAm: true,
geloeschtAm: true,
_count: { select: { typen: true, betroffenheiten: true } },
} satisfies Prisma.RueckrufSelect;
const rueckrufFelder = z.object({
hersteller: z.string().trim().min(1, "Der Hersteller fehlt.").max(160),
titel: z.string().trim().min(1, "Ein Titel gehört dazu.").max(200),
referenz: z.string().trim().max(80).nullish(),
beschreibung: z.string().trim().max(4000).nullish(),
massnahme: z.string().trim().max(2000).nullish(),
veroeffentlichtAm: z.coerce.date().nullish(),
quelle: z.string().trim().max(500).nullish(),
sperrtGeraete: z.boolean().default(false),
});
const typFelder = z.object({
geraetetypId: z.string().uuid().nullish(),
modell: z.string().trim().max(160).nullish(),
seriennummerVon: z.string().trim().max(120).nullish(),
seriennummerBis: z.string().trim().max(120).nullish(),
herstellungVon: z.coerce.date().nullish(),
herstellungBis: z.coerce.date().nullish(),
});
type TypEingrenzung = z.infer<typeof typFelder>;
/**
* Aus einer Eingrenzung eine Suchbedingung machen.
*
* Die Seriennummern werden als Text verglichen, nicht als Zahl. Das ist die
* einzige Variante, die immer funktioniert: Feuerwehrgerät trägt Nummern wie
* "PA-2019-0421" oder "A45X99". Wo Hersteller reine Zahlen vergeben, stimmt der
* Textvergleich, solange die Nummern gleich lang sind — und das sind sie in
* einer Charge fast immer. Deshalb ist das Ergebnis ein Vorschlag.
*/
function bedingungAusTyp(typ: TypEingrenzung): Prisma.GeraetWhereInput {
return {
geloeschtAm: null,
...(typ.geraetetypId ? { geraetetypId: typ.geraetetypId } : {}),
...(typ.modell ? { modell: { contains: typ.modell, mode: "insensitive" as const } } : {}),
...(typ.seriennummerVon || typ.seriennummerBis
? {
seriennummer: {
...(typ.seriennummerVon ? { gte: typ.seriennummerVon } : {}),
...(typ.seriennummerBis ? { lte: typ.seriennummerBis } : {}),
},
}
: {}),
...(typ.herstellungVon || typ.herstellungBis
? {
herstellungsdatum: {
...(typ.herstellungVon ? { gte: typ.herstellungVon } : {}),
...(typ.herstellungBis ? { lte: typ.herstellungBis } : {}),
},
}
: {}),
};
}
export const rueckrufRouter = router({
liste: mitRecht("pruefung.lesen")
.input(
listenEingabe(["veroeffentlichtAm", "hersteller", "titel"] as const).extend({
status: z.array(z.nativeEnum(RueckrufStatus)).optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.RueckrufWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(sucheUeber(["titel", "hersteller", "referenz", "beschreibung"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.rueckruf.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: input.sortierung
? { [input.sortierung]: input.richtung }
: [{ status: "asc" }, { veroeffentlichtAm: "desc" }],
...blaettern(input),
}),
db.rueckruf.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({
where: { id: input.id },
include: {
typen: { include: { geraetetyp: { select: { id: true, name: true } } } },
betroffenheiten: {
include: {
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
seriennummer: true,
status: true,
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
},
},
orderBy: { festgestelltAm: "asc" },
},
},
});
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
return rueckruf;
}),
),
anlegen: mitRecht("pruefung.planen")
.input(rueckrufFelder.extend({ typen: z.array(typFelder).max(20).default([]) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { typen, ...felder } = input;
const rueckruf = await db.rueckruf.create({
data: {
organisationId: ctx.sitzung.organisationId,
...felder,
typen: {
create: typen.map((typ) => ({
organisationId: ctx.sitzung.organisationId,
...typ,
})),
},
},
include: { typen: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Rueckruf",
entitaetId: rueckruf.id,
beschreibung: `Rückruf "${rueckruf.titel}" von ${rueckruf.hersteller} erfasst`,
sitzung: ctx.sitzung,
nachher: rueckruf,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return rueckruf;
}),
),
aendern: mitRecht("pruefung.planen")
.input(rueckrufFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.rueckruf.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
const nachher = await db.rueckruf.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rueckruf",
entitaetId: id,
beschreibung: `Rückruf "${nachher.titel}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
typErgaenzen: mitRecht("pruefung.planen")
.input(z.object({ rueckrufId: z.string().uuid() }).and(typFelder))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { rueckrufId, ...felder } = input;
const typ = await db.rueckrufGeraetetyp.create({
data: { organisationId: ctx.sitzung.organisationId, rueckrufId, ...felder },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rueckruf",
entitaetId: rueckrufId,
beschreibung: "Betroffene Bauart zum Rückruf ergänzt",
sitzung: ctx.sitzung,
nachher: typ,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return typ;
}),
),
/**
* Welche Geräte passen auf die Beschreibung?
*
* Liefert einen Vorschlag samt Hinweis, welche davon schon zugeordnet sind.
* Zugeordnet wird erst im nächsten Schritt und nur, was der Gerätewart
* bestätigt.
*/
geraeteSuchen: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({
where: { id: input.id },
include: { typen: true, betroffenheiten: { select: { geraetId: true } } },
});
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
if (rueckruf.typen.length === 0) {
return { treffer: [], hinweis: "Für diesen Rückruf ist noch keine Bauart hinterlegt." };
}
const bereitsZugeordnet = new Set(
rueckruf.betroffenheiten.map((eintrag) => eintrag.geraetId),
);
const treffer = await db.geraet.findMany({
where: { OR: rueckruf.typen.map(bedingungAusTyp) },
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
hersteller: true,
modell: true,
seriennummer: true,
herstellungsdatum: true,
status: true,
geraetetyp: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
orderBy: { inventarnummer: "asc" },
take: 500,
});
return {
treffer: treffer.map((geraet) => ({
...geraet,
bereitsZugeordnet: bereitsZugeordnet.has(geraet.id),
})),
hinweis: null,
};
}),
),
/** Geräte als betroffen festhalten und auf Wunsch sperren. */
zuordnen: mitRecht("pruefung.planen")
.input(
z.object({
rueckrufId: z.string().uuid(),
geraetIds: z.array(z.string().uuid()).min(1).max(500),
sperren: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({ where: { id: input.rueckrufId } });
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
const sperren = input.sperren ?? rueckruf.sperrtGeraete;
const geraete = await db.geraet.findMany({
where: { id: { in: input.geraetIds }, geloeschtAm: null },
select: { id: true, inventarnummer: true, status: true },
});
let gesperrt = 0;
for (const geraet of geraete) {
const sperrenJetzt = sperren && geraet.status !== "GESPERRT";
await db.rueckrufBetroffenheit.upsert({
where: {
rueckrufId_geraetId: { rueckrufId: input.rueckrufId, geraetId: geraet.id },
},
create: {
organisationId: ctx.sitzung.organisationId,
rueckrufId: input.rueckrufId,
geraetId: geraet.id,
geraetGesperrt: sperrenJetzt,
},
update: {},
});
if (sperrenJetzt) {
await db.geraet.update({ where: { id: geraet.id }, data: { status: "GESPERRT" } });
gesperrt += 1;
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Von Rückruf "${rueckruf.titel}" betroffen${
sperrenJetzt ? " und gesperrt" : ""
}`,
sitzung: ctx.sitzung,
vorher: { status: geraet.status },
nachher: { status: sperrenJetzt ? "GESPERRT" : geraet.status },
kontext: { rueckrufId: input.rueckrufId },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
if (rueckruf.status === RueckrufStatus.OFFEN && geraete.length > 0) {
await db.rueckruf.update({
where: { id: input.rueckrufId },
data: { status: RueckrufStatus.IN_BEARBEITUNG },
});
}
return { zugeordnet: geraete.length, gesperrt };
}),
),
/**
* Stand je Gerät fortschreiben. Mit "erledigt" oder "nicht betroffen" wird
* eine durch den Rückruf gesetzte Sperre wieder aufgehoben — sofern kein
* offener Mangel dasselbe Gerät sperrt.
*/
betroffenheitSetzen: mitRecht("pruefung.planen")
.input(
z.object({
id: z.string().uuid(),
status: z.nativeEnum(BetroffenheitStatus),
bemerkung: z.string().trim().max(1000).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.rueckrufBetroffenheit.findUnique({
where: { id: input.id },
include: {
geraet: { select: { id: true, inventarnummer: true, status: true } },
rueckruf: { select: { id: true, titel: true } },
},
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Eintrag gibt es nicht." });
}
const abgeschlossen =
input.status === BetroffenheitStatus.ERLEDIGT ||
input.status === BetroffenheitStatus.NICHT_BETROFFEN;
const nachher = await db.rueckrufBetroffenheit.update({
where: { id: input.id },
data: {
status: input.status,
bemerkung: input.bemerkung ?? vorher.bemerkung,
erledigtAm: abgeschlossen ? new Date() : null,
geraetGesperrt: abgeschlossen ? false : vorher.geraetGesperrt,
},
});
let freigegeben = false;
if (abgeschlossen && vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
const offeneMaengel = await db.mangel.count({
where: {
geraetId: vorher.geraetId,
geraetGesperrt: true,
status: { notIn: ["BEHOBEN", "VERWORFEN"] },
geloeschtAm: null,
},
});
const weitereRueckrufe = await db.rueckrufBetroffenheit.count({
where: {
geraetId: vorher.geraetId,
id: { not: input.id },
geraetGesperrt: true,
status: { in: [BetroffenheitStatus.OFFEN, BetroffenheitStatus.MASSNAHME_LAEUFT] },
},
});
if (offeneMaengel === 0 && weitereRueckrufe === 0) {
await db.geraet.update({
where: { id: vorher.geraetId },
data: { status: "EINSATZBEREIT" },
});
freigegeben = true;
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "RueckrufBetroffenheit",
entitaetId: input.id,
beschreibung: `${vorher.geraet.inventarnummer} im Rückruf "${vorher.rueckruf.titel}": ${input.status}${
freigegeben ? ", Gerät wieder einsatzbereit" : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { ...nachher, geraetFreigegeben: freigegeben };
}),
),
/** Rückruf abschließen, wenn jedes betroffene Gerät abgearbeitet ist. */
abschliessen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({
where: { id: input.id },
include: { betroffenheiten: { select: { status: true } } },
});
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
const offen = rueckruf.betroffenheiten.filter(
(eintrag) =>
eintrag.status === BetroffenheitStatus.OFFEN ||
eintrag.status === BetroffenheitStatus.MASSNAHME_LAEUFT,
).length;
if (offen > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Es sind noch ${offen} Geräte offen. Erst wenn zu jedem ein Ergebnis steht, lässt sich der Rückruf abschließen.`,
});
}
const nachher = await db.rueckruf.update({
where: { id: input.id },
data: { status: RueckrufStatus.ERLEDIGT, erledigtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rueckruf",
entitaetId: input.id,
beschreibung: `Rückruf "${rueckruf.titel}" abgeschlossen`,
sitzung: ctx.sitzung,
vorher: { status: rueckruf.status },
nachher: { status: nachher.status },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+513
View File
@@ -0,0 +1,513 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { Prisma, WartungsterminStatus } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
idsEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
} from "@/server/routers/stammdaten/gemeinsam";
import {
FRISTFENSTER_REIHENFOLGE,
fensterGrenzen,
terminFenster,
type Fristfenster,
} from "@/lib/wartung/fristen";
/**
* Prüftermine und das Fristen-Dashboard.
*
* Das Dashboard ist die Ansicht, die der Gerätewart morgens aufmacht. Es
* beantwortet genau eine Frage — was ist zu tun — und ordnet die Antwort nach
* Dringlichkeit: überfällig, diese Woche, diesen Monat, die nächsten 90 Tage.
*
* Gruppieren lässt sich das nach Fahrzeug, Standort, Gerätetyp oder Zuständigem.
* Das ist kein Zierrat: wer einen Prüftag ansetzt, arbeitet ein Fahrzeug ab,
* nicht eine alphabetische Liste.
*/
/** Wonach sich die Termine bündeln lassen. */
const GRUPPIERUNG = ["fahrzeug", "standort", "geraetetyp", "zustaendig"] as const;
type Gruppierung = (typeof GRUPPIERUNG)[number];
const LISTEN_AUSWAHL = {
id: true,
faelligAm: true,
status: true,
automatischErzeugt: true,
bemerkung: true,
erledigtAm: true,
geloeschtAm: true,
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
geraetetyp: { select: { id: true, name: true } },
standort: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
},
},
vorlage: {
select: { id: true, name: true, intervallMonate: true, pruefvorschrift: true, standardExtern: true },
},
zugewiesenAn: { select: { id: true, vorname: true, nachname: true } },
} satisfies Prisma.WartungsterminSelect;
type TerminZeile = Prisma.WartungsterminGetPayload<{ select: typeof LISTEN_AUSWAHL }>;
/** Nach welchem Merkmal eine Zeile gebündelt wird. */
function gruppeVon(
termin: TerminZeile,
nach: Gruppierung,
): { schluessel: string; bezeichnung: string } {
switch (nach) {
case "fahrzeug":
return termin.geraet.fahrzeug
? { schluessel: termin.geraet.fahrzeug.id, bezeichnung: termin.geraet.fahrzeug.funkrufname }
: { schluessel: "ohne", bezeichnung: "nicht verlastet" };
case "standort":
return termin.geraet.standort
? { schluessel: termin.geraet.standort.id, bezeichnung: termin.geraet.standort.name }
: { schluessel: "ohne", bezeichnung: "ohne Standort" };
case "geraetetyp":
return termin.geraet.geraetetyp
? { schluessel: termin.geraet.geraetetyp.id, bezeichnung: termin.geraet.geraetetyp.name }
: { schluessel: "ohne", bezeichnung: "ohne Gerätetyp" };
case "zustaendig":
return termin.zugewiesenAn
? {
schluessel: termin.zugewiesenAn.id,
bezeichnung: `${termin.zugewiesenAn.nachname}, ${termin.zugewiesenAn.vorname}`,
}
: { schluessel: "ohne", bezeichnung: "niemandem zugewiesen" };
}
}
const terminFilter = z.object({
status: z.array(z.nativeEnum(WartungsterminStatus)).optional(),
fenster: z.enum(["ueberfaellig", "diese_woche", "dieser_monat", "naechste_90_tage"]).optional(),
fahrzeugId: z.string().uuid().optional(),
standortId: z.string().uuid().optional(),
geraetetypId: z.string().uuid().optional(),
geraetId: z.string().uuid().optional(),
vorlageId: z.string().uuid().optional(),
zugewiesenAnId: z.string().uuid().optional(),
});
/** Übersetzt Filter und Zeitfenster in eine Prisma-Bedingung. */
function bedingung(
filter: z.infer<typeof terminFilter> & { suche?: string; mitGeloeschten?: boolean },
heute = new Date(),
): Prisma.WartungsterminWhereInput {
const grenzen = fensterGrenzen(heute);
const zeitraum: Prisma.WartungsterminWhereInput =
filter.fenster === "ueberfaellig"
? { faelligAm: { lt: grenzen.heute } }
: filter.fenster === "diese_woche"
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inSiebenTagen } }
: filter.fenster === "dieser_monat"
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inDreissigTagen } }
: filter.fenster === "naechste_90_tage"
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inNeunzigTagen } }
: {};
return {
...ohneGeloeschte(filter.mitGeloeschten ?? false),
...(filter.status?.length ? { status: { in: filter.status } } : {}),
...(filter.vorlageId ? { vorlageId: filter.vorlageId } : {}),
...(filter.geraetId ? { geraetId: filter.geraetId } : {}),
...(filter.zugewiesenAnId ? { zugewiesenAnId: filter.zugewiesenAnId } : {}),
...zeitraum,
geraet: {
geloeschtAm: null,
...(filter.fahrzeugId ? { fahrzeugId: filter.fahrzeugId } : {}),
...(filter.standortId ? { standortId: filter.standortId } : {}),
...(filter.geraetetypId ? { geraetetypId: filter.geraetetypId } : {}),
...(filter.suche
? {
OR: [
{ inventarnummer: { contains: filter.suche, mode: "insensitive" as const } },
{ bezeichnung: { contains: filter.suche, mode: "insensitive" as const } },
],
}
: {}),
},
};
}
export const wartungsterminRouter = router({
/** Terminliste mit allen Filtern des Dashboards. */
liste: mitRecht("pruefung.lesen")
.input(listenEingabe(["faelligAm", "erstelltAm"] as const).extend(terminFilter.shape))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo = bedingung({
...input,
status: input.status ?? [WartungsterminStatus.OFFEN],
});
const [eintraege, gesamt] = await Promise.all([
db.wartungstermin.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: { faelligAm: input.richtung },
...blaettern(input),
}),
db.wartungstermin.count({ where: wo }),
]);
return listenAntwort(
eintraege.map((termin) => ({
...termin,
fenster: terminFenster(termin.faelligAm),
})),
gesamt,
input,
);
}),
),
/**
* Das Fristen-Dashboard.
*
* Gezählt wird über die Datenbank, gebündelt in der Anwendung: die
* Gruppierung geht über zwei Beziehungen (Termin → Gerät → Fahrzeug), und
* das kann Prismas groupBy nicht. Die Menge ist überschaubar — eine Wehr hat
* offene Termine in der Größenordnung von hunderten, nicht von Millionen.
*/
dashboard: mitRecht("pruefung.lesen")
.input(
z
.object({
gruppierung: z.enum(GRUPPIERUNG).default("fahrzeug"),
fahrzeugId: z.string().uuid().optional(),
standortId: z.string().uuid().optional(),
zugewiesenAnId: z.string().uuid().optional(),
})
.default({ gruppierung: "fahrzeug" }),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const heute = new Date();
const grenzen = fensterGrenzen(heute);
const grundfilter = {
status: [WartungsterminStatus.OFFEN],
fahrzeugId: input.fahrzeugId,
standortId: input.standortId,
zugewiesenAnId: input.zugewiesenAnId,
};
const offene = await db.wartungstermin.findMany({
where: bedingung({ ...grundfilter }, heute),
select: LISTEN_AUSWAHL,
orderBy: { faelligAm: "asc" },
// Genug für jede Wehr; wird es je mehr, ist die Liste ohnehin
// unbrauchbar und es braucht einen Filter, keine längere Liste.
take: 2000,
});
const leer = (): Record<Fristfenster, number> => ({
ueberfaellig: 0,
diese_woche: 0,
dieser_monat: 0,
naechste_90_tage: 0,
spaeter: 0,
});
const gesamtFenster = leer();
const gruppen = new Map<
string,
{ schluessel: string; bezeichnung: string; fenster: Record<Fristfenster, number>; gesamt: number }
>();
for (const termin of offene) {
const fenster = terminFenster(termin.faelligAm, heute);
gesamtFenster[fenster] += 1;
const { schluessel, bezeichnung } = gruppeVon(termin, input.gruppierung);
const eintrag = gruppen.get(schluessel) ?? {
schluessel,
bezeichnung,
fenster: leer(),
gesamt: 0,
};
eintrag.fenster[fenster] += 1;
eintrag.gesamt += 1;
gruppen.set(schluessel, eintrag);
}
// Lebensdauerüberwachung: was läuft ab, was ist abgelaufen. Beides
// gehört neben die Prüffristen — für die Wehr ist es dieselbe Frage:
// welches Gerät darf nächsten Monat nicht mehr aufs Fahrzeug.
const [lebensdauerAbgelaufen, lebensdauerBald, maengelOffen, rueckrufeOffen] =
await Promise.all([
db.geraet.count({
where: { geloeschtAm: null, lebensdauerBis: { lt: grenzen.heute } },
}),
db.geraet.count({
where: {
geloeschtAm: null,
lebensdauerBis: { gte: grenzen.heute, lte: grenzen.inNeunzigTagen },
},
}),
db.mangel.count({
where: { geloeschtAm: null, status: { notIn: ["BEHOBEN", "VERWORFEN"] } },
}),
db.rueckruf.count({ where: { geloeschtAm: null, status: { not: "ERLEDIGT" } } }),
]);
return {
fenster: gesamtFenster,
reihenfolge: FRISTFENSTER_REIHENFOLGE,
gruppierung: input.gruppierung,
gruppen: [...gruppen.values()].sort((a, b) => {
// Wo es brennt, steht oben.
if (b.fenster.ueberfaellig !== a.fenster.ueberfaellig) {
return b.fenster.ueberfaellig - a.fenster.ueberfaellig;
}
return b.gesamt - a.gesamt;
}),
naechste: offene.slice(0, 25).map((termin) => ({
...termin,
fenster: terminFenster(termin.faelligAm, heute),
})),
lebensdauer: { abgelaufen: lebensdauerAbgelaufen, bald: lebensdauerBald },
maengelOffen,
rueckrufeOffen,
stand: heute,
};
}),
),
/**
* Geräte, deren Nutzungsdauer abläuft — mit Vorschlag zur Aussonderung.
* Helme, Seile und Schutzkleidung sind irgendwann unabhängig vom Zustand
* nicht mehr zugelassen; wer das übersieht, hat im Ernstfall ein Problem.
*/
lebensdauer: mitRecht("pruefung.lesen")
.input(
z.object({ vorwarnTage: z.number().int().min(0).max(1095).default(180) }).default({
vorwarnTage: 180,
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const heute = fensterGrenzen().heute;
const grenze = new Date(heute.getTime() + input.vorwarnTage * 86_400_000);
const geraete = await db.geraet.findMany({
where: {
geloeschtAm: null,
status: { not: "AUSGESONDERT" },
lebensdauerBis: { not: null, lte: grenze },
},
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
lebensdauerBis: true,
lebensdauerKategorie: true,
herstellungsdatum: true,
indienststellung: true,
geraetetyp: { select: { id: true, name: true, lebensdauerKategorie: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
orderBy: { lebensdauerBis: "asc" },
take: 500,
});
return geraete.map((geraet) => ({
...geraet,
abgelaufen: Boolean(geraet.lebensdauerBis && geraet.lebensdauerBis < heute),
}));
}),
),
/** Termin von Hand anlegen — für alles, was keine Vorlage abdeckt. */
anlegen: mitRecht("pruefung.planen")
.input(
z.object({
geraetId: z.string().uuid(),
vorlageId: z.string().uuid().nullish(),
faelligAm: z.coerce.date(),
zugewiesenAnId: z.string().uuid().nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.geraetId },
select: { id: true, inventarnummer: true, bezeichnung: true },
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const termin = await db.wartungstermin.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: input.geraetId,
vorlageId: input.vorlageId ?? null,
faelligAm: input.faelligAm,
zugewiesenAnId: input.zugewiesenAnId ?? null,
bemerkung: input.bemerkung ?? null,
automatischErzeugt: false,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wartungstermin",
entitaetId: termin.id,
beschreibung: `Prüftermin für ${geraet.inventarnummer} angelegt`,
sitzung: ctx.sitzung,
nachher: termin,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return termin;
}),
),
aendern: mitRecht("pruefung.planen")
.input(
z.object({
id: z.string().uuid(),
faelligAm: z.coerce.date().optional(),
zugewiesenAnId: z.string().uuid().nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.wartungstermin.findUnique({
where: { id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
}
if (vorher.status !== WartungsterminStatus.OFFEN) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Nur offene Termine lassen sich ändern.",
});
}
const nachher = await db.wartungstermin.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungstermin",
entitaetId: id,
beschreibung: `Prüftermin für ${vorher.geraet.inventarnummer} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/** Mehrere Termine auf einmal jemandem zuweisen — der Prüftag am Stück. */
zuweisen: mitRecht("pruefung.planen")
.input(idsEingabe.extend({ zugewiesenAnId: z.string().uuid().nullish() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const termine = await db.wartungstermin.findMany({
where: { id: { in: input.ids }, status: WartungsterminStatus.OFFEN, geloeschtAm: null },
select: { id: true, zugewiesenAnId: true, geraet: { select: { inventarnummer: true } } },
});
if (termine.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Keinen dieser Termine gibt es." });
}
await db.wartungstermin.updateMany({
where: { id: { in: termine.map((termin) => termin.id) } },
data: { zugewiesenAnId: input.zugewiesenAnId ?? null },
});
for (const termin of termine) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungstermin",
entitaetId: termin.id,
beschreibung: `Prüftermin für ${termin.geraet.inventarnummer} zugewiesen`,
sitzung: ctx.sitzung,
vorher: { zugewiesenAnId: termin.zugewiesenAnId },
nachher: { zugewiesenAnId: input.zugewiesenAnId ?? null },
kontext: { sammelvorgang: true, anzahl: termine.length },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
return { zugewiesen: termine.length };
}),
),
/**
* Termin für gegenstandslos erklären. Kein Löschen: dass ein Termin bestand
* und warum er entfiel, gehört zur Nachweiskette.
*/
entfallen: mitRecht("pruefung.planen")
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungstermin.findUnique({
where: { id: input.id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
}
const nachher = await db.wartungstermin.update({
where: { id: input.id },
data: {
status: WartungsterminStatus.ENTFALLEN,
bemerkung: input.grund ?? vorher.bemerkung,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungstermin",
entitaetId: input.id,
beschreibung: `Prüftermin für ${vorher.geraet.inventarnummer} entfällt${
input.grund ? `: ${input.grund}` : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+429
View File
@@ -0,0 +1,429 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { ChecklistenpunktTyp, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Wartungsvorlagen und ihre Checklisten.
*
* Die Vorlage ist die Regel — "Pressluftatmer, alle 12 Monate, DGUV 49" — und
* die Checklistenpunkte sind die Fragen, die dabei zu beantworten sind. Beides
* pflegt der Gerätewart, beides ist Voraussetzung für alles Weitere: ohne
* Vorlage gibt es keine Termine, ohne Punkte keine Prüfung.
*
* Gelöscht wird nur weich. Eine Vorlage hängt an abgeschlossenen Protokollen,
* und die müssen in zehn Jahren noch erklärbar sein.
*/
const SORTIERFELDER = ["name", "intervallMonate", "erstelltAm"] as const;
const SUCHFELDER = ["name", "pruefvorschrift", "beschreibung"];
const LISTEN_AUSWAHL = {
id: true,
name: true,
intervallMonate: true,
vorwarnzeitTage: true,
pruefvorschrift: true,
standardDauerMinuten: true,
standardExtern: true,
automatisch: true,
aktiv: true,
geloeschtAm: true,
geraetetyp: { select: { id: true, name: true, kategorie: true } },
_count: { select: { punkte: true, termine: true, pruefungen: true } },
} satisfies Prisma.WartungsvorlageSelect;
const vorlageFelder = z.object({
name: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(160),
geraetetypId: z.string().uuid().nullish(),
intervallMonate: z
.number()
.int()
.min(1, "Ein Intervall von null Monaten ergibt keinen Termin.")
.max(600),
vorwarnzeitTage: z.number().int().min(0).max(365).default(30),
pruefvorschrift: z.string().trim().max(200).nullish(),
standardDauerMinuten: z.number().int().min(1).max(10_000).nullish(),
standardExtern: z.boolean().default(false),
automatisch: z.boolean().default(true),
beschreibung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
const punktBasis = z.object({
text: z.string().trim().min(1, "Der Prüfpunkt braucht einen Text.").max(300),
typ: z.nativeEnum(ChecklistenpunktTyp).default(ChecklistenpunktTyp.JA_NEIN),
pflicht: z.boolean().default(true),
sollwert: z.string().trim().max(120).nullish(),
einheit: z.string().trim().max(20).nullish(),
minWert: z.number().nullish(),
maxWert: z.number().nullish(),
hinweis: z.string().trim().max(500).nullish(),
sortierung: z.number().int().min(0).max(9999).default(0),
});
/** Ein Bereich, dessen Untergrenze über der Obergrenze liegt, ist kein Bereich. */
function bereichSinnvoll(wert: { minWert?: number | null; maxWert?: number | null }): boolean {
if (wert.minWert === null || wert.minWert === undefined) return true;
if (wert.maxWert === null || wert.maxWert === undefined) return true;
return wert.minWert <= wert.maxWert;
}
const BEREICH_FEHLER = {
message: "Der kleinste zulässige Wert darf nicht über dem größten liegen.",
path: ["minWert"],
};
const punktFelder = punktBasis.refine(bereichSinnvoll, BEREICH_FEHLER);
const punktAenderung = punktBasis.partial().refine(bereichSinnvoll, BEREICH_FEHLER);
export const wartungsvorlageRouter = router({
liste: mitRecht("pruefung.lesen")
.input(
listenEingabe(SORTIERFELDER).extend({
geraetetypId: z.string().uuid().optional(),
nurAktive: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.WartungsvorlageWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.geraetetypId ? { geraetetypId: input.geraetetypId } : {}),
...(input.nurAktive ? { aktiv: true } : {}),
...(sucheUeber(SUCHFELDER, input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.wartungsvorlage.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.WartungsvorlageOrderByWithRelationInput,
...blaettern(input),
}),
db.wartungsvorlage.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
/** Eine Vorlage mit ihrer vollständigen Checkliste. */
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorlage = await db.wartungsvorlage.findUnique({
where: { id: input.id },
include: {
geraetetyp: { select: { id: true, name: true, kategorie: true } },
punkte: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
},
_count: { select: { termine: true, pruefungen: true } },
},
});
if (!vorlage) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
return vorlage;
}),
),
anlegen: mitRecht("pruefung.planen")
.input(vorlageFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorlage = await db.wartungsvorlage.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wartungsvorlage",
entitaetId: vorlage.id,
beschreibung: `Wartungsvorlage "${vorlage.name}" angelegt (alle ${vorlage.intervallMonate} Monate)`,
sitzung: ctx.sitzung,
nachher: vorlage,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return vorlage;
}),
),
aendern: mitRecht("pruefung.planen")
.input(vorlageFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.wartungsvorlage.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
const nachher = await db.wartungsvorlage.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungsvorlage",
entitaetId: id,
beschreibung: `Wartungsvorlage "${nachher.name}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Weiches Löschen. Offene Termine dieser Vorlage werden gleich mit auf
* "entfallen" gesetzt — sonst bliebe im Dashboard eine Fälligkeit stehen,
* zu der es keine Regel mehr gibt.
*/
loeschen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
const nachher = await db.wartungsvorlage.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
const entfallen = await db.wartungstermin.updateMany({
where: { vorlageId: input.id, status: "OFFEN", geloeschtAm: null },
data: { status: "ENTFALLEN" },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Wartungsvorlage",
entitaetId: input.id,
beschreibung: `Wartungsvorlage "${vorher.name}" gelöscht, ${entfallen.count} offene Termine entfallen`,
sitzung: ctx.sitzung,
vorher,
nachher,
kontext: { entfalleneTermine: entfallen.count },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true, entfalleneTermine: entfallen.count };
}),
),
wiederherstellen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
if (!vorher?.geloeschtAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Vorlage ist gar nicht gelöscht.",
});
}
const nachher = await db.wartungsvorlage.update({
where: { id: input.id },
data: { geloeschtAm: null, aktiv: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.WIEDERHERGESTELLT,
entitaet: "Wartungsvorlage",
entitaetId: input.id,
beschreibung: `Wartungsvorlage "${nachher.name}" wiederhergestellt`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
// ---------------------------------------------------------------------------
// Checklistenpunkte
// ---------------------------------------------------------------------------
punktAnlegen: mitRecht("pruefung.planen")
.input(z.object({ vorlageId: z.string().uuid() }).and(punktFelder))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { vorlageId, ...felder } = input;
const vorlage = await db.wartungsvorlage.findUnique({
where: { id: vorlageId },
select: { id: true, name: true },
});
if (!vorlage) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
// Neue Punkte hängen sich hinten an, wenn keine Sortierung angegeben ist.
const sortierung =
felder.sortierung > 0
? felder.sortierung
: ((
await db.checklistenpunkt.aggregate({
where: { vorlageId, geloeschtAm: null },
_max: { sortierung: true },
})
)._max.sortierung ?? 0) + 10;
const punkt = await db.checklistenpunkt.create({
data: {
organisationId: ctx.sitzung.organisationId,
vorlageId,
...felder,
sortierung,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Checklistenpunkt",
entitaetId: punkt.id,
beschreibung: `Prüfpunkt "${punkt.text}" zu "${vorlage.name}" ergänzt`,
sitzung: ctx.sitzung,
nachher: punkt,
kontext: { vorlageId },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return punkt;
}),
),
punktAendern: mitRecht("pruefung.planen")
.input(z.object({ id: z.string().uuid() }).and(punktAenderung))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.checklistenpunkt.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
const nachher = await db.checklistenpunkt.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Checklistenpunkt",
entitaetId: id,
beschreibung: `Prüfpunkt "${nachher.text}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Weiches Löschen — ein Punkt kann in abgeschlossenen Protokollen stehen.
* Die Datenbank verhindert das harte Löschen ohnehin (Restrict auf
* pruefergebnis), aber die verständliche Fassung steht hier.
*/
punktLoeschen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.checklistenpunkt.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
await db.checklistenpunkt.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Checklistenpunkt",
entitaetId: input.id,
beschreibung: `Prüfpunkt "${vorher.text}" entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/** Reihenfolge der Checkliste festlegen — die Prüfung läuft sie so ab. */
punkteSortieren: mitRecht("pruefung.planen")
.input(
z.object({
vorlageId: z.string().uuid(),
reihenfolge: z.array(z.string().uuid()).min(1).max(500),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
for (const [index, punktId] of input.reihenfolge.entries()) {
await db.checklistenpunkt.updateMany({
where: { id: punktId, vorlageId: input.vorlageId },
data: { sortierung: (index + 1) * 10 },
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungsvorlage",
entitaetId: input.vorlageId,
beschreibung: `Reihenfolge der Checkliste geändert (${input.reihenfolge.length} Punkte)`,
sitzung: ctx.sitzung,
kontext: { reihenfolge: input.reihenfolge },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { sortiert: input.reihenfolge.length };
}),
),
});
+106 -17
View File
@@ -1,38 +1,84 @@
// Im Container liefert Compose die Umgebung, lokal die .env. dotenv
// überschreibt nichts, was schon gesetzt ist — im Betrieb ändert die Zeile
// also nichts, macht aber "npm run worker" auf dem Entwicklungsrechner möglich.
import "dotenv/config";
import { Worker, type Job } from "bullmq"; import { Worker, type Job } from "bullmq";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange"; import {
import { verbindungenTrennen } from "@/lib/db"; AUFTRAG,
WARTESCHLANGE,
beauftrageWiederkehrend,
redis,
type AuftragsDaten,
} from "@/lib/warteschlange";
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
/** /**
* Hintergrundprozess. * Hintergrundprozess.
* *
* Läuft als eigener Container neben der Anwendung und arbeitet die * Läuft als eigener Container neben der Anwendung und arbeitet die
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich * Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in * Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung, * Aufbewahrung folgt in Phase 8.
* Fehlerbehandlung und ein sauberes Herunterfahren. *
* Beim Start trägt sich der Prozess die wiederkehrenden Läufe je Wehr ein.
* Das ist bewusst hier und nicht in der Anwendung: der Worker ist der Einzige,
* der sie auch ausführt, und ein Zeitplan ohne Ausführenden ist nutzlos.
*/ */
function protokoll(nachricht: string, ...weiteres: unknown[]): void { function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[worker] ${nachricht}`, ...weiteres); console.info(`[worker] ${nachricht}`, ...weiteres);
} }
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> { async function fristenPruefen(auftrag: Job<AuftragsDaten>) {
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`); const { organisationId } = auftrag.data;
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen. const ergebnis = await terminePlanen(organisationId);
return { geprueft: 0 };
protokoll(
`Fristen geprüft für ${organisationId}: ${ergebnis.angelegt} Termine geplant, ` +
`${ergebnis.statusGeaendert} Geräte im Status geändert`,
);
return ergebnis;
} }
async function erinnerungSenden(auftrag: Job<AuftragsDaten>): Promise<{ versendet: number }> { async function erinnerungSenden(auftrag: Job<AuftragsDaten>) {
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`); const { organisationId, anlass, mangelId } = auftrag.data as AuftragsDaten & {
// Ab Phase 7: Mail und Telegram über lib/integrations. anlass?: string;
return { versendet: 0 }; mangelId?: string;
};
// Mängel melden sich sofort, Termine im nächtlichen Sammellauf.
if (anlass?.startsWith("mangel") && mangelId) {
return mangelBenachrichtigung(organisationId, mangelId, anlass);
}
return erinnerungenSenden(organisationId);
} }
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> { async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`); const { organisationId, pruefungId } = auftrag.data as AuftragsDaten & { pruefungId?: string };
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
if (!pruefungId) {
throw new Error("PDF-Auftrag ohne Angabe, wozu — pruefungId fehlt.");
}
const ergebnis = await protokollErzeugen(organisationId, pruefungId);
if (!ergebnis) {
protokoll(`Kein Protokoll für Prüfung ${pruefungId} — unbekannt oder noch Entwurf.`);
return { erzeugt: false }; return { erzeugt: false };
}
protokoll(
ergebnis.neu
? `Protokoll für Prüfung ${pruefungId} erzeugt.`
: `Protokoll für Prüfung ${pruefungId} lag bereits vor.`,
);
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
} }
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> { async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
@@ -49,6 +95,36 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen, [AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
}; };
/**
* Trägt die nächtlichen Läufe für jede aktive Wehr ein.
*
* BullMQ hält wiederkehrende Aufträge in Redis; ein zweiter Eintrag mit
* derselben Auftragskennung ersetzt den ersten. Der Aufruf ist deshalb bei
* jedem Start wiederholbar, und eine neu angelegte Wehr kommt beim nächsten
* Neustart des Workers dazu.
*/
async function zeitplaeneEinrichten(): Promise<number> {
const organisationen = await ohneMandantenfilter(
"Wartungsjobs für alle Wehren einplanen",
(db) => db.organisation.findMany({ where: { aktiv: true, geloeschtAm: null }, select: { id: true, kurzname: true } }),
);
for (const organisation of organisationen) {
await beauftrageWiederkehrend(
AUFTRAG.FRISTEN_PRUEFEN,
{ organisationId: organisation.id },
env.WARTUNG_NACHTLAUF,
);
await beauftrageWiederkehrend(
AUFTRAG.ERINNERUNG_SENDEN,
{ organisationId: organisation.id },
env.WARTUNG_ERINNERUNG_LAUF,
);
}
return organisationen.length;
}
const worker = new Worker<AuftragsDaten>( const worker = new Worker<AuftragsDaten>(
WARTESCHLANGE, WARTESCHLANGE,
async (auftrag) => { async (auftrag) => {
@@ -78,6 +154,19 @@ worker.on("ready", () => {
protokoll( protokoll(
`bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`, `bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`,
); );
void zeitplaeneEinrichten()
.then((anzahl) =>
protokoll(
`Zeitpläne eingerichtet für ${anzahl} Wehren ` +
`(Planung ${env.WARTUNG_NACHTLAUF}, Erinnerungen ${env.WARTUNG_ERINNERUNG_LAUF})`,
),
)
.catch((fehler) => {
// Ein fehlgeschlagener Zeitplan darf den Worker nicht aufhalten: die
// Aufträge aus der Anwendung sollen weiter abgearbeitet werden.
console.error("[worker] Zeitpläne ließen sich nicht einrichten:", fehler);
});
}); });
/** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */ /** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */
+450
View File
@@ -0,0 +1,450 @@
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { fuerOrganisation } from "@/lib/db";
import { env } from "@/lib/env";
import { mail } from "@/lib/integrations/mail";
import { abgeleiteterGeraetStatus } from "@/lib/stammdaten/fristen";
import { erinnerungsstufe, type Erinnerungsstufe } from "@/lib/wartung/fristen";
import { fruehesteFaelligkeit, zuPlanendeTermine, type PlanungsGeraet } from "@/lib/wartung/planung";
import { alsDatum } from "@/lib/utils";
/**
* Die nächtliche Arbeit hinter dem Prüfmanagement.
*
* Zwei Läufe, die getrennt bleiben, weil sie Verschiedenes tun und
* verschieden schiefgehen können:
*
* terminePlanen legt fällige Termine an und zieht den Gerätestatus nach
* erinnerungenSenden verschickt Vorwarnungen zu offenen Terminen
*
* Beide sind wiederholbar. Wer sie zweimal laufen lässt, bekommt keine
* doppelten Termine (Eindeutigkeit über Gerät, Vorlage und Tag) und keine
* doppelten Mails (ein Erinnerungseintrag je Termin und Stufe).
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[wartung] ${nachricht}`, ...weiteres);
}
// -----------------------------------------------------------------------------
// Termine planen
// -----------------------------------------------------------------------------
export interface PlanungsErgebnis {
angelegt: number;
statusGeaendert: number;
vorlagen: number;
}
/**
* Plant Termine für eine Wehr und zieht danach den Status der Geräte nach.
*
* Der Status ist der eigentliche Zweck des Laufs: ein Gerät, dessen Prüfung
* heute fällig geworden ist, soll morgen früh nicht mehr als einsatzbereit in
* der Liste stehen. Von Hand gesetzte Zustände — gesperrt, ausgesondert, in
* Reparatur — bleiben unangetastet; darüber hat jemand bewusst entschieden.
*/
export async function terminePlanen(
organisationId: string,
heute = new Date(),
): Promise<PlanungsErgebnis> {
const horizontTage = env.WARTUNG_PLANUNGSHORIZONT_TAGE;
const angelegt = await fuerOrganisation(organisationId, async (db) => {
const vorlagen = await db.wartungsvorlage.findMany({
where: { aktiv: true, automatisch: true, geloeschtAm: null, geraetetypId: { not: null } },
select: { id: true, name: true, intervallMonate: true, geraetetypId: true },
});
let summe = 0;
for (const vorlage of vorlagen) {
const geraete = await db.geraet.findMany({
where: {
geraetetypId: vorlage.geraetetypId,
geloeschtAm: null,
status: { not: "AUSGESONDERT" },
},
select: {
id: true,
inventarnummer: true,
status: true,
indienststellung: true,
letztePruefung: true,
wartungstermine: {
where: { vorlageId: vorlage.id, status: "OFFEN", geloeschtAm: null },
select: { id: true },
take: 1,
},
pruefungen: {
where: { vorlageId: vorlage.id, status: "ABGESCHLOSSEN", geloeschtAm: null },
orderBy: { durchgefuehrtAm: "desc" },
select: { durchgefuehrtAm: true },
take: 1,
},
},
});
const planungsdaten: PlanungsGeraet[] = geraete.map((geraet) => ({
id: geraet.id,
inventarnummer: geraet.inventarnummer,
status: geraet.status,
indienststellung: geraet.indienststellung,
letztePruefungZurVorlage: geraet.pruefungen[0]?.durchgefuehrtAm ?? null,
letztePruefung: geraet.letztePruefung,
hatOffenenTermin: geraet.wartungstermine.length > 0,
}));
const geplant = zuPlanendeTermine(vorlage, planungsdaten, { horizontTage, heute });
if (geplant.length === 0) continue;
const ergebnis = await db.wartungstermin.createMany({
data: geplant.map((termin) => ({
organisationId,
geraetId: termin.geraetId,
vorlageId: termin.vorlageId,
faelligAm: termin.faelligAm,
automatischErzeugt: true,
bemerkung: termin.ohneBasisdatum
? "Ohne Angabe zu letzter Prüfung oder Indienststellung — sofort zu prüfen."
: null,
})),
// Ein zweiter Lauf am selben Tag legt nichts doppelt an.
skipDuplicates: true,
});
summe += ergebnis.count;
}
if (summe > 0) {
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wartungstermin",
beschreibung: `Nächtlicher Lauf: ${summe} Prüftermine geplant`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
kontext: { vorlagen: vorlagen.length, horizontTage },
});
}
return { summe, vorlagen: vorlagen.length };
});
const statusGeaendert = await geraeteStatusNachziehen(organisationId, heute);
return {
angelegt: angelegt.summe,
vorlagen: angelegt.vorlagen,
statusGeaendert,
};
}
/**
* Setzt an jedem Gerät die nächste Frist und den daraus folgenden Status.
*
* Die Frist am Gerät ist die früheste offene Terminfälligkeit — ein
* Pressluftatmer, der jährlich sichtgeprüft und alle sechs Jahre überholt wird,
* hat zwei Termine, aber nur eine nächste Frist.
*/
async function geraeteStatusNachziehen(organisationId: string, heute: Date): Promise<number> {
return fuerOrganisation(organisationId, async (db) => {
const geraete = await db.geraet.findMany({
where: { geloeschtAm: null, status: { not: "AUSGESONDERT" } },
select: {
id: true,
inventarnummer: true,
status: true,
naechstePruefung: true,
lebensdauerBis: true,
wartungstermine: {
where: { status: "OFFEN", geloeschtAm: null },
select: { faelligAm: true },
},
},
});
let geaendert = 0;
for (const geraet of geraete) {
const ausTerminen = fruehesteFaelligkeit(geraet.wartungstermine);
const naechste = ausTerminen ?? geraet.naechstePruefung;
const neuerStatus = abgeleiteterGeraetStatus({
aktuellerStatus: geraet.status,
naechstePruefung: naechste,
lebensdauerBis: geraet.lebensdauerBis,
heute,
});
const fristGleich =
(naechste?.getTime() ?? null) === (geraet.naechstePruefung?.getTime() ?? null);
if (neuerStatus === geraet.status && fristGleich) continue;
await db.geraet.update({
where: { id: geraet.id },
data: { status: neuerStatus, naechstePruefung: naechste },
});
if (neuerStatus !== geraet.status) {
geaendert += 1;
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Nächtlicher Lauf: Status von ${geraet.inventarnummer} auf ${neuerStatus}`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
vorher: { status: geraet.status, naechstePruefung: geraet.naechstePruefung },
nachher: { status: neuerStatus, naechstePruefung: naechste },
});
}
}
return geaendert;
});
}
// -----------------------------------------------------------------------------
// Erinnerungen
// -----------------------------------------------------------------------------
const STUFE_TEXT: Record<Erinnerungsstufe, string> = {
T_30: "Vorwarnung",
T_7: "in einer Woche fällig",
T_0: "heute fällig",
UEBERFAELLIG: "überfällig",
};
interface Faellig {
terminId: string;
stufe: Erinnerungsstufe;
faelligAm: Date;
inventarnummer: string;
bezeichnung: string;
vorlage: string;
empfaenger: string[];
}
/**
* Verschickt die Vorwarnungen zu offenen Terminen.
*
* Empfänger ist, wer den Termin übernommen hat. Ist niemand zuständig, gehen
* die Erinnerungen an die Gerätewarte der Wehr — der Kommandant bekommt nicht
* jeden Schlauchprüftermin auf den Tisch.
*
* Verschickt wird eine Sammelnachricht je Empfänger, nicht eine Mail je Gerät.
* Zwanzig einzelne Mails an einem Morgen liest niemand.
*/
export async function erinnerungenSenden(
organisationId: string,
heute = new Date(),
): Promise<{ termine: number; nachrichten: number }> {
const faellige = await fuerOrganisation(organisationId, async (db) => {
const termine = await db.wartungstermin.findMany({
where: { status: "OFFEN", geloeschtAm: null, geraet: { geloeschtAm: null } },
select: {
id: true,
faelligAm: true,
geraet: { select: { inventarnummer: true, bezeichnung: true } },
vorlage: { select: { name: true, vorwarnzeitTage: true } },
zugewiesenAn: { select: { email: true, aktiv: true } },
erinnerungen: { select: { stufe: true } },
},
take: 5000,
});
// Ersatzempfänger: die Gerätewarte der Wehr.
const geraetewarte = await db.person.findMany({
where: {
aktiv: true,
geloeschtAm: null,
email: { not: null },
rollen: {
some: {
rolle: { schluessel: { in: ["GERAETEWART", "ATEMSCHUTZGERAETEWART"] } },
},
},
},
select: { email: true },
});
const ersatz = geraetewarte
.map((person) => person.email)
.filter((adresse): adresse is string => Boolean(adresse));
const offen: Faellig[] = [];
for (const termin of termine) {
const stufe = erinnerungsstufe(
termin.faelligAm,
termin.vorlage?.vorwarnzeitTage ?? 30,
heute,
);
if (!stufe) continue;
const bereits = new Set(termin.erinnerungen.map((eintrag) => eintrag.stufe));
if (bereits.has(stufe)) continue;
const zustaendig =
termin.zugewiesenAn?.aktiv && termin.zugewiesenAn.email
? [termin.zugewiesenAn.email]
: ersatz;
offen.push({
terminId: termin.id,
stufe,
faelligAm: termin.faelligAm,
inventarnummer: termin.geraet.inventarnummer,
bezeichnung: termin.geraet.bezeichnung,
vorlage: termin.vorlage?.name ?? "Prüfung",
empfaenger: zustaendig,
});
}
return offen;
});
if (faellige.length === 0) return { termine: 0, nachrichten: 0 };
// Nach Empfänger bündeln.
const nachEmpfaenger = new Map<string, Faellig[]>();
for (const eintrag of faellige) {
for (const adresse of eintrag.empfaenger) {
const liste = nachEmpfaenger.get(adresse) ?? [];
liste.push(eintrag);
nachEmpfaenger.set(adresse, liste);
}
}
const versand = mail();
let nachrichten = 0;
for (const [adresse, eintraege] of nachEmpfaenger) {
const ueberfaellig = eintraege.filter((eintrag) => eintrag.stufe === "UEBERFAELLIG").length;
const zeilen = eintraege
.sort((a, b) => a.faelligAm.getTime() - b.faelligAm.getTime())
.map(
(eintrag) =>
`- ${eintrag.inventarnummer} (${eintrag.bezeichnung}): ${eintrag.vorlage}, ` +
`fällig am ${alsDatum(eintrag.faelligAm)} — ${STUFE_TEXT[eintrag.stufe]}`,
);
const ergebnis = await versand.senden({
an: adresse,
betreff:
ueberfaellig > 0
? `Prüftermine: ${ueberfaellig} überfällig, ${eintraege.length} insgesamt`
: `Prüftermine: ${eintraege.length} anstehend`,
text: [
"Folgende Prüfungen stehen an:",
"",
...zeilen,
"",
"Diese Nachricht kommt von FLORIAN und wiederholt sich je Termin und Stufe nur einmal.",
].join("\n"),
});
if (ergebnis.erfolgreich) nachrichten += 1;
}
// Erst nach dem Versand vermerken. Andersherum wäre eine ausgefallene
// Mailanbindung eine stillschweigend verschluckte Erinnerung.
await fuerOrganisation(organisationId, async (db) => {
for (const eintrag of faellige) {
await db.erinnerung.upsert({
where: { terminId_stufe: { terminId: eintrag.terminId, stufe: eintrag.stufe } },
create: {
organisationId,
terminId: eintrag.terminId,
stufe: eintrag.stufe,
kanal: versand.verfuegbar() ? "mail" : "protokoll",
empfaenger: eintrag.empfaenger,
erfolgreich: true,
},
update: {},
});
}
});
protokoll(`${faellige.length} Erinnerungen, ${nachrichten} Nachrichten verschickt`);
return { termine: faellige.length, nachrichten };
}
/**
* Benachrichtigung zu einem Mangel.
*
* Anders als die Terminerinnerung geht sie sofort raus: ein kritischer Mangel
* bedeutet, dass ein Gerät gerade aus dem Dienst genommen wurde. Das muss die
* Zuständigen noch am selben Tag erreichen.
*/
export async function mangelBenachrichtigung(
organisationId: string,
mangelId: string,
anlass: string,
): Promise<{ versendet: boolean }> {
const daten = await fuerOrganisation(organisationId, async (db) => {
const mangel = await db.mangel.findUnique({
where: { id: mangelId },
select: {
beschreibung: true,
prioritaet: true,
status: true,
geraetGesperrt: true,
melderName: true,
geraet: { select: { inventarnummer: true, bezeichnung: true } },
zugewiesenAn: { select: { email: true, aktiv: true } },
},
});
if (!mangel) return null;
const geraetewarte = await db.person.findMany({
where: {
aktiv: true,
geloeschtAm: null,
email: { not: null },
rollen: {
some: { rolle: { schluessel: { in: ["GERAETEWART", "ATEMSCHUTZGERAETEWART"] } } },
},
},
select: { email: true },
});
const empfaenger =
mangel.zugewiesenAn?.aktiv && mangel.zugewiesenAn.email
? [mangel.zugewiesenAn.email]
: geraetewarte
.map((person) => person.email)
.filter((adresse): adresse is string => Boolean(adresse));
return { mangel, empfaenger };
});
if (!daten || daten.empfaenger.length === 0) return { versendet: false };
const { mangel, empfaenger } = daten;
const ergebnis = await mail().senden({
an: empfaenger,
betreff: `Mangel an ${mangel.geraet.inventarnummer} (${mangel.prioritaet.toLowerCase()})`,
text: [
`Gerät: ${mangel.geraet.inventarnummer} — ${mangel.geraet.bezeichnung}`,
`Gemeldet von: ${mangel.melderName ?? "unbekannt"}`,
`Dringlichkeit: ${mangel.prioritaet}`,
`Stand: ${mangel.status}`,
mangel.geraetGesperrt ? "Das Gerät ist gesperrt." : "",
"",
mangel.beschreibung,
"",
`Anlass dieser Nachricht: ${anlass}`,
]
.filter(Boolean)
.join("\n"),
});
return { versendet: ergebnis.erfolgreich };
}
+225
View File
@@ -0,0 +1,225 @@
import { expect, test, type Page } from "@playwright/test";
/**
* E2E-Tests für Phase 2.
*
* Geprüft wird der Weg, den der Gerätewart wirklich geht: morgens ins
* Fristen-Dashboard schauen, eine Vorlage nachsehen, eine Prüfung durchführen
* und am Ende ein Protokoll in der Hand haben.
*
* Hinweis: Der Wizard-Test legt echte Prüfprotokolle an. Die lassen sich
* absichtlich nicht wieder löschen — genau das ist der Sinn der Sache. Für die
* Beispielwehr ist das unschädlich; vor einer Vorführung hilft ein frischer
* Seed.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
}
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Fristen-Dashboard", () => {
test("zeigt die vier Zeitfenster und die offenen Termine", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Prüfungen und Fristen" })).toBeVisible();
// Die vier Töpfe sind gleichzeitig Filter.
for (const bezeichnung of ["überfällig", "diese Woche", "diesen Monat", "nächste 90 Tage"]) {
await expect(page.getByRole("button", { name: new RegExp(bezeichnung) })).toBeVisible();
}
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
// Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe.
await expect(page.getByRole("cell", { name: /INV-\d{4}/ }).first()).toBeVisible();
});
test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
await page.getByRole("button", { name: /überfällig/ }).first().click();
await expect(page.getByRole("button", { name: "Filter aufheben" })).toBeVisible();
});
test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Verteilung" })).toBeVisible();
await page.getByLabel("Bündelung wählen").click();
await page.getByRole("option", { name: "nach Gerätetyp" }).click();
// Nach dem Umschalten stehen Gerätetypen in der Verteilungstabelle. Der
// Name steht dort allein in der Zelle — in der Terminliste darunter immer
// zusammen mit der Inventarnummer, deshalb "exact".
await expect(
page.getByRole("cell", { name: "Feuerwehrleine 30 m", exact: true }),
).toBeVisible();
});
});
test.describe("Wartungsvorlagen", () => {
test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await expect(page.getByRole("heading", { name: "Wartungsvorlagen" })).toBeVisible();
await expect(
page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }),
).toBeVisible();
await expect(page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" })).toBeVisible();
});
test("öffnet die Checkliste einer Vorlage", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }).click();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
await expect(page.getByText("Ansprechdruck der Warneinrichtung")).toBeVisible();
// Der zulässige Bereich steht am Punkt.
await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible();
});
});
/**
* Nacheinander statt parallel: der Wizard setzt einen vorhandenen Entwurf fort,
* statt einen zweiten anzulegen. Zwei gleichzeitig laufende Tests würden sich
* denselben Entwurf teilen — und der zweite liefe in die Sperre für
* abgeschlossene Protokolle.
*
* Aus demselben Grund bekommt jedes Testprojekt ein eigenes Gerät: Desktop und
* Tablet laufen parallel, aber unter demselben Benutzer.
*/
test.describe.configure({ mode: "serial" });
/** Welches der Leinen-Geräte dieses Projekt prüft. */
function geraetIndex(): number {
return test.info().project.name === "tablet" ? 1 : 0;
}
/** Von der Geräteliste in den Wizard — der Weg des Gerätewarts. */
async function pruefungStarten(page: Page): Promise<void> {
await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine");
await page.getByRole("link", { name: /INV-\d{4}/ }).nth(geraetIndex()).click();
await page.getByRole("link", { name: "Prüfung durchführen" }).click();
}
test.describe("Prüfwizard", () => {
test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({ page }) => {
await pruefungStarten(page);
await page.waitForURL(/\/pruefungen\/neu/);
// Die Checkliste der Leinenprüfung hat vier Punkte: drei Ja/Nein und einen
// Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst
// klickt der Test schneller, als die Oberfläche umschaltet.
for (const nummer of [1, 2, 3, 4]) {
await expect(page.getByText(new RegExp(`Schritt ${nummer} von 4`))).toBeVisible();
// "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung".
const inOrdnung = page.getByRole("button", { name: "in Ordnung", exact: true });
if ((await inOrdnung.count()) > 0) await inOrdnung.click();
const befund = page.getByLabel("Befund");
if ((await befund.count()) > 0) {
await befund.fill("Leine vollständig durch die Hand gezogen, ohne Befund.");
}
await page.getByRole("button", { name: "Weiter" }).click();
}
// Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch.
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
// Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet.
// Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare
// Fenster, nicht auf das Dokument. Liegt das Feld unterhalb, zeichnet der
// Test sonst ins Leere.
const feld = page.getByLabel(/Unterschriftenfeld/);
await feld.scrollIntoViewIfNeeded();
const rahmen = await feld.boundingBox();
expect(rahmen).not.toBeNull();
await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2);
await page.mouse.down();
await page.mouse.move(rahmen!.x + 90, rahmen!.y + 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 150, rahmen!.y + rahmen!.height - 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 });
await page.mouse.up();
// Sobald etwas auf dem Feld steht, lässt es sich löschen und übernehmen.
await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled();
await page.getByRole("button", { name: "Übernehmen" }).click();
await expect(page.getByRole("button", { name: "übernommen" })).toBeVisible();
await page.getByRole("button", { name: "Prüfung abschließen" }).click();
// Danach steht das Protokoll da — mit Nummer und Ergebnis.
await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ })).toBeVisible();
// Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld
// "Abgeschlossen" mit dem Zeitpunkt.
await expect(page.getByText("abgeschlossen", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
});
test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({ page }) => {
await pruefungStarten(page);
// Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an.
await expect(page.getByText(/Schritt 1 von \d/)).toBeVisible();
// Dann bis zum Abschluss durchklicken, ohne etwas zu beantworten.
for (let schritt = 0; schritt < 8; schritt += 1) {
const weiter = page.getByRole("button", { name: "Weiter" });
if (!(await weiter.isVisible())) break;
await weiter.click();
}
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
});
});
test.describe("Mängel und Rückrufe", () => {
test("zeigt den Mangel aus der Beispielprüfung", async ({ page }) => {
await page.goto("/maengel");
await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible();
await expect(page.getByText(/Leinenbeutel an einer Naht aufgescheuert/)).toBeVisible();
});
test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => {
await page.goto("/rueckrufe");
await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible();
await page.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ }).click();
await expect(page.getByRole("heading", { name: "Betroffene Geräte" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Mögliche Treffer im Bestand" })).toBeVisible();
});
});
test.describe("Protokolle", () => {
test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => {
await page.goto("/pruefungen/verlauf");
await expect(page.getByRole("heading", { name: "Prüfprotokolle" })).toBeVisible();
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
});
});
@@ -0,0 +1,338 @@
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Die Unveränderbarkeit abgeschlossener Prüfprotokolle — geprüft dort, wo sie
* durchgesetzt wird: in der Datenbank.
*
* Ein 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 einem Trigger (Migration 20260903000300) und nicht nur im
* Anwendungscode — ein vergessener Zustandscheck im Router würde sonst reichen,
* um sie auszuhebeln.
*
* Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt
* er sich selbst, damit "npm run test" auch ohne Docker durchläuft.
*/
const APP_URL = process.env.DATABASE_URL ?? "";
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
const SLUG = "test-pruefprotokoll-wehr";
let erreichbar = false;
let organisationId = "";
let geraetId = "";
let vorlageId = "";
let punktId = "";
beforeAll(async () => {
try {
await system.$queryRaw`SELECT 1`;
erreichbar = true;
} catch {
return;
}
const organisation = await system.organisation.upsert({
where: { slug: SLUG },
update: {},
create: { slug: SLUG, name: "Prüfprotokoll-Testwehr", kurzname: "PPT" },
});
organisationId = organisation.id;
const standort = await system.standort.upsert({
where: { organisationId_name: { organisationId, name: "Gerätehaus" } },
update: {},
create: { organisationId, name: "Gerätehaus", art: "GERAETEHAUS" },
});
const geraet = await system.geraet.upsert({
where: { organisationId_inventarnummer: { organisationId, inventarnummer: "PPT-0001" } },
update: {},
create: {
organisationId,
inventarnummer: "PPT-0001",
bezeichnung: "Prüfling",
standortId: standort.id,
},
});
geraetId = geraet.id;
const vorlage = await system.wartungsvorlage.upsert({
where: { organisationId_name: { organisationId, name: "Testvorlage" } },
update: {},
create: { organisationId, name: "Testvorlage", intervallMonate: 12 },
});
vorlageId = vorlage.id;
const punkt = await system.checklistenpunkt.create({
data: { organisationId, vorlageId, text: "Sichtprüfung", typ: "JA_NEIN" },
});
punktId = punkt.id;
});
afterAll(async () => {
if (erreichbar) {
// Der Schalter ist zweimal nötig: für die Audit-Einträge und für die
// abgeschlossenen Prüfungen, die derselbe Aufbewahrungslauf abräumt.
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: SLUG } });
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
/** Legt eine abgeschlossene Prüfung an — so wie es der Wizard tut. */
async function abgeschlossenePruefung(nummer: string) {
const pruefung = await system.pruefung.create({
data: {
organisationId,
geraetId,
vorlageId,
durchgefuehrtAm: new Date("2026-06-01T00:00:00.000Z"),
prueferName: "Testprüfer",
status: "ENTWURF",
ergebnisse: { create: [{ organisationId, punktId, jaNein: true, inOrdnung: true }] },
signaturen: { create: [{ organisationId, name: "Testprüfer" }] },
},
});
return system.pruefung.update({
where: { id: pruefung.id },
data: {
status: "ABGESCHLOSSEN",
ergebnis: "BESTANDEN",
protokollNummer: nummer,
abgeschlossenAm: new Date(),
},
});
}
describe.runIf(process.env.DATABASE_URL)("Abgeschlossene Prüfprotokolle", () => {
it("lässt Entwürfe uneingeschränkt ändern", async ({ skip }) => {
if (!erreichbar) return skip();
const entwurf = await system.pruefung.create({
data: {
organisationId,
geraetId,
vorlageId,
durchgefuehrtAm: new Date(),
status: "ENTWURF",
},
});
const geaendert = await system.pruefung.update({
where: { id: entwurf.id },
data: { bemerkung: "noch in Arbeit" },
});
expect(geaendert.bemerkung).toBe("noch in Arbeit");
await system.pruefung.delete({ where: { id: entwurf.id } });
});
it("weist jede inhaltliche Änderung am abgeschlossenen Protokoll ab", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0001");
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { ergebnis: "NICHT_BESTANDEN" },
}),
).rejects.toThrow(/unveraenderlich/i);
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { bemerkung: "nachträglich ergänzt" },
}),
).rejects.toThrow(/unveraenderlich/i);
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { durchgefuehrtAm: new Date("2020-01-01T00:00:00.000Z") },
}),
).rejects.toThrow(/unveraenderlich/i);
});
it("lässt das Protokoll-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0002");
const datei = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${pruefung.id}.pdf`,
dateiname: "protokoll.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(1024),
},
});
// Das ist der Weg des Hintergrundprozesses — er muss offen bleiben.
const mitPdf = await system.pruefung.update({
where: { id: pruefung.id },
data: { protokollDateiId: datei.id },
});
expect(mitPdf.protokollDateiId).toBe(datei.id);
// Ein zweites Mal aber nicht: sonst ließe sich das Protokoll austauschen.
const zweite = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${pruefung.id}-zwei.pdf`,
dateiname: "anderes.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(2048),
},
});
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { protokollDateiId: zweite.id },
}),
).rejects.toThrow(/unveraenderlich/i);
});
it("erlaubt genau einen Zustandswechsel: abgeschlossen wird ersetzt", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0003");
const ersetzt = await system.pruefung.update({
where: { id: pruefung.id },
data: { status: "ERSETZT" },
});
expect(ersetzt.status).toBe("ERSETZT");
// Zurück in den Entwurf geht es nicht.
await expect(
system.pruefung.update({ where: { id: pruefung.id }, data: { status: "ENTWURF" } }),
).rejects.toThrow(/unveraenderlich/i);
});
it("sperrt auch den Umweg über die Einzelergebnisse", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0004");
const ergebnis = await system.pruefergebnis.findFirstOrThrow({
where: { pruefungId: pruefung.id },
});
await expect(
system.pruefergebnis.update({
where: { id: ergebnis.id },
data: { jaNein: false, inOrdnung: false },
}),
).rejects.toThrow(/abgeschlossen/i);
await expect(
system.pruefergebnis.delete({ where: { id: ergebnis.id } }),
).rejects.toThrow(/abgeschlossen/i);
await expect(
system.pruefergebnis.create({
data: { organisationId, pruefungId: pruefung.id, punktId, jaNein: false },
}),
).rejects.toThrow(/abgeschlossen/i);
});
it("sperrt nachträgliche Unterschriften", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0005");
await expect(
system.signatur.create({
data: { organisationId, pruefungId: pruefung.id, name: "Nachzügler" },
}),
).rejects.toThrow(/abgeschlossen/i);
});
it("verhindert das Löschen abgeschlossener Protokolle", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0006");
await expect(system.pruefung.delete({ where: { id: pruefung.id } })).rejects.toThrow(
/nicht geloescht/i,
);
// Nur der ausdrückliche Aufbewahrungslauf darf es.
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.pruefung.delete({ where: { id: pruefung.id } });
});
expect(await system.pruefung.findUnique({ where: { id: pruefung.id } })).toBeNull();
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Wartungstabellen", () => {
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
if (!erreichbar) return skip();
const gefunden = await app.$transaction(async (tx) => ({
vorlagen: await tx.wartungsvorlage.findMany(),
termine: await tx.wartungstermin.findMany(),
pruefungen: await tx.pruefung.findMany(),
maengel: await tx.mangel.findMany(),
rueckrufe: await tx.rueckruf.findMany(),
}));
expect(gefunden.vorlagen).toHaveLength(0);
expect(gefunden.termine).toHaveLength(0);
expect(gefunden.pruefungen).toHaveLength(0);
expect(gefunden.maengel).toHaveLength(0);
expect(gefunden.rueckrufe).toHaveLength(0);
});
it("zeigt im Kontext der Wehr nur ihre eigenen Vorlagen", async ({ skip }) => {
if (!erreichbar) return skip();
const vorlagen = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.wartungsvorlage.findMany();
});
expect(vorlagen.length).toBeGreaterThan(0);
expect(vorlagen.every((vorlage) => vorlage.organisationId === organisationId)).toBe(true);
});
it("lässt keine Vorlage für eine fremde Wehr anlegen", async ({ skip }) => {
if (!erreichbar) return skip();
const fremde = await system.organisation.upsert({
where: { slug: "test-pruefprotokoll-fremd" },
update: {},
create: { slug: "test-pruefprotokoll-fremd", name: "Fremde Wehr", kurzname: "FW" },
});
await expect(
app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.wartungsvorlage.create({
data: { organisationId: fremde.id, name: "Schmuggelvorlage", intervallMonate: 12 },
});
}),
).rejects.toThrow();
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: "test-pruefprotokoll-fremd" } });
});
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from "vitest";
import {
beanstandetePunkte,
beantwortet,
dauerInMinuten,
ergebnisVorschlag,
geraetStatusNachPruefung,
messwertInOrdnung,
offenePflichtpunkte,
punktInOrdnung,
type Antwort,
type Punkt,
} from "@/lib/wartung/bewertung";
/**
* Bewertung einer Prüfung.
*
* Der Kern ist eine einzige fachliche Regel: ein durchgefallener Pflichtpunkt
* bedeutet "nicht bestanden", ein durchgefallener Kann-Punkt "bestanden mit
* Mängeln". Daran hängt, ob ein Gerät im Einsatz sein darf — deshalb steht das
* hier so ausführlich unter Test.
*/
const SICHTPRUEFUNG: Punkt = {
id: "p1",
text: "Trageeinrichtung ohne Beschädigung",
typ: "JA_NEIN",
pflicht: true,
};
const WARNDRUCK: Punkt = {
id: "p2",
text: "Ansprechdruck der Warneinrichtung",
typ: "MESSWERT",
pflicht: true,
minWert: 50,
maxWert: 60,
};
const BEUTEL: Punkt = {
id: "p3",
text: "Leinenbeutel unbeschädigt",
typ: "JA_NEIN",
pflicht: false,
};
const BEFUND: Punkt = {
id: "p4",
text: "Auffälligkeiten",
typ: "TEXT",
pflicht: false,
};
const FOTO: Punkt = { id: "p5", text: "Foto nach der Reinigung", typ: "FOTO", pflicht: false };
function antwort(punktId: string, teil: Partial<Antwort> = {}): Antwort {
return { punktId, ...teil };
}
describe("messwertInOrdnung", () => {
it("prüft gegen beide Grenzen", () => {
expect(messwertInOrdnung(55, 50, 60)).toBe(true);
expect(messwertInOrdnung(50, 50, 60)).toBe(true);
expect(messwertInOrdnung(60, 50, 60)).toBe(true);
expect(messwertInOrdnung(49.9, 50, 60)).toBe(false);
expect(messwertInOrdnung(61, 50, 60)).toBe(false);
});
it("behandelt eine fehlende Grenze als offen", () => {
expect(messwertInOrdnung(300, 270, null)).toBe(true);
expect(messwertInOrdnung(0.2, null, 0.5)).toBe(true);
expect(messwertInOrdnung(0.8, null, 0.5)).toBe(false);
});
it("wertet einen fehlenden Wert nicht als in Ordnung", () => {
expect(messwertInOrdnung(null, 50, 60)).toBe(false);
expect(messwertInOrdnung(undefined)).toBe(false);
expect(messwertInOrdnung(Number.NaN, 0, 10)).toBe(false);
});
});
describe("beantwortet", () => {
it("erkennt jede Antwortart", () => {
expect(beantwortet(SICHTPRUEFUNG, antwort("p1", { jaNein: false }))).toBe(true);
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: 0 }))).toBe(true);
expect(beantwortet(BEFUND, antwort("p4", { text: "ohne Befund" }))).toBe(true);
expect(beantwortet(FOTO, antwort("p5", { fotoDateiId: "abc" }))).toBe(true);
});
it("erkennt fehlende Antworten", () => {
expect(beantwortet(SICHTPRUEFUNG, undefined)).toBe(false);
expect(beantwortet(SICHTPRUEFUNG, antwort("p1"))).toBe(false);
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: null }))).toBe(false);
expect(beantwortet(BEFUND, antwort("p4", { text: " " }))).toBe(false);
});
it("wertet die Null als Antwort — sie ist ein Messwert wie jeder andere", () => {
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: 0 }))).toBe(true);
});
});
describe("punktInOrdnung", () => {
it("folgt bei Ja/Nein der Antwort", () => {
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: true }))).toBe(true);
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: false }))).toBe(false);
});
it("rechnet bei Messwerten den Bereich aus", () => {
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 55 }))).toBe(true);
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 45 }))).toBe(false);
});
it("lässt die ausdrückliche Angabe des Prüfers gewinnen", () => {
// Wert in der Toleranz, der Prüfer beanstandet trotzdem.
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 55, inOrdnung: false }))).toBe(
false,
);
// Und umgekehrt.
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: false, inOrdnung: true }))).toBe(
true,
);
});
});
describe("offenePflichtpunkte", () => {
it("nennt nur unbeantwortete Pflichtpunkte", () => {
const offen = offenePflichtpunkte(
[SICHTPRUEFUNG, WARNDRUCK, BEUTEL],
[antwort("p1", { jaNein: true })],
);
expect(offen.map((punkt) => punkt.id)).toEqual(["p2"]);
});
it("ist leer, wenn alle Pflichtpunkte beantwortet sind", () => {
const offen = offenePflichtpunkte(
[SICHTPRUEFUNG, WARNDRUCK, BEUTEL],
[antwort("p1", { jaNein: true }), antwort("p2", { zahlwert: 55 })],
);
expect(offen).toHaveLength(0);
});
});
describe("ergebnisVorschlag", () => {
const punkte = [SICHTPRUEFUNG, WARNDRUCK, BEUTEL, BEFUND];
it("bestanden, wenn nichts beanstandet ist", () => {
const antworten = [
antwort("p1", { jaNein: true }),
antwort("p2", { zahlwert: 55 }),
antwort("p3", { jaNein: true }),
];
expect(ergebnisVorschlag(punkte, antworten)).toBe("BESTANDEN");
});
it("mit Mängeln, wenn nur ein Kann-Punkt durchfällt", () => {
const antworten = [
antwort("p1", { jaNein: true }),
antwort("p2", { zahlwert: 55 }),
antwort("p3", { jaNein: false }),
];
expect(ergebnisVorschlag(punkte, antworten)).toBe("BESTANDEN_MIT_MAENGELN");
expect(beanstandetePunkte(punkte, antworten).map((punkt) => punkt.id)).toEqual(["p3"]);
});
it("nicht bestanden, sobald ein Pflichtpunkt durchfällt", () => {
const antworten = [
antwort("p1", { jaNein: true }),
// Messwert außerhalb der Toleranz — Pflichtpunkt.
antwort("p2", { zahlwert: 42 }),
antwort("p3", { jaNein: false }),
];
expect(ergebnisVorschlag(punkte, antworten)).toBe("NICHT_BESTANDEN");
});
it("wertet unbeantwortete Punkte nicht als Beanstandung", () => {
// Sonst wäre jede halb ausgefüllte Prüfung sofort "nicht bestanden".
expect(ergebnisVorschlag(punkte, [antwort("p1", { jaNein: true })])).toBe("BESTANDEN");
});
});
describe("geraetStatusNachPruefung", () => {
it("sperrt nur bei nicht bestanden", () => {
expect(geraetStatusNachPruefung("NICHT_BESTANDEN")).toBe("GESPERRT");
expect(geraetStatusNachPruefung("BESTANDEN_MIT_MAENGELN")).toBe("EINSATZBEREIT");
expect(geraetStatusNachPruefung("BESTANDEN")).toBe("EINSATZBEREIT");
});
});
describe("dauerInMinuten", () => {
it("rundet auf volle Minuten", () => {
const beginn = new Date("2026-06-15T09:00:00.000Z");
const ende = new Date("2026-06-15T09:24:40.000Z");
expect(dauerInMinuten(beginn, ende)).toBe(25);
});
it("gibt ohne Beginn nichts zurück", () => {
expect(dauerInMinuten(null, new Date())).toBeNull();
});
it("liefert keine negative Dauer, wenn die Uhr gesprungen ist", () => {
const beginn = new Date("2026-06-15T10:00:00.000Z");
const ende = new Date("2026-06-15T09:00:00.000Z");
expect(dauerInMinuten(beginn, ende)).toBeNull();
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
erinnerungsstufe,
fensterGrenzen,
innerhalbPlanungshorizont,
naechsteFaelligkeit,
protokollNummer,
protokollNummerLesen,
terminFenster,
} from "@/lib/wartung/fristen";
/**
* Fristenlogik der Wartung.
*
* Alle Tests rechnen mit einem festen "heute", nicht mit der Systemuhr. Sonst
* wären sie am 31. Dezember rot und am 1. Januar wieder grün — und niemand
* wüsste, warum.
*/
/** Ein Kalendertag als UTC-Mitternacht, so wie er aus einer DATE-Spalte kommt. */
function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat));
}
/** Mittags, damit die Zeitzonenumrechnung nicht zufällig aufgeht. */
const HEUTE = new Date("2026-06-15T12:00:00.000Z");
describe("terminFenster", () => {
it("erkennt Überfälligkeit ab dem Vortag", () => {
expect(terminFenster(tag(2026, 6, 14), HEUTE)).toBe("ueberfaellig");
expect(terminFenster(tag(2026, 1, 1), HEUTE)).toBe("ueberfaellig");
});
it("zählt den heutigen Tag zur laufenden Woche, nicht zur Überfälligkeit", () => {
expect(terminFenster(tag(2026, 6, 15), HEUTE)).toBe("diese_woche");
});
it("staffelt Woche, Monat und Quartal", () => {
expect(terminFenster(tag(2026, 6, 22), HEUTE)).toBe("diese_woche");
expect(terminFenster(tag(2026, 6, 23), HEUTE)).toBe("dieser_monat");
expect(terminFenster(tag(2026, 7, 15), HEUTE)).toBe("dieser_monat");
expect(terminFenster(tag(2026, 7, 16), HEUTE)).toBe("naechste_90_tage");
expect(terminFenster(tag(2026, 9, 13), HEUTE)).toBe("naechste_90_tage");
expect(terminFenster(tag(2026, 9, 14), HEUTE)).toBe("spaeter");
});
});
describe("fensterGrenzen", () => {
it("liefert die Grenzen als UTC-Kalendertage", () => {
const grenzen = fensterGrenzen(HEUTE);
expect(grenzen.heute.toISOString()).toBe("2026-06-15T00:00:00.000Z");
expect(grenzen.inSiebenTagen.toISOString()).toBe("2026-06-22T00:00:00.000Z");
expect(grenzen.inDreissigTagen.toISOString()).toBe("2026-07-15T00:00:00.000Z");
expect(grenzen.inNeunzigTagen.toISOString()).toBe("2026-09-13T00:00:00.000Z");
});
it("rechnet auch kurz nach Mitternacht Berliner Zeit den richtigen Tag", () => {
// 00:30 Uhr in Berlin ist nach UTC noch der Vortag.
const kurzNachMitternacht = new Date("2026-06-15T22:30:00.000Z");
expect(fensterGrenzen(kurzNachMitternacht).heute.toISOString()).toBe(
"2026-06-16T00:00:00.000Z",
);
});
});
describe("naechsteFaelligkeit", () => {
it("rechnet ab der letzten Prüfung", () => {
const faellig = naechsteFaelligkeit({
letztePruefung: tag(2026, 3, 10),
intervallMonate: 12,
});
expect(faellig?.toISOString()).toBe("2027-03-10T00:00:00.000Z");
});
it("nimmt ersatzweise die Indienststellung", () => {
const faellig = naechsteFaelligkeit({
indienststellung: tag(2024, 5, 1),
intervallMonate: 6,
});
expect(faellig?.toISOString()).toBe("2024-11-01T00:00:00.000Z");
});
it("klemmt überzählige Tage auf das Monatsende", () => {
const faellig = naechsteFaelligkeit({
letztePruefung: tag(2026, 1, 31),
intervallMonate: 1,
});
expect(faellig?.toISOString()).toBe("2026-02-28T00:00:00.000Z");
});
it("gibt ohne Anhaltspunkt oder Intervall nichts zurück", () => {
expect(naechsteFaelligkeit({ intervallMonate: 12 })).toBeNull();
expect(
naechsteFaelligkeit({ letztePruefung: tag(2026, 1, 1), intervallMonate: 0 }),
).toBeNull();
});
});
describe("erinnerungsstufe", () => {
it("meldet Überfälligkeit", () => {
expect(erinnerungsstufe(tag(2026, 6, 14), 30, HEUTE)).toBe("UEBERFAELLIG");
});
it("meldet den Fälligkeitstag selbst", () => {
expect(erinnerungsstufe(tag(2026, 6, 15), 30, HEUTE)).toBe("T_0");
});
it("staffelt die Vorwarnungen", () => {
expect(erinnerungsstufe(tag(2026, 6, 20), 30, HEUTE)).toBe("T_7");
expect(erinnerungsstufe(tag(2026, 7, 5), 30, HEUTE)).toBe("T_30");
expect(erinnerungsstufe(tag(2026, 8, 1), 30, HEUTE)).toBeNull();
});
it("richtet die erste Stufe nach der Vorwarnzeit der Vorlage", () => {
// 60 Tage Vorwarnzeit: ein Termin in 45 Tagen wird schon erinnert.
expect(erinnerungsstufe(tag(2026, 7, 30), 60, HEUTE)).toBe("T_30");
// Mit der Standardvorwarnzeit dagegen noch nicht.
expect(erinnerungsstufe(tag(2026, 7, 30), 30, HEUTE)).toBeNull();
});
it("erinnert auch bei spät angelegten Terminen — kleiner-gleich statt genau", () => {
// Der Tag T-30 ist längst vorbei, die erste Erinnerung darf trotzdem raus.
expect(erinnerungsstufe(tag(2026, 6, 30), 30, HEUTE)).toBe("T_30");
});
});
describe("innerhalbPlanungshorizont", () => {
it("plant nur, was in den Horizont fällt", () => {
expect(innerhalbPlanungshorizont(tag(2026, 9, 1), 180, HEUTE)).toBe(true);
expect(innerhalbPlanungshorizont(tag(2027, 6, 1), 180, HEUTE)).toBe(false);
});
it("bezieht Vergangenes immer ein — Überfälliges muss geplant bleiben", () => {
expect(innerhalbPlanungshorizont(tag(2025, 1, 1), 180, HEUTE)).toBe(true);
});
});
describe("Protokollnummern", () => {
it("füllt die laufende Nummer auf vier Stellen", () => {
expect(protokollNummer(2026, 1)).toBe("2026-0001");
expect(protokollNummer(2026, 137)).toBe("2026-0137");
});
it("liest die Nummer wieder auseinander", () => {
expect(protokollNummerLesen("2026-0042")).toEqual({ jahr: 2026, laufend: 42 });
expect(protokollNummerLesen("kaputt")).toBeNull();
expect(protokollNummerLesen(null)).toBeNull();
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
fruehesteFaelligkeit,
zuPlanendeTermine,
type PlanungsGeraet,
} from "@/lib/wartung/planung";
/**
* Terminplanung des nächtlichen Laufs.
*
* Der Lauf entscheidet, was im Fristen-Dashboard steht. Ein Fehler hier fällt
* niemandem auf — bis eine Prüfung fehlt, die hätte stattfinden müssen.
*/
const HEUTE = new Date("2026-06-15T02:15:00.000Z");
function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat));
}
const VORLAGE = { id: "v1", intervallMonate: 12 };
function geraet(teil: Partial<PlanungsGeraet> = {}): PlanungsGeraet {
return {
id: "g1",
inventarnummer: "INV-0001",
status: "EINSATZBEREIT",
hatOffenenTermin: false,
...teil,
};
}
describe("zuPlanendeTermine", () => {
it("rechnet ab der letzten Prüfung nach dieser Vorlage", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(1);
expect(geplant[0]!.faelligAm.toISOString()).toBe("2026-08-01T00:00:00.000Z");
expect(geplant[0]!.ohneBasisdatum).toBe(false);
});
it("nimmt ersatzweise das Prüfdatum am Gerät, dann die Indienststellung", () => {
const ausGeraet = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefung: tag(2025, 7, 1), indienststellung: tag(2020, 1, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(ausGeraet[0]!.faelligAm.toISOString()).toBe("2026-07-01T00:00:00.000Z");
const ausIndienststellung = zuPlanendeTermine(
{ id: "v1", intervallMonate: 6 },
[geraet({ indienststellung: tag(2026, 2, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(ausIndienststellung[0]!.faelligAm.toISOString()).toBe("2026-08-01T00:00:00.000Z");
});
it("setzt Geräte ohne jedes Datum sofort fällig", () => {
const geplant = zuPlanendeTermine(VORLAGE, [geraet()], {
horizontTage: 180,
heute: HEUTE,
});
expect(geplant).toHaveLength(1);
expect(geplant[0]!.ohneBasisdatum).toBe(true);
expect(geplant[0]!.faelligAm.toISOString()).toBe("2026-06-15T00:00:00.000Z");
});
it("plant nichts für ausgesonderte Geräte", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ status: "AUSGESONDERT", letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(0);
});
it("plant gesperrte Geräte weiter — die sollen ja wieder in Dienst", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ status: "GESPERRT", letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(1);
});
it("legt keinen zweiten Termin an, solange einer offen ist", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ hatOffenenTermin: true, letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(0);
});
it("plant nur innerhalb des Horizonts", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefungZurVorlage: tag(2026, 5, 1) })],
// Fällig am 01.05.2027 — mit 30 Tagen Horizont noch nicht zu planen.
{ horizontTage: 30, heute: HEUTE },
);
expect(geplant).toHaveLength(0);
});
it("plant Überfälliges immer, egal wie lange es her ist", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefungZurVorlage: tag(2019, 1, 1) })],
{ horizontTage: 30, heute: HEUTE },
);
expect(geplant).toHaveLength(1);
expect(geplant[0]!.faelligAm.toISOString()).toBe("2020-01-01T00:00:00.000Z");
});
});
describe("fruehesteFaelligkeit", () => {
it("nimmt den dringendsten von mehreren Terminen", () => {
const frueheste = fruehesteFaelligkeit([
{ faelligAm: tag(2026, 9, 1) },
{ faelligAm: tag(2026, 7, 1) },
{ faelligAm: tag(2027, 1, 1) },
]);
expect(frueheste?.toISOString()).toBe("2026-07-01T00:00:00.000Z");
});
it("gibt ohne Termine nichts zurück", () => {
expect(fruehesteFaelligkeit([])).toBeNull();
});
});