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 + +![Fristen-Dashboard](screenshots/11-fristen-light.png) + +Die Seite, die der Gerätewart morgens aufmacht. Sie beantwortet eine Frage: +**was ist zu tun.** + +Ganz oben stehen vier Zahlen, nach Dringlichkeit geordnet: + +| Topf | Bedeutung | +|---|---| +| **überfällig** | hätte längst geprüft sein müssen | +| **diese Woche** | in den nächsten sieben Tagen | +| **diesen Monat** | in den nächsten 30 Tagen | +| **nächste 90 Tage** | mittelfristig zu planen | + +Jede Zahl ist gleichzeitig ein Filter: ein Klick, und die Liste darunter zeigt +nur noch diesen Zeitraum. Nochmal klicken hebt den Filter wieder auf. + +„Überfällig" wird jeden Tag neu gerechnet und nicht gespeichert. Ein Termin, +der heute Nacht fällig geworden ist, steht morgen früh im richtigen Topf, ohne +dass jemand etwas anstoßen muss. + +### Verteilung + +Darunter steht, **wo** die offenen Termine hängen — wahlweise nach Fahrzeug, +Standort, Gerätetyp oder Zuständigem. Wer einen Prüftag ansetzt, arbeitet ein +Fahrzeug ab und keine alphabetische Liste; deshalb ist Fahrzeug die Vorgabe. + +### Termine zuweisen + +In der Terminliste lassen sich mehrere Zeilen ankreuzen und in einem Zug jemandem +zuweisen. Der Zuständige bekommt die Erinnerungen; ist niemand zuständig, gehen +sie an die Gerätewarte der Wehr. + +### Erinnerungen + +Nachts um 3:45 Uhr verschickt FLORIAN eine **Sammelnachricht je Empfänger** — +nicht eine Mail je Gerät. Erinnert wird bei 30 Tagen (oder der Vorwarnzeit der +Vorlage), bei 7 Tagen, am Fälligkeitstag und einmal, wenn ein Termin überfällig +geworden ist. Jede Stufe genau einmal. + +Solange `MAIL_ENABLED=false` steht, landen die Nachrichten im Protokoll statt +im Postfach. + +--- + +## 12. Wartungsvorlagen + +![Wartungsvorlagen](screenshots/12-vorlagen-light.png) + +Eine Wartungsvorlage sagt, **was** an einem Gerätetyp **in welchem Takt** +geprüft wird. Sie ist die Regel; der Termin ist ihre Anwendung auf ein einzelnes +Gerät. + +**Ohne Vorlage gibt es keine Termine.** Das ist die erste Seite, die eine Wehr +in diesem Modul ausfüllt. + +| Angabe | Wofür | +|---|---| +| **Gerätetyp** | für welche Bauart die Vorlage gilt. Ohne Gerätetyp plant der nächtliche Lauf nichts. | +| **Intervall in Monaten** | zählt ab der letzten durchgeführten Prüfung | +| **Vorwarnzeit in Tagen** | wann die erste Erinnerung rausgeht | +| **Prüfvorschrift** | steht später im Protokoll und in der Fußzeile des PDF | +| **Übliche Dauer** | Erfahrungswert für die Planung eines Prüftags | + +### Die Checkliste + +![Checkliste einer Vorlage](screenshots/17-checkliste-light.png) + +Jeder Punkt hat eine von vier Antwortarten: + +| Art | Wann | +|---|---| +| **Ja / Nein** | Sichtprüfungen: „ohne Risse", „Kennzeichnung lesbar" | +| **Messwert** | alles Gemessene, mit zulässigem Bereich und Einheit | +| **Text** | Befunde, die sich nicht ankreuzen lassen | +| **Foto** | wenn der Zustand belegt werden soll | + +Bei einem Messwert entscheidet der zulässige Bereich, ob der Punkt in Ordnung +ist. Gerechnet wird das auf dem Server, nicht im Browser. + +**Pflichtpunkt heißt: davon hängt die Einsatzbereitschaft ab.** Ein +Pflichtpunkt, der durchfällt, bedeutet „nicht bestanden" und sperrt das Gerät. +Ein Kann-Punkt, der durchfällt, bedeutet „bestanden mit Mängeln". Wer einen +Punkt als Pflicht anlegt, trifft also eine fachliche Entscheidung. + +Die Reihenfolge zählt: der Prüfwizard arbeitet die Punkte genau so ab, wie sie +hier stehen. Umsortiert wird mit den Pfeiltasten — das geht auch mit Handschuhen +und mit der Tastatur. + +--- + +## 13. Eine Prüfung durchführen + +Gestartet wird sie entweder im Fristen-Dashboard über **Prüfen** oder auf der +Gerätedetailseite über **Prüfung durchführen**. Wer das Gerät am Regal +gescannt hat, ist schon auf der richtigen Seite. + +Der Wizard zeigt **einen Prüfpunkt je Bildschirm**. Oben läuft die Zeit mit; +sie landet im Protokoll und gehört zum Nachweis, wie gründlich geprüft wurde. + +Zu jedem Punkt lassen sich zusätzlich ein Foto und eine Bemerkung erfassen. +Auf dem Tablet öffnet der Fotoknopf unmittelbar die Kamera. + +**Jede Antwort geht sofort zum Server.** Wenn im Gerätehaus das WLAN wegbricht, +ist höchstens die letzte Antwort verloren. Beim erneuten Öffnen wird der +angefangene Durchgang fortgesetzt — es entsteht keine zweite halbe Prüfung. + +### Der Abschluss + +Am Ende steht: + +1. **Ergebnis** — vorgeschlagen wird, was sich aus den Antworten ergibt. + Übersteuern darf der Prüfer; er trägt die Verantwortung, nicht die Software. +2. **Mängel** — aus den beanstandeten Punkten vorgeschlagen, frei ergänzbar. + Ein kritischer Mangel sperrt das Gerät. +3. **Unterschrift** — auf dem Zeichenfeld, mit Finger, Stift oder Maus. Ohne + Unterschrift lässt sich nicht abschließen: ein Protokoll ohne Unterschrift + ist kein Nachweis. + +Wurde die Prüfung **extern** durch eine Fachfirma gemacht, lassen sich hier +Firma, Kosten und das Fremdprotokoll als PDF anhängen. + +Mit dem Abschluss passiert alles auf einmal: die Prüfung wird festgeschrieben, +der Termin gilt als erledigt, der nächste wird geplant, der Gerätestatus zieht +nach, und das PDF entsteht im Hintergrund. + +--- + +## 14. Prüfprotokolle + +![Prüfprotokoll](screenshots/18-pruefprotokoll-light.png) + +Jedes abgeschlossene Protokoll bekommt eine laufende Nummer im Format +`JJJJ-NNNN` — das ist die Nummer, die man bei einer Rückfrage der Unfallkasse +nennt. + +Über **Protokoll als PDF** kommt die Fassung zum Ausdrucken oder Weiterreichen: +Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist und +Befund, die Fotos, die Unterschriften und eine Fußzeile mit Prüfvorschrift und +Prüfdatum. Alles ist im PDF eingebettet — es lädt nichts nach und sieht in zehn +Jahren noch genauso aus. + +### Ein Protokoll lässt sich nicht ändern + +Das ist keine Bequemlichkeitsfrage, sondern der Kern der Sache: ein Nachweis, +den man nachträglich ändern kann, ist keiner. FLORIAN verhindert das in der +Datenbank, nicht bloß in der Oberfläche. + +Wenn etwas falsch ist, legt **Korrektur anlegen** eine neue Prüfung an — mit den +bisherigen Antworten vorbelegt und einem Verweis auf das alte Protokoll. Sobald +sie abgeschlossen ist, gilt das alte als „ersetzt". **Beide bleiben lesbar**, +und im Korrekturprotokoll steht, warum korrigiert wurde. + +--- + +## 15. Mängel + +![Mängel](screenshots/13-maengel-light.png) + +Der Weg ist immer derselbe: + +``` +gemeldet → zugewiesen → in Arbeit → behoben +``` + +**Melden darf jedes Mitglied.** Der Riss im Schlauch fällt dem auf, der ihn in +der Hand hat, nicht dem Gerätewart am Schreibtisch. Zum Melden gehören das +Gerät, eine Beschreibung, eine Dringlichkeit und auf Wunsch ein Foto. + +Ein Mangel der Stufe **kritisch** sperrt das Gerät sofort. Beim Beheben wird +die Sperre wieder aufgehoben — aber nur, wenn kein anderer offener Mangel +dasselbe Gerät sperrt. Ein behobener Kratzer gibt kein Gerät frei, das wegen +eines Risses gesperrt ist. + +Eine Fehlmeldung wird **verworfen**, nicht gelöscht: dass gemeldet wurde und +was, bleibt nachvollziehbar. + +--- + +## 16. Rückrufe + +![Rückrufe](screenshots/14-rueckrufe-light.png) + +Kommt ein Sicherheitshinweis des Herstellers, wird er hier erfasst — mit der +betroffenen Bauart, wahlweise eingegrenzt über Modell, Seriennummernbereich und +Herstellungszeitraum. + +FLORIAN sucht dann im Bestand nach passenden Geräten und zeigt sie als +**Vorschlag**. Zugeordnet wird nur, was der Gerätewart bestätigt — eine +Chargenangabe des Herstellers passt selten eins zu eins auf die eigenen +Seriennummern. + +Zugeordnete Geräte lassen sich in einem Zug sperren. Je Gerät wird +fortgeschrieben, wie es steht: offen, nicht betroffen, Maßnahme läuft, erledigt. +Mit „erledigt" fällt die Sperre wieder — sofern kein Mangel dasselbe Gerät +sperrt. Abschließen lässt sich der Rückruf erst, wenn zu jedem betroffenen Gerät +ein Ergebnis steht. + +--- + +## 17. Lebensdauer + +![Lebensdauer](screenshots/15-lebensdauer-light.png) + +Helme, Seile, Schutzkleidung und Pressluftatmer sind ab einem Stichtag nicht +mehr zugelassen — unabhängig davon, wie gut sie aussehen. Diese Seite zeigt, +was abgelaufen ist und was in den nächsten Monaten abläuft. + +Ein Gerät mit abgelaufener Nutzungsdauer wird nachts automatisch **gesperrt**. +**Ausgesondert** wird es von einem Menschen: das entscheidet, wer weiß, ob +Ersatz da ist. Der Knopf dazu steht in der Zeile. + +Voraussetzung ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist +(Kapitel 5). + +## 18. Rollen und was sie dürfen Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in `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

