Phase 2: Wartungs- und Prüfmanagement (#3)
@@ -213,6 +213,19 @@ LLM_BASE_URL=http://ollama:11434/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODELL=qwen2.5:14b
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wartung und Prüfung (Phase 2)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wann der nächtliche Lauf Prüftermine plant. Cron-Syntax, Zeitzone
|
||||
# Europe/Berlin. Der Worker trägt den Zeitplan beim Start je Wehr selbst ein.
|
||||
WARTUNG_NACHTLAUF=15 3 * * *
|
||||
# Wann die Erinnerungen verschickt werden. Bewusst nach der Planung: sonst
|
||||
# erinnert der Lauf an Termine, die es noch gar nicht gibt.
|
||||
WARTUNG_ERINNERUNG_LAUF=45 3 * * *
|
||||
# Wie weit im Voraus Termine angelegt werden. Ohne Horizont stünden Termine für
|
||||
# die nächsten zehn Jahre im Dashboard.
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE=180
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Datenschutz und Aufbewahrung (DSGVO)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,10 +8,110 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [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
|
||||
|
||||
**Datenmodell**
|
||||
|
||||
- `Wartungsvorlage` — was an einem Gerätetyp in welchem Takt geprüft wird, mit
|
||||
Intervall, Vorwarnzeit, Prüfvorschrift und Erfahrungswert für die Dauer.
|
||||
- `Checklistenpunkt` in vier Ausprägungen: Ja/Nein, Messwert (mit zulässigem
|
||||
Bereich und Einheit), Text und Foto. Pflichtpunkte sind die, an denen die
|
||||
Einsatzbereitschaft hängt.
|
||||
- `Wartungstermin` — ein fälliger Termin an einem konkreten Gerät. Eindeutig
|
||||
über Gerät, Vorlage und Fälligkeitstag, damit ein zweiter nächtlicher Lauf
|
||||
nichts doppelt anlegt.
|
||||
- `Pruefung`, `Pruefergebnis` und `Signatur` — die durchgeführte Prüfung mit
|
||||
Antworten je Punkt und Unterschrift.
|
||||
- `Mangel` mit dem Weg melden → zugewiesen → in Arbeit → behoben.
|
||||
- `Rueckruf`, `RueckrufGeraetetyp` und `RueckrufBetroffenheit` — eine
|
||||
Herstellerwarnung, die betroffene Bauart und der Stand je Gerät.
|
||||
- `Erinnerung` als Nachweis und gleichzeitig als Sperre gegen Doppelversand.
|
||||
|
||||
**Unveränderbarkeit der Protokolle**
|
||||
|
||||
- Ein Trigger in der Datenbank weist jede Änderung an einer abgeschlossenen
|
||||
Prüfung ab. Zulässig bleiben genau drei Dinge: der Wechsel auf „ersetzt", das
|
||||
Nachtragen des PDF, solange keines hinterlegt ist, und `geaendertAm`.
|
||||
- Dieselbe Sperre gilt für die Bestandteile: an einem abgeschlossenen Protokoll
|
||||
lassen sich weder Einzelergebnisse ändern noch Unterschriften nachtragen.
|
||||
- Korrekturen erzeugen eine **neue** Prüfung mit Verweis auf die alte. Beide
|
||||
bleiben lesbar.
|
||||
- Gelöscht wird ein Protokoll nur im gekennzeichneten Aufbewahrungslauf —
|
||||
derselbe Schalter wie beim Audit-Log.
|
||||
|
||||
**Fristen-Dashboard** (`/pruefungen`)
|
||||
|
||||
- Vier Töpfe nach Dringlichkeit: überfällig, diese Woche, diesen Monat, nächste
|
||||
90 Tage. Jede Zahl ist gleichzeitig ein Filter.
|
||||
- Bündelung nach Fahrzeug, Standort, Gerätetyp oder Zuständigem — wer einen
|
||||
Prüftag ansetzt, arbeitet ein Fahrzeug ab, keine alphabetische Liste.
|
||||
- Sammelzuweisung mehrerer Termine an eine Person.
|
||||
- Daneben die Lebensdauerüberwachung (`/pruefungen/lebensdauer`) mit Vorschlag
|
||||
zur Aussonderung.
|
||||
|
||||
**Prüfwizard** (`/pruefungen/neu`)
|
||||
|
||||
- Ein Prüfpunkt je Bildschirm, große Schaltflächen, Foto direkt aus der Kamera.
|
||||
- Zeiterfassung läuft mit und landet im Protokoll.
|
||||
- Unterschrift auf einem Zeichenfeld, das Finger, Stift und Maus versteht; das
|
||||
PNG landet in MinIO.
|
||||
- Jede Antwort geht sofort zum Server — bricht das WLAN weg, ist höchstens die
|
||||
letzte verloren, und der Entwurf wird beim nächsten Öffnen fortgesetzt.
|
||||
- Mängel werden aus den beanstandeten Punkten vorgeschlagen.
|
||||
- Externe Wartung: Fachfirma, Kosten und das Fremdprotokoll als PDF.
|
||||
|
||||
**Prüfprotokoll als PDF**
|
||||
|
||||
- Erzeugt der Hintergrundprozess über Gotenberg und legt es in MinIO ab: Kopf
|
||||
mit Wehr, Gerät und Prüfer-Kennung, Checkliste mit Soll, Ist und Befund,
|
||||
eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift, Prüfdatum
|
||||
und Seitenzahl.
|
||||
- Laufende Protokollnummer `JJJJ-NNNN` je Wehr und Jahr.
|
||||
|
||||
**Nächtlicher Lauf**
|
||||
|
||||
- `fristen.pruefen` (3:15 Uhr) plant Termine aus den Vorlagen und zieht Frist
|
||||
und Status an jedem Gerät nach.
|
||||
- `erinnerung.senden` (3:45 Uhr) verschickt Vorwarnungen bei T-30, T-7, T-0 und
|
||||
einmal bei Überfälligkeit — als Sammelnachricht je Empfänger.
|
||||
- Beide Läufe sind wiederholbar und tragen sich beim Start des Workers je Wehr
|
||||
selbst ein.
|
||||
|
||||
**Mängel und Rückrufe**
|
||||
|
||||
- Melden darf jedes Mitglied; ein kritischer Mangel sperrt das Gerät sofort.
|
||||
- Beim Beheben wird die Sperre aufgehoben — aber nur, wenn kein anderer offener
|
||||
Mangel dasselbe Gerät sperrt.
|
||||
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum. Das Ergebnis ist ein Vorschlag, keine automatische
|
||||
Sperre.
|
||||
|
||||
**Sonstiges**
|
||||
|
||||
- Datei-Upload unter `/api/dateien` für Fotos, Unterschriften und
|
||||
Fremdprotokolle — als Multipart, nicht Base64 durch eine JSON-Antwort.
|
||||
- Der Seed legt 5 Wartungsvorlagen mit echten Prüfvorgaben an (DGUV 112-190,
|
||||
DIN 14811, DGUV Vorschrift 3, DIN 14920), dazu die fälligen Termine, ein
|
||||
abgeschlossenes Protokoll samt Mangel und einen Beispielrückruf.
|
||||
- 79 neue Tests: 47 Unit-Tests zu Fristen, Bewertung und Planung, 10 gegen eine
|
||||
echte Datenbank zur Unveränderbarkeit und Mandantentrennung, 22 Playwright-
|
||||
Durchläufe über Dashboard, Vorlagen, Wizard, Mängel und Rückrufe.
|
||||
|
||||
**Nachzügler aus Phase 1**
|
||||
|
||||
Mit dieser Version wird auch das Geräteformular ausgeliefert, das nach der
|
||||
0.2.0 entstanden ist:
|
||||
|
||||
- **Formular zum Anlegen und Bearbeiten eines Geräts** (`/geraete/neu` und
|
||||
`/geraete/<id>/bearbeiten`). Damit ist die letzte offene Lücke aus Phase 1
|
||||
geschlossen: Ein Neuzugang lässt sich einzeln erfassen, ohne den Umweg über
|
||||
@@ -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
|
||||
Touch-Emulation begründet übersprungen).
|
||||
|
||||
### Behoben
|
||||
Dabei behoben:
|
||||
|
||||
- **Das Formular stürzte beim ersten Rechnen ab.** Die Vorgabewerte lagen in
|
||||
einem `"use client"`-Modul und wurden von einer Server-Komponente importiert;
|
||||
@@ -38,6 +138,29 @@ Nächster Schritt ist Phase 2 (Wartungs- und Prüfmanagement).
|
||||
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) —
|
||||
Next.js hängt einen eigenen Routen-Ansager mit derselben Rolle in jede Seite.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Die Datumsarithmetik aus Phase 1 (`utcTag`, `monateAddieren`, `heutigerTag`,
|
||||
`tagesabstand`) ist jetzt exportiert. Zwei Umsetzungen derselben Rechnung
|
||||
wären früher oder später um einen Tag auseinandergelaufen.
|
||||
- Die Seitenleiste zeigt Prüfungen, Mängel und Rückrufe als verfügbar.
|
||||
- Die Gerätedetailseite zeigt Prüfhistorie, offene Mängel und anstehende
|
||||
Termine — und startet die Prüfung.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer.**
|
||||
Die eindeutige Bedingung in der Datenbank fing das ab, aber der zweite Prüfer
|
||||
bekam einen Datenbankfehler statt eines Protokolls. Die Nummer wird jetzt
|
||||
unter einer Transaktionssperre je Wehr und Jahr vergeben — gefunden von den
|
||||
E2E-Tests, weil Desktop- und Tablet-Durchlauf parallel prüften.
|
||||
- **Der Worker ließ sich außerhalb des Containers nicht starten.** Ihm fehlte
|
||||
das Laden der `.env`, und `server-only` warf beim Import unter Node. Beide
|
||||
Startbefehle laufen jetzt mit `--conditions=react-server`, womit der leere
|
||||
Export dieses Pakets greift.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-09-03
|
||||
|
||||
Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit
|
||||
|
||||
@@ -25,7 +25,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
|---|---|
|
||||
| Phase 0 — Fundament | 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 4 — Kiosk-PWA | offen |
|
||||
| Phase 5 — Atemschutz (FwDV 7) | offen |
|
||||
@@ -152,6 +152,133 @@ ausschließlich über [`src/lib/storage.ts`](src/lib/storage.ts). Objektschlüss
|
||||
Downloads laufen über presignte Links mit begrenzter Gültigkeit
|
||||
(`S3_URL_TTL`, Vorgabe 15 Minuten).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Wartung und Prüfung
|
||||
|
||||
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt: Prüffristen
|
||||
im Blick behalten und im Streitfall nachweisen können, dass geprüft wurde.
|
||||
|
||||
### Der Weg durch das Modul
|
||||
|
||||
```
|
||||
Wartungsvorlage was an einem Gerätetyp in welchem Takt geprüft wird
|
||||
└─ Checklistenpunkt die einzelne Frage: ja/nein, Messwert, Text, Foto
|
||||
↓ nächtlicher Lauf
|
||||
Wartungstermin ein fälliger Termin an einem konkreten Gerät
|
||||
↓ Prüfwizard
|
||||
Pruefung durchgeführte Prüfung, unterschrieben, festgeschrieben
|
||||
├─ Pruefergebnis je Checklistenpunkt eine Antwort
|
||||
├─ Signatur Unterschrift aus dem Zeichenfeld, PNG in MinIO
|
||||
└─ Mangel was aufgefallen ist und behoben werden muss
|
||||
```
|
||||
|
||||
Ohne Vorlage gibt es keine Termine, ohne Termine kein Fristen-Dashboard. Die
|
||||
erste Seite, die eine Wehr in Phase 2 ausfüllt, ist deshalb
|
||||
`/pruefungen/vorlagen`.
|
||||
|
||||
### Das Protokoll ist unveränderbar
|
||||
|
||||
Ein abgeschlossenes Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse.
|
||||
Wäre es nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
|
||||
deshalb in der Datenbank (Trigger `pruefung_unveraenderlich`, Migration
|
||||
`20260903000300_wartung_rls`) und nicht nur im Anwendungscode — ein vergessener
|
||||
Zustandscheck in einem Router würde sonst reichen, um sie auszuhebeln.
|
||||
|
||||
Nach dem Abschluss sind genau drei Änderungen zulässig, und alle drei ergänzen
|
||||
das Protokoll, statt es zu verfälschen:
|
||||
|
||||
| Änderung | Warum |
|
||||
|---|---|
|
||||
| `ABGESCHLOSSEN` → `ERSETZT` | eine Korrekturprüfung ist nachgefolgt |
|
||||
| `protokollDateiId`, solange leer | das PDF entsteht im Hintergrund und wird nachgetragen |
|
||||
| `geaendertAm` | setzt Prisma bei jedem Schreibvorgang mit |
|
||||
|
||||
Dasselbe gilt für die Bestandteile: an einem abgeschlossenen Protokoll lassen
|
||||
sich weder Einzelergebnisse ändern noch Unterschriften nachtragen. **Korrekturen
|
||||
erzeugen eine neue Prüfung** mit Verweis auf die alte; beide bleiben lesbar.
|
||||
|
||||
Gelöscht wird ein Protokoll nie — außer im ausdrücklich gekennzeichneten
|
||||
Aufbewahrungslauf, demselben Schalter wie beim Audit-Log
|
||||
(`SET LOCAL app.audit_bereinigung = 'an'`).
|
||||
|
||||
### Der nächtliche Lauf
|
||||
|
||||
Der Worker trägt sich beim Start je Wehr zwei wiederkehrende Aufträge ein:
|
||||
|
||||
| Auftrag | Vorgabe | Was er tut |
|
||||
|---|---|---|
|
||||
| `fristen.pruefen` | 03:15 Uhr | Termine aus den Vorlagen planen, Frist und Status an jedem Gerät nachziehen |
|
||||
| `erinnerung.senden` | 03:45 Uhr | Vorwarnungen zu offenen Terminen verschicken |
|
||||
|
||||
Beide sind wiederholbar: doppelte Termine verhindert die Eindeutigkeit über
|
||||
Gerät, Vorlage und Fälligkeitstag, doppelte Mails ein Erinnerungseintrag je
|
||||
Termin und Stufe. Erinnert wird bei T-30 (bzw. der Vorwarnzeit der Vorlage),
|
||||
T-7, T-0 und einmal bei Überfälligkeit — als **eine** Sammelnachricht je
|
||||
Empfänger, nicht als eine Mail je Gerät.
|
||||
|
||||
„Überfällig" ist bewusst kein gespeicherter Zustand, sondern wird aus dem
|
||||
Fälligkeitsdatum und dem heutigen Tag gerechnet. Gespeicherte Zustände laufen
|
||||
der Uhr hinterher; berechnete nicht.
|
||||
|
||||
### Prüfwizard
|
||||
|
||||
Zugeschnitten auf das Tablet im Gerätehaus: ein Prüfpunkt je Bildschirm, große
|
||||
Schaltflächen, Foto direkt aus der Kamera, Unterschrift auf einem Zeichenfeld,
|
||||
das Finger, Stift und Maus gleichermaßen versteht.
|
||||
|
||||
Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens die
|
||||
letzte Antwort verloren, nicht die ganze Prüfung — beim erneuten Öffnen wird der
|
||||
Entwurf fortgesetzt.
|
||||
|
||||
Die Bewertung rechnet der Server, nicht der Browser
|
||||
([`src/lib/wartung/bewertung.ts`](src/lib/wartung/bewertung.ts)):
|
||||
|
||||
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
|
||||
- ein beanstandeter **Pflichtpunkt** bedeutet „nicht bestanden" und sperrt das
|
||||
Gerät,
|
||||
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
|
||||
|
||||
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er
|
||||
trägt die Verantwortung.
|
||||
|
||||
### Prüfprotokoll als PDF
|
||||
|
||||
Erzeugt der Worker über Gotenberg
|
||||
([`src/lib/wartung/protokoll.ts`](src/lib/wartung/protokoll.ts)) und legt es in
|
||||
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
|
||||
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
|
||||
Prüfdatum und Seitenzahl.
|
||||
|
||||
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder.
|
||||
Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
|
||||
|
||||
Die laufende Protokollnummer (`JJJJ-NNNN`) wird je Wehr und Jahr unter einer
|
||||
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
|
||||
verschiedene Nummern.
|
||||
|
||||
### Mängel und Rückrufe
|
||||
|
||||
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben.
|
||||
**Melden darf jedes Mitglied** — der Mangel fällt dem auf, der das Gerät
|
||||
benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das
|
||||
Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn
|
||||
kein anderer offener Mangel dasselbe Gerät sperrt.
|
||||
|
||||
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein
|
||||
**Vorschlag**, keine automatische Sperre: eine Chargenangabe des Herstellers
|
||||
passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
|
||||
|
||||
### Zusätzliche Umgebungsvariablen
|
||||
|
||||
| Variable | Vorgabe | Bedeutung |
|
||||
|---|---|---|
|
||||
| `WARTUNG_NACHTLAUF` | `15 3 * * *` | wann Termine geplant werden (cron, Europe/Berlin) |
|
||||
| `WARTUNG_ERINNERUNG_LAUF` | `45 3 * * *` | wann Erinnerungen verschickt werden |
|
||||
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
@@ -61,6 +61,9 @@ x-app-env: &app-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||
LLM_MODELL: ${LLM_MODELL:-}
|
||||
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
|
||||
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
|
||||
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
|
||||
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
||||
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
||||
@@ -298,7 +301,11 @@ services:
|
||||
image: florian-app:${IMAGE_TAG:-latest}
|
||||
container_name: florian-worker
|
||||
restart: unless-stopped
|
||||
command: ["node", "--enable-source-maps", "dist/worker/index.js"]
|
||||
# --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
|
||||
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
|
||||
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
|
||||
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
|
||||
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
|
||||
environment:
|
||||
<<: *app-env
|
||||
ROLLE: worker
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 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.
|
||||
|
||||
> Prüfungen, Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den
|
||||
> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
> Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den folgenden Phasen
|
||||
> dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +70,7 @@ derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
|
||||
### Die Seitenleiste
|
||||
|
||||
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt
|
||||
rechts sein Phasenkürzel — `P2` heißt „kommt in Phase 2".
|
||||
rechts sein Phasenkürzel — `P3` heißt „kommt in Phase 3".
|
||||
|
||||
Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
|
||||
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
|
||||
@@ -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
|
||||
|
||||

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

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

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

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

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

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

