diff --git a/.env.example b/.env.example
index ce12391..5125c1b 100644
--- a/.env.example
+++ b/.env.example
@@ -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)
# -----------------------------------------------------------------------------
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b478ed2..212e783 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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//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
diff --git a/README.md b/README.md
index 3b4d1f3..615dbb8 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 1d1441d..dab9a2c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md
index 507bdcd..14454c4 100644
--- a/docs/ANLEITUNG.md
+++ b/docs/ANLEITUNG.md
@@ -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 |
|---|---|---|
diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md
index 7724ba6..16cb5c1 100644
--- a/docs/AUFWANDSSCHAETZUNG.md
+++ b/docs/AUFWANDSSCHAETZUNG.md
@@ -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**
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index 791008d..400872e 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -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
---
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 4aec3f1..05b9e1f 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -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
diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png
index c83cb1d..1195787 100644
Binary files a/docs/screenshots/02-uebersicht-dark.png and b/docs/screenshots/02-uebersicht-dark.png differ
diff --git a/docs/screenshots/02-uebersicht-light.png b/docs/screenshots/02-uebersicht-light.png
index 0a8e940..5db6a1c 100644
Binary files a/docs/screenshots/02-uebersicht-light.png and b/docs/screenshots/02-uebersicht-light.png differ
diff --git a/docs/screenshots/03-geraeteliste-dark.png b/docs/screenshots/03-geraeteliste-dark.png
index 265649a..287aba5 100644
Binary files a/docs/screenshots/03-geraeteliste-dark.png and b/docs/screenshots/03-geraeteliste-dark.png differ
diff --git a/docs/screenshots/03-geraeteliste-light.png b/docs/screenshots/03-geraeteliste-light.png
index 4c0a446..3ca275f 100644
Binary files a/docs/screenshots/03-geraeteliste-light.png and b/docs/screenshots/03-geraeteliste-light.png differ
diff --git a/docs/screenshots/04-geraet-dark.png b/docs/screenshots/04-geraet-dark.png
index 0664012..950c2f5 100644
Binary files a/docs/screenshots/04-geraet-dark.png and b/docs/screenshots/04-geraet-dark.png differ
diff --git a/docs/screenshots/04-geraet-light.png b/docs/screenshots/04-geraet-light.png
index 8e7a614..5d9aa3b 100644
Binary files a/docs/screenshots/04-geraet-light.png and b/docs/screenshots/04-geraet-light.png differ
diff --git a/docs/screenshots/05-fahrzeuge-dark.png b/docs/screenshots/05-fahrzeuge-dark.png
index cef348c..c8b353e 100644
Binary files a/docs/screenshots/05-fahrzeuge-dark.png and b/docs/screenshots/05-fahrzeuge-dark.png differ
diff --git a/docs/screenshots/05-fahrzeuge-light.png b/docs/screenshots/05-fahrzeuge-light.png
index afcca5e..b128e8b 100644
Binary files a/docs/screenshots/05-fahrzeuge-light.png and b/docs/screenshots/05-fahrzeuge-light.png differ
diff --git a/docs/screenshots/06-fahrzeug-dark.png b/docs/screenshots/06-fahrzeug-dark.png
index f8b53a2..d56a31d 100644
Binary files a/docs/screenshots/06-fahrzeug-dark.png and b/docs/screenshots/06-fahrzeug-dark.png differ
diff --git a/docs/screenshots/06-fahrzeug-light.png b/docs/screenshots/06-fahrzeug-light.png
index e3cc83f..3255f88 100644
Binary files a/docs/screenshots/06-fahrzeug-light.png and b/docs/screenshots/06-fahrzeug-light.png differ
diff --git a/docs/screenshots/07-mitglieder-dark.png b/docs/screenshots/07-mitglieder-dark.png
index b3c32a4..38599ae 100644
Binary files a/docs/screenshots/07-mitglieder-dark.png and b/docs/screenshots/07-mitglieder-dark.png differ
diff --git a/docs/screenshots/07-mitglieder-light.png b/docs/screenshots/07-mitglieder-light.png
index f655b27..a3c0c47 100644
Binary files a/docs/screenshots/07-mitglieder-light.png and b/docs/screenshots/07-mitglieder-light.png differ
diff --git a/docs/screenshots/08-qualifikationen-dark.png b/docs/screenshots/08-qualifikationen-dark.png
index ac6ff2e..596b7fa 100644
Binary files a/docs/screenshots/08-qualifikationen-dark.png and b/docs/screenshots/08-qualifikationen-dark.png differ
diff --git a/docs/screenshots/08-qualifikationen-light.png b/docs/screenshots/08-qualifikationen-light.png
index f437c07..5ed17d3 100644
Binary files a/docs/screenshots/08-qualifikationen-light.png and b/docs/screenshots/08-qualifikationen-light.png differ
diff --git a/docs/screenshots/09-import-dark.png b/docs/screenshots/09-import-dark.png
index 4ed4126..ce83bea 100644
Binary files a/docs/screenshots/09-import-dark.png and b/docs/screenshots/09-import-dark.png differ
diff --git a/docs/screenshots/09-import-light.png b/docs/screenshots/09-import-light.png
index 168bee0..3eb9d3f 100644
Binary files a/docs/screenshots/09-import-light.png and b/docs/screenshots/09-import-light.png differ
diff --git a/docs/screenshots/10-geraet-neu-dark.png b/docs/screenshots/10-geraet-neu-dark.png
index 589d493..db5c352 100644
Binary files a/docs/screenshots/10-geraet-neu-dark.png and b/docs/screenshots/10-geraet-neu-dark.png differ
diff --git a/docs/screenshots/10-geraet-neu-light.png b/docs/screenshots/10-geraet-neu-light.png
index 49c8af9..273d9b9 100644
Binary files a/docs/screenshots/10-geraet-neu-light.png and b/docs/screenshots/10-geraet-neu-light.png differ
diff --git a/docs/screenshots/11-fristen-dark.png b/docs/screenshots/11-fristen-dark.png
new file mode 100644
index 0000000..57f51ef
Binary files /dev/null and b/docs/screenshots/11-fristen-dark.png differ
diff --git a/docs/screenshots/11-fristen-light.png b/docs/screenshots/11-fristen-light.png
new file mode 100644
index 0000000..bdc1635
Binary files /dev/null and b/docs/screenshots/11-fristen-light.png differ
diff --git a/docs/screenshots/12-vorlagen-dark.png b/docs/screenshots/12-vorlagen-dark.png
new file mode 100644
index 0000000..dbdcc14
Binary files /dev/null and b/docs/screenshots/12-vorlagen-dark.png differ
diff --git a/docs/screenshots/12-vorlagen-light.png b/docs/screenshots/12-vorlagen-light.png
new file mode 100644
index 0000000..17a59f5
Binary files /dev/null and b/docs/screenshots/12-vorlagen-light.png differ
diff --git a/docs/screenshots/13-maengel-dark.png b/docs/screenshots/13-maengel-dark.png
new file mode 100644
index 0000000..5c51dd5
Binary files /dev/null and b/docs/screenshots/13-maengel-dark.png differ
diff --git a/docs/screenshots/13-maengel-light.png b/docs/screenshots/13-maengel-light.png
new file mode 100644
index 0000000..4343a5d
Binary files /dev/null and b/docs/screenshots/13-maengel-light.png differ
diff --git a/docs/screenshots/14-rueckrufe-dark.png b/docs/screenshots/14-rueckrufe-dark.png
new file mode 100644
index 0000000..a4fa1fb
Binary files /dev/null and b/docs/screenshots/14-rueckrufe-dark.png differ
diff --git a/docs/screenshots/14-rueckrufe-light.png b/docs/screenshots/14-rueckrufe-light.png
new file mode 100644
index 0000000..fcf87ae
Binary files /dev/null and b/docs/screenshots/14-rueckrufe-light.png differ
diff --git a/docs/screenshots/15-lebensdauer-dark.png b/docs/screenshots/15-lebensdauer-dark.png
new file mode 100644
index 0000000..1fd938f
Binary files /dev/null and b/docs/screenshots/15-lebensdauer-dark.png differ
diff --git a/docs/screenshots/15-lebensdauer-light.png b/docs/screenshots/15-lebensdauer-light.png
new file mode 100644
index 0000000..1838bb6
Binary files /dev/null and b/docs/screenshots/15-lebensdauer-light.png differ
diff --git a/docs/screenshots/16-protokolle-dark.png b/docs/screenshots/16-protokolle-dark.png
new file mode 100644
index 0000000..42495fa
Binary files /dev/null and b/docs/screenshots/16-protokolle-dark.png differ
diff --git a/docs/screenshots/16-protokolle-light.png b/docs/screenshots/16-protokolle-light.png
new file mode 100644
index 0000000..59302e3
Binary files /dev/null and b/docs/screenshots/16-protokolle-light.png differ
diff --git a/docs/screenshots/17-checkliste-dark.png b/docs/screenshots/17-checkliste-dark.png
new file mode 100644
index 0000000..fd40c23
Binary files /dev/null and b/docs/screenshots/17-checkliste-dark.png differ
diff --git a/docs/screenshots/17-checkliste-light.png b/docs/screenshots/17-checkliste-light.png
new file mode 100644
index 0000000..84f2b1d
Binary files /dev/null and b/docs/screenshots/17-checkliste-light.png differ
diff --git a/docs/screenshots/18-pruefprotokoll-dark.png b/docs/screenshots/18-pruefprotokoll-dark.png
new file mode 100644
index 0000000..bcdd323
Binary files /dev/null and b/docs/screenshots/18-pruefprotokoll-dark.png differ
diff --git a/docs/screenshots/18-pruefprotokoll-light.png b/docs/screenshots/18-pruefprotokoll-light.png
new file mode 100644
index 0000000..e94e339
Binary files /dev/null and b/docs/screenshots/18-pruefprotokoll-light.png differ
diff --git a/package.json b/package.json
index bc32a4e..a5869d2 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/migrations/20260903000200_wartung_und_pruefung/migration.sql b/prisma/migrations/20260903000200_wartung_und_pruefung/migration.sql
new file mode 100644
index 0000000..9fd1804
--- /dev/null
+++ b/prisma/migrations/20260903000200_wartung_und_pruefung/migration.sql
@@ -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;
diff --git a/prisma/migrations/20260903000300_wartung_rls/migration.sql b/prisma/migrations/20260903000300_wartung_rls/migration.sql
new file mode 100644
index 0000000..7a13bdb
--- /dev/null
+++ b/prisma/migrations/20260903000300_wartung_rls/migration.sql
@@ -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;
+$$;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..044d57c
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -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"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e0ed241..43d0cb9 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -145,27 +145,40 @@ model Organisation {
/// Funkrufname-Präfix der Wehr, z. B. "Florian Musterhausen".
funkPraefix String? @db.VarChar(80)
- zeitzone String @default("Europe/Berlin") @db.VarChar(60)
+ zeitzone String @default("Europe/Berlin") @db.VarChar(60)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
- personen Person[]
- rollen Rolle[]
- personRollen PersonRolle[]
- rolleRechte RolleBerechtigung[]
- ausweisTokens AusweisToken[]
- auditEintraege AuditLog[]
- dateien Datei[]
- fahrzeuge Fahrzeug[]
- geraete Geraet[]
- standorte Standort[]
- geraeteraeume Geraeteraum[]
- geraetetypen Geraetetyp[]
+ personen Person[]
+ rollen Rolle[]
+ personRollen PersonRolle[]
+ rolleRechte RolleBerechtigung[]
+ ausweisTokens AusweisToken[]
+ auditEintraege AuditLog[]
+ dateien Datei[]
+ fahrzeuge Fahrzeug[]
+ geraete Geraet[]
+ standorte Standort[]
+ geraeteraeume Geraeteraum[]
+ geraetetypen Geraetetyp[]
qualifikationstypen Qualifikationstyp[]
- qualifikationen Qualifikation[]
+ qualifikationen Qualifikation[]
+
+ // Phase 2 — Wartung und Prüfung
+ wartungsvorlagen Wartungsvorlage[]
+ checklistenpunkte Checklistenpunkt[]
+ wartungstermine Wartungstermin[]
+ pruefungen Pruefung[]
+ pruefergebnisse Pruefergebnis[]
+ signaturen Signatur[]
+ maengel Mangel[]
+ rueckrufe Rueckruf[]
+ rueckrufTypen RueckrufGeraetetyp[]
+ rueckrufBetroffenheiten RueckrufBetroffenheit[]
+ erinnerungen Erinnerung[]
@@index([aktiv])
@@map("organisation")
@@ -182,16 +195,16 @@ model Person {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
- vorname String @db.VarChar(100)
- nachname String @db.VarChar(100)
+ vorname String @db.VarChar(100)
+ nachname String @db.VarChar(100)
/// Fortlaufende Mitgliedsnummer innerhalb der Wehr.
- mitgliedsnummer String? @db.VarChar(30)
- geburtsdatum DateTime? @db.Date
- dienstgrad Dienstgrad @default(KEIN)
+ mitgliedsnummer String? @db.VarChar(30)
+ geburtsdatum DateTime? @db.Date
+ dienstgrad Dienstgrad @default(KEIN)
- email String? @db.VarChar(200)
+ email String? @db.VarChar(200)
telefon String? @db.VarChar(40)
- mobil String? @db.VarChar(40)
+ mobil String? @db.VarChar(40)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
@@ -222,16 +235,24 @@ model Person {
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
- organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
- avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
- rollen PersonRolle[] @relation("PersonRollenInhaber")
- vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
- ausweisTokens AusweisToken[]
- auditEintraege AuditLog[]
- hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
- qualifikationen Qualifikation[] @relation("QualifikationInhaber")
+ organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
+ avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
+ rollen PersonRolle[] @relation("PersonRollenInhaber")
+ vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
+ ausweisTokens AusweisToken[]
+ auditEintraege AuditLog[]
+ hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
+ qualifikationen Qualifikation[] @relation("QualifikationInhaber")
bestaetigteQualifikationen Qualifikation[] @relation("QualifikationBestaetiger")
+ // Phase 2 — Wartung und Prüfung
+ zugewieseneTermine Wartungstermin[] @relation("TerminZustaendig")
+ durchgefuehrtePruefungen Pruefung[] @relation("PruefungPruefer")
+ signaturen Signatur[] @relation("SignaturUnterzeichner")
+ gemeldeteMaengel Mangel[] @relation("MangelMelder")
+ zugewieseneMaengel Mangel[] @relation("MangelZustaendig")
+ behobeneMaengel Mangel[] @relation("MangelBehebung")
+
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -249,11 +270,11 @@ model Rolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
- schluessel RolleSchluessel
- name String @db.VarChar(100)
- beschreibung String? @db.VarChar(500)
+ schluessel RolleSchluessel
+ name String @db.VarChar(100)
+ beschreibung String? @db.VarChar(500)
/// Systemrollen dürfen nicht gelöscht werden.
- systemRolle Boolean @default(false)
+ systemRolle Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
@@ -275,14 +296,14 @@ model PersonRolle {
personId String @db.Uuid
rolleId String @db.Uuid
- zugewiesenAm DateTime @default(now()) @db.Timestamptz(3)
- zugewiesenVonId String? @db.Uuid
+ zugewiesenAm DateTime @default(now()) @db.Timestamptz(3)
+ zugewiesenVonId String? @db.Uuid
/// Befristete Zuweisung, z. B. Vertretung während Urlaub.
- gueltigBis DateTime? @db.Timestamptz(3)
+ gueltigBis DateTime? @db.Timestamptz(3)
/// true, wenn die Rolle aus einer authentik-Gruppe vorbelegt wurde. Sobald
/// jemand sie in FLORIAN ändert, steht hier false und OIDC überschreibt nicht
/// mehr.
- ausOidcGruppe Boolean @default(false)
+ ausOidcGruppe Boolean @default(false)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("PersonRollenInhaber", fields: [personId], references: [id], onDelete: Cascade)
@@ -304,11 +325,11 @@ model PersonRolle {
/// trägt organisationId. Die Tabelle ist deshalb für alle lesbar und für die
/// Anwendungsrolle nicht beschreibbar.
model Berechtigung {
- id String @id @default(uuid()) @db.Uuid
- schluessel String @unique @db.VarChar(80)
+ id String @id @default(uuid()) @db.Uuid
+ schluessel String @unique @db.VarChar(80)
/// Fachbereich für die Gruppierung in der Oberfläche.
- bereich String @db.VarChar(60)
- bezeichnung String @db.VarChar(160)
+ bereich String @db.VarChar(60)
+ bezeichnung String @db.VarChar(160)
beschreibung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
@@ -350,17 +371,17 @@ model AusweisToken {
typ AusweisTokenTyp
/// bcrypt-Hash der PIN bzw. der RFID-UID.
- tokenHash String @db.VarChar(120)
+ tokenHash String @db.VarChar(120)
/// Letzte vier Stellen der Karten-UID, damit man Karten unterscheiden kann.
- kennzeichen String? @db.VarChar(20)
- bezeichnung String? @db.VarChar(120)
+ kennzeichen String? @db.VarChar(20)
+ bezeichnung String? @db.VarChar(120)
- aktiv Boolean @default(true)
- gueltigAb DateTime @default(now()) @db.Timestamptz(3)
- gueltigBis DateTime? @db.Timestamptz(3)
- letzteNutzung DateTime? @db.Timestamptz(3)
- fehlversuche Int @default(0)
- gesperrtBis DateTime? @db.Timestamptz(3)
+ aktiv Boolean @default(true)
+ gueltigAb DateTime @default(now()) @db.Timestamptz(3)
+ gueltigBis DateTime? @db.Timestamptz(3)
+ letzteNutzung DateTime? @db.Timestamptz(3)
+ fehlversuche Int @default(0)
+ gesperrtBis DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
@@ -387,19 +408,19 @@ model AuditLog {
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
- akteurTyp AkteurTyp @default(PERSON)
- akteurPersonId String? @db.Uuid
+ akteurTyp AkteurTyp @default(PERSON)
+ akteurPersonId String? @db.Uuid
/// Klarname zum Zeitpunkt der Aktion — bleibt lesbar, auch wenn die Person
/// später anonymisiert wird.
- akteurName String? @db.VarChar(200)
+ akteurName String? @db.VarChar(200)
anmeldeArt AnmeldeArt?
- aktion AuditAktion
+ aktion AuditAktion
/// Modellname, z. B. "Geraet".
- entitaet String @db.VarChar(80)
- entitaetId String? @db.Uuid
+ entitaet String @db.VarChar(80)
+ entitaetId String? @db.Uuid
/// Menschenlesbare Kurzfassung für die Oberfläche.
- beschreibung String? @db.VarChar(500)
+ beschreibung String? @db.VarChar(500)
vorher Json? @db.JsonB
nachher Json? @db.JsonB
@@ -428,15 +449,15 @@ model Datei {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
- bucket String @db.VarChar(80)
+ bucket String @db.VarChar(80)
/// Objektschlüssel in MinIO, z. B. "ff-musterhausen/geraet//foto.jpg".
objektSchluessel String @db.VarChar(500)
- dateiname String @db.VarChar(300)
- mimeTyp String @db.VarChar(160)
+ dateiname String @db.VarChar(300)
+ mimeTyp String @db.VarChar(160)
groesseBytes BigInt
/// SHA-256 des Inhalts. Erkennt Dubletten und belegt Unverändertheit.
- pruefsumme String? @db.VarChar(64)
+ pruefsumme String? @db.VarChar(64)
/// Woran die Datei hängt (z. B. "Geraet") und an welchem Datensatz.
entitaet String? @db.VarChar(80)
@@ -444,15 +465,22 @@ model Datei {
/// Freie Einordnung: "pruefprotokoll", "foto", "rechnung", "export".
kategorie String? @db.VarChar(60)
- hochgeladenVonId String? @db.Uuid
- erstelltAm DateTime @default(now()) @db.Timestamptz(3)
- geaendertAm DateTime @updatedAt @db.Timestamptz(3)
+ hochgeladenVonId String? @db.Uuid
+ erstelltAm DateTime @default(now()) @db.Timestamptz(3)
+ geaendertAm DateTime @updatedAt @db.Timestamptz(3)
/// Soft-Delete: das Objekt in MinIO räumt erst der Aufbewahrungs-Job ab.
geloeschtAm DateTime? @db.Timestamptz(3)
- organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
- hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
- alsAvatarGenutzt Person[] @relation("PersonAvatar")
+ organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
+ 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])
@@ -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")
+}
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 48bcb1a..20ca180 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -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) {
+ const karte = new Map();
+
+ 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,
+) {
+ 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,
+) {
+ 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 {
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:");
diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts
index fb8206b..eec1371 100644
--- a/scripts/screenshots.ts
+++ b/scripts/screenshots.ts
@@ -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 {
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 {
.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;
diff --git a/src/app/(app)/geraete/[id]/page.tsx b/src/app/(app)/geraete/[id]/page.tsx
index d30591a..010dd2a 100644
--- a/src/app/(app)/geraete/[id]/page.tsx
+++ b/src/app/(app)/geraete/[id]/page.tsx
@@ -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,14 +147,29 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
+
+
+ Anstehende Prüfungen
+
+ Offene Termine aus den Wartungsvorlagen. Die früheste Frist steht oben in den
+ Angaben.
+
+
+
+ {termine.length === 0 ? (
+
+ Kein offener Termin. Termine entstehen nachts aus den Wartungsvorlagen des
+ Gerätetyps.
+
+ ) : (
+
+ {termine.map((termin) => (
+
+ {termin.vorlage?.name ?? "Prüfung ohne Vorlage"}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ Offene Mängel
+
+
+ Was an diesem Gerät noch behoben werden muss.
+
+
+
+ {maengel.length === 0 ? (
+
+ Stand der Meldungen:{" "}
+ {Object.entries(kennzahlen.data?.nachStatus ?? {})
+ .map(
+ ([status, anzahl]) =>
+ `${MANGEL_STATUS_TEXT[status as MangelStatus] ?? status}: ${anzahl}`,
+ )
+ .join(" · ")}
+
+
+ );
+}
diff --git a/src/app/(app)/maengel/page.tsx b/src/app/(app)/maengel/page.tsx
new file mode 100644
index 0000000..8da5d08
--- /dev/null
+++ b/src/app/(app)/maengel/page.tsx
@@ -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 (
+
+
+
Mängel
+
+ Von der Meldung bis zur Behebung — mit Foto, Zuständigkeit und Gerätesperre.
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/[id]/aktionen.tsx b/src/app/(app)/pruefungen/[id]/aktionen.tsx
new file mode 100644
index 0000000..9142b69
--- /dev/null
+++ b/src/app/(app)/pruefungen/[id]/aktionen.tsx
@@ -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(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 (
+
+ Stand: {alsDatum(dashboard.data?.stand ?? new Date())}. Termine entstehen nachts aus den
+ Wartungsvorlagen; überfällig wird jeden Tag neu gerechnet, nicht gespeichert.
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/lebensdauer/lebensdauer-liste.tsx b/src/app/(app)/pruefungen/lebensdauer/lebensdauer-liste.tsx
new file mode 100644
index 0000000..a312e1e
--- /dev/null
+++ b/src/app/(app)/pruefungen/lebensdauer/lebensdauer-liste.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/lebensdauer/page.tsx b/src/app/(app)/pruefungen/lebensdauer/page.tsx
new file mode 100644
index 0000000..88241ad
--- /dev/null
+++ b/src/app/(app)/pruefungen/lebensdauer/page.tsx
@@ -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 (
+
+
+
+
Lebensdauer
+
+ Geräte am Ende ihrer zulässigen Nutzungsdauer — unabhängig vom Zustand.
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/neu/page.tsx b/src/app/(app)/pruefungen/neu/page.tsx
new file mode 100644
index 0000000..07e2bb9
--- /dev/null
+++ b/src/app/(app)/pruefungen/neu/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/neu/pruef-wizard.tsx b/src/app/(app)/pruefungen/neu/pruef-wizard.tsx
new file mode 100644
index 0000000..3895579
--- /dev/null
+++ b/src/app/(app)/pruefungen/neu/pruef-wizard.tsx
@@ -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(null);
+ const [schritt, setSchritt] = React.useState(0);
+ const [antworten, setAntworten] = React.useState>({});
+ const [fehler, setFehler] = React.useState(null);
+
+ // Abschlussangaben
+ const [ergebnis, setErgebnis] = React.useState(null);
+ const [bemerkung, setBemerkung] = React.useState("");
+ const [maengel, setMaengel] = React.useState([]);
+ const [signaturDateiId, setSignaturDateiId] = React.useState(null);
+ const [art, setArt] = React.useState(PruefungArt.INTERN);
+ const [externeFirma, setExterneFirma] = React.useState("");
+ const [kosten, setKosten] = React.useState("");
+ const [fremdprotokollId, setFremdprotokollId] = React.useState(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 = {};
+ 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) {
+ 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 (
+
+ Mängel
+
+ Was aufgefallen ist und noch behoben werden muss. Kritische Mängel sperren das
+ Gerät.
+
+
+
+
+
+
+ {maengel.length === 0 && (
+
+ Kein Mangel erfasst.
+
+ )}
+
+ {maengel.map((mangel, index) => (
+
+
+ ))}
+
+
+
+
+
+ Unterschrift
+
+ Ohne Unterschrift ist ein Protokoll kein Nachweis. Mit dem Abschluss wird es
+ festgeschrieben — Korrekturen erzeugen danach ein neues Protokoll.
+
+
+
+
+
+
+
+ )}
+
+ {/* Steuerung */}
+
+
+
+ {!amAbschluss ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/page.tsx b/src/app/(app)/pruefungen/page.tsx
new file mode 100644
index 0000000..25aebb2
--- /dev/null
+++ b/src/app/(app)/pruefungen/page.tsx
@@ -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 (
+
+
+
+
Prüfungen und Fristen
+
+ Was ansteht, was überfällig ist und was demnächst aus der Nutzungsdauer läuft.
+
+ ))}
+
+
+
+
+ {darfBearbeiten && (
+
+
+ Prüfpunkt ergänzen
+
+ Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung" — gerechnet
+ wird das auf dem Server, nicht im Browser.
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/vorlagen/[id]/page.tsx b/src/app/(app)/pruefungen/vorlagen/[id]/page.tsx
new file mode 100644
index 0000000..1d78895
--- /dev/null
+++ b/src/app/(app)/pruefungen/vorlagen/[id]/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/pruefungen/vorlagen/page.tsx b/src/app/(app)/pruefungen/vorlagen/page.tsx
new file mode 100644
index 0000000..2aa28ac
--- /dev/null
+++ b/src/app/(app)/pruefungen/vorlagen/page.tsx
@@ -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 (
+
+
+
+
Wartungsvorlagen
+
+ Was an einem Gerätetyp regelmäßig zu prüfen ist — Intervall, Vorschrift und Checkliste.
+
+
+ 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.
+
+
+ );
+}
diff --git a/src/app/(app)/rueckrufe/[id]/page.tsx b/src/app/(app)/rueckrufe/[id]/page.tsx
new file mode 100644
index 0000000..57f6927
--- /dev/null
+++ b/src/app/(app)/rueckrufe/[id]/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/rueckrufe/page.tsx b/src/app/(app)/rueckrufe/page.tsx
new file mode 100644
index 0000000..97b95b0
--- /dev/null
+++ b/src/app/(app)/rueckrufe/page.tsx
@@ -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 (
+
+
+
Rückrufe
+
+ Herstellerwarnungen, betroffene Geräte und die Maßnahmen dazu.
+
+
+ {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
+
+ )}
+
+ {fehler && }
+
+ );
+}
diff --git a/src/components/wartung/unterschrift.tsx b/src/components/wartung/unterschrift.tsx
new file mode 100644
index 0000000..994a181
--- /dev/null
+++ b/src/components/wartung/unterschrift.tsx
@@ -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(null);
+ const zeichnet = React.useRef(false);
+ const [leer, setLeer] = React.useState(true);
+ const [laeuft, setLaeuft] = React.useState(false);
+ const [fehler, setFehler] = React.useState(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) {
+ const canvas = canvasRef.current!;
+ const rahmen = canvas.getBoundingClientRect();
+ return { x: ereignis.clientX - rahmen.left, y: ereignis.clientY - rahmen.top };
+ }
+
+ function beginnen(ereignis: React.PointerEvent) {
+ 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) {
+ 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) {
+ 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 (
+
+
+
+
+
+ {name}
+ {rolle && · {rolle}}
+
+
+
+
+
+
+
+ {fehler && }
+
+ );
+}
diff --git a/src/lib/env.ts b/src/lib/env.ts
index c80c922..cc40b35 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -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),
diff --git a/src/lib/hochladen.ts b/src/lib/hochladen.ts
new file mode 100644
index 0000000..296636e
--- /dev/null
+++ b/src/lib/hochladen.ts
@@ -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 {
+ 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 {
+ 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");
+ });
+}
diff --git a/src/lib/stammdaten/fristen.ts b/src/lib/stammdaten/fristen.ts
index f38f1f6..9311896 100644
--- a/src/lib/stammdaten/fristen.ts
+++ b/src/lib/stammdaten/fristen.ts
@@ -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);
}
diff --git a/src/lib/wartung/bewertung.ts b/src/lib/wartung/bewertung.ts
new file mode 100644
index 0000000..61cf78f
--- /dev/null
+++ b/src/lib/wartung/bewertung.ts
@@ -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 = {
+ 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;
+}
diff --git a/src/lib/wartung/fristen.ts b/src/lib/wartung/fristen.ts
new file mode 100644
index 0000000..eb6361c
--- /dev/null
+++ b/src/lib/wartung/fristen.ts
@@ -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 = {
+ 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]) };
+}
diff --git a/src/lib/wartung/planung.ts b/src/lib/wartung/planung.ts
new file mode 100644
index 0000000..014ea17
--- /dev/null
+++ b/src/lib/wartung/planung.ts
@@ -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,
+ );
+}
diff --git a/src/lib/wartung/protokoll-erzeugen.ts b/src/lib/wartung/protokoll-erzeugen.ts
new file mode 100644
index 0000000..e6d385e
--- /dev/null
+++ b/src/lib/wartung/protokoll-erzeugen.ts
@@ -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 = {
+ PRUEFER: "Prüfer",
+ GERAETEWART: "Gerätewart",
+ FREMDFIRMA: "Fachfirma",
+};
+
+const PRIORITAET_TEXT: Record = {
+ 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();
+ 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 };
+ });
+}
diff --git a/src/lib/wartung/protokoll.ts b/src/lib/wartung/protokoll.ts
new file mode 100644
index 0000000..c65b55b
--- /dev/null
+++ b/src/lib/wartung/protokoll.ts
@@ -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 = {
+ BESTANDEN: "#15803d",
+ BESTANDEN_MIT_MAENGELN: "#b45309",
+ NICHT_BESTANDEN: "#b91c1c",
+};
+
+function zeile(bezeichnung: string, wert: string | null | undefined): string {
+ return `
+
+ 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.
+
+
+`;
+}
+
+/** Kopfzeile des PDF — sie wiederholt sich auf jeder Seite. */
+export function kopfzeileHtml(daten: ProtokollDaten): string {
+ return `
+