- {hatRecht(sitzung, "geraet.bearbeiten") && ( - - )} +
+ {hatRecht(sitzung, "pruefung.durchfuehren") && geraet.status !== "AUSGESONDERT" && ( + + )} + + {hatRecht(sitzung, "geraet.bearbeiten") && ( + + )} +
@@ -195,6 +255,121 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
+ {(termine.length > 0 || maengel.length > 0) && ( +
+ + + 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 ? ( +

Kein offener Mangel.

+ ) : ( +
    + {maengel.map((mangel) => ( +
  • +
    + + + + {alsDatum(mangel.gemeldetAm)} + {mangel.melderName && ` · ${mangel.melderName}`} + +
    +

    {mangel.beschreibung}

    +
  • + ))} +
+ )} + +
+
+
+ )} + + + + Prüfhistorie + + Abgeschlossene Prüfungen mit Ergebnis. Das PDF liegt am jeweiligen Protokoll. + + + + {pruefungen.length === 0 ? ( +

+ Zu diesem Gerät ist noch keine Prüfung dokumentiert. +

+ ) : ( +
    + {pruefungen.map((pruefung) => ( +
  • + + {pruefung.protokollNummer ?? "Entwurf"} + + + {alsDatum(pruefung.durchgefuehrtAm)} + + {pruefung.vorlage?.name ?? "—"} + + {pruefung.art === "EXTERN" && ( + extern + )} + {pruefung.prueferName && ( + + — {pruefung.prueferName} + + )} +
  • + ))} +
+ )} +
+
+ Verlauf diff --git a/src/app/(app)/maengel/maengel-liste.tsx b/src/app/(app)/maengel/maengel-liste.tsx new file mode 100644 index 0000000..39dedf0 --- /dev/null +++ b/src/app/(app)/maengel/maengel-liste.tsx @@ -0,0 +1,453 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Check, Play, Plus, Search, X } from "lucide-react"; +import { MangelPrioritaet, MangelStatus } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige"; +import { MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige"; +import { DateiFeld } from "@/components/wartung/datei-feld"; +import { MANGEL_STATUS_TEXT, PRIORITAET_TEXT } from "@/lib/wartung/texte"; +import { alsDatumZeit } from "@/lib/utils"; + +/** + * Mängelliste mit dem vollständigen Weg: melden → zugewiesen → in Arbeit → + * behoben. + * + * Die Schaltflächen zeigen immer nur den nächsten sinnvollen Schritt. Ein + * Formular, in dem man jeden Zustand frei wählen kann, führt dazu, dass Mängel + * auf „behoben" springen, ohne dass jemand daran gearbeitet hat. + */ +export function MaengelListe({ + darfBearbeiten, + darfZuweisen, +}: { + darfBearbeiten: boolean; + darfZuweisen: boolean; +}) { + const [nurOffene, setNurOffene] = React.useState(true); + const [suche, setSuche] = React.useState(""); + const [meldenOffen, setMeldenOffen] = React.useState(false); + const [meldung, setMeldung] = React.useState(null); + + // Formular zum Melden + const [geraetSuche, setGeraetSuche] = React.useState(""); + const [geraetId, setGeraetId] = React.useState(""); + const [beschreibung, setBeschreibung] = React.useState(""); + const [prioritaet, setPrioritaet] = React.useState(MangelPrioritaet.MITTEL); + const [fotoDateiId, setFotoDateiId] = React.useState(null); + + const werkzeuge = trpc.useUtils(); + + const maengel = trpc.mangel.liste.useQuery({ + seite: 1, + seitengroesse: 100, + richtung: "desc", + nurOffene, + suche: suche || undefined, + }); + + const kennzahlen = trpc.mangel.kennzahlen.useQuery(); + + const geraete = trpc.geraet.liste.useQuery( + { seite: 1, seitengroesse: 25, richtung: "asc", suche: geraetSuche || undefined }, + { enabled: meldenOffen }, + ); + + const mitglieder = trpc.person.liste.useQuery( + { seite: 1, seitengroesse: 200, richtung: "asc" }, + { enabled: darfZuweisen }, + ); + + const aktualisieren = () => { + void werkzeuge.mangel.invalidate(); + void werkzeuge.geraet.invalidate(); + }; + + const melden = trpc.mangel.melden.useMutation({ + onSuccess: () => { + setMeldenOffen(false); + setGeraetId(""); + setBeschreibung(""); + setFotoDateiId(null); + setPrioritaet(MangelPrioritaet.MITTEL); + aktualisieren(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const zuweisen = trpc.mangel.zuweisen.useMutation({ + onSuccess: aktualisieren, + onError: (fehler) => setMeldung(fehler.message), + }); + + const inArbeit = trpc.mangel.inArbeit.useMutation({ + onSuccess: aktualisieren, + onError: (fehler) => setMeldung(fehler.message), + }); + + const beheben = trpc.mangel.beheben.useMutation({ + onSuccess: (ergebnis) => { + setMeldung( + ergebnis.geraetFreigegeben + ? "Mangel behoben — das Gerät ist wieder einsatzbereit." + : "Mangel behoben.", + ); + aktualisieren(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const verwerfen = trpc.mangel.verwerfen.useMutation({ + onSuccess: aktualisieren, + onError: (fehler) => setMeldung(fehler.message), + }); + + const eintraege = maengel.data?.eintraege ?? []; + + return ( +
+
+
+ + setSuche(ereignis.target.value)} + placeholder="Beschreibung oder Inventarnummer …" + className="pl-8" + aria-label="Mängel durchsuchen" + /> +
+ + + + +
+ + {kennzahlen.data && ( +

+ {kennzahlen.data.offen} offen + {kennzahlen.data.kritisch > 0 && ( + + {" "} + · {kennzahlen.data.kritisch} kritisch + + )} +

+ )} + + {meldung && } + {maengel.error && } + +
+ + + + Gerät + Mangel + Dringlichkeit + Stand + Zuständig + + + + + {maengel.isLoading && } + + {eintraege.map((mangel) => ( + + + + {mangel.geraet.inventarnummer} + +
+ {mangel.geraet.bezeichnung} +
+ {mangel.geraetGesperrt && ( +
+ wegen dieses Mangels gesperrt +
+ )} +
+ + +

{mangel.beschreibung}

+

+ gemeldet {alsDatumZeit(mangel.gemeldetAm)} + {mangel.melderName && ` von ${mangel.melderName}`} + {mangel.pruefung?.protokollNummer && + ` · aus Protokoll ${mangel.pruefung.protokollNummer}`} +

+ {mangel.behebung && ( +

+ Behebung: {mangel.behebung} +

+ )} +
+ + + + + + + + + + + {darfZuweisen ? ( + + ) : mangel.zugewiesenAn ? ( + `${mangel.zugewiesenAn.nachname}, ${mangel.zugewiesenAn.vorname}` + ) : ( + "—" + )} + + + + {darfBearbeiten && + mangel.status !== MangelStatus.BEHOBEN && + mangel.status !== MangelStatus.VERWORFEN && ( +
+ {mangel.status !== MangelStatus.IN_ARBEIT && ( + + )} + + + + +
+ )} +
+
+ ))} +
+
+ + {!maengel.isLoading && eintraege.length === 0 && ( +
+ setMeldenOffen(true)}> + + Mangel melden + + } + /> +
+ )} +
+ + + +
{ + ereignis.preventDefault(); + setMeldung(null); + melden.mutate({ + geraetId, + beschreibung, + prioritaet, + fotoDateiId, + }); + }} + className="space-y-4" + > + + Mangel melden + + Ein kritischer Mangel sperrt das Gerät sofort — ohne Rückfrage. + + + +
+ + setGeraetSuche(ereignis.target.value)} + placeholder="Inventarnummer oder Bezeichnung" + /> + + +
+ +
+ +