|
||||
|
||||
Helme, Seile, Schutzkleidung und Pressluftatmer sind ab einem Stichtag nicht
|
||||
mehr zugelassen — unabhängig davon, wie gut sie aussehen. Diese Seite zeigt,
|
||||
was abgelaufen ist und was in den nächsten Monaten abläuft.
|
||||
|
||||
Ein Gerät mit abgelaufener Nutzungsdauer wird nachts automatisch **gesperrt**.
|
||||
**Ausgesondert** wird es von einem Menschen: das entscheidet, wer weiß, ob
|
||||
Ersatz da ist. Der Knopf dazu steht in der Zeile.
|
||||
|
||||
Voraussetzung ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist
|
||||
(Kapitel 5).
|
||||
|
||||
## 18. Rollen und was sie dürfen
|
||||
|
||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -470,7 +689,7 @@ Huber), PIN `471203`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Was protokolliert wird
|
||||
## 21. Was protokolliert wird
|
||||
|
||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||
@@ -495,7 +714,7 @@ jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 15. Wenn etwas nicht geht
|
||||
## 22. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
|
||||
@@ -32,7 +32,7 @@ Faktoren, die in die Schätzung eingehen:
|
||||
|---|---|---:|---:|---:|
|
||||
| 0 | Fundament | 12–16 PT | — | **1:00 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 |
|
||||
| 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 |
|
||||
@@ -105,7 +105,7 @@ Datumsfelder, die sich in Chromium nicht automatisiert füllen lassen.
|
||||
|
||||
## Phase 2 — Wartung und Prüfung
|
||||
|
||||
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase
|
||||
**Vergleichsaufwand: 18–24 PT** · **Tatsächlich: 2:25 h Sitzungszeit** — die aufwendigste Phase
|
||||
|
||||
| Baustein | PT |
|
||||
|---|---:|
|
||||
@@ -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,
|
||||
warum der Audit-Log schon in Phase 0 append-only ist.
|
||||
|
||||
**Rückblick.** Deutlich unter der geschätzten Sitzungszeit, und das aus einem
|
||||
Grund, der sich benennen lässt: die Fristenrechnung aus Phase 1 war schon da
|
||||
und musste nur exportiert werden. Wäre sie hier entstanden — samt der
|
||||
Zeitzonen-Falle, über die Phase 1 gestolpert ist —, sähe die Zahl anders aus.
|
||||
|
||||
Teurer als gedacht war das, was zwischen den Bausteinen liegt. Drei Beispiele:
|
||||
|
||||
- Die Reihenfolge beim Abschluss einer Prüfung ist nicht beliebig.
|
||||
Unterschriften und Mängel müssen vor dem Statuswechsel entstehen, denn danach
|
||||
lässt die Datenbank am Protokoll nichts mehr zu. Derselbe Stolperstein traf
|
||||
auch den Seed, der die Beispielprüfung zunächst fertig anlegen wollte.
|
||||
- Die Protokollnummer muss unter einer Sperre vergeben werden. Aufgefallen ist
|
||||
das erst, weil die E2E-Tests Desktop und Tablet gleichzeitig prüfen ließen —
|
||||
zwei Prüfer, die im selben Moment abschließen, sind im Gerätehaus kein
|
||||
konstruierter Fall.
|
||||
- Das PDF entsteht zwischen zwei Datenbanktransaktionen, nicht in einer. Fotos
|
||||
aus MinIO holen und Gotenberg rendern lassen dauert länger, als eine offene
|
||||
Transaktion warten darf.
|
||||
|
||||
Billiger als angesetzt war das Unterschriftenfeld: Pointer-Events decken Finger,
|
||||
Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und
|
||||
`touch-action: none` braucht es nicht.
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
|
||||
**Vergleichsaufwand: 12–16 PT**
|
||||
|
||||
@@ -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 1 — Stammdaten | 1:15 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
|
||||
```
|
||||
Phase 0 ████████████████████ fertig 0.1.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 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.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
|
||||
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.
|
||||
|
||||
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
|
||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage
|
||||
- Nächtlicher Job erzeugt Termine, setzt Status, erinnert bei T-30, T-7 und T-0
|
||||
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift
|
||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO
|
||||
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage,
|
||||
gebündelt nach Fahrzeug, Standort, Gerätetyp oder Zuständigem
|
||||
- Nächtlicher Lauf erzeugt Termine, zieht den Gerätestatus nach und erinnert bei
|
||||
T-30, T-7, T-0 und einmal bei Überfälligkeit
|
||||
- Prüfwizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift auf
|
||||
dem Zeichenfeld
|
||||
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO — die Sperre setzt
|
||||
ein Datenbanktrigger durch, nicht der Anwendungscode
|
||||
- Korrekturen erzeugen ein neues Protokoll mit Verweis auf das alte
|
||||
- Lebensdauerüberwachung mit Aussonderungsvorschlag
|
||||
- Rückrufe markieren betroffene Geräte automatisch
|
||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben
|
||||
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
|
||||
und Herstellungszeitraum
|
||||
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben, mit
|
||||
Gerätesperre bei kritischen Mängeln
|
||||
- Externe Wartung: Fachfirma, Kosten und Fremdprotokoll als PDF
|
||||
|
||||
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
|
||||
die Unfallkasse standhält.
|
||||
|
||||
## Phase 3 — Einsätze und Übungen
|
||||
## Phase 3 — Einsätze und Übungen · als Nächstes
|
||||
|
||||
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
|
||||
Einsatzende, eingerückt, einsatzbereit
|
||||
|
||||
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 408 KiB After Width: | Height: | Size: 409 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 727 KiB After Width: | Height: | Size: 728 KiB |
|
Before Width: | Height: | Size: 746 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 246 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"engines": {
|
||||
@@ -10,8 +10,8 @@
|
||||
"dev": "next dev -p 3100",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3100",
|
||||
"worker": "tsx src/worker/index.ts",
|
||||
"worker:dev": "tsx watch src/worker/index.ts",
|
||||
"worker": "tsx --conditions=react-server src/worker/index.ts",
|
||||
"worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
-- =============================================================================
|
||||
-- Phase 2: Tabellen für Wartung und Prüfung.
|
||||
--
|
||||
-- Erzeugt mit `prisma migrate dev --create-only` und danach an einer Stelle von
|
||||
-- Hand bereinigt: Prisma wollte die Volltext- und Geoindizes aus Phase 1
|
||||
-- (gin_trgm_ops, gist auf Koordinaten) löschen, weil es sie im Schema nicht
|
||||
-- kennt — Prisma kann diese Indexarten nicht abbilden. Die DROP INDEX-Zeilen
|
||||
-- sind deshalb entfernt. Wer diese Migration neu erzeugt, muss das wieder tun.
|
||||
--
|
||||
-- Mandantentrennung, Unveränderbarkeit der Protokolle und die Suchindizes für
|
||||
-- die neuen Tabellen stehen in der nachfolgenden Migration.
|
||||
-- =============================================================================
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WartungsterminStatus" AS ENUM ('OFFEN', 'ERLEDIGT', 'ENTFALLEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefungStatus" AS ENUM ('ENTWURF', 'ABGESCHLOSSEN', 'ERSETZT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefErgebnis" AS ENUM ('BESTANDEN', 'BESTANDEN_MIT_MAENGELN', 'NICHT_BESTANDEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PruefungArt" AS ENUM ('INTERN', 'EXTERN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChecklistenpunktTyp" AS ENUM ('JA_NEIN', 'MESSWERT', 'TEXT', 'FOTO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SignaturRolle" AS ENUM ('PRUEFER', 'GERAETEWART', 'FREMDFIRMA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MangelPrioritaet" AS ENUM ('NIEDRIG', 'MITTEL', 'HOCH', 'KRITISCH');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MangelStatus" AS ENUM ('GEMELDET', 'ZUGEWIESEN', 'IN_ARBEIT', 'BEHOBEN', 'VERWORFEN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RueckrufStatus" AS ENUM ('OFFEN', 'IN_BEARBEITUNG', 'ERLEDIGT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BetroffenheitStatus" AS ENUM ('OFFEN', 'NICHT_BETROFFEN', 'MASSNAHME_LAEUFT', 'ERLEDIGT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ErinnerungStufe" AS ENUM ('T_30', 'T_7', 'T_0', 'UEBERFAELLIG');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wartungsvorlage" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"geraetetypId" UUID,
|
||||
"intervallMonate" INTEGER NOT NULL,
|
||||
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 30,
|
||||
"pruefvorschrift" VARCHAR(200),
|
||||
"standardDauerMinuten" INTEGER,
|
||||
"standardExtern" BOOLEAN NOT NULL DEFAULT false,
|
||||
"automatisch" BOOLEAN NOT NULL DEFAULT true,
|
||||
"beschreibung" VARCHAR(1000),
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "wartungsvorlage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "checklistenpunkt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"vorlageId" UUID NOT NULL,
|
||||
"text" VARCHAR(300) NOT NULL,
|
||||
"typ" "ChecklistenpunktTyp" NOT NULL DEFAULT 'JA_NEIN',
|
||||
"pflicht" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sollwert" VARCHAR(120),
|
||||
"einheit" VARCHAR(20),
|
||||
"minWert" DECIMAL(12,3),
|
||||
"maxWert" DECIMAL(12,3),
|
||||
"hinweis" VARCHAR(500),
|
||||
"sortierung" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "checklistenpunkt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wartungstermin" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"vorlageId" UUID,
|
||||
"faelligAm" DATE NOT NULL,
|
||||
"status" "WartungsterminStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"automatischErzeugt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"zugewiesenAnId" UUID,
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "wartungstermin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pruefung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"terminId" UUID,
|
||||
"vorlageId" UUID,
|
||||
"art" "PruefungArt" NOT NULL DEFAULT 'INTERN',
|
||||
"status" "PruefungStatus" NOT NULL DEFAULT 'ENTWURF',
|
||||
"prueferId" UUID,
|
||||
"prueferName" VARCHAR(200),
|
||||
"durchgefuehrtAm" DATE NOT NULL,
|
||||
"begonnenUm" TIMESTAMPTZ(3),
|
||||
"dauerMinuten" INTEGER,
|
||||
"ergebnis" "PruefErgebnis",
|
||||
"externeFirma" VARCHAR(200),
|
||||
"externeProtokollDateiId" UUID,
|
||||
"kosten" DECIMAL(10,2),
|
||||
"naechstePruefungAm" DATE,
|
||||
"protokollDateiId" UUID,
|
||||
"protokollNummer" VARCHAR(40),
|
||||
"abgeschlossenAm" TIMESTAMPTZ(3),
|
||||
"ersetztPruefungId" UUID,
|
||||
"korrekturGrund" VARCHAR(500),
|
||||
"bemerkung" VARCHAR(2000),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "pruefung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pruefergebnis" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"pruefungId" UUID NOT NULL,
|
||||
"punktId" UUID NOT NULL,
|
||||
"jaNein" BOOLEAN,
|
||||
"zahlwert" DECIMAL(12,3),
|
||||
"text" VARCHAR(1000),
|
||||
"fotoDateiId" UUID,
|
||||
"inOrdnung" BOOLEAN NOT NULL DEFAULT true,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pruefergebnis_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "signatur" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"pruefungId" UUID NOT NULL,
|
||||
"rolle" "SignaturRolle" NOT NULL DEFAULT 'PRUEFER',
|
||||
"personId" UUID,
|
||||
"name" VARCHAR(200) NOT NULL,
|
||||
"bildDateiId" UUID,
|
||||
"unterzeichnetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "signatur_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mangel" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"pruefungId" UUID,
|
||||
"melderId" UUID,
|
||||
"melderName" VARCHAR(200),
|
||||
"beschreibung" VARCHAR(2000) NOT NULL,
|
||||
"fotoDateiId" UUID,
|
||||
"prioritaet" "MangelPrioritaet" NOT NULL DEFAULT 'MITTEL',
|
||||
"status" "MangelStatus" NOT NULL DEFAULT 'GEMELDET',
|
||||
"zugewiesenAnId" UUID,
|
||||
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"behobenAm" TIMESTAMPTZ(3),
|
||||
"behobenVonId" UUID,
|
||||
"behebung" VARCHAR(2000),
|
||||
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "mangel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"hersteller" VARCHAR(160) NOT NULL,
|
||||
"titel" VARCHAR(200) NOT NULL,
|
||||
"referenz" VARCHAR(80),
|
||||
"beschreibung" VARCHAR(4000),
|
||||
"massnahme" VARCHAR(2000),
|
||||
"veroeffentlichtAm" DATE,
|
||||
"quelle" VARCHAR(500),
|
||||
"sperrtGeraete" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "RueckrufStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "rueckruf_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf_geraetetyp" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"rueckrufId" UUID NOT NULL,
|
||||
"geraetetypId" UUID,
|
||||
"modell" VARCHAR(160),
|
||||
"seriennummerVon" VARCHAR(120),
|
||||
"seriennummerBis" VARCHAR(120),
|
||||
"herstellungVon" DATE,
|
||||
"herstellungBis" DATE,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "rueckruf_geraetetyp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rueckruf_betroffenheit" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"rueckrufId" UUID NOT NULL,
|
||||
"geraetId" UUID NOT NULL,
|
||||
"status" "BetroffenheitStatus" NOT NULL DEFAULT 'OFFEN',
|
||||
"festgestelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"erledigtAm" TIMESTAMPTZ(3),
|
||||
"bemerkung" VARCHAR(1000),
|
||||
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "rueckruf_betroffenheit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "erinnerung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"terminId" UUID NOT NULL,
|
||||
"stufe" "ErinnerungStufe" NOT NULL,
|
||||
"versendetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"kanal" VARCHAR(40) NOT NULL DEFAULT 'mail',
|
||||
"empfaenger" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
|
||||
"meldung" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "erinnerung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungsvorlage_organisationId_geraetetypId_idx" ON "wartungsvorlage"("organisationId", "geraetetypId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungsvorlage_organisationId_aktiv_idx" ON "wartungsvorlage"("organisationId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wartungsvorlage_organisationId_name_key" ON "wartungsvorlage"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "checklistenpunkt_organisationId_vorlageId_idx" ON "checklistenpunkt"("organisationId", "vorlageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_faelligAm_idx" ON "wartungstermin"("organisationId", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_status_faelligAm_idx" ON "wartungstermin"("organisationId", "status", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_geraetId_idx" ON "wartungstermin"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wartungstermin_organisationId_zugewiesenAnId_idx" ON "wartungstermin"("organisationId", "zugewiesenAnId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wartungstermin_geraetId_vorlageId_faelligAm_key" ON "wartungstermin"("geraetId", "vorlageId", "faelligAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_geraetId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "geraetId", "durchgefuehrtAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_status_idx" ON "pruefung"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "durchgefuehrtAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefung_organisationId_prueferId_idx" ON "pruefung"("organisationId", "prueferId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pruefung_organisationId_protokollNummer_key" ON "pruefung"("organisationId", "protokollNummer");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pruefergebnis_organisationId_pruefungId_idx" ON "pruefergebnis"("organisationId", "pruefungId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pruefergebnis_pruefungId_punktId_key" ON "pruefergebnis"("pruefungId", "punktId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "signatur_organisationId_pruefungId_idx" ON "signatur"("organisationId", "pruefungId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_status_prioritaet_idx" ON "mangel"("organisationId", "status", "prioritaet");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_geraetId_idx" ON "mangel"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mangel_organisationId_zugewiesenAnId_idx" ON "mangel"("organisationId", "zugewiesenAnId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_organisationId_status_idx" ON "rueckruf"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_organisationId_hersteller_idx" ON "rueckruf"("organisationId", "hersteller");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_geraetetyp_organisationId_rueckrufId_idx" ON "rueckruf_geraetetyp"("organisationId", "rueckrufId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_betroffenheit_organisationId_rueckrufId_idx" ON "rueckruf_betroffenheit"("organisationId", "rueckrufId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rueckruf_betroffenheit_organisationId_geraetId_idx" ON "rueckruf_betroffenheit"("organisationId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "rueckruf_betroffenheit_rueckrufId_geraetId_key" ON "rueckruf_betroffenheit"("rueckrufId", "geraetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "erinnerung_organisationId_versendetAm_idx" ON "erinnerung"("organisationId", "versendetAm" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "erinnerung_terminId_stufe_key" ON "erinnerung"("terminId", "stufe");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_externeProtokollDateiId_fkey" FOREIGN KEY ("externeProtokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_ersetztPruefungId_fkey" FOREIGN KEY ("ersetztPruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_punktId_fkey" FOREIGN KEY ("punktId") REFERENCES "checklistenpunkt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_bildDateiId_fkey" FOREIGN KEY ("bildDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_melderId_fkey" FOREIGN KEY ("melderId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_behobenVonId_fkey" FOREIGN KEY ("behobenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf" ADD CONSTRAINT "rueckruf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,243 @@
|
||||
-- =============================================================================
|
||||
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
|
||||
--
|
||||
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
|
||||
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
|
||||
--
|
||||
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
|
||||
-- für die Tabellen aus Phase 0 und 1.
|
||||
--
|
||||
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
|
||||
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
|
||||
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
|
||||
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
|
||||
-- der Datenbank, nicht nur in lib/ und server/.
|
||||
--
|
||||
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
|
||||
-- Rückrufe.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'wartungsvorlage',
|
||||
'checklistenpunkt',
|
||||
'wartungstermin',
|
||||
'pruefung',
|
||||
'pruefergebnis',
|
||||
'signatur',
|
||||
'mangel',
|
||||
'rueckruf',
|
||||
'rueckruf_geraetetyp',
|
||||
'rueckruf_betroffenheit',
|
||||
'erinnerung'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON %I FOR ALL '
|
||||
'USING ("organisationId" = aktuelle_organisation()) '
|
||||
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||
tabelle || '_mandant', tabelle
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Unveränderbarkeit abgeschlossener Prüfungen
|
||||
--
|
||||
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
|
||||
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
|
||||
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
|
||||
--
|
||||
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
|
||||
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
|
||||
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
|
||||
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
|
||||
--
|
||||
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
|
||||
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
vergleich "pruefung"%ROWTYPE;
|
||||
BEGIN
|
||||
-- Entwürfe sind frei änderbar.
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
|
||||
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
|
||||
vergleich := NEW;
|
||||
vergleich."geaendertAm" := OLD."geaendertAm";
|
||||
|
||||
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
|
||||
vergleich."status" := OLD."status";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollDateiId" IS NULL THEN
|
||||
vergleich."protokollDateiId" := OLD."protokollDateiId";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollNummer" IS NULL THEN
|
||||
vergleich."protokollNummer" := OLD."protokollNummer";
|
||||
END IF;
|
||||
|
||||
IF vergleich IS DISTINCT FROM OLD THEN
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
|
||||
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
|
||||
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
|
||||
|
||||
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
|
||||
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
|
||||
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
|
||||
'(nur im Aufbewahrungslauf).',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefung_abschluss_schuetzen
|
||||
BEFORE UPDATE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
|
||||
|
||||
CREATE TRIGGER pruefung_loeschschutz
|
||||
BEFORE DELETE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Dasselbe für die Bestandteile eines Protokolls.
|
||||
--
|
||||
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||||
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
|
||||
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
|
||||
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
betroffene_pruefung uuid;
|
||||
zustand "PruefungStatus";
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
betroffene_pruefung := OLD."pruefungId";
|
||||
ELSE
|
||||
betroffene_pruefung := NEW."pruefungId";
|
||||
END IF;
|
||||
|
||||
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
|
||||
|
||||
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
|
||||
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
|
||||
betroffene_pruefung, TG_OP, TG_TABLE_NAME
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefergebnis_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
CREATE TRIGGER signatur_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
|
||||
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
|
||||
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
|
||||
);
|
||||
|
||||
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
|
||||
-- erzeugen.
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
|
||||
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indizes
|
||||
--
|
||||
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
|
||||
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
|
||||
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
|
||||
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
|
||||
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
|
||||
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
|
||||
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||
|
||||
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
|
||||
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
|
||||
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
|
||||
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -167,6 +167,19 @@ model Organisation {
|
||||
qualifikationstypen Qualifikationstyp[]
|
||||
qualifikationen Qualifikation[]
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
wartungsvorlagen Wartungsvorlage[]
|
||||
checklistenpunkte Checklistenpunkt[]
|
||||
wartungstermine Wartungstermin[]
|
||||
pruefungen Pruefung[]
|
||||
pruefergebnisse Pruefergebnis[]
|
||||
signaturen Signatur[]
|
||||
maengel Mangel[]
|
||||
rueckrufe Rueckruf[]
|
||||
rueckrufTypen RueckrufGeraetetyp[]
|
||||
rueckrufBetroffenheiten RueckrufBetroffenheit[]
|
||||
erinnerungen Erinnerung[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("organisation")
|
||||
}
|
||||
@@ -232,6 +245,14 @@ model Person {
|
||||
qualifikationen Qualifikation[] @relation("QualifikationInhaber")
|
||||
bestaetigteQualifikationen Qualifikation[] @relation("QualifikationBestaetiger")
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
zugewieseneTermine Wartungstermin[] @relation("TerminZustaendig")
|
||||
durchgefuehrtePruefungen Pruefung[] @relation("PruefungPruefer")
|
||||
signaturen Signatur[] @relation("SignaturUnterzeichner")
|
||||
gemeldeteMaengel Mangel[] @relation("MangelMelder")
|
||||
zugewieseneMaengel Mangel[] @relation("MangelZustaendig")
|
||||
behobeneMaengel Mangel[] @relation("MangelBehebung")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
@@unique([organisationId, email])
|
||||
@@ -454,6 +475,13 @@ model Datei {
|
||||
hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
|
||||
alsAvatarGenutzt Person[] @relation("PersonAvatar")
|
||||
|
||||
// Phase 2 — woran eine Datei sonst noch hängen kann
|
||||
alsPruefprotokoll Pruefung[] @relation("PruefungProtokoll")
|
||||
alsFremdprotokoll Pruefung[] @relation("PruefungFremdprotokoll")
|
||||
alsErgebnisFoto Pruefergebnis[] @relation("PruefergebnisFoto")
|
||||
alsSignaturbild Signatur[] @relation("SignaturBild")
|
||||
alsMangelFoto Mangel[] @relation("MangelFoto")
|
||||
|
||||
@@unique([bucket, objektSchluessel])
|
||||
@@index([organisationId, entitaet, entitaetId])
|
||||
@@index([organisationId, kategorie])
|
||||
@@ -688,6 +716,10 @@ model Geraetetyp {
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraete Geraet[]
|
||||
|
||||
// Phase 2
|
||||
wartungsvorlagen Wartungsvorlage[]
|
||||
rueckrufTypen RueckrufGeraetetyp[]
|
||||
|
||||
@@unique([organisationId, name])
|
||||
@@index([organisationId, kategorie])
|
||||
@@index([organisationId, aktiv])
|
||||
@@ -746,6 +778,12 @@ model Geraet {
|
||||
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
|
||||
geraeteraum Geraeteraum? @relation(fields: [geraeteraumId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Phase 2 — alles, was am Gerät geprüft, gemeldet und zurückgerufen wird
|
||||
wartungstermine Wartungstermin[]
|
||||
pruefungen Pruefung[]
|
||||
maengel Mangel[]
|
||||
rueckrufBetroffenheiten RueckrufBetroffenheit[]
|
||||
|
||||
@@unique([organisationId, inventarnummer])
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, naechstePruefung])
|
||||
@@ -826,3 +864,542 @@ model Qualifikation {
|
||||
@@index([organisationId, gueltigBis])
|
||||
@@map("qualifikation")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 2 — Wartungs- und Prüfmanagement
|
||||
//
|
||||
// Ordnungsprinzip:
|
||||
// Wartungsvorlage was geprüft wird und in welchem Takt — je Gerätetyp
|
||||
// Checklistenpunkt eine einzelne Frage der Prüfung
|
||||
// Wartungstermin ein konkreter Prüftermin an einem konkreten Gerät
|
||||
// Pruefung die durchgeführte Prüfung samt Ergebnis
|
||||
// Pruefergebnis die Antwort auf einen Checklistenpunkt
|
||||
// Signatur die Unterschrift unter das Protokoll
|
||||
// Mangel was dabei auffiel und noch behoben werden muss
|
||||
// Rueckruf eine Herstellerwarnung und die betroffenen Geräte
|
||||
//
|
||||
// Zwei Festlegungen, die den Aufbau erklären:
|
||||
//
|
||||
// 1. Ein abgeschlossenes Prüfprotokoll ist unveränderbar. Ein Trigger in der
|
||||
// Datenbank lässt an einer Prüfung mit Status ABGESCHLOSSEN keine
|
||||
// Änderung mehr zu — außer der Kennzeichnung als ERSETZT. Eine Korrektur
|
||||
// ist eine neue Prüfung, die über ersetztPruefungId auf die alte zeigt.
|
||||
//
|
||||
// 2. "Überfällig" ist kein gespeicherter Zustand, sondern ergibt sich aus
|
||||
// faelligAm und dem heutigen Tag. Gespeicherte Zustände laufen der Uhr
|
||||
// hinterher; berechnete nicht.
|
||||
// =============================================================================
|
||||
|
||||
/// Zustand eines Prüftermins. Überfälligkeit steht bewusst nicht hier drin.
|
||||
enum WartungsterminStatus {
|
||||
OFFEN
|
||||
ERLEDIGT
|
||||
/// Der Termin ist gegenstandslos geworden — Gerät ausgesondert, Vorlage
|
||||
/// abgeschafft, Prüfpflicht entfallen.
|
||||
ENTFALLEN
|
||||
}
|
||||
|
||||
/// Lebenszyklus einer Prüfung.
|
||||
enum PruefungStatus {
|
||||
/// Der Wizard läuft noch. Nur in diesem Zustand ist Ändern erlaubt.
|
||||
ENTWURF
|
||||
/// Unterschrieben und festgeschrieben.
|
||||
ABGESCHLOSSEN
|
||||
/// Durch eine Korrekturprüfung abgelöst. Bleibt erhalten und lesbar.
|
||||
ERSETZT
|
||||
}
|
||||
|
||||
/// Ergebnis einer Prüfung nach DGUV-Logik.
|
||||
enum PruefErgebnis {
|
||||
BESTANDEN
|
||||
BESTANDEN_MIT_MAENGELN
|
||||
NICHT_BESTANDEN
|
||||
}
|
||||
|
||||
/// Wer geprüft hat — die eigene Wehr oder eine Fachfirma.
|
||||
enum PruefungArt {
|
||||
INTERN
|
||||
EXTERN
|
||||
}
|
||||
|
||||
/// Art eines Checklistenpunkts. Bestimmt, welches Eingabefeld der Wizard zeigt.
|
||||
enum ChecklistenpunktTyp {
|
||||
JA_NEIN
|
||||
MESSWERT
|
||||
TEXT
|
||||
FOTO
|
||||
}
|
||||
|
||||
/// In welcher Eigenschaft jemand unterschreibt.
|
||||
enum SignaturRolle {
|
||||
PRUEFER
|
||||
GERAETEWART
|
||||
/// Bei externer Wartung: der Vertreter der Fachfirma.
|
||||
FREMDFIRMA
|
||||
}
|
||||
|
||||
/// Dringlichkeit eines Mangels.
|
||||
enum MangelPrioritaet {
|
||||
NIEDRIG
|
||||
MITTEL
|
||||
HOCH
|
||||
/// Gerät ist nicht mehr einsatzbereit.
|
||||
KRITISCH
|
||||
}
|
||||
|
||||
/// Bearbeitungsstand eines Mangels: melden → zugewiesen → in Arbeit → behoben.
|
||||
enum MangelStatus {
|
||||
GEMELDET
|
||||
ZUGEWIESEN
|
||||
IN_ARBEIT
|
||||
BEHOBEN
|
||||
/// Kein Mangel — Fehlmeldung, bleibt aber nachvollziehbar stehen.
|
||||
VERWORFEN
|
||||
}
|
||||
|
||||
/// Bearbeitungsstand eines Rückrufs.
|
||||
enum RueckrufStatus {
|
||||
OFFEN
|
||||
IN_BEARBEITUNG
|
||||
ERLEDIGT
|
||||
}
|
||||
|
||||
/// Stand je betroffenem Gerät innerhalb eines Rückrufs.
|
||||
enum BetroffenheitStatus {
|
||||
OFFEN
|
||||
/// Geprüft und doch nicht betroffen — falsche Charge, anderes Baujahr.
|
||||
NICHT_BETROFFEN
|
||||
MASSNAHME_LAEUFT
|
||||
ERLEDIGT
|
||||
}
|
||||
|
||||
/// Vorwarnstufe einer Erinnerung. Je Termin und Stufe wird höchstens einmal
|
||||
/// erinnert — der Eintrag ist gleichzeitig die Sperre gegen Doppelversand.
|
||||
enum ErinnerungStufe {
|
||||
T_30
|
||||
T_7
|
||||
T_0
|
||||
UEBERFAELLIG
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Was an einem Gerätetyp regelmäßig zu prüfen ist.
|
||||
///
|
||||
/// Die Vorlage ist die Regel, der Wartungstermin ihre Anwendung auf ein
|
||||
/// einzelnes Gerät. Ändert sich die Vorlage, ändern sich künftige Termine —
|
||||
/// bereits abgeschlossene Prüfungen bleiben unberührt, weil ihre Ergebnisse
|
||||
/// samt Fragestellung erhalten bleiben.
|
||||
model Wartungsvorlage {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(160)
|
||||
/// Für welchen Gerätetyp sie gilt. Leer bedeutet: nur von Hand zuweisbar.
|
||||
geraetetypId String? @db.Uuid
|
||||
|
||||
/// Prüfintervall in Monaten. Zählt ab der letzten durchgeführten Prüfung.
|
||||
intervallMonate Int
|
||||
/// Wie viele Tage vorher gewarnt und erinnert wird.
|
||||
vorwarnzeitTage Int @default(30)
|
||||
/// Fundstelle, z. B. "DGUV Vorschrift 49" oder "DIN 14800-16".
|
||||
pruefvorschrift String? @db.VarChar(200)
|
||||
|
||||
/// Erfahrungswert für die Planung, in Minuten.
|
||||
standardDauerMinuten Int?
|
||||
/// Vorbelegung: diese Prüfung macht üblicherweise eine Fachfirma.
|
||||
standardExtern Boolean @default(false)
|
||||
/// Der nächtliche Lauf erzeugt Termine für alle Geräte dieses Typs.
|
||||
automatisch Boolean @default(true)
|
||||
|
||||
beschreibung String? @db.VarChar(1000)
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
|
||||
punkte Checklistenpunkt[]
|
||||
termine Wartungstermin[]
|
||||
pruefungen Pruefung[]
|
||||
|
||||
@@unique([organisationId, name])
|
||||
@@index([organisationId, geraetetypId])
|
||||
@@index([organisationId, aktiv])
|
||||
@@map("wartungsvorlage")
|
||||
}
|
||||
|
||||
/// Eine einzelne Frage der Checkliste.
|
||||
model Checklistenpunkt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
vorlageId String @db.Uuid
|
||||
|
||||
text String @db.VarChar(300)
|
||||
typ ChecklistenpunktTyp @default(JA_NEIN)
|
||||
/// Pflichtpunkte müssen beantwortet sein, bevor sich die Prüfung
|
||||
/// abschließen lässt.
|
||||
pflicht Boolean @default(true)
|
||||
|
||||
/// Sollwert als Klartext für die Anzeige, z. B. "mindestens 270 bar".
|
||||
sollwert String? @db.VarChar(120)
|
||||
/// Einheit eines Messwerts, z. B. "bar", "mbar", "V", "kN".
|
||||
einheit String? @db.VarChar(20)
|
||||
/// Zulässiger Bereich eines Messwerts. Außerhalb davon gilt der Punkt als
|
||||
/// nicht in Ordnung — gerechnet wird das im Server, nicht im Browser.
|
||||
minWert Decimal? @db.Decimal(12, 3)
|
||||
maxWert Decimal? @db.Decimal(12, 3)
|
||||
|
||||
hinweis String? @db.VarChar(500)
|
||||
sortierung Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
vorlage Wartungsvorlage @relation(fields: [vorlageId], references: [id], onDelete: Cascade)
|
||||
ergebnisse Pruefergebnis[]
|
||||
|
||||
@@index([organisationId, vorlageId])
|
||||
@@map("checklistenpunkt")
|
||||
}
|
||||
|
||||
/// Ein fälliger Prüftermin an einem einzelnen Gerät.
|
||||
///
|
||||
/// Termine entstehen entweder im nächtlichen Lauf aus den Vorlagen oder von
|
||||
/// Hand. Die Eindeutigkeit über Gerät, Vorlage und Fälligkeitstag verhindert,
|
||||
/// dass ein zweiter Lauf denselben Termin noch einmal anlegt.
|
||||
model Wartungstermin {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
vorlageId String? @db.Uuid
|
||||
|
||||
faelligAm DateTime @db.Date
|
||||
status WartungsterminStatus @default(OFFEN)
|
||||
|
||||
/// true, wenn der nächtliche Lauf den Termin erzeugt hat.
|
||||
automatischErzeugt Boolean @default(false)
|
||||
/// Wer den Termin übernehmen soll.
|
||||
zugewiesenAnId String? @db.Uuid
|
||||
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
erledigtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
||||
zugewiesenAn Person? @relation("TerminZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull)
|
||||
pruefungen Pruefung[]
|
||||
erinnerungen Erinnerung[]
|
||||
|
||||
/// Doppelte Termine aus wiederholten Läufen ausschließen. Für Termine ohne
|
||||
/// Vorlage greift die Bedingung nicht — Postgres wertet NULL als
|
||||
/// unterschiedlich —, die legt aber ohnehin nur ein Mensch an.
|
||||
@@unique([geraetId, vorlageId, faelligAm])
|
||||
@@index([organisationId, faelligAm])
|
||||
@@index([organisationId, status, faelligAm])
|
||||
@@index([organisationId, geraetId])
|
||||
@@index([organisationId, zugewiesenAnId])
|
||||
@@map("wartungstermin")
|
||||
}
|
||||
|
||||
/// Eine durchgeführte Prüfung.
|
||||
///
|
||||
/// Ist der Status ABGESCHLOSSEN, ist der Datensatz festgeschrieben: die
|
||||
/// Datenbank weist jede weitere Änderung ab. Was korrigiert werden muss, wird
|
||||
/// als neue Prüfung erfasst, die über ersetztPruefungId auf die alte zeigt.
|
||||
model Pruefung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
terminId String? @db.Uuid
|
||||
vorlageId String? @db.Uuid
|
||||
|
||||
art PruefungArt @default(INTERN)
|
||||
status PruefungStatus @default(ENTWURF)
|
||||
|
||||
prueferId String? @db.Uuid
|
||||
/// Klarname des Prüfers zum Zeitpunkt der Prüfung. Bleibt lesbar, auch wenn
|
||||
/// die Person später austritt und anonymisiert wird — die Nachweispflicht
|
||||
/// gegenüber der Unfallkasse verlangt einen benennbaren Prüfer.
|
||||
prueferName String? @db.VarChar(200)
|
||||
|
||||
durchgefuehrtAm DateTime @db.Date
|
||||
/// Beginn der Zeiterfassung im Wizard.
|
||||
begonnenUm DateTime? @db.Timestamptz(3)
|
||||
dauerMinuten Int?
|
||||
|
||||
ergebnis PruefErgebnis?
|
||||
|
||||
/// Nur bei art = EXTERN: wer geprüft hat, was es gekostet hat und das
|
||||
/// Protokoll der Fachfirma als PDF.
|
||||
externeFirma String? @db.VarChar(200)
|
||||
externeProtokollDateiId String? @db.Uuid
|
||||
kosten Decimal? @db.Decimal(10, 2)
|
||||
|
||||
/// Nächster Termin laut dieser Prüfung. Kann vom Intervall abweichen, wenn
|
||||
/// der Prüfer eine kürzere Frist ansetzt.
|
||||
naechstePruefungAm DateTime? @db.Date
|
||||
|
||||
/// Das erzeugte Protokoll-PDF in MinIO und seine laufende Nummer.
|
||||
protokollDateiId String? @db.Uuid
|
||||
protokollNummer String? @db.VarChar(40)
|
||||
|
||||
abgeschlossenAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
/// Korrektur: diese Prüfung ersetzt eine frühere.
|
||||
ersetztPruefungId String? @db.Uuid
|
||||
korrekturGrund String? @db.VarChar(500)
|
||||
|
||||
bemerkung String? @db.VarChar(2000)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
termin Wartungstermin? @relation(fields: [terminId], references: [id], onDelete: SetNull)
|
||||
vorlage Wartungsvorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
||||
pruefer Person? @relation("PruefungPruefer", fields: [prueferId], references: [id], onDelete: SetNull)
|
||||
protokoll Datei? @relation("PruefungProtokoll", fields: [protokollDateiId], references: [id], onDelete: SetNull)
|
||||
externesProtokoll Datei? @relation("PruefungFremdprotokoll", fields: [externeProtokollDateiId], references: [id], onDelete: SetNull)
|
||||
ersetzt Pruefung? @relation("PruefungKorrektur", fields: [ersetztPruefungId], references: [id], onDelete: SetNull)
|
||||
ersetztDurch Pruefung[] @relation("PruefungKorrektur")
|
||||
ergebnisse Pruefergebnis[]
|
||||
signaturen Signatur[]
|
||||
maengel Mangel[]
|
||||
|
||||
@@unique([organisationId, protokollNummer])
|
||||
@@index([organisationId, geraetId, durchgefuehrtAm(sort: Desc)])
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, durchgefuehrtAm(sort: Desc)])
|
||||
@@index([organisationId, prueferId])
|
||||
@@map("pruefung")
|
||||
}
|
||||
|
||||
/// Die Antwort auf einen Checklistenpunkt.
|
||||
model Pruefergebnis {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
pruefungId String @db.Uuid
|
||||
punktId String @db.Uuid
|
||||
|
||||
/// Je nach Typ des Punktes ist eines der drei Felder belegt.
|
||||
jaNein Boolean?
|
||||
zahlwert Decimal? @db.Decimal(12, 3)
|
||||
text String? @db.VarChar(1000)
|
||||
|
||||
fotoDateiId String? @db.Uuid
|
||||
|
||||
/// Ob der Punkt in Ordnung ist. Bei Messwerten rechnet der Server das aus
|
||||
/// dem zulässigen Bereich aus, sonst folgt es der Antwort.
|
||||
inOrdnung Boolean @default(true)
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade)
|
||||
punkt Checklistenpunkt @relation(fields: [punktId], references: [id], onDelete: Restrict)
|
||||
foto Datei? @relation("PruefergebnisFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([pruefungId, punktId])
|
||||
@@index([organisationId, pruefungId])
|
||||
@@map("pruefergebnis")
|
||||
}
|
||||
|
||||
/// Unterschrift unter ein Prüfprotokoll. Das Bild ist ein PNG aus dem
|
||||
/// Zeichenfeld und liegt wie alles andere in MinIO.
|
||||
model Signatur {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
pruefungId String @db.Uuid
|
||||
|
||||
rolle SignaturRolle @default(PRUEFER)
|
||||
personId String? @db.Uuid
|
||||
/// Klarname, so wie er im Protokoll steht.
|
||||
name String @db.VarChar(200)
|
||||
|
||||
bildDateiId String? @db.Uuid
|
||||
unterzeichnetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
pruefung Pruefung @relation(fields: [pruefungId], references: [id], onDelete: Cascade)
|
||||
person Person? @relation("SignaturUnterzeichner", fields: [personId], references: [id], onDelete: SetNull)
|
||||
bild Datei? @relation("SignaturBild", fields: [bildDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, pruefungId])
|
||||
@@map("signatur")
|
||||
}
|
||||
|
||||
/// Ein Mangel an einem Gerät — gemeldet aus einer Prüfung heraus oder einzeln.
|
||||
model Mangel {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
/// Gesetzt, wenn der Mangel bei einer Prüfung aufgefallen ist.
|
||||
pruefungId String? @db.Uuid
|
||||
|
||||
melderId String? @db.Uuid
|
||||
melderName String? @db.VarChar(200)
|
||||
|
||||
beschreibung String @db.VarChar(2000)
|
||||
fotoDateiId String? @db.Uuid
|
||||
|
||||
prioritaet MangelPrioritaet @default(MITTEL)
|
||||
status MangelStatus @default(GEMELDET)
|
||||
|
||||
zugewiesenAnId String? @db.Uuid
|
||||
gemeldetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
behobenAm DateTime? @db.Timestamptz(3)
|
||||
behobenVonId String? @db.Uuid
|
||||
behebung String? @db.VarChar(2000)
|
||||
|
||||
/// Beim Melden wurde das Gerät gesperrt. Merkt sich, dass die Sperre zu
|
||||
/// diesem Mangel gehört — beim Beheben wird sie wieder aufgehoben.
|
||||
geraetGesperrt Boolean @default(false)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
pruefung Pruefung? @relation(fields: [pruefungId], references: [id], onDelete: SetNull)
|
||||
melder Person? @relation("MangelMelder", fields: [melderId], references: [id], onDelete: SetNull)
|
||||
zugewiesenAn Person? @relation("MangelZustaendig", fields: [zugewiesenAnId], references: [id], onDelete: SetNull)
|
||||
behobenVon Person? @relation("MangelBehebung", fields: [behobenVonId], references: [id], onDelete: SetNull)
|
||||
foto Datei? @relation("MangelFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, status, prioritaet])
|
||||
@@index([organisationId, geraetId])
|
||||
@@index([organisationId, zugewiesenAnId])
|
||||
@@map("mangel")
|
||||
}
|
||||
|
||||
/// Eine Herstellerwarnung. Betroffene Geräte werden gesucht, markiert und auf
|
||||
/// Wunsch gesperrt.
|
||||
model Rueckruf {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
hersteller String @db.VarChar(160)
|
||||
titel String @db.VarChar(200)
|
||||
/// Nummer der Bekanntmachung des Herstellers.
|
||||
referenz String? @db.VarChar(80)
|
||||
|
||||
beschreibung String? @db.VarChar(4000)
|
||||
massnahme String? @db.VarChar(2000)
|
||||
|
||||
veroeffentlichtAm DateTime? @db.Date
|
||||
/// Fundstelle, z. B. die Adresse der Herstellermitteilung.
|
||||
quelle String? @db.VarChar(500)
|
||||
|
||||
/// Betroffene Geräte beim Zuordnen automatisch sperren.
|
||||
sperrtGeraete Boolean @default(false)
|
||||
status RueckrufStatus @default(OFFEN)
|
||||
erledigtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
typen RueckrufGeraetetyp[]
|
||||
betroffenheiten RueckrufBetroffenheit[]
|
||||
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, hersteller])
|
||||
@@map("rueckruf")
|
||||
}
|
||||
|
||||
/// Welche Bauart und welche Charge ein Rückruf betrifft.
|
||||
model RueckrufGeraetetyp {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
rueckrufId String @db.Uuid
|
||||
geraetetypId String? @db.Uuid
|
||||
|
||||
/// Freitext, wenn die betroffene Bauart nicht als Gerätetyp erfasst ist.
|
||||
modell String? @db.VarChar(160)
|
||||
|
||||
/// Eingrenzung der betroffenen Stücke. Alle Angaben sind freiwillig; was
|
||||
/// leer bleibt, schränkt nicht ein.
|
||||
seriennummerVon String? @db.VarChar(120)
|
||||
seriennummerBis String? @db.VarChar(120)
|
||||
herstellungVon DateTime? @db.Date
|
||||
herstellungBis DateTime? @db.Date
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade)
|
||||
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, rueckrufId])
|
||||
@@map("rueckruf_geraetetyp")
|
||||
}
|
||||
|
||||
/// Ein einzelnes Gerät innerhalb eines Rückrufs.
|
||||
model RueckrufBetroffenheit {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
rueckrufId String @db.Uuid
|
||||
geraetId String @db.Uuid
|
||||
|
||||
status BetroffenheitStatus @default(OFFEN)
|
||||
festgestelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
erledigtAm DateTime? @db.Timestamptz(3)
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
|
||||
/// Der Rückruf hat dieses Gerät gesperrt — beim Erledigen wird die Sperre
|
||||
/// wieder aufgehoben.
|
||||
geraetGesperrt Boolean @default(false)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
rueckruf Rueckruf @relation(fields: [rueckrufId], references: [id], onDelete: Cascade)
|
||||
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([rueckrufId, geraetId])
|
||||
@@index([organisationId, rueckrufId])
|
||||
@@index([organisationId, geraetId])
|
||||
@@map("rueckruf_betroffenheit")
|
||||
}
|
||||
|
||||
/// Nachweis einer versendeten Erinnerung. Der Eintrag ist gleichzeitig die
|
||||
/// Sperre gegen Doppelversand: je Termin und Stufe gibt es ihn nur einmal.
|
||||
model Erinnerung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
terminId String @db.Uuid
|
||||
|
||||
stufe ErinnerungStufe
|
||||
versendetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
/// Kanal laut lib/integrations — "mail", "telegram", "protokoll".
|
||||
kanal String @default("mail") @db.VarChar(40)
|
||||
empfaenger String[] @default([])
|
||||
erfolgreich Boolean @default(true)
|
||||
meldung String? @db.VarChar(500)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
termin Wartungstermin @relation(fields: [terminId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([terminId, stufe])
|
||||
@@index([organisationId, versendetAm(sort: Desc)])
|
||||
@@map("erinnerung")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
|
||||
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
|
||||
* 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,
|
||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||
@@ -19,15 +21,22 @@
|
||||
import "dotenv/config";
|
||||
import {
|
||||
AusweisTokenTyp,
|
||||
ChecklistenpunktTyp,
|
||||
Dienstgrad,
|
||||
FahrzeugArt,
|
||||
GeraeteKategorie,
|
||||
GeraetStatus,
|
||||
LebensdauerBezug,
|
||||
LebensdauerKategorie,
|
||||
MangelPrioritaet,
|
||||
MangelStatus,
|
||||
PrismaClient,
|
||||
PruefErgebnis,
|
||||
PruefungArt,
|
||||
PruefungStatus,
|
||||
QualifikationsArt,
|
||||
RolleSchluessel,
|
||||
SignaturRolle,
|
||||
StandortArt,
|
||||
} from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -1092,6 +1101,417 @@ async function qualifikationenAnlegen(organisationId: string, typen: Map<string,
|
||||
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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -1113,6 +1533,12 @@ async function main(): Promise<void> {
|
||||
const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
|
||||
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("");
|
||||
log("Anmeldung für den Erststart:");
|
||||
|
||||
@@ -80,6 +80,44 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "Formular für ein neues Gerät",
|
||||
angemeldet: true,
|
||||
},
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
{
|
||||
name: "11-fristen",
|
||||
pfad: "/pruefungen",
|
||||
beschreibung: "Fristen-Dashboard mit Zeitfenstern und Bündelung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "12-vorlagen",
|
||||
pfad: "/pruefungen/vorlagen",
|
||||
beschreibung: "Wartungsvorlagen mit Intervall und Prüfvorschrift",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "13-maengel",
|
||||
pfad: "/maengel",
|
||||
beschreibung: "Mängel von der Meldung bis zur Behebung",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "14-rueckrufe",
|
||||
pfad: "/rueckrufe",
|
||||
beschreibung: "Rückrufe des Herstellers",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "15-lebensdauer",
|
||||
pfad: "/pruefungen/lebensdauer",
|
||||
beschreibung: "Geräte am Ende ihrer Nutzungsdauer",
|
||||
angemeldet: true,
|
||||
},
|
||||
{
|
||||
name: "16-protokolle",
|
||||
pfad: "/pruefungen/verlauf",
|
||||
beschreibung: "Abgeschlossene Prüfprotokolle",
|
||||
angemeldet: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -92,6 +130,12 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
|
||||
/** Findet den Link auf der Übersichtsseite. */
|
||||
von: string;
|
||||
linkMuster: RegExp;
|
||||
/**
|
||||
* Zusätzliche Bedingung an die Beschriftung des Links. Nötig bei den
|
||||
* Protokollen: dort stehen auch Entwürfe in der Liste, und die zeigen ein
|
||||
* halb leeres Bild.
|
||||
*/
|
||||
textMuster?: RegExp;
|
||||
}> = [
|
||||
{
|
||||
name: "04-geraet",
|
||||
@@ -105,6 +149,20 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
|
||||
von: "/fahrzeuge",
|
||||
linkMuster: /^\/fahrzeuge\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
{
|
||||
name: "17-checkliste",
|
||||
beschreibung: "Checkliste einer Wartungsvorlage",
|
||||
von: "/pruefungen/vorlagen",
|
||||
linkMuster: /^\/pruefungen\/vorlagen\/[0-9a-f-]{36}$/,
|
||||
},
|
||||
{
|
||||
name: "18-pruefprotokoll",
|
||||
beschreibung: "Abgeschlossenes Prüfprotokoll mit Checkliste und Unterschrift",
|
||||
von: "/pruefungen/verlauf",
|
||||
linkMuster: /^\/pruefungen\/[0-9a-f-]{36}$/,
|
||||
// Nur fertige Protokolle: die tragen eine Nummer, Entwürfe nicht.
|
||||
textMuster: /^\d{4}-\d{4}$/,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -116,6 +174,12 @@ const DYNAMISCHE_AUFNAHMEN: Array<{
|
||||
*/
|
||||
async function fertigGeladen(seite: Page): Promise<void> {
|
||||
await seite.waitForLoadState("networkidle");
|
||||
|
||||
// Die Entwicklungsanzeige von Next.js gehört nicht in eine Anleitung.
|
||||
await seite
|
||||
.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||
.catch(() => undefined);
|
||||
|
||||
try {
|
||||
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
|
||||
timeout: 15_000,
|
||||
@@ -177,11 +241,18 @@ async function main(): Promise<void> {
|
||||
.locator("a[href]")
|
||||
.evaluateAll((elemente) =>
|
||||
elemente
|
||||
.map((element) => element.getAttribute("href") ?? "")
|
||||
.filter((href) => href.startsWith("/")),
|
||||
.map((element) => ({
|
||||
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) {
|
||||
console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`);
|
||||
continue;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
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 { hatRecht } from "@/lib/authz";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { StatusAbzeichen, FristAnzeige } from "@/components/stammdaten/anzeige";
|
||||
import { ErgebnisAbzeichen, MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige";
|
||||
|
||||
export const metadata = { title: "Gerät" };
|
||||
|
||||
@@ -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();
|
||||
|
||||
const { geraet, verlauf } = daten;
|
||||
const { geraet, verlauf, pruefungen, maengel, termine } = daten;
|
||||
const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
|
||||
|
||||
const angaben: Array<[string, React.ReactNode]> = [
|
||||
@@ -102,8 +147,22 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hatRecht(sitzung, "geraet.bearbeiten") && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hatRecht(sitzung, "pruefung.durchfuehren") && geraet.status !== "AUSGESONDERT" && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/pruefungen/neu?geraetId=${geraet.id}${
|
||||
termine[0] ? `&terminId=${termine[0].id}` : ""
|
||||
}`}
|
||||
>
|
||||
<ClipboardCheck aria-hidden />
|
||||
Prüfung durchführen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hatRecht(sitzung, "geraet.bearbeiten") && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/geraete/${geraet.id}/bearbeiten`}>
|
||||
<Pencil aria-hidden />
|
||||
Bearbeiten
|
||||
@@ -111,6 +170,7 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
@@ -195,6 +255,121 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{(termine.length > 0 || maengel.length > 0) && (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anstehende Prüfungen</CardTitle>
|
||||
<CardDescription>
|
||||
Offene Termine aus den Wartungsvorlagen. Die früheste Frist steht oben in den
|
||||
Angaben.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{termine.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Kein offener Termin. Termine entstehen nachts aus den Wartungsvorlagen des
|
||||
Gerätetyps.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{termine.map((termin) => (
|
||||
<li
|
||||
key={termin.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--border)] pb-2 text-sm last:border-0"
|
||||
>
|
||||
<span>{termin.vorlage?.name ?? "Prüfung ohne Vorlage"}</span>
|
||||
<FristAnzeige datum={termin.faelligAm} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="size-4" aria-hidden />
|
||||
Offene Mängel
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Was an diesem Gerät noch behoben werden muss.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{maengel.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Kein offener Mangel.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{maengel.map((mangel) => (
|
||||
<li key={mangel.id} className="text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PrioritaetAbzeichen prioritaet={mangel.prioritaet} />
|
||||
<MangelStatusAbzeichen status={mangel.status} />
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{alsDatum(mangel.gemeldetAm)}
|
||||
{mangel.melderName && ` · ${mangel.melderName}`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1">{mangel.beschreibung}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button variant="outline" size="sm" asChild className="mt-4">
|
||||
<Link href="/maengel">Zur Mängelverwaltung</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prüfhistorie</CardTitle>
|
||||
<CardDescription>
|
||||
Abgeschlossene Prüfungen mit Ergebnis. Das PDF liegt am jeweiligen Protokoll.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pruefungen.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Zu diesem Gerät ist noch keine Prüfung dokumentiert.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{pruefungen.map((pruefung) => (
|
||||
<li
|
||||
key={pruefung.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--border)] pb-2 text-sm last:border-0"
|
||||
>
|
||||
<Link
|
||||
href={`/pruefungen/${pruefung.id}`}
|
||||
className="tabellenzahlen font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{pruefung.protokollNummer ?? "Entwurf"}
|
||||
</Link>
|
||||
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
|
||||
{alsDatum(pruefung.durchgefuehrtAm)}
|
||||
</span>
|
||||
<span>{pruefung.vorlage?.name ?? "—"}</span>
|
||||
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||
{pruefung.art === "EXTERN" && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">extern</span>
|
||||
)}
|
||||
{pruefung.prueferName && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
— {pruefung.prueferName}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verlauf</CardTitle>
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, Play, Plus, Search, X } from "lucide-react";
|
||||
import { MangelPrioritaet, MangelStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { DateiFeld } from "@/components/wartung/datei-feld";
|
||||
import { MANGEL_STATUS_TEXT, PRIORITAET_TEXT } from "@/lib/wartung/texte";
|
||||
import { alsDatumZeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Mängelliste mit dem vollständigen Weg: melden → zugewiesen → in Arbeit →
|
||||
* behoben.
|
||||
*
|
||||
* Die Schaltflächen zeigen immer nur den nächsten sinnvollen Schritt. Ein
|
||||
* Formular, in dem man jeden Zustand frei wählen kann, führt dazu, dass Mängel
|
||||
* auf „behoben" springen, ohne dass jemand daran gearbeitet hat.
|
||||
*/
|
||||
export function MaengelListe({
|
||||
darfBearbeiten,
|
||||
darfZuweisen,
|
||||
}: {
|
||||
darfBearbeiten: boolean;
|
||||
darfZuweisen: boolean;
|
||||
}) {
|
||||
const [nurOffene, setNurOffene] = React.useState(true);
|
||||
const [suche, setSuche] = React.useState("");
|
||||
const [meldenOffen, setMeldenOffen] = React.useState(false);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
// Formular zum Melden
|
||||
const [geraetSuche, setGeraetSuche] = React.useState("");
|
||||
const [geraetId, setGeraetId] = React.useState("");
|
||||
const [beschreibung, setBeschreibung] = React.useState("");
|
||||
const [prioritaet, setPrioritaet] = React.useState<MangelPrioritaet>(MangelPrioritaet.MITTEL);
|
||||
const [fotoDateiId, setFotoDateiId] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const maengel = trpc.mangel.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "desc",
|
||||
nurOffene,
|
||||
suche: suche || undefined,
|
||||
});
|
||||
|
||||
const kennzahlen = trpc.mangel.kennzahlen.useQuery();
|
||||
|
||||
const geraete = trpc.geraet.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 25, richtung: "asc", suche: geraetSuche || undefined },
|
||||
{ enabled: meldenOffen },
|
||||
);
|
||||
|
||||
const mitglieder = trpc.person.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||
{ enabled: darfZuweisen },
|
||||
);
|
||||
|
||||
const aktualisieren = () => {
|
||||
void werkzeuge.mangel.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
};
|
||||
|
||||
const melden = trpc.mangel.melden.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldenOffen(false);
|
||||
setGeraetId("");
|
||||
setBeschreibung("");
|
||||
setFotoDateiId(null);
|
||||
setPrioritaet(MangelPrioritaet.MITTEL);
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const zuweisen = trpc.mangel.zuweisen.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const inArbeit = trpc.mangel.inArbeit.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const beheben = trpc.mangel.beheben.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setMeldung(
|
||||
ergebnis.geraetFreigegeben
|
||||
? "Mangel behoben — das Gerät ist wieder einsatzbereit."
|
||||
: "Mangel behoben.",
|
||||
);
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const verwerfen = trpc.mangel.verwerfen.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const eintraege = maengel.data?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-56 flex-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Beschreibung oder Inventarnummer …"
|
||||
className="pl-8"
|
||||
aria-label="Mängel durchsuchen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={nurOffene ? "offen" : "alle"}
|
||||
onValueChange={(wert) => setNurOffene(wert === "offen")}
|
||||
>
|
||||
<SelectTrigger className="w-48" aria-label="Nach Stand filtern">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="offen">nur offene</SelectItem>
|
||||
<SelectItem value="alle">alle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={() => setMeldenOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Mangel melden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{kennzahlen.data && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{kennzahlen.data.offen} offen
|
||||
{kennzahlen.data.kritisch > 0 && (
|
||||
<span className="font-medium text-[var(--gesperrt)]">
|
||||
{" "}
|
||||
· {kennzahlen.data.kritisch} kritisch
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="mangelmeldung" />}
|
||||
{maengel.error && <Fehlermeldung meldung={maengel.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Mangel</TableHead>
|
||||
<TableHead>Dringlichkeit</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead>Zuständig</TableHead>
|
||||
<TableHead className="w-56" aria-label="Aktionen" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{maengel.isLoading && <LadeZeilen spalten={6} />}
|
||||
|
||||
{eintraege.map((mangel) => (
|
||||
<TableRow key={mangel.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${mangel.geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{mangel.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{mangel.geraet.bezeichnung}
|
||||
</div>
|
||||
{mangel.geraetGesperrt && (
|
||||
<div className="text-xs font-medium text-[var(--gesperrt)]">
|
||||
wegen dieses Mangels gesperrt
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="max-w-md">
|
||||
<p className="text-sm">{mangel.beschreibung}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
gemeldet {alsDatumZeit(mangel.gemeldetAm)}
|
||||
{mangel.melderName && ` von ${mangel.melderName}`}
|
||||
{mangel.pruefung?.protokollNummer &&
|
||||
` · aus Protokoll ${mangel.pruefung.protokollNummer}`}
|
||||
</p>
|
||||
{mangel.behebung && (
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
Behebung: {mangel.behebung}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<PrioritaetAbzeichen prioritaet={mangel.prioritaet} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<MangelStatusAbzeichen status={mangel.status} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{darfZuweisen ? (
|
||||
<Select
|
||||
value={mangel.zugewiesenAn?.id ?? "niemand"}
|
||||
onValueChange={(wert) =>
|
||||
zuweisen.mutate({
|
||||
id: mangel.id,
|
||||
zugewiesenAnId: wert === "niemand" ? null : wert,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-44"
|
||||
aria-label={`Zuständigkeit für Mangel an ${mangel.geraet.inventarnummer}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="niemand">niemand</SelectItem>
|
||||
{mitglieder.data?.eintraege.map((person) => (
|
||||
<SelectItem key={person.id} value={person.id}>
|
||||
{person.nachname}, {person.vorname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : mangel.zugewiesenAn ? (
|
||||
`${mangel.zugewiesenAn.nachname}, ${mangel.zugewiesenAn.vorname}`
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{darfBearbeiten &&
|
||||
mangel.status !== MangelStatus.BEHOBEN &&
|
||||
mangel.status !== MangelStatus.VERWORFEN && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mangel.status !== MangelStatus.IN_ARBEIT && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inArbeit.mutate({ id: mangel.id })}
|
||||
>
|
||||
<Play aria-hidden />
|
||||
In Arbeit
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const behebung = window.prompt("Was wurde gemacht?");
|
||||
if (behebung === null) return;
|
||||
beheben.mutate({ id: mangel.id, behebung: behebung || null });
|
||||
}}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Behoben
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const grund = window.prompt("Warum ist das kein Mangel?");
|
||||
if (grund === null) return;
|
||||
verwerfen.mutate({ id: mangel.id, grund: grund || undefined });
|
||||
}}
|
||||
aria-label="Meldung verwerfen"
|
||||
>
|
||||
<X aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!maengel.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel={nurOffene ? "Kein offener Mangel" : "Noch kein Mangel gemeldet"}
|
||||
hinweis="Mängel entstehen bei einer Prüfung oder werden hier von Hand gemeldet."
|
||||
aktion={
|
||||
<Button onClick={() => setMeldenOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Mangel melden
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={meldenOffen} onOpenChange={setMeldenOffen}>
|
||||
<DialogContent>
|
||||
<form
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
setMeldung(null);
|
||||
melden.mutate({
|
||||
geraetId,
|
||||
beschreibung,
|
||||
prioritaet,
|
||||
fotoDateiId,
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mangel melden</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ein kritischer Mangel sperrt das Gerät sofort — ohne Rückfrage.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geraetsuche">Gerät suchen</Label>
|
||||
<Input
|
||||
id="geraetsuche"
|
||||
value={geraetSuche}
|
||||
onChange={(ereignis) => setGeraetSuche(ereignis.target.value)}
|
||||
placeholder="Inventarnummer oder Bezeichnung"
|
||||
/>
|
||||
|
||||
<Select value={geraetId} onValueChange={setGeraetId}>
|
||||
<SelectTrigger aria-label="Gerät auswählen">
|
||||
<SelectValue placeholder="Gerät wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{geraete.data?.eintraege.map((geraet) => (
|
||||
<SelectItem key={geraet.id} value={geraet.id}>
|
||||
{geraet.inventarnummer} — {geraet.bezeichnung}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mangelbeschreibung">Was ist der Mangel?</Label>
|
||||
<Textarea
|
||||
id="mangelbeschreibung"
|
||||
required
|
||||
rows={3}
|
||||
value={beschreibung}
|
||||
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
|
||||
placeholder="Riss im Schlauch, etwa 20 cm von der Kupplung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mangelprio">Dringlichkeit</Label>
|
||||
<Select
|
||||
value={prioritaet}
|
||||
onValueChange={(wert) => setPrioritaet(wert as MangelPrioritaet)}
|
||||
>
|
||||
<SelectTrigger id="mangelprio">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(PRIORITAET_TEXT) as MangelPrioritaet[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{PRIORITAET_TEXT[wert]}
|
||||
{wert === MangelPrioritaet.KRITISCH && " — sperrt das Gerät"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Foto</Label>
|
||||
<DateiFeld
|
||||
entitaet="Mangel"
|
||||
kategorie="foto"
|
||||
dateiId={fotoDateiId}
|
||||
onHochgeladen={setFotoDateiId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{melden.error && <Fehlermeldung meldung={melden.error.message} />}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setMeldenOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={melden.isPending || !geraetId}>
|
||||
{melden.isPending ? "wird gemeldet …" : "Melden"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Stand der Meldungen:{" "}
|
||||
{Object.entries(kennzahlen.data?.nachStatus ?? {})
|
||||
.map(
|
||||
([status, anzahl]) =>
|
||||
`${MANGEL_STATUS_TEXT[status as MangelStatus] ?? status}: ${anzahl}`,
|
||||
)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { MaengelListe } from "@/app/(app)/maengel/maengel-liste";
|
||||
|
||||
export const metadata = { title: "Mängel" };
|
||||
|
||||
/**
|
||||
* Mängelverwaltung.
|
||||
*
|
||||
* Melden darf jedes Mitglied — der Mangel fällt dem auf, der das Gerät
|
||||
* benutzt. Bearbeiten und beheben bleibt beim Gerätewart.
|
||||
*/
|
||||
export default async function MaengelSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mängel</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Von der Meldung bis zur Behebung — mit Foto, Zuständigkeit und Gerätesperre.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MaengelListe
|
||||
darfBearbeiten={hatRecht(sitzung, "pruefung.durchfuehren")}
|
||||
darfZuweisen={hatRecht(sitzung, "pruefung.planen")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FileDown, PencilLine } from "lucide-react";
|
||||
import type { PruefungStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Die beiden Dinge, die man mit einem fertigen Protokoll tut: herunterladen
|
||||
* oder korrigieren.
|
||||
*
|
||||
* Korrigieren heißt hier nicht ändern. Es entsteht ein neuer Entwurf mit den
|
||||
* bisherigen Antworten und einem Verweis auf dieses Protokoll; erst mit seinem
|
||||
* Abschluss gilt das alte als ersetzt. So bleibt nachvollziehbar, was zuerst
|
||||
* dastand — genau darum geht es bei einem Nachweis.
|
||||
*/
|
||||
export function ProtokollAktionen({
|
||||
pruefungId,
|
||||
status,
|
||||
hatProtokoll,
|
||||
darfKorrigieren,
|
||||
}: {
|
||||
pruefungId: string;
|
||||
status: PruefungStatus;
|
||||
hatProtokoll: boolean;
|
||||
darfKorrigieren: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const link = trpc.pruefung.protokollLink.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
if (ergebnis.bereit && ergebnis.adresse) {
|
||||
window.open(ergebnis.adresse, "_blank", "noopener");
|
||||
setMeldung(null);
|
||||
} else {
|
||||
setMeldung(
|
||||
"Das PDF wird gerade erzeugt. In ein paar Sekunden noch einmal versuchen — Gotenberg braucht einen Moment.",
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const korrigieren = trpc.pruefung.korrigieren.useMutation({
|
||||
// Der Wizard nimmt den frischen Entwurf auf: er sucht zu Gerät, Vorlage
|
||||
// und Prüfer nach einer angefangenen Prüfung und setzt sie fort — samt der
|
||||
// übernommenen Antworten aus dem alten Protokoll.
|
||||
onSuccess: (neu) =>
|
||||
router.push(
|
||||
`/pruefungen/neu?geraetId=${neu.geraetId}${neu.vorlageId ? `&vorlageId=${neu.vorlageId}` : ""}`,
|
||||
),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => link.mutate({ id: pruefungId })}
|
||||
disabled={link.isPending || status === "ENTWURF"}
|
||||
>
|
||||
<FileDown aria-hidden />
|
||||
{hatProtokoll ? "Protokoll als PDF" : "PDF erzeugen"}
|
||||
</Button>
|
||||
|
||||
{darfKorrigieren && status !== "ENTWURF" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const grund = window.prompt(
|
||||
"Warum wird korrigiert? Der Grund steht später im neuen Protokoll.",
|
||||
);
|
||||
if (grund?.trim()) korrigieren.mutate({ id: pruefungId, grund: grund.trim() });
|
||||
}}
|
||||
disabled={korrigieren.isPending}
|
||||
>
|
||||
<PencilLine aria-hidden />
|
||||
Korrektur anlegen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="protokollmeldung" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ProtokollAktionen } from "@/app/(app)/pruefungen/[id]/aktionen";
|
||||
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { PRIORITAET_TEXT, PUNKTTYP_TEXT } from "@/lib/wartung/texte";
|
||||
|
||||
export const metadata = { title: "Prüfprotokoll" };
|
||||
|
||||
/**
|
||||
* Ein Prüfprotokoll auf dem Bildschirm.
|
||||
*
|
||||
* Zeigt dasselbe wie das PDF, nur ohne Papierlayout. Das PDF bleibt der
|
||||
* Nachweis; diese Seite ist der schnelle Blick, ohne erst eine Datei öffnen zu
|
||||
* müssen.
|
||||
*/
|
||||
export default async function PruefungSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const pruefung = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.pruefung.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
geraet: {
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true, seriennummer: true },
|
||||
},
|
||||
vorlage: { select: { id: true, name: true, pruefvorschrift: true, intervallMonate: true } },
|
||||
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
|
||||
ergebnisse: { include: { punkt: true } },
|
||||
signaturen: true,
|
||||
maengel: { where: { geloeschtAm: null } },
|
||||
ersetzt: { select: { id: true, protokollNummer: true } },
|
||||
ersetztDurch: { select: { id: true, protokollNummer: true, status: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!pruefung) notFound();
|
||||
|
||||
const ergebnisseSortiert = [...pruefung.ergebnisse].sort(
|
||||
(a, b) => a.punkt.sortierung - b.punkt.sortierung,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/pruefungen/verlauf">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Protokollen
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Prüfprotokoll {pruefung.protokollNummer ?? ""}
|
||||
</h1>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<Link href={`/geraete/${pruefung.geraet.id}`} className="underline underline-offset-4">
|
||||
{pruefung.geraet.inventarnummer} — {pruefung.geraet.bezeichnung}
|
||||
</Link>
|
||||
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||
<PruefungStatusAbzeichen status={pruefung.status} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProtokollAktionen
|
||||
pruefungId={pruefung.id}
|
||||
status={pruefung.status}
|
||||
hatProtokoll={Boolean(pruefung.protokollDateiId)}
|
||||
darfKorrigieren={hatRecht(sitzung, "pruefung.durchfuehren")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pruefung.ersetzt && (
|
||||
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
|
||||
Dieses Protokoll ist eine Korrektur zu{" "}
|
||||
<Link href={`/pruefungen/${pruefung.ersetzt.id}`} className="underline underline-offset-4">
|
||||
{pruefung.ersetzt.protokollNummer ?? "einem früheren Protokoll"}
|
||||
</Link>
|
||||
{pruefung.korrekturGrund && `. Grund: ${pruefung.korrekturGrund}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pruefung.ersetztDurch.length > 0 && (
|
||||
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
|
||||
Dieses Protokoll wurde ersetzt durch{" "}
|
||||
{pruefung.ersetztDurch.map((nachfolger) => (
|
||||
<Link
|
||||
key={nachfolger.id}
|
||||
href={`/pruefungen/${nachfolger.id}`}
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
{nachfolger.protokollNummer ?? "eine Korrektur"}
|
||||
</Link>
|
||||
))}
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
["Prüfdatum", alsDatum(pruefung.durchgefuehrtAm)],
|
||||
[
|
||||
"Prüfer",
|
||||
pruefung.pruefer
|
||||
? `${pruefung.pruefer.vorname} ${pruefung.pruefer.nachname}`
|
||||
: (pruefung.prueferName ?? "—"),
|
||||
],
|
||||
["Prüfer-Kennung", pruefung.pruefer?.mitgliedsnummer ?? "—"],
|
||||
["Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern"],
|
||||
["Fachfirma", pruefung.externeFirma ?? "—"],
|
||||
["Kosten", pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"],
|
||||
["Vorlage", pruefung.vorlage?.name ?? "—"],
|
||||
["Prüfvorschrift", pruefung.vorlage?.pruefvorschrift ?? "—"],
|
||||
["Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : "—"],
|
||||
["Nächste Prüfung", alsDatum(pruefung.naechstePruefungAm)],
|
||||
["Abgeschlossen", alsDatumZeit(pruefung.abgeschlossenAm)],
|
||||
] as Array<[string, string]>
|
||||
).map(([bezeichnung, wert]) => (
|
||||
<div key={bezeichnung}>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{bezeichnung}
|
||||
</dt>
|
||||
<dd className="text-sm">{wert}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{pruefung.bemerkung && (
|
||||
<div className="mt-4 border-t border-[var(--border)] pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Bemerkung
|
||||
</p>
|
||||
<p className="whitespace-pre-line text-sm">{pruefung.bemerkung}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unterschriften</CardTitle>
|
||||
<CardDescription>
|
||||
Die Bilder liegen in MinIO und sind im PDF eingebettet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{pruefung.signaturen.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Keine Unterschrift erfasst.</p>
|
||||
) : (
|
||||
pruefung.signaturen.map((signatur) => (
|
||||
<div key={signatur.id} className="text-sm">
|
||||
<p className="font-medium">{signatur.name}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{signatur.rolle} · {alsDatumZeit(signatur.unterzeichnetAm)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Checkliste</CardTitle>
|
||||
<CardDescription>
|
||||
{ergebnisseSortiert.filter((eintrag) => !eintrag.inOrdnung).length} von{" "}
|
||||
{ergebnisseSortiert.length} Punkten beanstandet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Prüfpunkt</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Soll</TableHead>
|
||||
<TableHead>Ist</TableHead>
|
||||
<TableHead>Befund</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ergebnisseSortiert.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell>
|
||||
{eintrag.punkt.text}
|
||||
{eintrag.punkt.pflicht && (
|
||||
<span className="ml-1 text-[var(--gesperrt)]">*</span>
|
||||
)}
|
||||
{eintrag.bemerkung && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{eintrag.bemerkung}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||
{PUNKTTYP_TEXT[eintrag.punkt.typ]}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{eintrag.punkt.sollwert ?? "—"}
|
||||
{eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{eintrag.punkt.typ === "JA_NEIN"
|
||||
? eintrag.jaNein === true
|
||||
? "ja"
|
||||
: eintrag.jaNein === false
|
||||
? "nein"
|
||||
: "—"
|
||||
: eintrag.punkt.typ === "MESSWERT"
|
||||
? eintrag.zahlwert
|
||||
? `${Number(eintrag.zahlwert).toLocaleString("de-DE")}${
|
||||
eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""
|
||||
}`
|
||||
: "—"
|
||||
: eintrag.punkt.typ === "FOTO"
|
||||
? eintrag.fotoDateiId
|
||||
? "Foto liegt bei"
|
||||
: "—"
|
||||
: (eintrag.text ?? "—")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={
|
||||
eintrag.inOrdnung ? "text-sm" : "text-sm font-medium text-[var(--gesperrt)]"
|
||||
}
|
||||
>
|
||||
{eintrag.inOrdnung ? "i. O." : "nicht i. O."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pruefung.maengel.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Festgestellte Mängel</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{pruefung.maengel.map((mangel) => (
|
||||
<li key={mangel.id} className="text-sm">
|
||||
<Link href="/maengel" className="underline underline-offset-4">
|
||||
{PRIORITAET_TEXT[mangel.prioritaet]}
|
||||
</Link>
|
||||
: {mangel.beschreibung}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { CalendarClock, ClipboardCheck, Hourglass, Users } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Fehlermeldung,
|
||||
FristAnzeige,
|
||||
LadeZeilen,
|
||||
NichtsGefunden,
|
||||
StatusAbzeichen,
|
||||
} from "@/components/stammdaten/anzeige";
|
||||
import { FensterAbzeichen, Kennzahl } from "@/components/wartung/anzeige";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
import type { Fristfenster } from "@/lib/wartung/fristen";
|
||||
|
||||
/**
|
||||
* Fristen-Dashboard.
|
||||
*
|
||||
* Aufbau von oben nach unten in der Reihenfolge, in der ein Gerätewart denkt:
|
||||
*
|
||||
* 1. Wie viel ist überfällig? (die vier Zahlen, gleichzeitig Filter)
|
||||
* 2. Wo steckt es? (gebündelt nach Fahrzeug, Standort, Typ oder Zuständigem)
|
||||
* 3. Was genau ist zu tun? (die Terminliste, aus der die Prüfung startet)
|
||||
*
|
||||
* Die Bündelung ist kein Zierrat: wer einen Prüftag ansetzt, arbeitet ein
|
||||
* Fahrzeug ab. Eine alphabetische Gesamtliste hilft dabei nicht.
|
||||
*/
|
||||
|
||||
const GRUPPIERUNGEN = [
|
||||
{ wert: "fahrzeug", text: "nach Fahrzeug" },
|
||||
{ wert: "standort", text: "nach Standort" },
|
||||
{ wert: "geraetetyp", text: "nach Gerätetyp" },
|
||||
{ wert: "zustaendig", text: "nach Zuständigem" },
|
||||
] as const;
|
||||
|
||||
type Gruppierung = (typeof GRUPPIERUNGEN)[number]["wert"];
|
||||
type Zeitfenster = "ueberfaellig" | "diese_woche" | "dieser_monat" | "naechste_90_tage";
|
||||
|
||||
export function FristenDashboard({
|
||||
darfPlanen,
|
||||
darfPruefen,
|
||||
}: {
|
||||
darfPlanen: boolean;
|
||||
darfPruefen: boolean;
|
||||
}) {
|
||||
const [gruppierung, setGruppierung] = React.useState<Gruppierung>("fahrzeug");
|
||||
const [fenster, setFenster] = React.useState<Zeitfenster | null>(null);
|
||||
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const dashboard = trpc.wartungstermin.dashboard.useQuery({ gruppierung });
|
||||
|
||||
const termine = trpc.wartungstermin.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "asc",
|
||||
fenster: fenster ?? undefined,
|
||||
});
|
||||
|
||||
const mitglieder = trpc.person.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||
{ enabled: darfPlanen },
|
||||
);
|
||||
|
||||
const zuweisen = trpc.wartungstermin.zuweisen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setMeldung(`${ergebnis.zugewiesen} Termine zugewiesen.`);
|
||||
setAusgewaehlt(new Set());
|
||||
void werkzeuge.wartungstermin.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const zahlen = dashboard.data?.fenster;
|
||||
const eintraege = termine.data?.eintraege ?? [];
|
||||
|
||||
function auswahlUmschalten(id: string) {
|
||||
setAusgewaehlt((alt) => {
|
||||
const neu = new Set(alt);
|
||||
if (neu.has(id)) neu.delete(id);
|
||||
else neu.add(id);
|
||||
return neu;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Die vier Zahlen. Ein Klick filtert die Liste darunter. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Kennzahl
|
||||
wert={zahlen?.ueberfaellig ?? 0}
|
||||
bezeichnung="überfällig"
|
||||
hinweis="hätte längst geprüft sein müssen"
|
||||
dringend
|
||||
aktiv={fenster === "ueberfaellig"}
|
||||
onClick={() => setFenster(fenster === "ueberfaellig" ? null : "ueberfaellig")}
|
||||
/>
|
||||
<Kennzahl
|
||||
wert={zahlen?.diese_woche ?? 0}
|
||||
bezeichnung="diese Woche"
|
||||
hinweis="in den nächsten sieben Tagen"
|
||||
aktiv={fenster === "diese_woche"}
|
||||
onClick={() => setFenster(fenster === "diese_woche" ? null : "diese_woche")}
|
||||
/>
|
||||
<Kennzahl
|
||||
wert={zahlen?.dieser_monat ?? 0}
|
||||
bezeichnung="diesen Monat"
|
||||
hinweis="in den nächsten 30 Tagen"
|
||||
aktiv={fenster === "dieser_monat"}
|
||||
onClick={() => setFenster(fenster === "dieser_monat" ? null : "dieser_monat")}
|
||||
/>
|
||||
<Kennzahl
|
||||
wert={zahlen?.naechste_90_tage ?? 0}
|
||||
bezeichnung="nächste 90 Tage"
|
||||
hinweis="mittelfristig zu planen"
|
||||
aktiv={fenster === "naechste_90_tage"}
|
||||
onClick={() => setFenster(fenster === "naechste_90_tage" ? null : "naechste_90_tage")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Was sonst noch Aufmerksamkeit braucht. */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Link
|
||||
href="/pruefungen/lebensdauer"
|
||||
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
<Hourglass className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Lebensdauer: {dashboard.data?.lebensdauer.abgelaufen ?? 0} abgelaufen
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{dashboard.data?.lebensdauer.bald ?? 0} laufen in den nächsten 90 Tagen ab
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/maengel"
|
||||
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
<ClipboardCheck className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{dashboard.data?.maengelOffen ?? 0} offene Mängel</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">gemeldet, zugewiesen, in Arbeit</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/rueckrufe"
|
||||
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
<CalendarClock className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{dashboard.data?.rueckrufeOffen ?? 0} offene Rückrufe
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">Herstellerwarnungen in Arbeit</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="dashboardmeldung" />}
|
||||
{dashboard.error && <Fehlermeldung meldung={dashboard.error.message} />}
|
||||
|
||||
{/* Bündelung */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Verteilung</CardTitle>
|
||||
<CardDescription>Wo die offenen Termine hängen.</CardDescription>
|
||||
</div>
|
||||
<Select
|
||||
value={gruppierung}
|
||||
onValueChange={(wert) => setGruppierung(wert as Gruppierung)}
|
||||
>
|
||||
<SelectTrigger className="w-52" aria-label="Bündelung wählen">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GRUPPIERUNGEN.map((eintrag) => (
|
||||
<SelectItem key={eintrag.wert} value={eintrag.wert}>
|
||||
{eintrag.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bezeichnung</TableHead>
|
||||
<TableHead className="text-right">überfällig</TableHead>
|
||||
<TableHead className="text-right">diese Woche</TableHead>
|
||||
<TableHead className="text-right">diesen Monat</TableHead>
|
||||
<TableHead className="text-right">gesamt offen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboard.isLoading && <LadeZeilen spalten={5} zeilen={4} />}
|
||||
|
||||
{dashboard.data?.gruppen.map((gruppe) => (
|
||||
<TableRow key={gruppe.schluessel}>
|
||||
<TableCell className="font-medium">{gruppe.bezeichnung}</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{gruppe.fenster.ueberfaellig > 0 ? (
|
||||
<span className="font-medium text-[var(--gesperrt)]">
|
||||
{gruppe.fenster.ueberfaellig}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{gruppe.fenster.diese_woche || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{gruppe.fenster.dieser_monat || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen font-medium">
|
||||
{gruppe.gesamt}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!dashboard.isLoading && (dashboard.data?.gruppen.length ?? 0) === 0 && (
|
||||
<div className="p-4">
|
||||
<NichtsGefunden
|
||||
titel="Keine offenen Prüftermine"
|
||||
hinweis={
|
||||
darfPlanen
|
||||
? "Termine entstehen aus den Wartungsvorlagen — der nächtliche Lauf legt sie an. Ohne Vorlage gibt es keine Termine."
|
||||
: "Sobald Termine anstehen, erscheinen sie hier."
|
||||
}
|
||||
aktion={
|
||||
darfPlanen ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/pruefungen/vorlagen">Vorlagen einrichten</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminliste */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>
|
||||
Offene Termine
|
||||
{fenster && (
|
||||
<span className="ml-2 text-sm font-normal text-[var(--muted-foreground)]">
|
||||
gefiltert auf „{fenster.replace(/_/g, " ")}"
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{termine.data?.gesamt ?? 0} Termine · nach Fälligkeit sortiert
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{fenster && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setFenster(null)}>
|
||||
Filter aufheben
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{darfPlanen && ausgewaehlt.size > 0 && (
|
||||
<Select
|
||||
onValueChange={(wert) =>
|
||||
zuweisen.mutate({
|
||||
ids: [...ausgewaehlt],
|
||||
zugewiesenAnId: wert === "niemand" ? null : wert,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-64" aria-label="Ausgewählte Termine zuweisen">
|
||||
<SelectValue placeholder={`${ausgewaehlt.size} Termine zuweisen an …`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="niemand">niemandem</SelectItem>
|
||||
{mitglieder.data?.eintraege.map((person) => (
|
||||
<SelectItem key={person.id} value={person.id}>
|
||||
{person.nachname}, {person.vorname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{darfPlanen && <TableHead className="w-10" aria-label="Auswahl" />}
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Prüfung</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Zuständig</TableHead>
|
||||
<TableHead className="text-right">fällig</TableHead>
|
||||
<TableHead className="w-32" aria-label="Aktion" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{termine.isLoading && <LadeZeilen spalten={darfPlanen ? 7 : 6} />}
|
||||
|
||||
{eintraege.map((termin) => (
|
||||
<TableRow key={termin.id}>
|
||||
{darfPlanen && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={ausgewaehlt.has(termin.id)}
|
||||
onCheckedChange={() => auswahlUmschalten(termin.id)}
|
||||
aria-label={`Termin für ${termin.geraet.inventarnummer} auswählen`}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${termin.geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{termin.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{termin.geraet.bezeichnung}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<StatusAbzeichen status={termin.geraet.status} />
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{termin.vorlage?.name ?? "ohne Vorlage"}
|
||||
{termin.vorlage?.pruefvorschrift && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{termin.vorlage.pruefvorschrift}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{termin.geraet.fahrzeug
|
||||
? `${termin.geraet.fahrzeug.funkrufname}${
|
||||
termin.geraet.geraeteraum
|
||||
? ` · ${termin.geraet.geraeteraum.bezeichnung}`
|
||||
: ""
|
||||
}`
|
||||
: (termin.geraet.standort?.name ?? "—")}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{termin.zugewiesenAn ? (
|
||||
`${termin.zugewiesenAn.nachname}, ${termin.zugewiesenAn.vorname}`
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--muted-foreground)]">
|
||||
<Users className="size-3.5" aria-hidden />
|
||||
offen
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<FristAnzeige datum={termin.faelligAm} vorwarnTage={30} />
|
||||
<div className="mt-1">
|
||||
<FensterAbzeichen fenster={termin.fenster as Fristfenster} />
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{darfPruefen && (
|
||||
<Button size="sm" asChild>
|
||||
<Link
|
||||
href={`/pruefungen/neu?geraetId=${termin.geraet.id}&terminId=${termin.id}`}
|
||||
>
|
||||
Prüfen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!termine.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-4">
|
||||
<NichtsGefunden
|
||||
titel={fenster ? "In diesem Zeitraum steht nichts an" : "Keine offenen Termine"}
|
||||
hinweis={
|
||||
fenster
|
||||
? "Ein anderer Zeitraum zeigt vielleicht mehr."
|
||||
: "Alles geprüft — oder es sind noch keine Vorlagen eingerichtet."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-[var(--muted-foreground)]">
|
||||
Stand: {alsDatum(dashboard.data?.stand ?? new Date())}. Termine entstehen nachts aus den
|
||||
Wartungsvorlagen; überfällig wird jeden Tag neu gerechnet, nicht gespeichert.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Archive } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Fehlermeldung,
|
||||
FristAnzeige,
|
||||
LadeZeilen,
|
||||
NichtsGefunden,
|
||||
StatusAbzeichen,
|
||||
} from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Geräte am Ende der Nutzungsdauer.
|
||||
*
|
||||
* Der Vorschlag zur Aussonderung ist bewusst ein Knopf und kein Automatismus:
|
||||
* ein abgelaufener Helm wird gesperrt (das macht der nächtliche Lauf), aber
|
||||
* ausgesondert wird er von einem Menschen, der weiß, ob Ersatz da ist.
|
||||
*/
|
||||
export function LebensdauerListe({ darfAussondern }: { darfAussondern: boolean }) {
|
||||
const [vorwarnTage, setVorwarnTage] = React.useState(180);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const geraete = trpc.wartungstermin.lebensdauer.useQuery({ vorwarnTage });
|
||||
|
||||
const aussondern = trpc.geraet.loeschen.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung("Gerät ausgesondert.");
|
||||
void werkzeuge.wartungstermin.lebensdauer.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const eintraege = geraete.data ?? [];
|
||||
const abgelaufen = eintraege.filter((geraet) => geraet.abgelaufen).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vorwarn">Zeitraum</Label>
|
||||
<Select
|
||||
value={String(vorwarnTage)}
|
||||
onValueChange={(wert) => setVorwarnTage(Number(wert))}
|
||||
>
|
||||
<SelectTrigger id="vorwarn" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">nur bereits abgelaufen</SelectItem>
|
||||
<SelectItem value="90">nächste 90 Tage</SelectItem>
|
||||
<SelectItem value="180">nächste 6 Monate</SelectItem>
|
||||
<SelectItem value="365">nächstes Jahr</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{abgelaufen} abgelaufen · {eintraege.length - abgelaufen} laufen demnächst ab
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="lebensdauermeldung" />}
|
||||
{geraete.error && <Fehlermeldung meldung={geraete.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Kategorie</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">nutzbar bis</TableHead>
|
||||
<TableHead className="w-40" aria-label="Aktion" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{geraete.isLoading && <LadeZeilen spalten={6} />}
|
||||
|
||||
{eintraege.map((geraet) => (
|
||||
<TableRow key={geraet.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">{geraet.bezeichnung}</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{geraet.lebensdauerKategorie ??
|
||||
geraet.geraetetyp?.lebensdauerKategorie ??
|
||||
"—"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={geraet.status} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={vorwarnTage || 90} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{darfAussondern && geraet.abgelaufen && geraet.status !== "AUSGESONDERT" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`${geraet.inventarnummer} aussondern? Das Gerät bleibt mit allen Prüfungen erhalten, verschwindet aber aus dem Bestand.`,
|
||||
)
|
||||
) {
|
||||
aussondern.mutate({
|
||||
id: geraet.id,
|
||||
grund: "Nutzungsdauer abgelaufen",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Archive aria-hidden />
|
||||
Aussondern
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!geraete.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Nichts läuft ab"
|
||||
hinweis="In diesem Zeitraum erreicht kein Gerät das Ende seiner Nutzungsdauer. Voraussetzung dafür ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LebensdauerListe } from "@/app/(app)/pruefungen/lebensdauer/lebensdauer-liste";
|
||||
|
||||
export const metadata = { title: "Lebensdauer" };
|
||||
|
||||
/**
|
||||
* Lebensdauerüberwachung.
|
||||
*
|
||||
* Helme, Seile, Schutzkleidung und Pressluftatmer sind ab einem Stichtag nicht
|
||||
* mehr zugelassen — unabhängig davon, wie gut sie aussehen. Diese Seite zeigt,
|
||||
* was abgelaufen ist und was demnächst abläuft, und schlägt die Aussonderung
|
||||
* vor.
|
||||
*/
|
||||
export default async function LebensdauerSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/pruefungen">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Fristen
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Lebensdauer</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Geräte am Ende ihrer zulässigen Nutzungsdauer — unabhängig vom Zustand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LebensdauerListe darfAussondern={hatRecht(sitzung, "geraet.aussondern")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { PruefWizard } from "@/app/(app)/pruefungen/neu/pruef-wizard";
|
||||
|
||||
export const metadata = { title: "Prüfung durchführen" };
|
||||
|
||||
/**
|
||||
* Der Prüfwizard.
|
||||
*
|
||||
* Aufgerufen wird er aus dem Fristen-Dashboard (mit Termin) oder von der
|
||||
* Gerätedetailseite (nur mit Gerät). Beides kommt als Suchparameter herein —
|
||||
* so lässt sich der Vorgang auch über einen QR-Scan am Regal starten.
|
||||
*/
|
||||
export default async function NeuePruefungSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ geraetId?: string; terminId?: string; vorlageId?: string }>;
|
||||
}) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
if (!hatRecht(sitzung, "pruefung.durchfuehren")) {
|
||||
redirect("/pruefungen");
|
||||
}
|
||||
|
||||
const { geraetId, terminId, vorlageId } = await searchParams;
|
||||
|
||||
if (!geraetId) redirect("/pruefungen");
|
||||
|
||||
return (
|
||||
<PruefWizard
|
||||
geraetId={geraetId}
|
||||
terminId={terminId ?? null}
|
||||
vorlageId={vorlageId ?? null}
|
||||
prueferName={sitzung.name}
|
||||
prueferId={sitzung.personId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ChecklistenpunktTyp, MangelPrioritaet, PruefErgebnis, PruefungArt } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { DateiFeld } from "@/components/wartung/datei-feld";
|
||||
import { UnterschriftFeld } from "@/components/wartung/unterschrift";
|
||||
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
|
||||
|
||||
/**
|
||||
* Prüfwizard — Schritt für Schritt durch die Checkliste.
|
||||
*
|
||||
* Der Ablauf ist auf das Tablet im Gerätehaus zugeschnitten:
|
||||
*
|
||||
* * Ein Punkt je Bildschirm, große Schaltflächen. Wer Handschuhe trägt,
|
||||
* trifft keine 24-Pixel-Kästchen.
|
||||
* * Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens
|
||||
* die letzte Antwort verloren, nicht die ganze Prüfung.
|
||||
* * Die Zeit läuft im Hintergrund mit und landet im Protokoll — sie ist
|
||||
* Bestandteil des Nachweises, wie gründlich geprüft wurde.
|
||||
*
|
||||
* Am Ende steht der Abschluss: Ergebnis, Mängel, Unterschrift. Danach ist das
|
||||
* Protokoll festgeschrieben; Korrekturen erzeugen ein neues.
|
||||
*/
|
||||
|
||||
interface Antwortstand {
|
||||
jaNein?: boolean | null;
|
||||
zahlwert?: string;
|
||||
text?: string;
|
||||
fotoDateiId?: string | null;
|
||||
bemerkung?: string;
|
||||
inOrdnung?: boolean | null;
|
||||
}
|
||||
|
||||
interface MangelEntwurf {
|
||||
beschreibung: string;
|
||||
prioritaet: MangelPrioritaet;
|
||||
fotoDateiId?: string | null;
|
||||
}
|
||||
|
||||
function verstricheneZeit(seit: Date | null): string {
|
||||
if (!seit) return "0:00";
|
||||
const sekunden = Math.max(0, Math.floor((Date.now() - seit.getTime()) / 1000));
|
||||
const minuten = Math.floor(sekunden / 60);
|
||||
return `${minuten}:${String(sekunden % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function PruefWizard({
|
||||
geraetId,
|
||||
terminId,
|
||||
vorlageId,
|
||||
prueferName,
|
||||
prueferId,
|
||||
}: {
|
||||
geraetId: string;
|
||||
terminId: string | null;
|
||||
vorlageId: string | null;
|
||||
prueferName: string;
|
||||
prueferId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [pruefungId, setPruefungId] = React.useState<string | null>(null);
|
||||
const [schritt, setSchritt] = React.useState(0);
|
||||
const [antworten, setAntworten] = React.useState<Record<string, Antwortstand>>({});
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
// Abschlussangaben
|
||||
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | null>(null);
|
||||
const [bemerkung, setBemerkung] = React.useState("");
|
||||
const [maengel, setMaengel] = React.useState<MangelEntwurf[]>([]);
|
||||
const [signaturDateiId, setSignaturDateiId] = React.useState<string | null>(null);
|
||||
const [art, setArt] = React.useState<PruefungArt>(PruefungArt.INTERN);
|
||||
const [externeFirma, setExterneFirma] = React.useState("");
|
||||
const [kosten, setKosten] = React.useState("");
|
||||
const [fremdprotokollId, setFremdprotokollId] = React.useState<string | null>(null);
|
||||
|
||||
const [uhr, setUhr] = React.useState(0);
|
||||
const gestartet = React.useRef(false);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const starten = trpc.pruefung.starten.useMutation({
|
||||
onSuccess: (ergebnisDaten) => {
|
||||
const pruefung = ergebnisDaten.pruefung;
|
||||
setPruefungId(pruefung.id);
|
||||
setArt(pruefung.art);
|
||||
|
||||
// Fortgesetzter Entwurf: bereits gegebene Antworten übernehmen.
|
||||
const vorhandene: Record<string, Antwortstand> = {};
|
||||
for (const eintrag of pruefung.ergebnisse) {
|
||||
vorhandene[eintrag.punktId] = {
|
||||
jaNein: eintrag.jaNein,
|
||||
zahlwert: eintrag.zahlwert ? String(eintrag.zahlwert) : "",
|
||||
text: eintrag.text ?? "",
|
||||
fotoDateiId: eintrag.fotoDateiId,
|
||||
bemerkung: eintrag.bemerkung ?? "",
|
||||
};
|
||||
}
|
||||
setAntworten(vorhandene);
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
// Genau einmal starten — React ruft Effekte in der Entwicklung doppelt auf.
|
||||
React.useEffect(() => {
|
||||
if (gestartet.current) return;
|
||||
gestartet.current = true;
|
||||
starten.mutate({
|
||||
geraetId,
|
||||
terminId,
|
||||
vorlageId,
|
||||
art: PruefungArt.INTERN,
|
||||
});
|
||||
}, [geraetId, terminId, vorlageId, starten]);
|
||||
|
||||
const pruefung = starten.data?.pruefung;
|
||||
const punkte = React.useMemo(() => pruefung?.vorlage?.punkte ?? [], [pruefung]);
|
||||
const begonnenUm = pruefung?.begonnenUm ? new Date(pruefung.begonnenUm) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
const zeitgeber = setInterval(() => setUhr((wert) => wert + 1), 1000);
|
||||
return () => clearInterval(zeitgeber);
|
||||
}, []);
|
||||
|
||||
const speichern = trpc.pruefung.ergebnisSpeichern.useMutation({
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const abschliessen = trpc.pruefung.abschliessen.useMutation({
|
||||
onSuccess: (fertig) => {
|
||||
void werkzeuge.wartungstermin.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
router.push(`/pruefungen/${fertig.id}`);
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
function antwortSetzen(punktId: string, teil: Partial<Antwortstand>) {
|
||||
setAntworten((alt) => ({ ...alt, [punktId]: { ...alt[punktId], ...teil } }));
|
||||
}
|
||||
|
||||
/** Antwort zum aktuellen Punkt zum Server schicken. */
|
||||
async function punktSpeichern(punktId: string) {
|
||||
if (!pruefungId) return;
|
||||
const antwort = antworten[punktId];
|
||||
if (!antwort) return;
|
||||
|
||||
await speichern.mutateAsync({
|
||||
pruefungId,
|
||||
punktId,
|
||||
jaNein: antwort.jaNein ?? null,
|
||||
zahlwert:
|
||||
antwort.zahlwert !== undefined && antwort.zahlwert !== ""
|
||||
? Number(antwort.zahlwert)
|
||||
: null,
|
||||
text: antwort.text || null,
|
||||
fotoDateiId: antwort.fotoDateiId ?? null,
|
||||
inOrdnung: antwort.inOrdnung ?? null,
|
||||
bemerkung: antwort.bemerkung || null,
|
||||
});
|
||||
}
|
||||
|
||||
async function weiter() {
|
||||
setFehler(null);
|
||||
const punkt = punkte[schritt];
|
||||
if (punkt) await punktSpeichern(punkt.id);
|
||||
setSchritt((wert) => Math.min(wert + 1, punkte.length));
|
||||
}
|
||||
|
||||
function zurueck() {
|
||||
setFehler(null);
|
||||
setSchritt((wert) => Math.max(0, wert - 1));
|
||||
}
|
||||
|
||||
/** Mängel aus den beanstandeten Punkten vorschlagen. */
|
||||
function maengelVorschlagen() {
|
||||
const vorschlaege: MangelEntwurf[] = [];
|
||||
|
||||
for (const punkt of punkte) {
|
||||
const antwort = antworten[punkt.id];
|
||||
if (!antwort) continue;
|
||||
|
||||
const nichtInOrdnung =
|
||||
antwort.inOrdnung === false ||
|
||||
(punkt.typ === ChecklistenpunktTyp.JA_NEIN && antwort.jaNein === false);
|
||||
|
||||
if (!nichtInOrdnung) continue;
|
||||
|
||||
vorschlaege.push({
|
||||
beschreibung: `${punkt.text}${antwort.bemerkung ? `: ${antwort.bemerkung}` : ""}`,
|
||||
prioritaet: punkt.pflicht ? MangelPrioritaet.KRITISCH : MangelPrioritaet.MITTEL,
|
||||
fotoDateiId: antwort.fotoDateiId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
setMaengel((alt) => [...alt, ...vorschlaege]);
|
||||
}
|
||||
|
||||
const amAbschluss = schritt >= punkte.length;
|
||||
|
||||
// Vorschlag für das Gesamtergebnis, sobald der Abschluss erreicht ist.
|
||||
const stand = trpc.pruefung.stand.useQuery(
|
||||
{ id: pruefungId ?? "" },
|
||||
{ enabled: Boolean(pruefungId) && amAbschluss },
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (amAbschluss && stand.data && ergebnis === null) {
|
||||
setErgebnis(stand.data.vorschlag as PruefErgebnis);
|
||||
if (stand.data.vorschlag !== "BESTANDEN" && maengel.length === 0) maengelVorschlagen();
|
||||
}
|
||||
// maengelVorschlagen hängt nur an bereits gesetztem Zustand.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [amAbschluss, stand.data]);
|
||||
|
||||
if (starten.isPending || !pruefung) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Prüfung wird vorbereitet …</h1>
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const punkt = punkte[schritt];
|
||||
const antwort = punkt ? (antworten[punkt.id] ?? {}) : {};
|
||||
const fortschritt = punkte.length === 0 ? 100 : Math.round((schritt / punkte.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Kopf: Gerät, Uhr, Fortschritt */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{pruefung.geraet.bezeichnung}
|
||||
</h1>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-[var(--muted-foreground)]">
|
||||
<Clock className="size-4" aria-hidden />
|
||||
<span className="tabellenzahlen" aria-live="off">
|
||||
{verstricheneZeit(begonnenUm)}
|
||||
</span>
|
||||
<span className="sr-only">seit Beginn der Prüfung ({uhr} Sekunden aktualisiert)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
|
||||
{pruefung.vorlage && ` · ${pruefung.vorlage.name}`}
|
||||
{starten.data?.fortgesetzt && " · angefangene Prüfung wird fortgesetzt"}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-3 h-2 overflow-hidden rounded-full bg-[var(--muted)]"
|
||||
role="progressbar"
|
||||
aria-valuenow={fortschritt}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Fortschritt der Prüfung"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--primary)] transition-all"
|
||||
style={{ width: `${fortschritt}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} kennung="wizardfehler" />}
|
||||
|
||||
{punkte.length === 0 && (
|
||||
<Fehlermeldung
|
||||
meldung="Zu dieser Prüfung gibt es keine Checkliste. Ohne Prüfpunkte lässt sich nichts dokumentieren — bitte erst eine Wartungsvorlage mit Punkten anlegen."
|
||||
kennung="ohnecheckliste"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ein Prüfpunkt */}
|
||||
{!amAbschluss && punkt && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Schritt {schritt + 1} von {punkte.length}
|
||||
{punkt.pflicht && " · Pflichtpunkt"}
|
||||
</CardDescription>
|
||||
<CardTitle className="text-xl">{punkt.text}</CardTitle>
|
||||
{punkt.hinweis && (
|
||||
<CardDescription>{punkt.hinweis}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{punkt.sollwert && (
|
||||
<p className="rounded-md bg-[var(--muted)] px-3 py-2 text-sm">
|
||||
Soll: <strong>{punkt.sollwert}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.JA_NEIN && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={antwort.jaNein === true ? "default" : "outline"}
|
||||
className="h-16 text-base"
|
||||
onClick={() => antwortSetzen(punkt.id, { jaNein: true, inOrdnung: null })}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
in Ordnung
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={antwort.jaNein === false ? "destructive" : "outline"}
|
||||
className="h-16 text-base"
|
||||
onClick={() => antwortSetzen(punkt.id, { jaNein: false, inOrdnung: null })}
|
||||
>
|
||||
<X aria-hidden />
|
||||
nicht in Ordnung
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.MESSWERT && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="messwert">
|
||||
Messwert{punkt.einheit ? ` in ${punkt.einheit}` : ""}
|
||||
</Label>
|
||||
<Input
|
||||
id="messwert"
|
||||
type="number"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
className="h-14 text-lg"
|
||||
value={antwort.zahlwert ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
antwortSetzen(punkt.id, { zahlwert: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
{(punkt.minWert !== null || punkt.maxWert !== null) && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
zulässig {punkt.minWert !== null ? String(punkt.minWert) : "…"} bis{" "}
|
||||
{punkt.maxWert !== null ? String(punkt.maxWert) : "…"}
|
||||
{punkt.einheit ? ` ${punkt.einheit}` : ""} — außerhalb gilt der Punkt als
|
||||
beanstandet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.TEXT && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="freitext">Befund</Label>
|
||||
<Textarea
|
||||
id="freitext"
|
||||
rows={4}
|
||||
value={antwort.text ?? ""}
|
||||
onChange={(ereignis) => antwortSetzen(punkt.id, { text: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.FOTO && (
|
||||
<div className="space-y-2">
|
||||
<Label>Foto</Label>
|
||||
<DateiFeld
|
||||
entitaet="Pruefung"
|
||||
entitaetId={pruefungId ?? undefined}
|
||||
kategorie="foto"
|
||||
dateiId={antwort.fotoDateiId ?? null}
|
||||
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Foto und Bemerkung sind zu jedem Punkt möglich, nicht nur beim Fototyp. */}
|
||||
{punkt.typ !== ChecklistenpunktTyp.FOTO && (
|
||||
<div className="space-y-2">
|
||||
<Label>Foto (freiwillig)</Label>
|
||||
<DateiFeld
|
||||
entitaet="Pruefung"
|
||||
entitaetId={pruefungId ?? undefined}
|
||||
kategorie="foto"
|
||||
dateiId={antwort.fotoDateiId ?? null}
|
||||
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="punktbemerkung">Bemerkung</Label>
|
||||
<Input
|
||||
id="punktbemerkung"
|
||||
value={antwort.bemerkung ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
antwortSetzen(punkt.id, { bemerkung: ereignis.target.value })
|
||||
}
|
||||
placeholder="Was aufgefallen ist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={antwort.inOrdnung === false}
|
||||
onCheckedChange={(an) =>
|
||||
antwortSetzen(punkt.id, { inOrdnung: an === true ? false : null })
|
||||
}
|
||||
/>
|
||||
Trotz Wert beanstanden — der Prüfer sieht mehr als die Zahl
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Abschluss */}
|
||||
{amAbschluss && (
|
||||
<div className="space-y-6">
|
||||
{stand.data && stand.data.offenePflichtpunkte.length > 0 && (
|
||||
<Fehlermeldung
|
||||
kennung="offenepflichtpunkte"
|
||||
meldung={`Es fehlen noch Pflichtangaben: ${stand.data.offenePflichtpunkte
|
||||
.map((offen) => offen.text)
|
||||
.join(", ")}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ergebnis</CardTitle>
|
||||
<CardDescription>
|
||||
Vorgeschlagen ist {stand.data ? ERGEBNIS_TEXT[stand.data.vorschlag] : "…"} —
|
||||
{" "}
|
||||
{stand.data?.beantwortet ?? 0} von {stand.data?.gesamt ?? punkte.length} Punkten
|
||||
beantwortet. Die Entscheidung trifft der Prüfer.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
|
||||
<Button
|
||||
key={wert}
|
||||
type="button"
|
||||
variant={ergebnis === wert ? "default" : "outline"}
|
||||
className="h-14"
|
||||
onClick={() => setErgebnis(wert)}
|
||||
>
|
||||
{ERGEBNIS_TEXT[wert]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="abschlussbemerkung">Bemerkung zum Protokoll</Label>
|
||||
<Textarea
|
||||
id="abschlussbemerkung"
|
||||
rows={3}
|
||||
value={bemerkung}
|
||||
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="art">Durchgeführt</Label>
|
||||
<Select value={art} onValueChange={(wert) => setArt(wert as PruefungArt)}>
|
||||
<SelectTrigger id="art">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PruefungArt.INTERN}>intern durch die Wehr</SelectItem>
|
||||
<SelectItem value={PruefungArt.EXTERN}>extern durch eine Fachfirma</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{art === PruefungArt.EXTERN && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firma">Fachfirma</Label>
|
||||
<Input
|
||||
id="firma"
|
||||
value={externeFirma}
|
||||
onChange={(ereignis) => setExterneFirma(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kosten">Kosten in Euro</Label>
|
||||
<Input
|
||||
id="kosten"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={kosten}
|
||||
onChange={(ereignis) => setKosten(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Fremdprotokoll als PDF</Label>
|
||||
<DateiFeld
|
||||
entitaet="Pruefung"
|
||||
entitaetId={pruefungId ?? undefined}
|
||||
kategorie="fremdprotokoll"
|
||||
nurBilder={false}
|
||||
beschriftung="Protokoll der Fachfirma anhängen"
|
||||
dateiId={fremdprotokollId}
|
||||
onHochgeladen={setFremdprotokollId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Mängel</CardTitle>
|
||||
<CardDescription>
|
||||
Was aufgefallen ist und noch behoben werden muss. Kritische Mängel sperren das
|
||||
Gerät.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setMaengel((alt) => [
|
||||
...alt,
|
||||
{ beschreibung: "", prioritaet: MangelPrioritaet.MITTEL },
|
||||
])
|
||||
}
|
||||
>
|
||||
<TriangleAlert aria-hidden />
|
||||
Mangel ergänzen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{maengel.length === 0 && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Kein Mangel erfasst.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{maengel.map((mangel, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-2 rounded-md border border-[var(--border)] p-3"
|
||||
>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={mangel.beschreibung}
|
||||
onChange={(ereignis) =>
|
||||
setMaengel((alt) =>
|
||||
alt.map((eintrag, position) =>
|
||||
position === index
|
||||
? { ...eintrag, beschreibung: ereignis.target.value }
|
||||
: eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Was ist beschädigt, fehlt oder funktioniert nicht?"
|
||||
aria-label={`Beschreibung des Mangels ${index + 1}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={mangel.prioritaet}
|
||||
onValueChange={(wert) =>
|
||||
setMaengel((alt) =>
|
||||
alt.map((eintrag, position) =>
|
||||
position === index
|
||||
? { ...eintrag, prioritaet: wert as MangelPrioritaet }
|
||||
: eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-44"
|
||||
aria-label={`Dringlichkeit des Mangels ${index + 1}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={MangelPrioritaet.NIEDRIG}>niedrig</SelectItem>
|
||||
<SelectItem value={MangelPrioritaet.MITTEL}>mittel</SelectItem>
|
||||
<SelectItem value={MangelPrioritaet.HOCH}>hoch</SelectItem>
|
||||
<SelectItem value={MangelPrioritaet.KRITISCH}>
|
||||
kritisch — sperrt das Gerät
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setMaengel((alt) => alt.filter((_, position) => position !== index))
|
||||
}
|
||||
>
|
||||
<X aria-hidden />
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unterschrift</CardTitle>
|
||||
<CardDescription>
|
||||
Ohne Unterschrift ist ein Protokoll kein Nachweis. Mit dem Abschluss wird es
|
||||
festgeschrieben — Korrekturen erzeugen danach ein neues Protokoll.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UnterschriftFeld
|
||||
name={prueferName}
|
||||
rolle="Prüfer"
|
||||
pruefungId={pruefungId ?? undefined}
|
||||
onFertig={setSignaturDateiId}
|
||||
uebernommen={Boolean(signaturDateiId)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steuerung */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--border)] pt-4">
|
||||
<Button type="button" variant="outline" onClick={zurueck} disabled={schritt === 0}>
|
||||
<ChevronLeft aria-hidden />
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
{!amAbschluss ? (
|
||||
<Button type="button" onClick={weiter} disabled={speichern.isPending}>
|
||||
{speichern.isPending ? "wird gespeichert …" : "Weiter"}
|
||||
<ChevronRight aria-hidden />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
abschliessen.isPending ||
|
||||
!ergebnis ||
|
||||
!signaturDateiId ||
|
||||
(stand.data?.offenePflichtpunkte.length ?? 0) > 0
|
||||
}
|
||||
onClick={() => {
|
||||
if (!pruefungId || !ergebnis || !signaturDateiId) return;
|
||||
setFehler(null);
|
||||
|
||||
abschliessen.mutate({
|
||||
id: pruefungId,
|
||||
ergebnis,
|
||||
bemerkung: bemerkung || null,
|
||||
externeFirma: art === PruefungArt.EXTERN ? externeFirma || null : null,
|
||||
externeProtokollDateiId: art === PruefungArt.EXTERN ? fremdprotokollId : null,
|
||||
kosten: art === PruefungArt.EXTERN && kosten ? Number(kosten) : null,
|
||||
signaturen: [
|
||||
{
|
||||
rolle: "PRUEFER",
|
||||
name: prueferName,
|
||||
personId: prueferId,
|
||||
bildDateiId: signaturDateiId,
|
||||
},
|
||||
],
|
||||
maengel: maengel
|
||||
.filter((mangel) => mangel.beschreibung.trim().length > 0)
|
||||
.map((mangel) => ({
|
||||
beschreibung: mangel.beschreibung,
|
||||
prioritaet: mangel.prioritaet,
|
||||
fotoDateiId: mangel.fotoDateiId ?? null,
|
||||
})),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
{abschliessen.isPending ? "wird abgeschlossen …" : "Prüfung abschließen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ClipboardList, ListChecks, ShieldAlert, TriangleAlert } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FristenDashboard } from "@/app/(app)/pruefungen/fristen-dashboard";
|
||||
|
||||
export const metadata = { title: "Prüfungen" };
|
||||
|
||||
/**
|
||||
* Das Fristen-Dashboard — die Startseite des Gerätewarts.
|
||||
*
|
||||
* Die Rechteprüfung passiert hier auf dem Server; die Client-Komponente bekommt
|
||||
* nur das Ergebnis. So bleibt die Regel aus CLAUDE.md gewahrt: keine
|
||||
* Rollenprüfung in Komponenten.
|
||||
*/
|
||||
export default async function PruefungenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const darfPlanen = hatRecht(sitzung, "pruefung.planen");
|
||||
const darfPruefen = hatRecht(sitzung, "pruefung.durchfuehren");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Prüfungen und Fristen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Was ansteht, was überfällig ist und was demnächst aus der Nutzungsdauer läuft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/maengel">
|
||||
<TriangleAlert aria-hidden />
|
||||
Mängel
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/rueckrufe">
|
||||
<ShieldAlert aria-hidden />
|
||||
Rückrufe
|
||||
</Link>
|
||||
</Button>
|
||||
{darfPlanen && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/pruefungen/vorlagen">
|
||||
<ListChecks aria-hidden />
|
||||
Vorlagen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{darfPruefen && (
|
||||
<Button asChild>
|
||||
<Link href="/pruefungen/verlauf">
|
||||
<ClipboardList aria-hidden />
|
||||
Protokolle
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FristenDashboard darfPlanen={darfPlanen} darfPruefen={darfPruefen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProtokollListe } from "@/app/(app)/pruefungen/verlauf/protokoll-liste";
|
||||
|
||||
export const metadata = { title: "Prüfprotokolle" };
|
||||
|
||||
/**
|
||||
* Alle Prüfprotokolle der Wehr.
|
||||
*
|
||||
* Der Nachweis nach außen: wenn die Unfallkasse fragt, wird hier gesucht —
|
||||
* nach Gerät, nach Zeitraum, nach Ergebnis.
|
||||
*/
|
||||
export default async function VerlaufSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/pruefungen">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Fristen
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Prüfprotokolle</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Abgeschlossene Prüfungen mit Ergebnis, Prüfer und PDF.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProtokollListe />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { PruefErgebnis, PruefungStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
|
||||
import { alsDatum, alsEuro } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Liste der Protokolle.
|
||||
*
|
||||
* Entwürfe stehen mit drin, aber gekennzeichnet: eine angefangene Prüfung ist
|
||||
* kein Nachweis, und wer eine sucht, um sie zu Ende zu bringen, findet sie hier.
|
||||
*/
|
||||
export function ProtokollListe() {
|
||||
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | "alle">("alle");
|
||||
const [vonAm, setVonAm] = React.useState("");
|
||||
const [bisAm, setBisAm] = React.useState("");
|
||||
|
||||
const pruefungen = trpc.pruefung.liste.useQuery({
|
||||
anzahl: 200,
|
||||
ergebnis: ergebnis === "alle" ? undefined : [ergebnis],
|
||||
vonAm: vonAm ? new Date(vonAm) : undefined,
|
||||
bisAm: bisAm ? new Date(bisAm) : undefined,
|
||||
});
|
||||
|
||||
const eintraege = pruefungen.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ergebnisfilter">Ergebnis</Label>
|
||||
<Select
|
||||
value={ergebnis}
|
||||
onValueChange={(wert) => setErgebnis(wert as PruefErgebnis | "alle")}
|
||||
>
|
||||
<SelectTrigger id="ergebnisfilter" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Ergebnisse</SelectItem>
|
||||
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{ERGEBNIS_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="von">geprüft ab</Label>
|
||||
<Input
|
||||
id="von"
|
||||
type="date"
|
||||
value={vonAm}
|
||||
onChange={(ereignis) => setVonAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bis">geprüft bis</Label>
|
||||
<Input
|
||||
id="bis"
|
||||
type="date"
|
||||
value={bisAm}
|
||||
onChange={(ereignis) => setBisAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pruefungen.error && <Fehlermeldung meldung={pruefungen.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Protokoll</TableHead>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Prüfung</TableHead>
|
||||
<TableHead>Prüfer</TableHead>
|
||||
<TableHead>Ergebnis</TableHead>
|
||||
<TableHead className="text-right">geprüft am</TableHead>
|
||||
<TableHead className="text-right">Kosten</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pruefungen.isLoading && <LadeZeilen spalten={7} />}
|
||||
|
||||
{eintraege.map((pruefung) => (
|
||||
<TableRow key={pruefung.id}>
|
||||
<TableCell className="tabellenzahlen font-medium">
|
||||
<Link
|
||||
href={`/pruefungen/${pruefung.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{pruefung.protokollNummer ?? "Entwurf"}
|
||||
</Link>
|
||||
{pruefung.status !== PruefungStatus.ABGESCHLOSSEN && (
|
||||
<div className="mt-1">
|
||||
<PruefungStatusAbzeichen status={pruefung.status} />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${pruefung.geraet.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{pruefung.geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{pruefung.vorlage?.name ?? "—"}
|
||||
{pruefung.art === "EXTERN" && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
extern: {pruefung.externeFirma ?? "Fachfirma"}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{pruefung.pruefer
|
||||
? `${pruefung.pruefer.nachname}, ${pruefung.pruefer.vorname}`
|
||||
: (pruefung.prueferName ?? "—")}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||
{pruefung._count.maengel > 0 && (
|
||||
<div className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{pruefung._count.maengel} Mängel
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDatum(pruefung.durchgefuehrtAm)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!pruefungen.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Protokoll"
|
||||
hinweis="Sobald eine Prüfung abgeschlossen ist, steht sie hier — mit PDF, Unterschrift und Ergebnis."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import { ChecklistenpunktTyp } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { PUNKTTYP_TEXT } from "@/lib/wartung/texte";
|
||||
|
||||
/**
|
||||
* Checklisten-Editor.
|
||||
*
|
||||
* Die Reihenfolge ist fachlich wichtig: der Prüfwizard arbeitet die Punkte
|
||||
* genau so ab, wie sie hier stehen. Ein Gerätewart legt sie in der Reihenfolge
|
||||
* an, in der er das Gerät in die Hand nimmt — erst außen, dann innen, zuletzt
|
||||
* die Messwerte.
|
||||
*
|
||||
* Umsortiert wird mit Pfeiltasten statt Ziehen und Fallenlassen: das
|
||||
* funktioniert auch mit Handschuhen auf einem Tablet und mit der Tastatur.
|
||||
*/
|
||||
|
||||
const LEERER_PUNKT = {
|
||||
text: "",
|
||||
typ: ChecklistenpunktTyp.JA_NEIN as ChecklistenpunktTyp,
|
||||
pflicht: true,
|
||||
sollwert: "",
|
||||
einheit: "",
|
||||
minWert: "",
|
||||
maxWert: "",
|
||||
hinweis: "",
|
||||
};
|
||||
|
||||
export function ChecklistenEditor({
|
||||
vorlageId,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
vorlageId: string;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [neu, setNeu] = React.useState(LEERER_PUNKT);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const vorlage = trpc.wartungsvorlage.einzeln.useQuery({ id: vorlageId });
|
||||
|
||||
const aktualisieren = () => void werkzeuge.wartungsvorlage.einzeln.invalidate({ id: vorlageId });
|
||||
|
||||
const punktAnlegen = trpc.wartungsvorlage.punktAnlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setNeu(LEERER_PUNKT);
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const punktLoeschen = trpc.wartungsvorlage.punktLoeschen.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const sortieren = trpc.wartungsvorlage.punkteSortieren.useMutation({
|
||||
onSuccess: aktualisieren,
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const punkte = vorlage.data?.punkte ?? [];
|
||||
|
||||
function verschieben(index: number, richtung: -1 | 1) {
|
||||
const ziel = index + richtung;
|
||||
if (ziel < 0 || ziel >= punkte.length) return;
|
||||
|
||||
const reihenfolge = punkte.map((punkt) => punkt.id);
|
||||
[reihenfolge[index], reihenfolge[ziel]] = [reihenfolge[ziel]!, reihenfolge[index]!];
|
||||
|
||||
sortieren.mutate({ vorlageId, reihenfolge });
|
||||
}
|
||||
|
||||
function punktHinzufuegen(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
punktAnlegen.mutate({
|
||||
vorlageId,
|
||||
text: neu.text,
|
||||
typ: neu.typ,
|
||||
pflicht: neu.pflicht,
|
||||
sollwert: neu.sollwert || null,
|
||||
einheit: neu.einheit || null,
|
||||
minWert: neu.minWert === "" ? null : Number(neu.minWert),
|
||||
maxWert: neu.maxWert === "" ? null : Number(neu.maxWert),
|
||||
hinweis: neu.hinweis || null,
|
||||
sortierung: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (vorlage.isLoading) {
|
||||
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
|
||||
}
|
||||
|
||||
if (vorlage.error) return <Fehlermeldung meldung={vorlage.error.message} />;
|
||||
if (!vorlage.data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{vorlage.data.name}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
alle {vorlage.data.intervallMonate} Monate
|
||||
{vorlage.data.geraetetyp && ` · ${vorlage.data.geraetetyp.name}`}
|
||||
{vorlage.data.pruefvorschrift && ` · ${vorlage.data.pruefvorschrift}`}
|
||||
{" · "}
|
||||
{vorlage.data._count.pruefungen} Prüfungen bisher
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Checkliste</CardTitle>
|
||||
<CardDescription>
|
||||
Der Prüfwizard fragt die Punkte in dieser Reihenfolge ab. Pflichtpunkte müssen
|
||||
beantwortet sein, bevor sich eine Prüfung abschließen lässt — und ein durchgefallener
|
||||
Pflichtpunkt bedeutet „nicht bestanden".
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{punkte.length === 0 && (
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Prüfpunkt"
|
||||
hinweis="Eine Vorlage ohne Punkte erzeugt Termine, aber keine sinnvolle Prüfung."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ol className="space-y-2">
|
||||
{punkte.map((punkt, index) => (
|
||||
<li
|
||||
key={punkt.id}
|
||||
className="flex flex-wrap items-start gap-3 rounded-md border border-[var(--border)] p-3"
|
||||
>
|
||||
<span className="tabellenzahlen w-6 shrink-0 pt-1 text-sm text-[var(--muted-foreground)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div className="min-w-48 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{punkt.text}
|
||||
{punkt.pflicht && (
|
||||
<span className="ml-1 text-[var(--gesperrt)]" title="Pflichtpunkt">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{PUNKTTYP_TEXT[punkt.typ]}
|
||||
{punkt.sollwert && ` · Soll: ${punkt.sollwert}`}
|
||||
{(punkt.minWert !== null || punkt.maxWert !== null) &&
|
||||
` · zulässig ${punkt.minWert ?? "…"} bis ${punkt.maxWert ?? "…"}${
|
||||
punkt.einheit ? ` ${punkt.einheit}` : ""
|
||||
}`}
|
||||
</p>
|
||||
{punkt.hinweis && (
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">{punkt.hinweis}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => verschieben(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={`${punkt.text} nach oben`}
|
||||
>
|
||||
<ArrowUp aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => verschieben(index, 1)}
|
||||
disabled={index === punkte.length - 1}
|
||||
aria-label={`${punkt.text} nach unten`}
|
||||
>
|
||||
<ArrowDown aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Prüfpunkt "${punkt.text}" entfernen?`)) {
|
||||
punktLoeschen.mutate({ id: punkt.id });
|
||||
}
|
||||
}}
|
||||
aria-label={`${punkt.text} entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prüfpunkt ergänzen</CardTitle>
|
||||
<CardDescription>
|
||||
Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung" — gerechnet
|
||||
wird das auf dem Server, nicht im Browser.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={punktHinzufuegen} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="punkttext">Frage oder Prüfschritt</Label>
|
||||
<Input
|
||||
id="punkttext"
|
||||
required
|
||||
value={neu.text}
|
||||
onChange={(ereignis) => setNeu({ ...neu, text: ereignis.target.value })}
|
||||
placeholder="Maskenkörper auf Risse und Verformung prüfen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="punkttyp">Art der Antwort</Label>
|
||||
<Select
|
||||
value={neu.typ}
|
||||
onValueChange={(wert) =>
|
||||
setNeu({ ...neu, typ: wert as ChecklistenpunktTyp })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="punkttyp">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PUNKTTYP_TEXT).map(([wert, text]) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sollwert">Sollwert im Klartext</Label>
|
||||
<Input
|
||||
id="sollwert"
|
||||
value={neu.sollwert}
|
||||
onChange={(ereignis) => setNeu({ ...neu, sollwert: ereignis.target.value })}
|
||||
placeholder="mindestens 270 bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{neu.typ === ChecklistenpunktTyp.MESSWERT && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minwert">kleinster zulässiger Wert</Label>
|
||||
<Input
|
||||
id="minwert"
|
||||
type="number"
|
||||
step="any"
|
||||
value={neu.minWert}
|
||||
onChange={(ereignis) => setNeu({ ...neu, minWert: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxwert">größter zulässiger Wert</Label>
|
||||
<Input
|
||||
id="maxwert"
|
||||
type="number"
|
||||
step="any"
|
||||
value={neu.maxWert}
|
||||
onChange={(ereignis) => setNeu({ ...neu, maxWert: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="einheit">Einheit</Label>
|
||||
<Input
|
||||
id="einheit"
|
||||
value={neu.einheit}
|
||||
onChange={(ereignis) => setNeu({ ...neu, einheit: ereignis.target.value })}
|
||||
placeholder="bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hinweis">Hinweis für den Prüfer</Label>
|
||||
<Input
|
||||
id="hinweis"
|
||||
value={neu.hinweis}
|
||||
onChange={(ereignis) => setNeu({ ...neu, hinweis: ereignis.target.value })}
|
||||
placeholder="Sichtprüfung bei gutem Licht, Maske dabei dehnen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={neu.pflicht}
|
||||
onCheckedChange={(an) => setNeu({ ...neu, pflicht: an === true })}
|
||||
/>
|
||||
Pflichtpunkt — davon hängt die Einsatzbereitschaft ab
|
||||
</label>
|
||||
|
||||
{punktAnlegen.error && <Fehlermeldung meldung={punktAnlegen.error.message} />}
|
||||
|
||||
<Button type="submit" disabled={punktAnlegen.isPending}>
|
||||
<Plus aria-hidden />
|
||||
{punktAnlegen.isPending ? "wird ergänzt …" : "Punkt ergänzen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChecklistenEditor } from "@/app/(app)/pruefungen/vorlagen/[id]/checkliste";
|
||||
|
||||
export const metadata = { title: "Checkliste" };
|
||||
|
||||
/**
|
||||
* Eine Vorlage mit ihrer Checkliste.
|
||||
*
|
||||
* Der Editor ist eine Client-Komponente: Punkte werden hinzugefügt, umsortiert
|
||||
* und geändert, ohne dass die Seite dazwischen neu lädt.
|
||||
*/
|
||||
export default async function VorlageSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/pruefungen/vorlagen">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Vorlagen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ChecklistenEditor vorlageId={id} darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { VorlagenVerwaltung } from "@/app/(app)/pruefungen/vorlagen/vorlagen-verwaltung";
|
||||
|
||||
export const metadata = { title: "Wartungsvorlagen" };
|
||||
|
||||
/**
|
||||
* Wartungsvorlagen.
|
||||
*
|
||||
* Hier steht, was in welchem Takt geprüft wird. Ohne Vorlage entstehen keine
|
||||
* Termine — deshalb ist das die erste Seite, die eine Wehr in Phase 2 ausfüllt.
|
||||
*/
|
||||
export default async function VorlagenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/pruefungen">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Fristen
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Wartungsvorlagen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Was an einem Gerätetyp regelmäßig zu prüfen ist — Intervall, Vorschrift und Checkliste.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VorlagenVerwaltung darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Search, Trash2 } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Verwaltung der Wartungsvorlagen.
|
||||
*
|
||||
* Die Liste zeigt, was jede Vorlage kostet und bringt: Intervall, Zahl der
|
||||
* Prüfpunkte, Zahl der offenen Termine. Die Checkliste selbst wird auf der
|
||||
* Detailseite gepflegt — sie ist zu umfangreich für eine Tabellenzeile.
|
||||
*/
|
||||
|
||||
const LEER = {
|
||||
name: "",
|
||||
geraetetypId: "",
|
||||
intervallMonate: 12,
|
||||
vorwarnzeitTage: 30,
|
||||
pruefvorschrift: "",
|
||||
standardDauerMinuten: "",
|
||||
standardExtern: false,
|
||||
automatisch: true,
|
||||
beschreibung: "",
|
||||
};
|
||||
|
||||
export function VorlagenVerwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const [suche, setSuche] = React.useState("");
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const [formular, setFormular] = React.useState(LEER);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const vorlagen = trpc.wartungsvorlage.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "asc",
|
||||
suche: suche || undefined,
|
||||
});
|
||||
|
||||
const geraetetypen = trpc.geraetetyp.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 200,
|
||||
richtung: "asc",
|
||||
});
|
||||
|
||||
const anlegen = trpc.wartungsvorlage.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setOffen(false);
|
||||
setFormular(LEER);
|
||||
void werkzeuge.wartungsvorlage.liste.invalidate();
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const loeschen = trpc.wartungsvorlage.loeschen.useMutation({
|
||||
onSuccess: () => void werkzeuge.wartungsvorlage.liste.invalidate(),
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
function absenden(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
anlegen.mutate({
|
||||
name: formular.name,
|
||||
geraetetypId: formular.geraetetypId || null,
|
||||
intervallMonate: Number(formular.intervallMonate),
|
||||
vorwarnzeitTage: Number(formular.vorwarnzeitTage),
|
||||
pruefvorschrift: formular.pruefvorschrift || null,
|
||||
standardDauerMinuten: formular.standardDauerMinuten
|
||||
? Number(formular.standardDauerMinuten)
|
||||
: null,
|
||||
standardExtern: formular.standardExtern,
|
||||
automatisch: formular.automatisch,
|
||||
beschreibung: formular.beschreibung || null,
|
||||
aktiv: true,
|
||||
});
|
||||
}
|
||||
|
||||
const eintraege = vorlagen.data?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-56 flex-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Name oder Prüfvorschrift …"
|
||||
className="pl-8"
|
||||
aria-label="Vorlagen durchsuchen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Neue Vorlage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
{vorlagen.error && <Fehlermeldung meldung={vorlagen.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bezeichnung</TableHead>
|
||||
<TableHead>Gerätetyp</TableHead>
|
||||
<TableHead>Vorschrift</TableHead>
|
||||
<TableHead className="text-right">Intervall</TableHead>
|
||||
<TableHead className="text-right">Punkte</TableHead>
|
||||
<TableHead className="text-right">offene Termine</TableHead>
|
||||
<TableHead className="w-24" aria-label="Aktionen" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vorlagen.isLoading && <LadeZeilen spalten={7} />}
|
||||
|
||||
{eintraege.map((vorlage) => (
|
||||
<TableRow key={vorlage.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/pruefungen/vorlagen/${vorlage.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{vorlage.name}
|
||||
</Link>
|
||||
{!vorlage.aktiv && (
|
||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">(inaktiv)</span>
|
||||
)}
|
||||
{!vorlage.automatisch && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
keine automatische Planung
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{vorlage.geraetetyp?.name ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||
{vorlage.pruefvorschrift ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{vorlage.intervallMonate} Mon.
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">{vorlage._count.punkte}</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">{vorlage._count.termine}</TableCell>
|
||||
<TableCell>
|
||||
{darfBearbeiten && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Vorlage "${vorlage.name}" löschen? Offene Termine dazu entfallen.`,
|
||||
)
|
||||
) {
|
||||
loeschen.mutate({ id: vorlage.id });
|
||||
}
|
||||
}}
|
||||
aria-label={`Vorlage ${vorlage.name} löschen`}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!vorlagen.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Noch keine Wartungsvorlage"
|
||||
hinweis="Eine Vorlage sagt, was an einem Gerätetyp in welchem Takt zu prüfen ist. Ohne sie entstehen keine Termine — und ohne Termine kein Fristen-Dashboard."
|
||||
aktion={
|
||||
darfBearbeiten ? (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Erste Vorlage anlegen
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={offen} onOpenChange={setOffen}>
|
||||
<DialogContent>
|
||||
<form onSubmit={absenden} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Wartungsvorlage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die Prüfpunkte kommen im nächsten Schritt dazu — erst die Regel, dann die Fragen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Bezeichnung</Label>
|
||||
<Input
|
||||
id="name"
|
||||
required
|
||||
value={formular.name}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, name: ereignis.target.value })
|
||||
}
|
||||
placeholder="Sichtprüfung Pressluftatmer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geraetetyp">Gilt für Gerätetyp</Label>
|
||||
<Select
|
||||
value={formular.geraetetypId || "keiner"}
|
||||
onValueChange={(wert) =>
|
||||
setFormular({ ...formular, geraetetypId: wert === "keiner" ? "" : wert })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="geraetetyp">
|
||||
<SelectValue placeholder="Gerätetyp wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keiner">keiner — nur von Hand zuweisbar</SelectItem>
|
||||
{geraetetypen.data?.eintraege.map((typ) => (
|
||||
<SelectItem key={typ.id} value={typ.id}>
|
||||
{typ.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Ohne Gerätetyp plant der nächtliche Lauf keine Termine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="intervall">Intervall in Monaten</Label>
|
||||
<Input
|
||||
id="intervall"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
required
|
||||
value={formular.intervallMonate}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, intervallMonate: Number(ereignis.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorwarn">Vorwarnzeit in Tagen</Label>
|
||||
<Input
|
||||
id="vorwarn"
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={formular.vorwarnzeitTage}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, vorwarnzeitTage: Number(ereignis.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorschrift">Prüfvorschrift</Label>
|
||||
<Input
|
||||
id="vorschrift"
|
||||
value={formular.pruefvorschrift}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, pruefvorschrift: ereignis.target.value })
|
||||
}
|
||||
placeholder="DGUV Vorschrift 49"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dauer">Übliche Dauer in Minuten</Label>
|
||||
<Input
|
||||
id="dauer"
|
||||
type="number"
|
||||
min={1}
|
||||
value={formular.standardDauerMinuten}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, standardDauerMinuten: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="beschreibung">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="beschreibung"
|
||||
rows={2}
|
||||
value={formular.beschreibung}
|
||||
onChange={(ereignis) =>
|
||||
setFormular({ ...formular, beschreibung: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={formular.automatisch}
|
||||
onCheckedChange={(an) => setFormular({ ...formular, automatisch: an === true })}
|
||||
/>
|
||||
Termine nachts automatisch planen
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={formular.standardExtern}
|
||||
onCheckedChange={(an) =>
|
||||
setFormular({ ...formular, standardExtern: an === true })
|
||||
}
|
||||
/>
|
||||
Wird üblicherweise von einer Fachfirma durchgeführt
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={anlegen.isPending}>
|
||||
{anlegen.isPending ? "wird angelegt …" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { BetroffenheitStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, NichtsGefunden, StatusAbzeichen } from "@/components/stammdaten/anzeige";
|
||||
import { BetroffenheitAbzeichen, RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { BETROFFENHEIT_TEXT } from "@/lib/wartung/texte";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Rückruf bearbeiten: suchen, zuordnen, abarbeiten.
|
||||
*
|
||||
* Der Ablauf entspricht dem, was eine Wehr wirklich tut, wenn ein
|
||||
* Sicherheitshinweis eintrifft: nachsehen, was man davon hat, es aus dem Dienst
|
||||
* nehmen, die Maßnahme durchführen, wieder freigeben.
|
||||
*/
|
||||
export function RueckrufBearbeitung({
|
||||
rueckrufId,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
rueckrufId: string;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const rueckruf = trpc.rueckruf.einzeln.useQuery({ id: rueckrufId });
|
||||
const treffer = trpc.rueckruf.geraeteSuchen.useQuery({ id: rueckrufId });
|
||||
|
||||
const aktualisieren = () => {
|
||||
void werkzeuge.rueckruf.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
};
|
||||
|
||||
const zuordnen = trpc.rueckruf.zuordnen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setMeldung(
|
||||
`${ergebnis.zugeordnet} Geräte zugeordnet, ${ergebnis.gesperrt} davon gesperrt.`,
|
||||
);
|
||||
setAusgewaehlt(new Set());
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const betroffenheitSetzen = trpc.rueckruf.betroffenheitSetzen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
if (ergebnis.geraetFreigegeben) setMeldung("Gerät wieder einsatzbereit.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const abschliessen = trpc.rueckruf.abschliessen.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung("Rückruf abgeschlossen.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
if (rueckruf.isLoading) {
|
||||
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
|
||||
}
|
||||
if (rueckruf.error) return <Fehlermeldung meldung={rueckruf.error.message} />;
|
||||
if (!rueckruf.data) return null;
|
||||
|
||||
const daten = rueckruf.data;
|
||||
const offeneTreffer = (treffer.data?.treffer ?? []).filter(
|
||||
(geraet) => !geraet.bereitsZugeordnet,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
{daten.hersteller}
|
||||
{daten.referenz && ` · ${daten.referenz}`}
|
||||
{daten.veroeffentlichtAm && ` · ${alsDatum(daten.veroeffentlichtAm)}`}
|
||||
<RueckrufStatusAbzeichen status={daten.status} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && daten.status !== "ERLEDIGT" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => abschliessen.mutate({ id: rueckrufId })}
|
||||
disabled={abschliessen.isPending}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Rückruf abschließen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="rueckrufmeldung" />}
|
||||
|
||||
{(daten.beschreibung || daten.massnahme) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Warnung des Herstellers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{daten.beschreibung && <p className="whitespace-pre-line">{daten.beschreibung}</p>}
|
||||
{daten.massnahme && (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Maßnahme
|
||||
</p>
|
||||
<p className="whitespace-pre-line">{daten.massnahme}</p>
|
||||
</div>
|
||||
)}
|
||||
{daten.typen.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Betroffene Bauart
|
||||
</p>
|
||||
<ul className="list-inside list-disc">
|
||||
{daten.typen.map((typ) => (
|
||||
<li key={typ.id}>
|
||||
{typ.geraetetyp?.name ?? typ.modell ?? "ohne nähere Eingrenzung"}
|
||||
{(typ.seriennummerVon || typ.seriennummerBis) &&
|
||||
` · Seriennummern ${typ.seriennummerVon ?? "…"} bis ${typ.seriennummerBis ?? "…"}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vorschlag aus dem Bestand */}
|
||||
{darfBearbeiten && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Mögliche Treffer im Bestand</CardTitle>
|
||||
<CardDescription>
|
||||
Vorschlag anhand von Bauart, Modell und Seriennummer. Zugeordnet wird nur, was hier
|
||||
bestätigt wird.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{ausgewaehlt.size > 0 && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
zuordnen.mutate({
|
||||
rueckrufId,
|
||||
geraetIds: [...ausgewaehlt],
|
||||
})
|
||||
}
|
||||
disabled={zuordnen.isPending}
|
||||
>
|
||||
{ausgewaehlt.size} Geräte zuordnen
|
||||
{daten.sperrtGeraete && " und sperren"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{treffer.data?.hinweis && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{treffer.data.hinweis}</p>
|
||||
)}
|
||||
|
||||
{offeneTreffer.length === 0 && !treffer.data?.hinweis && (
|
||||
<NichtsGefunden
|
||||
titel="Kein weiteres Gerät passt auf die Beschreibung"
|
||||
hinweis="Entweder ist der Bestand nicht betroffen, oder alle Treffer sind bereits zugeordnet."
|
||||
/>
|
||||
)}
|
||||
|
||||
{offeneTreffer.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" aria-label="Auswahl" />
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Modell</TableHead>
|
||||
<TableHead>Seriennummer</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{offeneTreffer.map((geraet) => (
|
||||
<TableRow key={geraet.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={ausgewaehlt.has(geraet.id)}
|
||||
onCheckedChange={() =>
|
||||
setAusgewaehlt((alt) => {
|
||||
const neu = new Set(alt);
|
||||
if (neu.has(geraet.id)) neu.delete(geraet.id);
|
||||
else neu.add(geraet.id);
|
||||
return neu;
|
||||
})
|
||||
}
|
||||
aria-label={`${geraet.inventarnummer} auswählen`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{geraet.modell ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{geraet.seriennummer ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={geraet.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bereits zugeordnete Geräte */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Betroffene Geräte</CardTitle>
|
||||
<CardDescription>
|
||||
{daten.betroffenheiten.length} zugeordnet ·{" "}
|
||||
{
|
||||
daten.betroffenheiten.filter(
|
||||
(eintrag) =>
|
||||
eintrag.status === "OFFEN" || eintrag.status === "MASSNAHME_LAEUFT",
|
||||
).length
|
||||
}{" "}
|
||||
noch offen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{daten.betroffenheiten.length === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Gerät zugeordnet"
|
||||
hinweis="Solange kein Gerät zugeordnet ist, hat der Rückruf keine Wirkung auf den Bestand."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Seriennummer</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Gerätestatus</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead className="w-56" aria-label="Aktion" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daten.betroffenheiten.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${eintrag.geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{eintrag.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{eintrag.geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{eintrag.geraet.seriennummer ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{eintrag.geraet.fahrzeug?.funkrufname ??
|
||||
eintrag.geraet.standort?.name ??
|
||||
"—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={eintrag.geraet.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<BetroffenheitAbzeichen status={eintrag.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{darfBearbeiten && (
|
||||
<Select
|
||||
value={eintrag.status}
|
||||
onValueChange={(wert) =>
|
||||
betroffenheitSetzen.mutate({
|
||||
id: eintrag.id,
|
||||
status: wert as BetroffenheitStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-52"
|
||||
aria-label={`Stand für ${eintrag.geraet.inventarnummer}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(BETROFFENHEIT_TEXT) as BetroffenheitStatus[]).map(
|
||||
(wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{BETROFFENHEIT_TEXT[wert]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<Search className="size-3.5" aria-hidden />
|
||||
Die Suche vergleicht Seriennummern als Text. Wo Hersteller reine Zahlen vergeben, stimmt
|
||||
das nur bei gleicher Stellenzahl — deshalb ist das Ergebnis ein Vorschlag und keine
|
||||
automatische Sperre.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RueckrufBearbeitung } from "@/app/(app)/rueckrufe/[id]/bearbeitung";
|
||||
|
||||
export const metadata = { title: "Rückruf" };
|
||||
|
||||
/**
|
||||
* Ein Rückruf mit Gerätesuche und Bearbeitungsstand.
|
||||
*
|
||||
* Die Suche schlägt vor, was auf die Beschreibung passt; zugeordnet wird nur,
|
||||
* was der Gerätewart bestätigt. Eine Chargenangabe des Herstellers passt selten
|
||||
* eins zu eins auf den eigenen Bestand.
|
||||
*/
|
||||
export default async function RueckrufSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/rueckrufe">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Rückrufen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RueckrufBearbeitung
|
||||
rueckrufId={id}
|
||||
darfBearbeiten={hatRecht(sitzung, "pruefung.planen")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { RueckrufListe } from "@/app/(app)/rueckrufe/rueckruf-liste";
|
||||
|
||||
export const metadata = { title: "Rückrufe" };
|
||||
|
||||
/**
|
||||
* Herstellerrückrufe.
|
||||
*
|
||||
* Kommt eine Warnung des Herstellers, muss die Wehr wissen, ob sie betroffene
|
||||
* Stücke im Bestand hat. Genau das ist der Zweck dieser Seite.
|
||||
*/
|
||||
export default async function RueckrufeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Rückrufe</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Herstellerwarnungen, betroffene Geräte und die Maßnahmen dazu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RueckrufListe darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Liste der Rückrufe.
|
||||
*
|
||||
* Beim Anlegen wird gleich die betroffene Bauart mit erfasst — ohne sie kann
|
||||
* die Suche nach betroffenen Geräten nichts finden, und der Rückruf bliebe ein
|
||||
* Zettel ohne Wirkung.
|
||||
*/
|
||||
export function RueckrufListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const [hersteller, setHersteller] = React.useState("");
|
||||
const [titel, setTitel] = React.useState("");
|
||||
const [referenz, setReferenz] = React.useState("");
|
||||
const [beschreibung, setBeschreibung] = React.useState("");
|
||||
const [massnahme, setMassnahme] = React.useState("");
|
||||
const [veroeffentlichtAm, setVeroeffentlichtAm] = React.useState("");
|
||||
const [sperrtGeraete, setSperrtGeraete] = React.useState(true);
|
||||
const [geraetetypId, setGeraetetypId] = React.useState("");
|
||||
const [modell, setModell] = React.useState("");
|
||||
const [seriennummerVon, setSeriennummerVon] = React.useState("");
|
||||
const [seriennummerBis, setSeriennummerBis] = React.useState("");
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const rueckrufe = trpc.rueckruf.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "desc",
|
||||
});
|
||||
|
||||
const geraetetypen = trpc.geraetetyp.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||
{ enabled: offen },
|
||||
);
|
||||
|
||||
const anlegen = trpc.rueckruf.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setOffen(false);
|
||||
setHersteller("");
|
||||
setTitel("");
|
||||
setReferenz("");
|
||||
setBeschreibung("");
|
||||
setMassnahme("");
|
||||
setGeraetetypId("");
|
||||
setModell("");
|
||||
setSeriennummerVon("");
|
||||
setSeriennummerBis("");
|
||||
void werkzeuge.rueckruf.liste.invalidate();
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const eintraege = rueckrufe.data?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{darfBearbeiten && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Rückruf erfassen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
{rueckrufe.error && <Fehlermeldung meldung={rueckrufe.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Titel</TableHead>
|
||||
<TableHead>Hersteller</TableHead>
|
||||
<TableHead>Referenz</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead className="text-right">betroffene Geräte</TableHead>
|
||||
<TableHead className="text-right">veröffentlicht</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rueckrufe.isLoading && <LadeZeilen spalten={6} />}
|
||||
|
||||
{eintraege.map((rueckruf) => (
|
||||
<TableRow key={rueckruf.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/rueckrufe/${rueckruf.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{rueckruf.titel}
|
||||
</Link>
|
||||
{rueckruf.sperrtGeraete && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sperrt betroffene Geräte
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{rueckruf.hersteller}</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{rueckruf.referenz ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RueckrufStatusAbzeichen status={rueckruf.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{rueckruf._count.betroffenheiten}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDatum(rueckruf.veroeffentlichtAm)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!rueckrufe.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Kein Rückruf erfasst"
|
||||
hinweis="Kommt eine Herstellerwarnung, wird sie hier eingetragen. FLORIAN sucht dann die betroffenen Geräte im Bestand."
|
||||
aktion={
|
||||
darfBearbeiten ? (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Rückruf erfassen
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={offen} onOpenChange={setOffen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<form
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
anlegen.mutate({
|
||||
hersteller,
|
||||
titel,
|
||||
referenz: referenz || null,
|
||||
beschreibung: beschreibung || null,
|
||||
massnahme: massnahme || null,
|
||||
veroeffentlichtAm: veroeffentlichtAm ? new Date(veroeffentlichtAm) : null,
|
||||
quelle: null,
|
||||
sperrtGeraete,
|
||||
typen:
|
||||
geraetetypId || modell || seriennummerVon || seriennummerBis
|
||||
? [
|
||||
{
|
||||
geraetetypId: geraetetypId || null,
|
||||
modell: modell || null,
|
||||
seriennummerVon: seriennummerVon || null,
|
||||
seriennummerBis: seriennummerBis || null,
|
||||
herstellungVon: null,
|
||||
herstellungBis: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rückruf erfassen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die betroffene Bauart gleich mit angeben — danach sucht FLORIAN im Bestand.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hersteller">Hersteller</Label>
|
||||
<Input
|
||||
id="hersteller"
|
||||
required
|
||||
value={hersteller}
|
||||
onChange={(ereignis) => setHersteller(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="referenz">Referenz des Herstellers</Label>
|
||||
<Input
|
||||
id="referenz"
|
||||
value={referenz}
|
||||
onChange={(ereignis) => setReferenz(ereignis.target.value)}
|
||||
placeholder="SI-2026-04"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titel">Titel</Label>
|
||||
<Input
|
||||
id="titel"
|
||||
required
|
||||
value={titel}
|
||||
onChange={(ereignis) => setTitel(ereignis.target.value)}
|
||||
placeholder="Sicherheitshinweis Lungenautomat Baureihe X"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="beschreibung">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="beschreibung"
|
||||
rows={3}
|
||||
value={beschreibung}
|
||||
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="massnahme">Maßnahme</Label>
|
||||
<Textarea
|
||||
id="massnahme"
|
||||
rows={2}
|
||||
value={massnahme}
|
||||
onChange={(ereignis) => setMassnahme(ereignis.target.value)}
|
||||
placeholder="Zur Überprüfung an den Hersteller einsenden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="veroeffentlicht">Veröffentlicht am</Label>
|
||||
<Input
|
||||
id="veroeffentlicht"
|
||||
type="date"
|
||||
value={veroeffentlichtAm}
|
||||
onChange={(ereignis) => setVeroeffentlichtAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="typ">Betroffener Gerätetyp</Label>
|
||||
<Select
|
||||
value={geraetetypId || "keiner"}
|
||||
onValueChange={(wert) => setGeraetetypId(wert === "keiner" ? "" : wert)}
|
||||
>
|
||||
<SelectTrigger id="typ">
|
||||
<SelectValue placeholder="Gerätetyp" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keiner">nicht über den Typ eingrenzen</SelectItem>
|
||||
{geraetetypen.data?.eintraege.map((typ) => (
|
||||
<SelectItem key={typ.id} value={typ.id}>
|
||||
{typ.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modell">Modell enthält</Label>
|
||||
<Input
|
||||
id="modell"
|
||||
value={modell}
|
||||
onChange={(ereignis) => setModell(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serievon">Seriennummer von</Label>
|
||||
<Input
|
||||
id="serievon"
|
||||
value={seriennummerVon}
|
||||
onChange={(ereignis) => setSeriennummerVon(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="seriebis">Seriennummer bis</Label>
|
||||
<Input
|
||||
id="seriebis"
|
||||
value={seriennummerBis}
|
||||
onChange={(ereignis) => setSeriennummerBis(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={sperrtGeraete}
|
||||
onCheckedChange={(an) => setSperrtGeraete(an === true)}
|
||||
/>
|
||||
Betroffene Geräte beim Zuordnen sperren
|
||||
</label>
|
||||
|
||||
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={anlegen.isPending}>
|
||||
{anlegen.isPending ? "wird erfasst …" : "Erfassen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
|
||||
/**
|
||||
* Datei-Upload.
|
||||
*
|
||||
* Eigener Endpunkt statt einer tRPC-Prozedur, aus demselben Grund wie beim
|
||||
* Etikettenbogen: Dateien sind keine JSON-Daten. Über tRPC müssten sie
|
||||
* Base64-kodiert durch die Leitung — ein Drittel mehr Bytes, alles im
|
||||
* Arbeitsspeicher, und die Fortschrittsanzeige des Browsers wäre weg.
|
||||
*
|
||||
* Verwendet für die Fotos im Prüfwizard, die Unterschrift aus dem Zeichenfeld
|
||||
* und das Fremdprotokoll einer Fachfirma.
|
||||
*
|
||||
* Die Datei geht ausschließlich über lib/storage.ts nach MinIO — Projektregel.
|
||||
* Was hier entsteht, ist der Datei-Datensatz mit der Kennung, die der Aufrufer
|
||||
* anschließend an Prüfergebnis, Signatur oder Mangel hängt.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Was hochgeladen werden darf. Alles andere hat in einem Protokoll nichts verloren. */
|
||||
const ERLAUBTE_TYPEN = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"application/pdf",
|
||||
]);
|
||||
|
||||
/** 20 MB. Ein Handyfoto liegt darunter, ein Fremdprotokoll erst recht. */
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const felder = z.object({
|
||||
/** Woran die Datei hängt: "Pruefung", "Mangel", "Signatur". */
|
||||
entitaet: z.string().trim().min(1).max(80),
|
||||
entitaetId: z.string().uuid().optional(),
|
||||
/** Einordnung: "foto", "signatur", "fremdprotokoll". */
|
||||
kategorie: z.string().trim().max(60).default("foto"),
|
||||
});
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "datei.hochladen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung zum Hochladen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const formular = await anfrage.formData().catch(() => null);
|
||||
if (!formular) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Die Anfrage enthält kein Formular mit einer Datei." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const datei = formular.get("datei");
|
||||
if (!(datei instanceof File)) {
|
||||
return NextResponse.json({ fehler: "Es wurde keine Datei übergeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const geprueft = felder.safeParse({
|
||||
entitaet: formular.get("entitaet") ?? "",
|
||||
entitaetId: formular.get("entitaetId") || undefined,
|
||||
kategorie: formular.get("kategorie") || undefined,
|
||||
});
|
||||
|
||||
if (!geprueft.success) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Es fehlt die Angabe, wozu die Datei gehört." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const mimeTyp = datei.type || "application/octet-stream";
|
||||
|
||||
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler: `Dateien vom Typ ${mimeTyp} werden nicht angenommen. Erlaubt sind Bilder und PDF.`,
|
||||
},
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
if (datei.size > MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ fehler: `Die Datei ist zu groß (${Math.round(datei.size / 1024 / 1024)} MB, erlaubt sind 20 MB).` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = Buffer.from(await datei.arrayBuffer());
|
||||
|
||||
try {
|
||||
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { slug: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: organisation?.slug ?? sitzung.organisationId,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId,
|
||||
dateiname: datei.name || "datei",
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, inhalt, mimeTyp, {
|
||||
kategorie: geprueft.data.kategorie,
|
||||
hochgeladenvon: sitzung.personId,
|
||||
});
|
||||
|
||||
const datensatz = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const angelegt = await db.datei.create({
|
||||
data: {
|
||||
organisationId: sitzung.organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname: datei.name || "datei",
|
||||
mimeTyp,
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId ?? null,
|
||||
kategorie: geprueft.data.kategorie,
|
||||
hochgeladenVonId: sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId ?? null,
|
||||
beschreibung: `Datei "${angelegt.dateiname}" hochgeladen (${geprueft.data.kategorie})`,
|
||||
sitzung,
|
||||
nachher: { dateiId: angelegt.id, mimeTyp, groesseBytes: abgelegt.groesseBytes },
|
||||
});
|
||||
|
||||
return angelegt;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: datensatz.id,
|
||||
dateiname: datensatz.dateiname,
|
||||
mimeTyp: datensatz.mimeTyp,
|
||||
groesseBytes: abgelegt.groesseBytes,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[dateien] Upload fehlgeschlagen:", fehler);
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler:
|
||||
"Die Datei ließ sich nicht ablegen. Läuft der MinIO-Container? (docker compose ps)",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
ShieldAlert,
|
||||
Siren,
|
||||
TriangleAlert,
|
||||
Truck,
|
||||
Wallet,
|
||||
Users,
|
||||
@@ -64,6 +66,20 @@ const EINTRAEGE: Eintrag[] = [
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{
|
||||
titel: "Mängel",
|
||||
pfad: "/maengel",
|
||||
symbol: TriangleAlert,
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{
|
||||
titel: "Rückrufe",
|
||||
pfad: "/rueckrufe",
|
||||
symbol: ShieldAlert,
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
|
||||
{
|
||||
titel: "Terminal",
|
||||
@@ -99,7 +115,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 1;
|
||||
const AKTUELLE_PHASE = 2;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -106,6 +106,14 @@ const schema = z.object({
|
||||
LLM_API_KEY: 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_JAHRE: zahl(10),
|
||||
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -23,10 +23,14 @@ import { formatInTimeZone } from "date-fns-tz";
|
||||
*
|
||||
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
|
||||
* 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. */
|
||||
function utcTag(wert: Date): Date {
|
||||
export function utcTag(wert: Date): Date {
|
||||
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
|
||||
* 3. März.
|
||||
*/
|
||||
function monateAddieren(basis: Date, monate: number): Date {
|
||||
export function monateAddieren(basis: Date, monate: number): Date {
|
||||
const jahr = basis.getUTCFullYear();
|
||||
const monat = basis.getUTCMonth() + monate;
|
||||
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
|
||||
* abhängen.
|
||||
*/
|
||||
function heutigerTag(jetzt: Date): Date {
|
||||
export function heutigerTag(jetzt: Date): Date {
|
||||
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
|
||||
.split("-")
|
||||
.map(Number);
|
||||
@@ -64,7 +68,7 @@ function heutigerTag(jetzt: Date): Date {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]) };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -9,6 +9,11 @@ import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/o
|
||||
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
|
||||
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
|
||||
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.
|
||||
@@ -139,6 +144,13 @@ export const appRouter = router({
|
||||
qualifikationstyp: qualifikationstypRouter,
|
||||
qualifikation: qualifikationRouter,
|
||||
datenImport: importRouter,
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
wartungsvorlage: wartungsvorlageRouter,
|
||||
wartungstermin: wartungsterminRouter,
|
||||
pruefung: pruefungRouter,
|
||||
mangel: mangelRouter,
|
||||
rueckruf: rueckrufRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -1,40 +1,86 @@
|
||||
// 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 { env } from "@/lib/env";
|
||||
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange";
|
||||
import { verbindungenTrennen } from "@/lib/db";
|
||||
import {
|
||||
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.
|
||||
*
|
||||
* Läuft als eigener Container neben der Anwendung und arbeitet die
|
||||
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich
|
||||
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in
|
||||
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung,
|
||||
* Fehlerbehandlung und ein sauberes Herunterfahren.
|
||||
* Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
|
||||
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die
|
||||
* Aufbewahrung folgt in Phase 8.
|
||||
*
|
||||
* 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 {
|
||||
console.info(`[worker] ${nachricht}`, ...weiteres);
|
||||
}
|
||||
|
||||
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> {
|
||||
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen.
|
||||
return { geprueft: 0 };
|
||||
async function fristenPruefen(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId } = auftrag.data;
|
||||
const ergebnis = await terminePlanen(organisationId);
|
||||
|
||||
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 }> {
|
||||
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 7: Mail und Telegram über lib/integrations.
|
||||
return { versendet: 0 };
|
||||
async function erinnerungSenden(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId, anlass, mangelId } = auftrag.data as AuftragsDaten & {
|
||||
anlass?: string;
|
||||
mangelId?: string;
|
||||
};
|
||||
|
||||
// Mängel melden sich sofort, Termine im nächtlichen Sammellauf.
|
||||
if (anlass?.startsWith("mangel") && mangelId) {
|
||||
return mangelBenachrichtigung(organisationId, mangelId, anlass);
|
||||
}
|
||||
|
||||
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> {
|
||||
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
|
||||
return erinnerungenSenden(organisationId);
|
||||
}
|
||||
|
||||
async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId, pruefungId } = auftrag.data as AuftragsDaten & { pruefungId?: string };
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 }> {
|
||||
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
|
||||
@@ -49,6 +95,36 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
|
||||
[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>(
|
||||
WARTESCHLANGE,
|
||||
async (auftrag) => {
|
||||
@@ -78,6 +154,19 @@ worker.on("ready", () => {
|
||||
protokoll(
|
||||
`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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||