diff --git a/.env.example b/.env.example index b772b7b..a753c20 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,21 @@ KIOSK_SPERRDAUER=300 # Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden. KIOSK_IDLE_TIMEOUT=120 +# ----------------------------------------------------------------------------- +# Wetterwarnungen auf dem Hallenmonitor (optional, DWD) +# Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut — +# deshalb standardmaessig aus. Ohne WETTER_ENABLED=true wird nichts abgerufen. +# ----------------------------------------------------------------------------- +WETTER_ENABLED=false +# Warnzellen-Nummer der eigenen Gemeinde aus der DWD-Warnzellenliste: +# https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv +DWD_WARNCELL= +DWD_WARNUNGEN_URL=https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json +# Zeitlimit fuer den Abruf, in Sekunden. Der Monitor darf davon nicht haengen. +WETTER_TIMEOUT=5 +# Wie lange eine Antwort gilt, bevor neu gefragt wird, in Sekunden. +WETTER_ZWISCHENSPEICHER=300 + # ----------------------------------------------------------------------------- # E-Mail-Versand (optional, hinter lib/integrations/mail.ts abstrahiert) # Ohne MAIL_ENABLED=true wird nur ins Log geschrieben — praktisch für Tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac9d4d..b8354ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,135 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). ## [Unveröffentlicht] -Nächster Schritt ist Phase 4 (Kiosk-PWA mit Offline-Betrieb). +Nächster Schritt ist Phase 5 (Atemschutzverwaltung nach FwDV 7). + +--- + +## [0.5.0] — 2026-09-03 + +Phase 4: das Terminal im Gerätehaus. Ein Wandtablet, das mit Handschuhen +bedienbar ist und weiterarbeitet, wenn das Netz weg ist. + +### Hinzugefügt + +**Datenmodell** + +- `Fahrt` — Fahrtenbuch je Fahrzeug: Fahrer, Zweck, Ziel, Kilometerstand bei + Abfahrt und Rückkehr, getankte Menge. Eine Fahrt ohne Rückkehr ist die + laufende Fahrt. +- `Anwesenheit` — wer im Gerätehaus ist, mit Anlass (Dienst, Übung, Einsatz, + Arbeitsdienst) und offener Abmeldung, solange jemand da ist. +- `Waescheabgabe` — Schutzkleidung in der Reinigung, mit Grund und Stand von + „abgegeben" bis „zurück". +- `Inventurgang` und `Inventurposten` — der Gerätewart geht mit dem Tablet durch + die Halle und scannt; am Ende steht die Liste dessen, was fehlt. +- `KioskVorgang` — jeder vom Terminal übermittelte Vorgang, **mit der im + Browser erzeugten Kennung als Primärschlüssel**. Das ist der Kern des + Offline-Betriebs: kommt derselbe Vorgang nach einem abgebrochenen + Sendeversuch ein zweites Mal an, wird er wiedererkannt statt neu angelegt. +- `SyncKonflikt` — was sich beim Nachtragen nicht anwenden ließ. Nichts geht + verloren, es wartet auf eine Entscheidung. + +**Terminaloberfläche unter `/kiosk`** + +- Kachelschirm: sieben Kacheln, jede mindestens 160 Pixel hoch, jede mit Symbol + und Text, sichtbar nach Rechten. Keine Navigationsleiste. +- Anmeldung auf drei Wegen: Ausweis auflegen (der RFID-Leser tippt die Kennung + als Tastatureingabe, erkannt am Anschlagstempo), QR-Code vom Handy oder + Mitgliedsnummer samt PIN. +- Mangel melden in drei Schritten — Gerät scannen, Foto und ein Satz dazu, + Dringlichkeit antippen. Anspruch war „unter dreißig Sekunden". +- Kommen und Gehen mit zwei Tipps, Fahrtenbuch, Wäscheabgabe und Inventurgang. +- Abmeldeuhr: nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit meldet sich das + Terminal von selbst ab, mit sichtbarer Warnung vorher. +- Hallenmonitor unter `/kiosk/monitor`: Uhrzeit, laufender Einsatz, wer im Haus + ist, Fahrzeuge, fällige Prüfungen und die nächsten Termine — Vollbild ohne + Bedienung. + +**Offline-Betrieb** + +- Ein von Hand geschriebener Service Worker (`public/sw.js`): App-Shell und + Stammdaten im Zwischenspeicher, `network-first` für Seiten mit + Ausweichseite `/kiosk/offline`, `stale-while-revalidate` für lesende + tRPC-Aufrufe. Schreibende Anfragen und alles unter `/api/auth` fasst er nicht + an — eine zwischengespeicherte Anmeldung wäre eine Sicherheitslücke. +- Warteschlange in einer IndexedDB (Dexie): **jede** schreibende Aktion geht + zuerst dorthin, auch online. Sonst gäbe es zwei Wege durch den Code, und der + seltenere wäre der ungetestete. +- Fotos liegen als Blob in derselben Warteschlange und werden beim Übertragen + zuerst hochgeladen. +- Sichtbarer Zustand als Leiste über allem: verbunden, wie viele Änderungen + warten, kein Netz — in Klartext, nicht nur als Farbe. +- Erledigt heißt: angenommen **oder** als bereits verarbeitet erkannt **oder** + in der Konfliktliste gelandet. In allen drei Fällen ist der Vorgang beim + Server und darf aus der Warteschlange verschwinden. + +**Anmeldemarke** + +- `/anmeldemarke` zeigt einen QR-Code fürs Handy: signiert mit `AUTH_SECRET`, + gültig 60 Sekunden, nicht gespeichert. Ein abfotografierter Code aus der + Umkleide nützt nach einer Minute nichts mehr. + +**Terminalübersicht in der Verwaltung** + +- `/terminal` zeigt Konflikte zuerst — das sind die Vorgänge, die ein Mensch + anfassen muss —, danach Anwesende, Wäsche und Fahrtenbuch. + +**Installierbar als App** + +- Web-App-Manifest, eigene Symbole (`npm run icons`), Startseite `/kiosk`. Kein + externes CDN, keine fremde Schriftart. + +**Wetterwarnungen (optional, aus)** + +- Warnungen des DWD auf dem Hallenmonitor, hinter einer Schnittstelle in + `lib/integrations/`. Standardmäßig **aus** — es ist die einzige Verbindung, + die FLORIAN von sich aus nach außen aufbaut. Fällt sie aus, bleibt die Kachel + leer. + +**Sonstiges** + +- Der Seed legt ein Fahrtenbuch mit vier Fahrten (eine läuft noch), vier + Anwesenheiten (eine Person ist noch im Haus) und drei Wäscheabgaben an. +- 31 neue Tests: 20 Unit-Tests zu Anmeldemarke und Warteschlange (letztere + unter jsdom mit `fake-indexeddb`), 11 gegen eine echte Datenbank + (Kilometerstände, Anwesenheitszeiten, Unveränderbarkeit der Vorgänge, + Mandantentrennung) und 9 Playwright-Durchläufe je Browser — darunter der + Offline-Fall mit `context.setOffline(true)` und der doppelt gesendete + Vorgang, dessen Antwort unterwegs verloren geht. + +### Geändert + +- Die Datenbank setzt vier neue Regeln selbst durch: Kilometerstände laufen + vorwärts, eine Rückkehr liegt nicht vor der Abfahrt, ein Gehen nicht vor dem + Kommen, eine Wäscheabgabe umfasst mindestens ein Stück. Dazu `REVOKE UPDATE, + DELETE` auf `kiosk_vorgang` — ein übermittelter Vorgang lässt sich nicht + nachträglich glätten. +- Die Beschriftung der DWD-Warnstufen liegt in + `src/lib/integrations/wetter-texte.ts`, getrennt vom `server-only`-Modul + daneben — dieselbe Trennung wie bei `qr.ts` und `qr-parser.ts`. +- Neue Rechte `fahrtenbuch.lesen` und `fahrtenbuch.fuehren`, beide im + Grundrechtebündel. + +### Behoben + +- Die Warteschlange rief sich selbst in eine Renderschleife: `jetztSenden` + hatte das Mutationsobjekt von React Query in seinen Abhängigkeiten, und das + ist bei jedem Rendern ein neues. Damit wurde der 20-Sekunden-Zeitgeber + ständig neu aufgezogen und lief nie ab — ein liegen gebliebener Vorgang wäre + ohne Zutun nie ein zweites Mal losgeschickt worden. Gefunden vom + Offline-E2E-Test, der genau darauf wartet. +- `/kiosk/einrichten` ließ sich nicht vorab erzeugen, weil `useSearchParams()` + ohne Suspense-Grenze stand. Der Produktionsbuild brach dabei ab. +- Der Anwesenheits- und der Fahrten-Wizard zeigten erst die eine, nach dem + Laden des eigenen Stands die andere Fassung — wer zügig tippte, drückte ins + Leere. Bis der Stand bekannt ist, steht dort jetzt ein Platzhalter. +- Die Offline-Seite blieb stehen, wenn nur eine einzelne Anfrage abgebrochen + war. Am Hallenmonitor, den niemand bedient, hieße das: bis jemand vorbeikommt. + Sie versucht es jetzt alle zehn Sekunden von selbst. +- Der Hallenmonitor stapelte alle laufenden Einsätze übereinander und schob + damit Anwesenheit, Fahrzeuge und Prüfungen aus dem Bild. Zwei stehen groß, + der Rest als Zeile. --- diff --git a/README.md b/README.md index 136ab4c..25a85ac 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 1 — Stammdaten | fertig | | Phase 2 — Wartung und Prüfung | fertig | | Phase 3 — Einsätze und Übungen | fertig | -| Phase 4 — Kiosk-PWA | offen | +| Phase 4 — Kiosk-PWA im Gerätehaus | fertig | | Phase 5 — Atemschutz (FwDV 7) | offen | | Phase 6 — Kartenmodule | offen | | Phase 7 — Benachrichtigungen und Termine | offen | @@ -402,6 +402,131 @@ nachgetragen werden. | `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde | +--- + +## Phase 4 — Das Terminal im Gerätehaus + +Ein Wandtablet neben der Fahrzeughalle. Es muss zwei Dinge können, die eine +Verwaltungsoberfläche am Schreibtisch nicht können muss: **mit Handschuhen +bedienbar** sein und **weiterarbeiten, wenn das Netz weg ist**. Stahlbeton, +Metalltore und ein Repeater, der irgendwann ausfällt — das ist der Normalfall, +nicht die Ausnahme. + +Die Oberfläche liegt unter `/kiosk` und ist eine eigene: große Kacheln statt +Navigationsleiste, Schaltflächen ab 64 Pixel Höhe, Symbol **und** Text. + +### Der Weg durch das Modul + +1. **Einmal einrichten** — `/kiosk/einrichten` verlangt den Kurznamen der Wehr + und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`). Beides nimmt + die Seite auch aus der Adresse an, damit am Tablet nichts abgetippt werden + muss: + + ``` + /kiosk/einrichten?wehr=ff-musterhausen&schluessel=… + ``` + +2. **Anmelden** — Ausweis auflegen, QR-Code vom Handy oder Mitgliedsnummer und + PIN. Ohne eingerichtetes Gerät kommt niemand hinein, auch nicht mit richtiger + PIN: das Gerätegeheimnis ist die erste Schranke. +3. **Erfassen** — Mangel melden, Kommen/Gehen, Fahrt beginnen und beenden, + Wäsche abgeben, Inventurgang. +4. **Von selbst abmelden** — nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit, + mit sichtbarer Warnung vorher. Ein Terminal, an dem die letzte Anmeldung + stehen bleibt, erfasst irgendwann alles auf den Falschen. + +Was dabei zusammenkommt, steht in der Verwaltung unter `/terminal`. + +### Mangel melden in unter dreißig Sekunden + +Der Vorgang, an dem sich das Terminal misst. Drei Schritte, kein Formular: + +1. QR-Code am Gerät scannen — oder die Inventarnummer eintippen. +2. Foto aufnehmen und einen Satz dazu. +3. Dringlichkeit antippen. Damit ist es abgeschickt. + +„Kritisch" sperrt das Gerät sofort. Eine unbekannte Inventarnummer hält die +Meldung **nicht** auf: sie geht raus und landet notfalls in der Konfliktliste — +lieber ein Zettel zu viel als ein Riss im Schlauch, den niemand gemeldet hat. + +### Offline: wie nichts verloren geht + +Drei Bausteine, und keiner davon ist optional: + +**Service Worker** (`public/sw.js`, von Hand geschrieben, keine Bibliothek): +App-Shell und Symbole liegen im Zwischenspeicher, Seitenaufrufe gehen +`network-first` mit Ausweichseite `/kiosk/offline`, lesende tRPC-Aufrufe +`stale-while-revalidate`. Schreibende Anfragen und alles unter `/api/auth` +fasst er **nicht** an — eine zwischengespeicherte Anmeldung wäre eine Lücke, +kein Komfort. + +**Warteschlange in der IndexedDB** (`src/lib/kiosk/outbox.ts`, Dexie): jede +schreibende Aktion geht zuerst dorthin — auch online. Sonst gäbe es zwei Wege +durch den Code, und der seltenere wäre der ungetestete. Fotos liegen als Blob +daneben und werden beim Übertragen zuerst hochgeladen. + +**Idempotenzschlüssel**: die Kennung erzeugt der Browser beim Einreihen +(`crypto.randomUUID()`), nicht der Server beim Empfangen. Bricht die +Übertragung ab und wird wiederholt, erkennt der Server denselben Vorgang an +derselben Kennung wieder und antwortet mit dem Ergebnis von damals. Die Tabelle +`kiosk_vorgang` hat diese Kennung als Primärschlüssel, und die Datenbank +verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf. + +Ein Vorgang verschwindet aus der Warteschlange, wenn der Server ihn angenommen +**oder** als bereits verarbeitet erkannt **oder** in die Konfliktliste gelegt +hat. In allen drei Fällen ist er angekommen. Liegen bleibt nur, was technisch +nicht durchging. + +Der Zustand steht als Leiste über allem — verbunden, wie viele Änderungen +warten, kein Netz — in Klartext und nicht nur als Farbe. + +**Wem ein Vorgang zugeschrieben wird.** Der Server nimmt dafür die Person, die +beim Empfang angemeldet ist. Im Regelfall ist das dieselbe, die erfasst hat — +wer offline erfasst, bleibt angemeldet, weil auch das Abmelden Netz braucht. +Damit dazwischen kein Fenster bleibt, überträgt das Terminal vor jedem +Abmelden — von Hand wie durch die Abmeldeuhr — noch einmal. + +### Konflikte + +Ein Vorgang, der sich nicht anwenden lässt (Gerät zwischenzeitlich ausgesondert, +Prüfung inzwischen festgeschrieben), wird nicht verworfen und nicht erzwungen, +sondern in `sync_konflikt` gelegt. Der Gerätewart sieht ihn unter `/terminal` +und entscheidet. Das ist der ehrliche Umgang mit einem Tablet, das drei Tage +offline war. + +### Anmeldemarke fürs Handy + +Wer den Ausweis vergessen hat, öffnet `/anmeldemarke` und hält den QR-Code vor +die Kamera des Terminals. Die Marke ist mit `AUTH_SECRET` signiert, **nicht +gespeichert**, und gilt 60 Sekunden — ein abfotografierter Code aus der Umkleide +nützt danach nichts mehr. Die Seite erneuert ihn von selbst. + +### Hallenmonitor + +`/kiosk/monitor` läuft im Vollbild ohne Bedienung: Uhrzeit, laufender Einsatz, +wer im Haus ist, Fahrzeuge samt laufenden Fahrten, fällige Prüfungen und die +nächsten Termine. Optional Wetterwarnungen des DWD — die einzige Verbindung, +die FLORIAN von sich aus nach außen aufbaut, und deshalb standardmäßig aus. + +### Als App installieren + +Das Manifest liegt unter `/manifest.webmanifest`, die Symbole entstehen mit +`npm run icons` und liegen unter `public/icons/`. Startseite ist `/kiosk`. Im +Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile. + +### Zusätzliche Umgebungsvariablen + +| Variable | Vorgabe | Bedeutung | +|---|---|---| +| `KIOSK_ENABLED` | `true` | Terminalanmeldung freischalten | +| `KIOSK_DEVICE_SECRET` | — | Geheimnis, mit dem sich ein Tablet ausweist | +| `KIOSK_IDLE_TIMEOUT` | `120` | Abmeldung nach Untätigkeit, in Sekunden | +| `WETTER_ENABLED` | `false` | Wetterwarnungen auf dem Monitor | +| `DWD_WARNCELL` | — | Warnzellen-Nummer der eigenen Gemeinde | +| `DWD_WARNUNGEN_URL` | DWD-Warnapp | Quelle der Warnungen | +| `WETTER_TIMEOUT` | `5` | Zeitlimit des Abrufs, in Sekunden | +| `WETTER_ZWISCHENSPEICHER` | `300` | Gültigkeit einer Antwort, in Sekunden | + --- ## Befehle diff --git a/docker-compose.yml b/docker-compose.yml index d5dc326..90a38ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,11 @@ x-app-env: &app-env KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5} KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300} KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120} + WETTER_ENABLED: ${WETTER_ENABLED:-false} + DWD_WARNCELL: ${DWD_WARNCELL:-} + DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json} + WETTER_TIMEOUT: ${WETTER_TIMEOUT:-5} + WETTER_ZWISCHENSPEICHER: ${WETTER_ZWISCHENSPEICHER:-300} MAIL_ENABLED: ${MAIL_ENABLED:-false} MAIL_HOST: ${MAIL_HOST:-} MAIL_PORT: ${MAIL_PORT:-587} diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index ad51313..91b1d22 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,10 +1,10 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 3 (Version 0.4.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 4 (Version 0.5.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. -> Atemschutz, Karten und die Kiosk-Oberfläche kommen in den folgenden Phasen -> dazu. Siehe [ROADMAP.md](ROADMAP.md). +> Atemschutz und Karten kommen in den folgenden Phasen dazu. Siehe +> [ROADMAP.md](ROADMAP.md). --- @@ -603,7 +603,7 @@ Bericht — wird später nachgetragen. Der Einsatz bekommt sofort eine fortlaufende Nummer im Format `JJJJ-NNNN`. **Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr -einen eigenen Nominatim betreibt (Kapitel 24). Ohne den bleibt das Feld leer — +einen eigenen Nominatim betreibt (Kapitel 30). Ohne den bleibt das Feld leer — das ist in Ordnung, die Karte kommt in Phase 6. --- @@ -758,7 +758,194 @@ der in der Jahreshauptversammlung gefragt wird und die bei Beförderungen zählt --- -## 24. Adressen zu Koordinaten (Nominatim) +## 24. Das Terminal im Gerätehaus + +![Kachelschirm des Terminals](screenshots/25-kiosk-kacheln-light.png) + +Das Terminal ist eine **eigene Oberfläche** unter `/kiosk`, gedacht für ein +Wandtablet neben der Fahrzeughalle. Große Kacheln statt Seitenleiste, alles +Bedienbare mindestens 64 Pixel hoch, jede Kachel mit Symbol **und** Text — das +ist kein Geschmack, sondern die Voraussetzung dafür, dass es mit +Feuerwehrhandschuhen bedienbar bleibt. + +### Einmal je Tablet einrichten + +Bevor sich jemand anmelden kann, muss das Tablet wissen, zu welcher Wehr es +gehört. Das macht der Administrator einmal unter `/kiosk/einrichten`: Kurzname +der Wehr und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`). + +Damit am Tablet nichts abgetippt werden muss, nimmt die Seite beides auch aus +der Adresse an: + +``` +http://florian.example/kiosk/einrichten?wehr=ff-musterhausen&schluessel=… +``` + +Diesen Link schickt sich der Administrator einmal selbst — etwa als QR-Code vom +eigenen Rechner — und ruft ihn am Tablet auf. + +> **Das Gerätegeheimnis ist die erste Schranke.** Ohne eingerichtetes Tablet +> nimmt der Server keine PIN und keine Karte an. Ein fremdes Handy, das +> `/kiosk` aufruft, kommt nicht weiter, auch nicht mit richtiger PIN. + +### Anmelden + +![Anmeldung am Terminal](screenshots/30-kiosk-anmeldung-light.png) + +Drei Wege, in dieser Reihenfolge: + +1. **Ausweis auflegen** — der Regelfall. Der Leser am Tablet gibt die + Kartenkennung wie eine Tastatur aus; es muss vorher nichts angetippt werden. +2. **QR-Code vom Handy** — für den, der den Ausweis vergessen hat (Kapitel 29). +3. **Mitgliedsnummer und PIN** — wenn beides fehlt. + +Nach `KIOSK_IDLE_TIMEOUT` Sekunden ohne Bedienung (Vorgabe: 120) meldet sich das +Terminal von selbst ab, mit sichtbarer Warnung vorher. Ein Terminal, an dem die +letzte Anmeldung stehen bleibt, schreibt irgendwann alles dem Falschen zu. + +### Was auf dem Kachelschirm steht + +Die Kacheln richten sich nach zwei Dingen: den Rechten der angemeldeten Person +und ihrem aktuellen Zustand. + +| Kachel | Wann sie sich ändert | +|---|---| +| Kommen / **Gehen** | Wer eingecheckt ist, sieht „Gehen" samt Zeit, seit wann | +| Fahrt beginnen / **Fahrt beenden** | Wer mit einem Fahrzeug unterwegs ist, sieht dessen Funkrufname | +| Wäsche abgeben | Zeigt an, wie viele Stücke noch nicht zurück sind | +| Inventur | Nur für den Gerätewart | +| Prüfung durchführen | Ohne Netz gesperrt, mit dem Hinweis „braucht Netz" | + +--- + +## 25. Mangel melden am Terminal + +![Mangel melden](screenshots/26-kiosk-mangel-light.png) + +Der Vorgang, an dem sich das Terminal misst — Anspruch war **unter dreißig +Sekunden**. Deshalb drei Schritte und kein Formular: + +1. **QR-Code am Gerät scannen.** Oder die Inventarnummer eintippen, wenn das + Etikett unleserlich ist. Steht die Nummer im Bestand, erscheint sofort + Bezeichnung und Zustand. +2. **Foto und ein Satz dazu.** Das Foto ist kein Pflichtfeld, macht aber die + halbe Arbeit: „Riss im Schlauch, etwa 20 cm von der Kupplung" plus Bild sagt + dem Gerätewart mehr als jedes Formular. +3. **Dringlichkeit antippen.** Damit ist die Meldung abgeschickt. + +| Dringlichkeit | Was passiert | +|---|---| +| Kleinigkeit | kann warten | +| Normal | beim nächsten Dienst | +| Dringend | vor dem nächsten Einsatz | +| **Kritisch** | Das Gerät wird **sofort gesperrt** | + +Eine unbekannte Inventarnummer hält die Meldung nicht auf. Sie geht trotzdem +raus und landet notfalls in der Konfliktliste (Kapitel 27) — lieber ein Zettel +zu viel als ein Riss im Schlauch, den niemand gemeldet hat. + +--- + +## 26. Ohne Netz weiterarbeiten + +In einer Fahrzeughalle ist das Netz regelmäßig weg: Stahlbeton, Tore aus +Metall, ein Repeater, der irgendwann ausfällt. Das Terminal ist dafür gebaut, +nicht dagegen abgesichert. + +Über jeder Terminalseite läuft eine Leiste, die den Zustand in Klartext sagt: + +| Leiste | Bedeutung | +|---|---| +| **Verbunden · alles übertragen** | Alles, was erfasst wurde, ist beim Server | +| **Verbunden · 2 Änderungen warten** | Wird gerade übertragen | +| **Kein Netz · 1 Änderung wartet** | Erfasst und gespeichert, wartet auf Netz | + +Und so funktioniert es: **jede** erfasste Aktion geht zuerst in eine +Warteschlange im Tablet — auch dann, wenn Netz da ist. Erst von dort geht sie +zum Server. Deshalb ist der Ablauf ohne Netz derselbe wie mit Netz, nur dauert +er länger. + +Was das im Betrieb bedeutet: + +- **Die Bestätigung kommt sofort.** Wer eine Meldung abschickt, kann das Tablet + weglegen. Es ist gespeichert, auch wenn es noch nicht übertragen ist. +- **Ein Neustart des Tablets schadet nicht.** Die Warteschlange liegt im + Speicher des Browsers, nicht im Arbeitsspeicher. +- **Fotos warten mit.** Das Bild liegt neben der Meldung und wird beim + Übertragen zuerst hochgeladen. +- **Nichts wird doppelt gebucht.** Jeder Vorgang bekommt beim Erfassen eine + eigene Kennung. Bricht die Übertragung mitten drin ab und wird wiederholt, + erkennt der Server dieselbe Kennung wieder und bucht nicht erneut. +- **Die Prüfung braucht Netz.** Ein Prüfprotokoll mit Checkliste, Messwerten, + Fotos und Unterschrift lässt sich nicht sinnvoll zwischenspeichern — ein halb + erfasstes Protokoll wäre schlechter als keins. Die Kachel sagt das, statt ins + Leere zu führen. + +Bleibt ein Vorgang mehr als fünf Versuche liegen, lässt er sich über „anzeigen" +einsehen und im Notfall verwerfen. Vorher nicht — und das ist Absicht. + +--- + +## 27. Konflikte nachbearbeiten + +![Terminalübersicht](screenshots/28-terminal-uebersicht-light.png) + +Ein Tablet, das drei Tage offline war, schickt irgendwann Vorgänge, deren Ziel +es nicht mehr gibt: das Gerät wurde ausgesondert, die Prüfung inzwischen +abgeschlossen und damit festgeschrieben. + +FLORIAN erzwingt in diesem Fall nichts und verwirft nichts. Der Vorgang landet +als **Konflikt** in der Übersicht unter `/terminal` — mit Zeitpunkt, Art, +Grund und der Person, die ihn erfasst hat. Der Gerätewart entscheidet und +vermerkt, wie es gelöst wurde. + +Auf derselben Seite steht das Übrige, was am Terminal zusammenkommt: wer im +Haus ist, welche Wäsche in der Reinigung ist und das Fahrtenbuch. + +--- + +## 28. Hallenmonitor + +![Hallenmonitor](screenshots/27-kiosk-monitor-light.png) + +`/kiosk/monitor` ist für einen Fernseher in der Fahrzeughalle gedacht: Vollbild, +große Schrift, **keine Bedienung**. Zu sehen sind Uhrzeit und Datum, ein +laufender Einsatz, wer gerade im Haus ist, die Fahrzeuge samt laufenden Fahrten, +fällige Prüfungen und die nächsten Termine. + +Optional dazu **Wetterwarnungen des DWD**: + +```bash +# .env +WETTER_ENABLED=true +DWD_WARNCELL=809162000 +``` + +> Das ist die einzige Verbindung, die FLORIAN von sich aus nach außen aufbaut, +> und sie ist deshalb standardmäßig aus. Nach draußen geht nur die +> Warnzellen-Nummer, keine Daten der Wehr. Fällt der Abruf aus, bleibt die +> Kachel leer — eine Wetterwarnung ist eine Zugabe, alarmiert wird über die +> Leitstelle. + +Die Nummer der eigenen Gemeinde steht in der +[Warnzellenliste des DWD](https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv). + +--- + +## 29. Anmeldemarke fürs Handy + +![Anmeldemarke](screenshots/29-anmeldemarke-light.png) + +Wer den Ausweis vergessen hat, öffnet in FLORIAN auf dem Handy die Seite +**Anmeldemarke** und hält den QR-Code vor die Kamera des Terminals. + +Der Code gilt **60 Sekunden** und wird von der Seite selbst erneuert. Er ist +signiert und nirgends gespeichert: ein abfotografierter Code aus der Umkleide +nützt eine Minute später nichts mehr. + +--- + +## 30. Adressen zu Koordinaten (Nominatim) FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim betreibt: @@ -778,7 +965,7 @@ GEOCODING_LAENDER=de,at,ch Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand eintragen oder weglassen. -## 25. Rollen und was sie dürfen +## 31. 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 @@ -807,7 +994,7 @@ Das Terminal selbst ist nur die Tür. --- -## 26. Anmeldung über authentik einrichten +## 32. Anmeldung über authentik einrichten authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks. @@ -860,9 +1047,10 @@ ganze Wehr aus. --- -## 27. Terminal-Anmeldung im Gerätehaus +## 33. PIN und Ausweiskarte verwalten -Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits. +Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum, +was dahinter liegt. Am Terminal wird zweifach geprüft: @@ -882,7 +1070,7 @@ Huber), PIN `471203`. --- -## 28. Was protokolliert wird +## 34. Was protokolliert wird Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person, betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich. @@ -907,7 +1095,7 @@ jeweiligen Detailseite sichtbar. --- -## 29. Wenn etwas nicht geht +## 35. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| @@ -922,6 +1110,11 @@ jeweiligen Detailseite sichtbar. | Umlaute im Import verstümmelt | Die Datei ist weder UTF-8 noch Windows-1252 | In Excel als „CSV UTF-8" speichern | | Gerät lässt sich nicht speichern | Standort **und** Fahrzeug gesetzt, oder keines von beidem | Genau eines von beiden angeben | | `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben | +| Terminal zeigt „Terminal nicht eingerichtet" | Das Tablet kennt keine Wehr, oder der Browserspeicher wurde geleert | `/kiosk/einrichten` erneut aufrufen | +| Terminal weist jede PIN ab | Gerätegeheimnis stimmt nicht mit `KIOSK_DEVICE_SECRET` überein | Einrichtung mit dem aktuellen Wert wiederholen | +| Die Leiste zeigt dauerhaft wartende Änderungen | Server erreichbar, aber der Vorgang wird abgelehnt | Über „anzeigen" den Grund lesen; landet er als Konflikt, steht er unter `/terminal` | +| „Der Code wurde nicht erkannt" beim QR-Login | Marke älter als 60 Sekunden | Auf dem Handy die Seite Anmeldemarke offen halten, sie erneuert den Code selbst | +| Der Monitor zeigt keine Wetterwarnung | `WETTER_ENABLED` aus oder `DWD_WARNCELL` leer | Beides setzen; ohne Warnzelle wird nichts abgerufen | Zustand aller Randsysteme auf einen Blick: diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index 27001c8..2b272e7 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -192,7 +192,7 @@ Zwei Dinge fielen erst beim Tun auf: ## Phase 4 — Kiosk-PWA -**Vergleichsaufwand: 16–22 PT** +**Vergleichsaufwand: 16–22 PT** · **Tatsächlich: 3:00 h Sitzungszeit** | Baustein | PT | |---|---:| @@ -211,6 +211,42 @@ unter Verbindungsabbruch, Doppelklick und parallelem Zugriff von zwei Terminals nichts verliert und nichts doppelt schreibt, ist die eigentliche Arbeit. Die Idempotenzschlüssel sind dafür der Schlüssel, nicht die Synchronisation. +**Rückblick.** Der Satz oben hat sich bestätigt, nur anders als gedacht. Die +Outbox selbst war in einer halben Stunde geschrieben und mit zwölf Unit-Tests +belegt. Zwei Stellen kosteten die Zeit, und keine davon steht in der +Aufstellung: + +- **Der Zeitgeber, der nie ablief.** Die Warteschlange hatte einen + Wiederholungstakt von 20 Sekunden — und der wurde bei jedem Rendern neu + aufgezogen, weil das Mutationsobjekt von React Query in den Abhängigkeiten + stand. Alles sah richtig aus, alle Unit-Tests waren grün, und der liegen + gebliebene Vorgang wäre im Gerätehaus trotzdem liegen geblieben. Gefunden hat + das nur der Playwright-Test, der `context.setOffline(true)` setzt und dann + wartet. Die 1,5 PT für „Playwright-Tests für den Offline-Fall" waren der + bestangelegte Posten der Phase. +- **Die Frage, wann ein Vorgang aus der Warteschlange darf.** Nicht „wenn + erfolgreich", sondern „wenn er beim Server angekommen ist" — dazu gehört auch + „bereits verarbeitet" und „in der Konfliktliste". Diese Unterscheidung ist die + ganze Idempotenz; ohne sie kreist ein Vorgang für immer. + +Billiger als angesetzt war die Anmeldung per RFID: die Aufstellung nennt WebHID, +gebraucht wurde es nicht. Handelsübliche Leser geben die Kartenkennung als +Tastatureingabe aus. Ein Zuhörer auf dem Fenster, der am Anschlagstempo +erkennt, dass da keine Hand tippt, ersetzt eine ganze Geräteschnittstelle. + +Billiger war auch der Service Worker — 80 Zeilen von Hand, ohne Workbox. Die +Regeln sind einfach, solange man sie sich traut aufzuschreiben: Seiten aus dem +Netz mit Ausweichseite, lesende Abfragen aus dem Zwischenspeicher mit +Nachladen, schreibende **überhaupt nicht** anfassen. Eine Bibliothek hätte hier +mehr Fragen aufgeworfen als beantwortet. + +Teurer war die Terminaloberfläche selbst. Nicht die Kacheln, sondern die +Zustände dahinter: Wer schon eingecheckt ist, sieht „Gehen" statt „Kommen". Wer +mit einem Fahrzeug unterwegs ist, sieht „Fahrt beenden". Wer keine Rechte für +die Inventur hat, sieht die Kachel nicht. Jede dieser Verzweigungen ist +trivial — sechs davon in einem Schirm, der ohne Nachdenken bedienbar bleiben +soll, sind es nicht. + ## Phase 5 — Atemschutz nach FwDV 7 **Vergleichsaufwand: 14–18 PT** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 3db4b3c..778a7cc 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -15,7 +15,105 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 | | Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 | | Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 | -| **Summe** | **7:40 h** | **5** | | +| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 | +| **Summe** | **10:40 h** | **6** | | + +--- + +## 2026-09-03 · Phase 4 — Kiosk-PWA im Gerätehaus + +**14:40 – 17:40 · 3:00 h · Version 0.5.0** + +### Was entstanden ist + +Das Terminal an der Wand. Sieben neue Tabellen, eine eigene Oberfläche unter +`/kiosk`, ein Service Worker und eine Warteschlange, die den Betrieb ohne Netz +trägt. + +| Bereich | Dateien | +|---|---| +| Datenmodell | `Fahrt`, `Anwesenheit`, `Waescheabgabe`, `Inventurgang`, `Inventurposten`, `KioskVorgang`, `SyncKonflikt` | +| Offline | `public/sw.js`, `lib/kiosk/outbox.ts`, `components/kiosk/warteschlange.tsx` | +| Anmeldung | `lib/kiosk/anmeldemarke.ts`, Anbieter `terminal-marke` in `lib/auth.ts`, `components/kiosk/anmeldung.tsx` | +| Oberfläche | `app/kiosk/` mit Kacheln, sechs Wizards, Monitor und Einrichtung | +| Verwaltung | `app/(app)/terminal/`, `app/(app)/anmeldemarke/` | +| Server | `server/routers/kiosk/kiosk.ts`, `lib/integrations/wetter.ts` | +| Tests | `tests/unit/kiosk-marke.test.ts`, `kiosk-outbox.test.ts`, `kiosk-datenbank.test.ts`, `tests/e2e/kiosk.spec.ts` | + +### Entscheidungen + +**Die Warteschlange ist der einzige Weg, auch online.** Es wäre naheliegend, +online direkt zu senden und nur im Fehlerfall zu puffern. Dann gäbe es zwei +Wege durch den Code — und der seltenere wäre der ungetestete, also genau der, +der im Gerätehaus gebraucht wird. Jetzt läuft alles durch dieselbe Bahn, und +der Offline-Fall ist der Normalfall mit längerer Wartezeit. + +**Die Kennung erzeugt der Browser, nicht der Server.** `crypto.randomUUID()` +beim Einreihen. Damit ist sie derselbe Wert über beliebig viele Sendeversuche +hinweg, und der Server kann einen wiederholten Vorgang erkennen, statt ihn +doppelt zu buchen. Die Kennung ist der Primärschlüssel von `kiosk_vorgang`, und +die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf — +sonst ließe sich die Spur nachträglich glätten. + +**Konflikte werden nicht erzwungen und nicht verworfen.** Ein Tablet, das drei +Tage offline war, schickt Vorgänge, deren Ziel es nicht mehr gibt oder das +inzwischen festgeschrieben ist. Beides ist kein Fehler des Anwenders. Der +Vorgang landet in `sync_konflikt`, verschwindet aus der Warteschlange (er ist +angekommen) und wartet unter `/terminal` auf eine Entscheidung. + +**Die Prüfung bleibt online.** Der Wizard aus Phase 2 mit Checkliste, Messwerten, +Fotos und Unterschrift lässt sich nicht sinnvoll in eine Warteschlange legen — +und ein halb erfasstes Protokoll ist schlechter als keins. Die Kachel zeigt +deshalb „braucht Netz" und ist ohne Verbindung gesperrt, statt ins Leere zu +führen. `PRUEFUNG_ERFASSEN` steht trotzdem im Datenmodell: die Atemschutzprüfung +in Phase 5 ist kurz genug dafür. + +**Die Anmeldemarke wird signiert, nicht gespeichert.** HMAC über Person, Wehr +und Zeitstempel mit `AUTH_SECRET`, gültig 60 Sekunden. Keine Tabelle, kein +Aufräumjob, kein zweiter Ort mit einem Geheimnis — und ein abfotografierter +Code aus der Umkleide ist nach einer Minute wertlos. + +**Der Kartenleser hängt am Fenster, nicht an einem Eingabefeld.** Ein RFID-Leser +tippt die Kennung in Millisekunden und schließt mit Enter ab. Wer die Karte +auflegt, soll vorher nichts antippen müssen; unterschieden wird am +Anschlagstempo (60 ms), nicht am Fokus. + +**Wetterwarnungen sind aus.** Es ist die einzige Verbindung, die FLORIAN von +sich aus nach außen aufbaut. Wer sie einschaltet, soll das bewusst tun; ohne +`DWD_WARNCELL` passiert nichts, und bei Ausfall bleibt die Kachel leer statt +den Monitor aufzuhalten. + +### Wo es gehakt hat + +| Problem | Ursache | Lösung | +|---|---|---| +| Die Warteschlange schickte einen liegen gebliebenen Vorgang nie ein zweites Mal | `jetztSenden` hatte das Mutationsobjekt von React Query in den Abhängigkeiten. Das ist bei jedem Rendern ein neues, also änderte sich die Identität der Funktion dauernd, der Effekt lief neu, zog den 20-Sekunden-Zeitgeber neu auf — und der lief nie ab. Dazu wurde der `online`-Zuhörer ständig neu registriert und verpasste das Ereignis manchmal | Mutation, tRPC-Werkzeuge und das „läuft gerade"-Kennzeichen über Referenzen statt über Abhängigkeiten. Damit ist `jetztSenden` stabil | +| Der Produktionsbuild brach bei `/kiosk/einrichten` ab | `useSearchParams()` ohne Suspense-Grenze lässt sich nicht vorab erzeugen | `` um das Formular | +| Der Build brach bei `wetter.ts` ab | Der Hallenmonitor ist eine Client-Komponente und importierte die Warnstufentexte aus einem `server-only`-Modul | Texte nach `lib/integrations/wetter-texte.ts`, dieselbe Trennung wie bei `qr.ts` / `qr-parser.ts` | +| Der Outbox-Test lief ins Leere | `speicherVerfuegbar()` fragt `window`, und die Testsuite läuft in Node | Diese Datei unter jsdom (`@vitest-environment`) mit `fake-indexeddb` | +| Das Einrichtungsformular ließ sich im Test nicht abschicken | Playwright klickte, bevor der Effekt die Felder aus der Adresse gefüllt hatte — das Pflichtfeld war leer | Im Test auf die Feldwerte warten | +| Der Idempotenztest sah in der Verwaltung kein Anmeldeformular | Im selben Browserkontext lief noch die Terminalsitzung, und `/anmelden` leitet Angemeldete weiter | Prüfung in einen eigenen Test mit frischem Kontext | +| `getByText("B 3 Wohnhausbrand")` in den Einsatztests wurde mehrdeutig | Frühere E2E-Läufe haben Einsätze mit ähnlichem Stichwort in der Demowehr angelegt | `exact: true`; die eigentliche Ursache bleibt offen (siehe unten) | + +### Abweichung von der Vorgabe + +**Kein Auto-Logout nach 90 Sekunden.** Die Roadmap nennt 90 Sekunden, die +Konfiguration hatte schon `KIOSK_IDLE_TIMEOUT=120`. Der konfigurierte Wert +gewinnt — 90 Sekunden sind knapp, wenn jemand mitten in einer Mangelmeldung +noch das Foto machen muss. Die Roadmap ist entsprechend berichtigt. + +**`npm run lint` läuft in diesem Projekt nicht.** Es gibt keine +ESLint-Konfiguration; `next lint` fragt interaktiv nach einer. Das ist älter als +diese Phase und in ihr nicht behoben — es würde quer über alle bisherigen +Dateien Befunde erzeugen und gehört in einen eigenen Schritt. + +### Nachweise + +- `npx tsc --noEmit` — ohne Ausgabe +- `npm run test` — 258 Tests grün, davon 19 gegen eine echte Datenbank +- `npx playwright test tests/e2e/kiosk.spec.ts` — 18 Tests grün (Desktop und Tablet) +- `npm run build` — 34 Routen, 103 kB gemeinsames JavaScript +- `npm run db:seed` — Fahrtenbuch, Anwesenheiten und Wäsche angelegt --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 345c6e3..be6f99a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,7 +13,7 @@ Phase 0 ████████████████████ fertig Phase 1 ████████████████████ fertig 0.2.0 Phase 2 ████████████████████ fertig 0.3.0 Phase 3 ████████████████████ fertig 0.4.0 -Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0 +Phase 4 ████████████████████ fertig 0.5.0 Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0 Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0 Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0 @@ -97,21 +97,26 @@ die Unfallkasse standhält. **Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen. -## Phase 4 — Kiosk-PWA · als Nächstes +## Phase 4 — Kiosk-PWA · fertig Für die Wandtablets in der Fahrzeughalle. - Eigenes Layout: große Kacheln, mindestens 64px, mit Handschuhen bedienbar -- Anmeldung per RFID, PIN oder QR vom Handy, Auto-Logout nach 90 Sekunden -- Wizards: Wartung, Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit +- Anmeldung per RFID, PIN oder QR vom Handy, Abmeldung nach Untätigkeit + (`KIOSK_IDLE_TIMEOUT`, Vorgabe 120 Sekunden) +- Wizards: Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit. Die + Prüfung bleibt beim Wizard aus Phase 2 — Checkliste, Fotos und Unterschrift + lassen sich nicht sinnvoll in eine Warteschlange legen; die Kachel sagt + „braucht Netz" - Hallen-Monitor: Vollbild mit Terminen, überfälligen Prüfungen, Fahrzeugstatus, Wetterwarnung -- Offline-Betrieb mit IndexedDB-Outbox und Synchronisation bei Reconnect +- Offline-Betrieb mit IndexedDB-Outbox, Idempotenzschlüssel und + Konfliktliste für alles, was sich beim Nachtragen nicht anwenden lässt **Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist. -## Phase 5 — Atemschutz nach FwDV 7 +## Phase 5 — Atemschutz nach FwDV 7 · als Nächstes Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die diff --git a/docs/screenshots/02-uebersicht-dark.png b/docs/screenshots/02-uebersicht-dark.png index 48a2476..d489684 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 dd93ff9..2a8120a 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 0da96d8..3e89cdb 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 02b2a3a..6c3d84a 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 f144805..28cb389 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 44d80ac..db21696 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 dbf0b93..8349859 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 41e503e..5cc4371 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 9645073..d92bba9 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 99df474..6be581f 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 18af5ac..404ba1c 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 d52fa3f..0c2f6e0 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 5b2ddd0..495a489 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 8a0996a..4ab0548 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 53a406d..a5d98de 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 1dc06d0..27ebe2e 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 ad02bd4..0e68869 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 4db3b08..5eaf6c8 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 index c48abc8..048e2f2 100644 Binary files a/docs/screenshots/11-fristen-dark.png 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 index f721046..6cf471d 100644 Binary files a/docs/screenshots/11-fristen-light.png 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 index acadb0a..6ca9597 100644 Binary files a/docs/screenshots/12-vorlagen-dark.png 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 index ca5e50a..59942a9 100644 Binary files a/docs/screenshots/12-vorlagen-light.png 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 index 0ddd64d..1f39af3 100644 Binary files a/docs/screenshots/13-maengel-dark.png 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 index 41cf942..2e81680 100644 Binary files a/docs/screenshots/13-maengel-light.png 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 index 1468011..01d7af2 100644 Binary files a/docs/screenshots/14-rueckrufe-dark.png 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 index 149b209..548943b 100644 Binary files a/docs/screenshots/14-rueckrufe-light.png 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 index 8545488..456ca1c 100644 Binary files a/docs/screenshots/15-lebensdauer-dark.png 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 index 575bea1..88d3ba8 100644 Binary files a/docs/screenshots/15-lebensdauer-light.png 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 index 054520f..e77bc1b 100644 Binary files a/docs/screenshots/16-protokolle-dark.png 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 index a8d9361..47cae28 100644 Binary files a/docs/screenshots/16-protokolle-light.png 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 index 1d2a3c4..0e54fb2 100644 Binary files a/docs/screenshots/17-checkliste-dark.png 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 index 9bd6147..bc04ff0 100644 Binary files a/docs/screenshots/17-checkliste-light.png 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 index 19b28ff..7ddfc8c 100644 Binary files a/docs/screenshots/18-pruefprotokoll-dark.png 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 index 1c3d35c..31306a8 100644 Binary files a/docs/screenshots/18-pruefprotokoll-light.png and b/docs/screenshots/18-pruefprotokoll-light.png differ diff --git a/docs/screenshots/19-einsaetze-dark.png b/docs/screenshots/19-einsaetze-dark.png index faa6a07..e0edebc 100644 Binary files a/docs/screenshots/19-einsaetze-dark.png and b/docs/screenshots/19-einsaetze-dark.png differ diff --git a/docs/screenshots/19-einsaetze-light.png b/docs/screenshots/19-einsaetze-light.png index d93ee53..675ab2b 100644 Binary files a/docs/screenshots/19-einsaetze-light.png and b/docs/screenshots/19-einsaetze-light.png differ diff --git a/docs/screenshots/20-statistik-dark.png b/docs/screenshots/20-statistik-dark.png index 772e903..c195df7 100644 Binary files a/docs/screenshots/20-statistik-dark.png and b/docs/screenshots/20-statistik-dark.png differ diff --git a/docs/screenshots/20-statistik-light.png b/docs/screenshots/20-statistik-light.png index 27070a6..0edcdce 100644 Binary files a/docs/screenshots/20-statistik-light.png and b/docs/screenshots/20-statistik-light.png differ diff --git a/docs/screenshots/21-uebungen-dark.png b/docs/screenshots/21-uebungen-dark.png index 1f90f58..8c96285 100644 Binary files a/docs/screenshots/21-uebungen-dark.png and b/docs/screenshots/21-uebungen-dark.png differ diff --git a/docs/screenshots/21-uebungen-light.png b/docs/screenshots/21-uebungen-light.png index b7b9a13..73b7793 100644 Binary files a/docs/screenshots/21-uebungen-light.png and b/docs/screenshots/21-uebungen-light.png differ diff --git a/docs/screenshots/22-einsatz-dark.png b/docs/screenshots/22-einsatz-dark.png index 50ec7a1..48da6c6 100644 Binary files a/docs/screenshots/22-einsatz-dark.png and b/docs/screenshots/22-einsatz-dark.png differ diff --git a/docs/screenshots/22-einsatz-light.png b/docs/screenshots/22-einsatz-light.png index 02cdcfa..f8a0639 100644 Binary files a/docs/screenshots/22-einsatz-light.png and b/docs/screenshots/22-einsatz-light.png differ diff --git a/docs/screenshots/23-uebung-dark.png b/docs/screenshots/23-uebung-dark.png index d305033..2805499 100644 Binary files a/docs/screenshots/23-uebung-dark.png and b/docs/screenshots/23-uebung-dark.png differ diff --git a/docs/screenshots/23-uebung-light.png b/docs/screenshots/23-uebung-light.png index 2bc6ad4..a732232 100644 Binary files a/docs/screenshots/23-uebung-light.png and b/docs/screenshots/23-uebung-light.png differ diff --git a/docs/screenshots/24-portal-dark.png b/docs/screenshots/24-portal-dark.png index a6d5aa9..a898c3d 100644 Binary files a/docs/screenshots/24-portal-dark.png and b/docs/screenshots/24-portal-dark.png differ diff --git a/docs/screenshots/24-portal-light.png b/docs/screenshots/24-portal-light.png index 4faf465..a938afe 100644 Binary files a/docs/screenshots/24-portal-light.png and b/docs/screenshots/24-portal-light.png differ diff --git a/docs/screenshots/25-kiosk-kacheln-dark.png b/docs/screenshots/25-kiosk-kacheln-dark.png new file mode 100644 index 0000000..85af0da Binary files /dev/null and b/docs/screenshots/25-kiosk-kacheln-dark.png differ diff --git a/docs/screenshots/25-kiosk-kacheln-light.png b/docs/screenshots/25-kiosk-kacheln-light.png new file mode 100644 index 0000000..3c65fb0 Binary files /dev/null and b/docs/screenshots/25-kiosk-kacheln-light.png differ diff --git a/docs/screenshots/26-kiosk-mangel-dark.png b/docs/screenshots/26-kiosk-mangel-dark.png new file mode 100644 index 0000000..314955c Binary files /dev/null and b/docs/screenshots/26-kiosk-mangel-dark.png differ diff --git a/docs/screenshots/26-kiosk-mangel-light.png b/docs/screenshots/26-kiosk-mangel-light.png new file mode 100644 index 0000000..7a4864d Binary files /dev/null and b/docs/screenshots/26-kiosk-mangel-light.png differ diff --git a/docs/screenshots/27-kiosk-monitor-dark.png b/docs/screenshots/27-kiosk-monitor-dark.png new file mode 100644 index 0000000..7e63750 Binary files /dev/null and b/docs/screenshots/27-kiosk-monitor-dark.png differ diff --git a/docs/screenshots/27-kiosk-monitor-light.png b/docs/screenshots/27-kiosk-monitor-light.png new file mode 100644 index 0000000..9e1da93 Binary files /dev/null and b/docs/screenshots/27-kiosk-monitor-light.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-dark.png b/docs/screenshots/28-terminal-uebersicht-dark.png new file mode 100644 index 0000000..d41640e Binary files /dev/null and b/docs/screenshots/28-terminal-uebersicht-dark.png differ diff --git a/docs/screenshots/28-terminal-uebersicht-light.png b/docs/screenshots/28-terminal-uebersicht-light.png new file mode 100644 index 0000000..044379a Binary files /dev/null and b/docs/screenshots/28-terminal-uebersicht-light.png differ diff --git a/docs/screenshots/29-anmeldemarke-dark.png b/docs/screenshots/29-anmeldemarke-dark.png new file mode 100644 index 0000000..70d8d36 Binary files /dev/null and b/docs/screenshots/29-anmeldemarke-dark.png differ diff --git a/docs/screenshots/29-anmeldemarke-light.png b/docs/screenshots/29-anmeldemarke-light.png new file mode 100644 index 0000000..2f0056d Binary files /dev/null and b/docs/screenshots/29-anmeldemarke-light.png differ diff --git a/docs/screenshots/30-kiosk-anmeldung-dark.png b/docs/screenshots/30-kiosk-anmeldung-dark.png new file mode 100644 index 0000000..931ee17 Binary files /dev/null and b/docs/screenshots/30-kiosk-anmeldung-dark.png differ diff --git a/docs/screenshots/30-kiosk-anmeldung-light.png b/docs/screenshots/30-kiosk-anmeldung-light.png new file mode 100644 index 0000000..92e201e Binary files /dev/null and b/docs/screenshots/30-kiosk-anmeldung-light.png differ diff --git a/package-lock.json b/package-lock.json index d330852..ad6eeb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "florian", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "florian", - "version": "0.1.0", + "version": "0.5.0", "dependencies": { "@prisma/client": "^6.16.2", "@radix-ui/react-avatar": "^1.1.10", @@ -33,6 +33,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "dexie": "^4.4.5", "exceljs": "^4.4.0", "ioredis": "^5.8.1", "lucide-react": "^0.544.0", @@ -64,6 +65,7 @@ "esbuild": "^0.25.10", "eslint": "^9.36.0", "eslint-config-next": "^15.5.4", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "prisma": "^6.16.2", "tailwindcss": "^4.1.13", @@ -6624,6 +6626,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dexie": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.5.tgz", + "integrity": "sha512-wWCHdihT3dmlUSuNhn5mMZDWSpG0suxjAni7YjjiZdzabZcKmy3uNZGZ7AeYYXBoLbpSXX35fikPLkPC44Osiw==", + "license": "Apache-2.0" + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -7529,6 +7537,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", diff --git a/package.json b/package.json index 3784d32..5ec9034 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { @@ -23,7 +23,8 @@ "db:push": "prisma db push", "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", - "screenshots": "tsx scripts/screenshots.ts" + "screenshots": "tsx scripts/screenshots.ts", + "icons": "tsx scripts/icons.ts" }, "dependencies": { "@prisma/client": "^6.16.2", @@ -51,6 +52,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "dexie": "^4.4.5", "exceljs": "^4.4.0", "ioredis": "^5.8.1", "lucide-react": "^0.544.0", @@ -82,6 +84,7 @@ "esbuild": "^0.25.10", "eslint": "^9.36.0", "eslint-config-next": "^15.5.4", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "prisma": "^6.16.2", "tailwindcss": "^4.1.13", diff --git a/playwright.config.ts b/playwright.config.ts index 3252029..973d225 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,7 +13,17 @@ export default defineConfig({ testDir: "./tests/e2e", fullyParallel: true, forbidOnly: Boolean(process.env.CI), - retries: process.env.CI ? 2 : 0, + /** + * Ein Wiederholungsversuch auch lokal. + * + * Der Grund ist derselbe wie bei den großzügigen Zeitlimits unten: "next + * dev" übersetzt jede Route beim ersten Aufruf, und mit vier gleichzeitigen + * Arbeitern kommt dazu die Anmeldung mit bcrypt (12 Runden, wie in + * Produktion). Unter dieser Last läuft gelegentlich ein Test ins Zeitlimit, + * der einzeln zuverlässig grün ist — verstreut über alle Dateien, nicht an + * einer Stelle. Ein echter Fehler fällt zweimal durch und bleibt sichtbar. + */ + retries: process.env.CI ? 2 : 1, /** * Bewusst wenige Worker: "next dev" übersetzt jede Route erst beim ersten * Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange diff --git a/prisma/migrations/20260903000600_kiosk_und_offline/migration.sql b/prisma/migrations/20260903000600_kiosk_und_offline/migration.sql new file mode 100644 index 0000000..d0dbc47 --- /dev/null +++ b/prisma/migrations/20260903000600_kiosk_und_offline/migration.sql @@ -0,0 +1,269 @@ +-- ============================================================================= +-- Phase 4: Tabellen für das Terminal im Gerätehaus und den Offline-Betrieb. +-- +-- Erzeugt mit `prisma migrate dev --create-only` und wie in den Phasen davor +-- um die DROP INDEX-Zeilen bereinigt: Prisma kennt die Trigram- und +-- Geoindizes nicht und würde sie sonst bei jeder Migration löschen wollen. +-- +-- Mandantentrennung, die Sperre gegen doppelte Vorgänge und die Indizes für +-- den Terminalbetrieb stehen in der nachfolgenden Migration. +-- ============================================================================= + +-- CreateEnum +CREATE TYPE "FahrtZweck" AS ENUM ('EINSATZ', 'UEBUNG', 'AUSBILDUNG', 'WERKSTATT', 'BESCHAFFUNG', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "WaescheStatus" AS ENUM ('ABGEGEBEN', 'IN_REINIGUNG', 'ZURUECK', 'VERSCHOLLEN'); + +-- CreateEnum +CREATE TYPE "AnwesenheitAnlass" AS ENUM ('DIENST', 'UEBUNG', 'EINSATZ', 'ARBEITSDIENST', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "InventurStatus" AS ENUM ('LAEUFT', 'ABGESCHLOSSEN', 'ABGEBROCHEN'); + +-- CreateEnum +CREATE TYPE "KioskVorgangArt" AS ENUM ('MANGEL_MELDEN', 'FAHRT_BEGINNEN', 'FAHRT_BEENDEN', 'ANWESENHEIT_KOMMEN', 'ANWESENHEIT_GEHEN', 'WAESCHE_ABGEBEN', 'INVENTUR_SCANNEN', 'PRUEFUNG_ERFASSEN'); + +-- CreateEnum +CREATE TYPE "KonfliktGrund" AS ENUM ('ZIEL_FEHLT', 'ZIEL_GESPERRT', 'DOPPELT', 'VERALTET', 'SONSTIGES'); + +-- CreateTable +CREATE TABLE "fahrt" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "fahrzeugId" UUID NOT NULL, + "fahrerId" UUID NOT NULL, + "zweck" "FahrtZweck" NOT NULL DEFAULT 'SONSTIGES', + "einsatzId" UUID, + "uebungId" UUID, + "abfahrtUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "rueckkehrUm" TIMESTAMPTZ(3), + "kilometerAbfahrt" INTEGER NOT NULL, + "kilometerRueckkehr" INTEGER, + "ziel" VARCHAR(200), + "tankmenge" DECIMAL(8,2), + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "fahrt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "anwesenheit" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "personId" UUID NOT NULL, + "anlass" "AnwesenheitAnlass" NOT NULL DEFAULT 'DIENST', + "angemeldetUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "abgemeldetUm" TIMESTAMPTZ(3), + "einsatzId" UUID, + "uebungId" UUID, + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "anwesenheit_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "waescheabgabe" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "personId" UUID NOT NULL, + "geraetId" UUID, + "bezeichnung" VARCHAR(200) NOT NULL, + "menge" INTEGER NOT NULL DEFAULT 1, + "status" "WaescheStatus" NOT NULL DEFAULT 'ABGEGEBEN', + "grund" VARCHAR(200), + "abgegebenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zurueckAm" TIMESTAMPTZ(3), + "angenommenVonId" UUID, + "bemerkung" VARCHAR(500), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "waescheabgabe_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "inventurgang" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "bezeichnung" VARCHAR(160) NOT NULL, + "status" "InventurStatus" NOT NULL DEFAULT 'LAEUFT', + "standortId" UUID, + "fahrzeugId" UUID, + "begonnenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "abgeschlossenAm" TIMESTAMPTZ(3), + "begonnenVonId" UUID, + "bemerkung" VARCHAR(1000), + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "inventurgang_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "inventurposten" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "inventurId" UUID NOT NULL, + "geraetId" UUID NOT NULL, + "gescanntAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "gefunden" BOOLEAN NOT NULL DEFAULT true, + "vorgefundenAn" VARCHAR(200), + "bemerkung" VARCHAR(500), + "erfasstVonId" UUID, + "erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "inventurposten_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "kiosk_vorgang" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "art" "KioskVorgangArt" NOT NULL, + "personId" UUID, + "erfasstAm" TIMESTAMPTZ(3) NOT NULL, + "empfangenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ergebnisId" UUID, + "nutzlast" JSONB, + "erfolgreich" BOOLEAN NOT NULL DEFAULT true, + "meldung" VARCHAR(500), + + CONSTRAINT "kiosk_vorgang_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sync_konflikt" ( + "id" UUID NOT NULL, + "organisationId" UUID NOT NULL, + "vorgangId" UUID NOT NULL, + "art" "KioskVorgangArt" NOT NULL, + "grund" "KonfliktGrund" NOT NULL DEFAULT 'SONSTIGES', + "beschreibung" VARCHAR(500) NOT NULL, + "nutzlast" JSONB, + "personId" UUID, + "entstandenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "erledigtAm" TIMESTAMPTZ(3), + "erledigtVonId" UUID, + "erledigung" VARCHAR(500), + + CONSTRAINT "sync_konflikt_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "fahrt_organisationId_fahrzeugId_abfahrtUm_idx" ON "fahrt"("organisationId", "fahrzeugId", "abfahrtUm" DESC); + +-- CreateIndex +CREATE INDEX "fahrt_organisationId_fahrerId_idx" ON "fahrt"("organisationId", "fahrerId"); + +-- CreateIndex +CREATE INDEX "anwesenheit_organisationId_personId_angemeldetUm_idx" ON "anwesenheit"("organisationId", "personId", "angemeldetUm" DESC); + +-- CreateIndex +CREATE INDEX "anwesenheit_organisationId_abgemeldetUm_idx" ON "anwesenheit"("organisationId", "abgemeldetUm"); + +-- CreateIndex +CREATE INDEX "waescheabgabe_organisationId_status_idx" ON "waescheabgabe"("organisationId", "status"); + +-- CreateIndex +CREATE INDEX "waescheabgabe_organisationId_personId_idx" ON "waescheabgabe"("organisationId", "personId"); + +-- CreateIndex +CREATE INDEX "inventurgang_organisationId_status_idx" ON "inventurgang"("organisationId", "status"); + +-- CreateIndex +CREATE INDEX "inventurposten_organisationId_inventurId_idx" ON "inventurposten"("organisationId", "inventurId"); + +-- CreateIndex +CREATE UNIQUE INDEX "inventurposten_inventurId_geraetId_key" ON "inventurposten"("inventurId", "geraetId"); + +-- CreateIndex +CREATE INDEX "kiosk_vorgang_organisationId_empfangenAm_idx" ON "kiosk_vorgang"("organisationId", "empfangenAm" DESC); + +-- CreateIndex +CREATE INDEX "kiosk_vorgang_organisationId_art_idx" ON "kiosk_vorgang"("organisationId", "art"); + +-- CreateIndex +CREATE INDEX "sync_konflikt_organisationId_erledigtAm_idx" ON "sync_konflikt"("organisationId", "erledigtAm"); + +-- AddForeignKey +ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrerId_fkey" FOREIGN KEY ("fahrerId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_angenommenVonId_fkey" FOREIGN KEY ("angenommenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_begonnenVonId_fkey" FOREIGN KEY ("begonnenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_inventurId_fkey" FOREIGN KEY ("inventurId") REFERENCES "inventurgang"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_erledigtVonId_fkey" FOREIGN KEY ("erledigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260903000700_kiosk_rls/migration.sql b/prisma/migrations/20260903000700_kiosk_rls/migration.sql new file mode 100644 index 0000000..3d9c808 --- /dev/null +++ b/prisma/migrations/20260903000700_kiosk_rls/migration.sql @@ -0,0 +1,110 @@ +-- ============================================================================= +-- Phase 4: Mandantentrennung und Regeln für den Terminalbetrieb. +-- +-- Drei Dinge: +-- +-- 1. Row Level Security für die sieben neuen Tabellen. +-- +-- 2. Regeln, die verhindern, dass ein offline erfasster Vorgang beim +-- Nachtragen Unsinn anrichtet: kein Kilometerstand, der rückwärts läuft, +-- keine Rückkehr vor der Abfahrt, keine Anwesenheit, die vor dem Kommen +-- endet. +-- +-- 3. Indizes für die Abfragen, die das Terminal und der Hallenmonitor +-- dauernd stellen: wer ist gerade da, welches Fahrzeug ist unterwegs. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Row Level Security +-- ----------------------------------------------------------------------------- +DO $$ +DECLARE + tabelle text; +BEGIN + FOREACH tabelle IN ARRAY ARRAY[ + 'fahrt', + 'anwesenheit', + 'waescheabgabe', + 'inventurgang', + 'inventurposten', + 'kiosk_vorgang', + 'sync_konflikt' + ] + 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; +$$; + +-- ----------------------------------------------------------------------------- +-- Was am Terminal nicht passieren darf +-- +-- Diese Bedingungen fangen ab, was beim Eintippen auf einem Tablet mit +-- Handschuhen tatsächlich passiert: eine vertippte Ziffer im Kilometerstand, +-- ein Rückkehrzeitpunkt vor der Abfahrt. Im Gerätehaus schaut niemand ein +-- zweites Mal hin, und offline erfasste Vorgänge kommen unter Umständen erst +-- Stunden später an. +-- ----------------------------------------------------------------------------- +ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_kilometer_vorwaerts" CHECK ( + "kilometerRueckkehr" IS NULL OR "kilometerRueckkehr" >= "kilometerAbfahrt" +); + +ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_rueckkehr_nach_abfahrt" CHECK ( + "rueckkehrUm" IS NULL OR "rueckkehrUm" >= "abfahrtUm" +); + +ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_gehen_nach_kommen" CHECK ( + "abgemeldetUm" IS NULL OR "abgemeldetUm" >= "angemeldetUm" +); + +ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_menge_positiv" CHECK ( + "menge" > 0 +); + +-- ----------------------------------------------------------------------------- +-- Indizes für den laufenden Betrieb +-- +-- Der Hallenmonitor fragt im Sekundentakt dasselbe: wer ist da, welches +-- Fahrzeug ist draußen. Beides sind Teilmengen, die klein bleiben, während +-- die Tabellen über Jahre wachsen — also Teilindizes. +-- ----------------------------------------------------------------------------- +CREATE INDEX "anwesenheit_anwesend" ON "anwesenheit" ("organisationId", "angemeldetUm" DESC) + WHERE "abgemeldetUm" IS NULL; + +CREATE INDEX "fahrt_unterwegs" ON "fahrt" ("organisationId", "fahrzeugId", "abfahrtUm" DESC) + WHERE "rueckkehrUm" IS NULL; + +CREATE INDEX "waesche_offen" ON "waescheabgabe" ("organisationId", "personId") + WHERE "status" <> 'ZURUECK'; + +CREATE INDEX "sync_konflikt_offen" ON "sync_konflikt" ("organisationId", "entstandenAm" DESC) + WHERE "erledigtAm" IS NULL; + +-- ----------------------------------------------------------------------------- +-- Rechte für die Anwendungsrolle auf die neuen Tabellen. +-- ----------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app'; + EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app'; + + EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app'; + EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app'; + + -- Ein empfangener Terminalvorgang wird nie geaendert oder geloescht: + -- er ist der Beleg dafuer, dass etwas schon einmal angekommen ist. + -- Ohne diese Sperre koennte ein zweiter Sendeversuch den ersten + -- ueberschreiben und derselbe Vorgang doch zweimal wirken. + EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app'; + ELSE + RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.'; + END IF; +END; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2fea6d5..861fb81 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -190,6 +190,15 @@ model Organisation { uebungen Uebung[] uebungsteilnahmen Uebungsteilnahme[] + // Phase 4 — Terminal im Gerätehaus + fahrten Fahrt[] + anwesenheiten Anwesenheit[] + waescheabgaben Waescheabgabe[] + inventurgaenge Inventurgang[] + inventurposten Inventurposten[] + kioskvorgaenge KioskVorgang[] + syncKonflikte SyncKonflikt[] + @@index([aktiv]) @@map("organisation") } @@ -272,6 +281,17 @@ model Person { geleiteteUebungen Uebung[] @relation("UebungLeitung") uebungsteilnahmen Uebungsteilnahme[] @relation("UebungTeilnahme") + // Phase 4 — Terminal im Gerätehaus + fahrten Fahrt[] @relation("FahrtFahrer") + anwesenheiten Anwesenheit[] @relation("AnwesenheitPerson") + waescheabgaben Waescheabgabe[] @relation("WaescheTraeger") + angenommeneWaesche Waescheabgabe[] @relation("WaescheAnnahme") + geleiteteInventuren Inventurgang[] @relation("InventurLeitung") + inventurposten Inventurposten[] @relation("InventurErfasser") + kioskvorgaenge KioskVorgang[] @relation("KioskVorgangPerson") + verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher") + erledigteKonflikte SyncKonflikt[] @relation("KonfliktErledigung") + /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@ -616,9 +636,10 @@ model Standort { geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) - organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) - fahrzeuge Fahrzeug[] - geraete Geraet[] + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + fahrzeuge Fahrzeug[] + geraete Geraet[] + inventurgaenge Inventurgang[] @@unique([organisationId, name]) @@index([organisationId, aktiv]) @@ -676,6 +697,10 @@ model Fahrzeug { einsaetze Einsatzfahrzeug[] einsatzkraefte Einsatzkraft[] + // Phase 4 + fahrten Fahrt[] + inventurgaenge Inventurgang[] + @@unique([organisationId, funkrufname]) @@unique([organisationId, kennzeichen]) @@index([organisationId, aktiv]) @@ -813,6 +838,10 @@ model Geraet { rueckrufBetroffenheiten RueckrufBetroffenheit[] materialverbrauch Materialverbrauch[] + // Phase 4 + waescheabgaben Waescheabgabe[] + inventurposten Inventurposten[] + @@unique([organisationId, inventarnummer]) @@index([organisationId, status]) @@index([organisationId, naechstePruefung]) @@ -1620,6 +1649,8 @@ model Einsatz { material Materialverbrauch[] lagemeldungen Lagemeldung[] portalzugaenge PortalZugang[] + fahrts Fahrt[] + anwesenheits Anwesenheit[] @@unique([organisationId, einsatznummer]) @@index([organisationId, alarmiertUm(sort: Desc)]) @@ -1822,10 +1853,12 @@ model Uebung { geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) - organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) - leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull) - berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) - teilnahmen Uebungsteilnahme[] + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull) + berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull) + teilnahmen Uebungsteilnahme[] + fahrten Fahrt[] + anwesenheiten Anwesenheit[] @@unique([organisationId, nummer]) @@index([organisationId, datum(sort: Desc)]) @@ -1856,3 +1889,327 @@ model Uebungsteilnahme { @@index([organisationId, personId]) @@map("uebungsteilnahme") } + +// ============================================================================= +// Phase 4 — Terminal im Gerätehaus +// +// Was am Wandtablet in der Fahrzeughalle erfasst wird: +// +// Fahrt das Fahrtenbuch — wer ist wann womit wohin gefahren +// Anwesenheit ein- und auschecken zu Dienst, Übung oder Einsatz +// Waescheabgabe Schutzkleidung zur Reinigung geben und zurückbekommen +// Inventurgang Bestand am Regal scannen, Fehlendes finden +// KioskVorgang Idempotenz: was das Terminal offline erfasst hat +// SyncKonflikt was sich beim Nachtragen nicht auflösen ließ +// +// Die beiden letzten sind der Kern des Offline-Betriebs. Ein Tablet in einer +// Halle aus Stahlbeton hat regelmäßig kein Netz; erfasst wird trotzdem, und +// nachgetragen wird, sobald es wieder geht. Damit ein zweimal abgeschickter +// Vorgang nicht zweimal ankommt, trägt jeder eine im Browser erzeugte Kennung. +// ============================================================================= + +/// Wozu eine Fahrt unternommen wurde. +enum FahrtZweck { + EINSATZ + UEBUNG + AUSBILDUNG + WERKSTATT + BESCHAFFUNG + /// Probefahrt, Überführung, Fahrsicherheitstraining. + SONSTIGES +} + +/// Weg der Schutzkleidung durch die Reinigung. +enum WaescheStatus { + ABGEGEBEN + IN_REINIGUNG + ZURUECK + /// Nicht wieder aufgetaucht — muss ersetzt werden. + VERSCHOLLEN +} + +/// Wozu jemand am Terminal eincheckt. +enum AnwesenheitAnlass { + DIENST + UEBUNG + EINSATZ + ARBEITSDIENST + SONSTIGES +} + +/// Stand eines Inventurgangs. +enum InventurStatus { + LAEUFT + ABGESCHLOSSEN + ABGEBROCHEN +} + +/// Was ein offline erfasster Vorgang bewirken soll. +enum KioskVorgangArt { + MANGEL_MELDEN + FAHRT_BEGINNEN + FAHRT_BEENDEN + ANWESENHEIT_KOMMEN + ANWESENHEIT_GEHEN + WAESCHE_ABGEBEN + INVENTUR_SCANNEN + PRUEFUNG_ERFASSEN +} + +/// Warum sich ein nachgetragener Vorgang nicht anwenden ließ. +enum KonfliktGrund { + /// Der Datensatz, auf den er sich bezieht, gibt es nicht mehr. + ZIEL_FEHLT + /// Der Datensatz wurde zwischenzeitlich festgeschrieben. + ZIEL_GESPERRT + /// Zwei Terminals haben dasselbe erfasst. + DOPPELT + /// Die Angaben passen nicht mehr zum Stand auf dem Server. + VERALTET + SONSTIGES +} + +// ----------------------------------------------------------------------------- + +/// Eine Fahrt im Fahrtenbuch. +/// +/// Nach § 6 StVO-Nachweispflicht und nach der Dienstanweisung der meisten +/// Wehren muss nachvollziehbar sein, wer wann mit welchem Fahrzeug unterwegs +/// war. Am Terminal ist das ein Vorgang von zwanzig Sekunden: Fahrzeug wählen, +/// Kilometerstand eintippen, Zweck antippen. +model Fahrt { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + fahrzeugId String @db.Uuid + fahrerId String @db.Uuid + + zweck FahrtZweck @default(SONSTIGES) + /// Wenn die Fahrt zu einem Einsatz oder einer Übung gehört. + einsatzId String? @db.Uuid + uebungId String? @db.Uuid + + abfahrtUm DateTime @default(now()) @db.Timestamptz(3) + rueckkehrUm DateTime? @db.Timestamptz(3) + + kilometerAbfahrt Int + kilometerRueckkehr Int? + + ziel String? @db.VarChar(200) + /// Getankte Menge in Litern, wenn unterwegs getankt wurde. + tankmenge Decimal? @db.Decimal(8, 2) + 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) + fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict) + fahrer Person @relation("FahrtFahrer", fields: [fahrerId], references: [id], onDelete: Restrict) + einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull) + uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull) + + @@index([organisationId, fahrzeugId, abfahrtUm(sort: Desc)]) + @@index([organisationId, fahrerId]) + @@map("fahrt") +} + +/// Anwesenheit am Gerätehaus. +/// +/// Wer eincheckt, ist da — für die Übungsteilnahme, für den Arbeitsdienst und +/// im Einsatzfall dafür, dass die Einsatzleitung weiß, wer im Haus ist. +model Anwesenheit { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + personId String @db.Uuid + + anlass AnwesenheitAnlass @default(DIENST) + angemeldetUm DateTime @default(now()) @db.Timestamptz(3) + abgemeldetUm DateTime? @db.Timestamptz(3) + + einsatzId String? @db.Uuid + uebungId String? @db.Uuid + 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) + person Person @relation("AnwesenheitPerson", fields: [personId], references: [id], onDelete: Cascade) + einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull) + uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull) + + @@index([organisationId, personId, angemeldetUm(sort: Desc)]) + @@index([organisationId, abgemeldetUm]) + @@map("anwesenheit") +} + +/// Schutzkleidung in der Reinigung. +/// +/// Wer seine Einsatzjacke abgibt, steht ohne da — deshalb hängt an der Abgabe +/// auch die Frage, ob Ersatz vorhanden ist. Erfasst wird am Terminal beim +/// Einwurf in den Wäschekorb. +model Waescheabgabe { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + personId String @db.Uuid + /// Wenn das Kleidungsstück als Gerät erfasst ist. + geraetId String? @db.Uuid + + bezeichnung String @db.VarChar(200) + menge Int @default(1) + status WaescheStatus @default(ABGEGEBEN) + /// Grund: Einsatzverschmutzung, Kontamination, Routine. + grund String? @db.VarChar(200) + + abgegebenAm DateTime @default(now()) @db.Timestamptz(3) + zurueckAm DateTime? @db.Timestamptz(3) + /// Wer die Rückgabe bestätigt hat. + angenommenVonId String? @db.Uuid + + 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) + person Person @relation("WaescheTraeger", fields: [personId], references: [id], onDelete: Cascade) + geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull) + angenommenVon Person? @relation("WaescheAnnahme", fields: [angenommenVonId], references: [id], onDelete: SetNull) + + @@index([organisationId, status]) + @@index([organisationId, personId]) + @@map("waescheabgabe") +} + +/// Ein Inventurgang. +/// +/// Der Gerätewart geht mit dem Tablet durch die Halle und scannt, was da ist. +/// Am Ende steht die Liste dessen, was fehlt — das ist der Zweck der Sache. +model Inventurgang { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + bezeichnung String @db.VarChar(160) + status InventurStatus @default(LAEUFT) + + /// Worauf sich der Gang bezieht — ein Standort oder ein Fahrzeug. + standortId String? @db.Uuid + fahrzeugId String? @db.Uuid + + begonnenAm DateTime @default(now()) @db.Timestamptz(3) + abgeschlossenAm DateTime? @db.Timestamptz(3) + begonnenVonId String? @db.Uuid + bemerkung String? @db.VarChar(1000) + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + geaendertAm DateTime @updatedAt @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull) + fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) + begonnenVon Person? @relation("InventurLeitung", fields: [begonnenVonId], references: [id], onDelete: SetNull) + posten Inventurposten[] + + @@index([organisationId, status]) + @@map("inventurgang") +} + +/// Ein gescanntes Gerät innerhalb eines Inventurgangs. +model Inventurposten { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + inventurId String @db.Uuid + geraetId String @db.Uuid + + gescanntAm DateTime @default(now()) @db.Timestamptz(3) + /// Am erwarteten Ort vorgefunden. + gefunden Boolean @default(true) + /// Wo es tatsächlich lag, wenn es nicht dort war, wo es sein sollte. + vorgefundenAn String? @db.VarChar(200) + bemerkung String? @db.VarChar(500) + erfasstVonId String? @db.Uuid + + erstelltAm DateTime @default(now()) @db.Timestamptz(3) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + inventur Inventurgang @relation(fields: [inventurId], references: [id], onDelete: Cascade) + geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade) + erfasstVon Person? @relation("InventurErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull) + + @@unique([inventurId, geraetId]) + @@index([organisationId, inventurId]) + @@map("inventurposten") +} + +/// Ein vom Terminal übermittelter Vorgang. +/// +/// Der Schlüssel zum Offline-Betrieb: die Kennung erzeugt der Browser, bevor +/// er den Vorgang in seine Warteschlange legt. Kommt derselbe Vorgang nach +/// einem abgebrochenen Sendeversuch ein zweites Mal an, erkennt der Server ihn +/// an dieser Kennung wieder und antwortet mit dem Ergebnis von damals, statt +/// alles noch einmal anzulegen. +/// +/// Deshalb ist die Kennung der Primärschlüssel und wird nicht vergeben, +/// sondern angenommen. +model KioskVorgang { + /// Vom Browser erzeugt (crypto.randomUUID) — nicht vom Server. + id String @id @db.Uuid + organisationId String @db.Uuid + + art KioskVorgangArt + personId String? @db.Uuid + + /// Wann der Vorgang am Terminal erfasst wurde — kann deutlich vor dem + /// Eingang liegen. + erfasstAm DateTime @db.Timestamptz(3) + empfangenAm DateTime @default(now()) @db.Timestamptz(3) + + /// Was der Vorgang bewirkt hat: die Kennung des angelegten Datensatzes. + ergebnisId String? @db.Uuid + /// Die Nutzlast, wie sie das Terminal geschickt hat — für die Fehlersuche + /// und für den Fall, dass ein Vorgang von Hand nachgezogen werden muss. + nutzlast Json? @db.JsonB + + erfolgreich Boolean @default(true) + meldung String? @db.VarChar(500) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person? @relation("KioskVorgangPerson", fields: [personId], references: [id], onDelete: SetNull) + + @@index([organisationId, empfangenAm(sort: Desc)]) + @@index([organisationId, art]) + @@map("kiosk_vorgang") +} + +/// Ein Vorgang, der sich beim Nachtragen nicht anwenden ließ. +/// +/// Die Regel dahinter: bei Stammdaten gewinnt der Server, bei neuen +/// Datensätzen der Client. Was in keine der beiden Schubladen passt — ein +/// Mangel zu einem inzwischen ausgesonderten Gerät, eine Prüfung zu einem +/// festgeschriebenen Protokoll — landet hier und wartet auf einen Menschen. +model SyncKonflikt { + id String @id @default(uuid()) @db.Uuid + organisationId String @db.Uuid + + /// Die Kennung des Vorgangs, der nicht durchging. + vorgangId String @db.Uuid + art KioskVorgangArt + grund KonfliktGrund @default(SONSTIGES) + + beschreibung String @db.VarChar(500) + nutzlast Json? @db.JsonB + + personId String? @db.Uuid + + entstandenAm DateTime @default(now()) @db.Timestamptz(3) + erledigtAm DateTime? @db.Timestamptz(3) + erledigtVonId String? @db.Uuid + erledigung String? @db.VarChar(500) + + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + person Person? @relation("KonfliktVerursacher", fields: [personId], references: [id], onDelete: SetNull) + erledigtVon Person? @relation("KonfliktErledigung", fields: [erledigtVonId], references: [id], onDelete: SetNull) + + @@index([organisationId, erledigtAm]) + @@map("sync_konflikt") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index e60ec5d..115f7cf 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,7 +8,10 @@ * Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus * fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein * Beispielrückruf. Ab Phase 3 dazu: acht Einsätze über das Jahr verteilt, - * sechs Übungen und die Beteiligung der Mannschaft. + * sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein + * Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am + * Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht + * beim ersten Start nicht leer ist. * * 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 @@ -22,12 +25,14 @@ import "dotenv/config"; import { Alarmierungsart, + AnwesenheitAnlass, AusweisTokenTyp, ChecklistenpunktTyp, EinsatzFunktion, EinsatzKategorie, EinsatzStatus, Dienstgrad, + FahrtZweck, FahrzeugArt, GeraeteKategorie, GeraetStatus, @@ -44,6 +49,7 @@ import { SignaturRolle, StandortArt, UebungsArt, + WaescheStatus, } from "@prisma/client"; import bcrypt from "bcryptjs"; @@ -1949,6 +1955,229 @@ async function uebungenAnlegen(organisationId: string) { log(`Übungen: ${UEBUNGEN.length} mit Teilnahmen`); } +// ----------------------------------------------------------------------------- +// Phase 4 — was am Terminal zusammenkommt +// ----------------------------------------------------------------------------- + +/** + * Fahrtenbuch, Anwesenheit und Wäsche. + * + * Alles drei entsteht im Betrieb am Wandtablet. Der Seed legt genug davon an, + * dass die Terminalübersicht beim ersten Start etwas zeigt — darunter bewusst + * je ein offener Fall: eine Fahrt, die noch läuft, jemand, der noch im Haus + * ist, und Kleidung, die noch nicht zurück ist. Das sind die Zustände, an + * denen sich die Ansichten beweisen müssen. + */ +async function terminaldatenAnlegen(organisationId: string) { + const vorhanden = await prisma.fahrt.count({ where: { organisationId } }); + if (vorhanden > 0) { + log(`Terminal: bereits ${vorhanden} Fahrten vorhanden`); + return; + } + + const personen = await prisma.person.findMany({ + where: { organisationId, aktiv: true, geloeschtAm: null }, + select: { id: true }, + orderBy: { mitgliedsnummer: "asc" }, + }); + + const fahrzeuge = await prisma.fahrzeug.findMany({ + where: { organisationId, geloeschtAm: null }, + select: { id: true, kilometerstand: true }, + orderBy: { funkrufname: "asc" }, + }); + + if (personen.length === 0 || fahrzeuge.length === 0) return; + + const jetzt = new Date(); + + function vorStunden(stunden: number): Date { + return new Date(jetzt.getTime() - stunden * 60 * 60 * 1000); + } + + // --- Fahrtenbuch ---------------------------------------------------------- + const FAHRTEN: Array<{ + fahrzeug: number; + fahrer: number; + zweck: FahrtZweck; + ziel: string; + vorStunden: number; + dauer: number; + kilometer: number; + /** Offen heißt: das Fahrzeug ist noch unterwegs. */ + offen?: boolean; + tankmenge?: number; + }> = [ + { + fahrzeug: 0, + fahrer: 2, + zweck: FahrtZweck.WERKSTATT, + ziel: "Fahrzeugbau Huber, Landshut", + vorStunden: 26 * 24, + dauer: 5, + kilometer: 84, + tankmenge: 42.5, + }, + { + fahrzeug: 1, + fahrer: 7, + zweck: FahrtZweck.UEBUNG, + ziel: "Übungsgelände Kiesgrube", + vorStunden: 8 * 24, + dauer: 3, + kilometer: 22, + }, + { + fahrzeug: 2, + fahrer: 5, + zweck: FahrtZweck.SONSTIGES, + ziel: "Materialabholung Kreisbrandinspektion", + vorStunden: 3 * 24, + dauer: 2, + kilometer: 47, + }, + { + fahrzeug: 0, + fahrer: 0, + zweck: FahrtZweck.AUSBILDUNG, + ziel: "Feuerwehrschule Geretsried", + vorStunden: 2, + dauer: 0, + kilometer: 0, + offen: true, + }, + ]; + + // Der Kilometerstand muss über die Fahrten hinweg vorwärts laufen — sonst + // schlägt die Prüfung in der Datenbank zu, und zwar zu Recht. Deshalb werden + // die erledigten Fahrten rückwärts vom heutigen Stand aus gerechnet. + const laufenderStand = new Map( + fahrzeuge.map((fahrzeug) => [fahrzeug.id, fahrzeug.kilometerstand]), + ); + + for (const vorlage of FAHRTEN) { + const fahrzeug = fahrzeuge[vorlage.fahrzeug % fahrzeuge.length]!; + const fahrer = personen[vorlage.fahrer % personen.length]!; + const abfahrt = vorStunden(vorlage.vorStunden); + + const stand = laufenderStand.get(fahrzeug.id) ?? 0; + const abfahrtKm = Math.max(0, stand - vorlage.kilometer); + + await prisma.fahrt.create({ + data: { + organisationId, + fahrzeugId: fahrzeug.id, + fahrerId: fahrer.id, + zweck: vorlage.zweck, + ziel: vorlage.ziel, + abfahrtUm: abfahrt, + kilometerAbfahrt: vorlage.offen ? stand : abfahrtKm, + ...(vorlage.offen + ? {} + : { + rueckkehrUm: new Date(abfahrt.getTime() + vorlage.dauer * 60 * 60 * 1000), + kilometerRueckkehr: stand, + tankmenge: vorlage.tankmenge ?? null, + }), + }, + }); + + if (!vorlage.offen) laufenderStand.set(fahrzeug.id, abfahrtKm); + } + + // --- Anwesenheit ---------------------------------------------------------- + const ANWESENHEITEN: Array<{ + person: number; + anlass: AnwesenheitAnlass; + vorStunden: number; + /** null heißt: noch im Haus. */ + dauer: number | null; + }> = [ + { person: 0, anlass: AnwesenheitAnlass.UEBUNG, vorStunden: 8 * 24, dauer: 3 }, + { person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 5 * 24, dauer: 4 }, + { person: 4, anlass: AnwesenheitAnlass.DIENST, vorStunden: 5 * 24, dauer: 2 }, + { person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 2, dauer: null }, + ]; + + for (const vorlage of ANWESENHEITEN) { + const person = personen[vorlage.person % personen.length]!; + const angemeldet = vorStunden(vorlage.vorStunden); + + await prisma.anwesenheit.create({ + data: { + organisationId, + personId: person.id, + anlass: vorlage.anlass, + angemeldetUm: angemeldet, + abgemeldetUm: + vorlage.dauer === null + ? null + : new Date(angemeldet.getTime() + vorlage.dauer * 60 * 60 * 1000), + }, + }); + } + + // --- Wäsche --------------------------------------------------------------- + const WAESCHE: Array<{ + person: number; + bezeichnung: string; + menge: number; + grund: string; + status: WaescheStatus; + vorStunden: number; + }> = [ + { + person: 3, + bezeichnung: "Einsatzjacke", + menge: 1, + grund: "Einsatzverschmutzung", + status: WaescheStatus.IN_REINIGUNG, + vorStunden: 4 * 24, + }, + { + person: 6, + bezeichnung: "Einsatzhose", + menge: 1, + grund: "Kontamination", + status: WaescheStatus.ABGEGEBEN, + vorStunden: 20, + }, + { + person: 1, + bezeichnung: "Flammschutzhaube", + menge: 2, + grund: "Routine", + status: WaescheStatus.ZURUECK, + vorStunden: 30 * 24, + }, + ]; + + for (const vorlage of WAESCHE) { + const person = personen[vorlage.person % personen.length]!; + const abgegeben = vorStunden(vorlage.vorStunden); + + await prisma.waescheabgabe.create({ + data: { + organisationId, + personId: person.id, + bezeichnung: vorlage.bezeichnung, + menge: vorlage.menge, + grund: vorlage.grund, + status: vorlage.status, + abgegebenAm: abgegeben, + zurueckAm: + vorlage.status === WaescheStatus.ZURUECK + ? new Date(abgegeben.getTime() + 6 * 24 * 60 * 60 * 1000) + : null, + }, + }); + } + + log( + `Terminal: ${FAHRTEN.length} Fahrten (eine läuft), ${ANWESENHEITEN.length} Anwesenheiten, ${WAESCHE.length} Wäscheabgaben`, + ); +} + // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- @@ -1980,6 +2209,9 @@ async function main(): Promise { await einsaetzeAnlegen(organisation.id, fahrzeuge); await uebungenAnlegen(organisation.id); + // Phase 4 — Terminal im Gerätehaus + await terminaldatenAnlegen(organisation.id); + log("Fertig."); log(""); log("Anmeldung für den Erststart:"); diff --git a/public/icons/florian-192.png b/public/icons/florian-192.png new file mode 100644 index 0000000..8f0de82 Binary files /dev/null and b/public/icons/florian-192.png differ diff --git a/public/icons/florian-512.png b/public/icons/florian-512.png new file mode 100644 index 0000000..41d9ed5 Binary files /dev/null and b/public/icons/florian-512.png differ diff --git a/public/icons/florian-maskable.png b/public/icons/florian-maskable.png new file mode 100644 index 0000000..c04ed3f Binary files /dev/null and b/public/icons/florian-maskable.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e85932b --- /dev/null +++ b/public/sw.js @@ -0,0 +1,163 @@ +/** + * Service Worker für das Terminal im Gerätehaus. + * + * Aufgabe: das Tablet soll auch dann etwas anzeigen, wenn das WLAN in der + * Halle gerade weg ist. Drei Strategien, je nachdem worum es geht: + * + * Navigation (HTML) Netz zuerst, bei Fehlschlag der zwischengespeicherte + * Stand, sonst die Offline-Seite. So sieht man immer die + * aktuelle Fassung, wenn es geht — und trotzdem etwas, + * wenn nicht. + * + * Abfragen (tRPC GET) Zwischenspeicher zuerst anzeigen, im Hintergrund + * erneuern. Für Stammdaten wie Fahrzeuge und Geräte ist + * ein paar Minuten alt völlig ausreichend. + * + * Änderungen (POST) Gar nicht anfassen. Was geschrieben wird, geht über + * die Warteschlange in der IndexedDB (siehe + * lib/kiosk/outbox.ts) — ein Service Worker, der + * Schreibvorgänge zwischenspeichert, führt zu Daten, die + * niemand mehr zuordnen kann. + * + * Bewusst von Hand geschrieben statt über ein Werkzeug erzeugt: es sind + * achtzig Zeilen, sie sind lesbar, und sie bringen keine Abhängigkeit mit, die + * bei jedem Update mitgezogen werden müsste. + */ + +const VERSION = "florian-kiosk-v1"; +const HUELLE = `${VERSION}-huelle`; +const DATEN = `${VERSION}-daten`; + +/** Was beim Einrichten schon einmal geholt wird. */ +const HUELLE_DATEIEN = ["/kiosk", "/kiosk/offline", "/manifest.webmanifest"]; + +self.addEventListener("install", (ereignis) => { + ereignis.waitUntil( + caches + .open(HUELLE) + .then((speicher) => speicher.addAll(HUELLE_DATEIEN)) + // Ein fehlender Eintrag darf die Einrichtung nicht verhindern. + .catch(() => undefined) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (ereignis) => { + ereignis.waitUntil( + caches + .keys() + .then((namen) => + Promise.all( + namen + .filter((name) => !name.startsWith(VERSION)) + .map((name) => caches.delete(name)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (ereignis) => { + const anfrage = ereignis.request; + const adresse = new URL(anfrage.url); + + // Nur eigene Adressen. Alles andere geht den Service Worker nichts an. + if (adresse.origin !== self.location.origin) return; + + // Schreibende Aufrufe niemals abfangen. + if (anfrage.method !== "GET") return; + + // Der Anmeldeweg bleibt außen vor: ein zwischengespeicherter Anmeldezustand + // wäre eine Sicherheitslücke, keine Bequemlichkeit. + if (adresse.pathname.startsWith("/api/auth")) return; + + if (anfrage.mode === "navigate") { + ereignis.respondWith(navigationBeantworten(anfrage)); + return; + } + + if (adresse.pathname.startsWith("/api/trpc")) { + ereignis.respondWith(abfrageBeantworten(anfrage)); + return; + } + + // Statische Dateien: erst nachsehen, dann holen. + if (adresse.pathname.startsWith("/_next/static") || adresse.pathname.startsWith("/icons")) { + ereignis.respondWith(zuerstSpeicher(anfrage)); + } +}); + +/** Netz zuerst, Zwischenspeicher als Rückfall, Offline-Seite als letzte Rettung. */ +async function navigationBeantworten(anfrage) { + try { + const antwort = await fetch(anfrage); + const speicher = await caches.open(HUELLE); + speicher.put(anfrage, antwort.clone()); + return antwort; + } catch (fehler) { + const speicher = await caches.open(HUELLE); + const gespeichert = await speicher.match(anfrage); + if (gespeichert) return gespeichert; + + const offlineSeite = await speicher.match("/kiosk/offline"); + if (offlineSeite) return offlineSeite; + + return new Response( + "Offline" + + "

Kein Netz und nichts im Zwischenspeicher.

", + { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } }, + ); + } +} + +/** Zwischenspeicher zuerst, im Hintergrund erneuern. */ +async function abfrageBeantworten(anfrage) { + const speicher = await caches.open(DATEN); + const gespeichert = await speicher.match(anfrage); + + const netz = fetch(anfrage) + .then((antwort) => { + if (antwort.ok) speicher.put(anfrage, antwort.clone()); + return antwort; + }) + .catch(() => null); + + if (gespeichert) { + // Die Erneuerung läuft weiter, das Ergebnis interessiert hier nicht mehr. + void netz; + return gespeichert; + } + + const antwort = await netz; + if (antwort) return antwort; + + return new Response(JSON.stringify({ fehler: "offline" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); +} + +async function zuerstSpeicher(anfrage) { + const speicher = await caches.open(HUELLE); + const gespeichert = await speicher.match(anfrage); + if (gespeichert) return gespeichert; + + try { + const antwort = await fetch(anfrage); + if (antwort.ok) speicher.put(anfrage, antwort.clone()); + return antwort; + } catch (fehler) { + return new Response("", { status: 504 }); + } +} + +/** + * Die Anwendung kann den Zwischenspeicher der Daten leeren lassen — etwa nach + * dem Abmelden am Terminal, damit der nächste Anwender keinen fremden Stand + * sieht. + */ +self.addEventListener("message", (ereignis) => { + if (ereignis.data === "daten-leeren") { + ereignis.waitUntil(caches.delete(DATEN)); + } +}); diff --git a/scripts/icons.ts b/scripts/icons.ts new file mode 100644 index 0000000..6f3b03b --- /dev/null +++ b/scripts/icons.ts @@ -0,0 +1,116 @@ +/** + * Erzeugt die Symbole für das Web-App-Manifest. + * + * Warum ein Skript und keine gezeichneten Dateien: die Symbole sollen aus + * derselben Quelle stammen wie das Zeichen in der Seitenleiste, und sie sollen + * sich ändern lassen, ohne dass jemand ein Grafikprogramm öffnen muss. + * + * Gezeichnet wird die Flamme als SVG auf farbigem Grund, aufgenommen wird mit + * demselben Chromium, das ohnehin für die Tests da ist. + * + * npm run icons + * + * Die Ergebnisse liegen in public/icons und werden mit eingecheckt — ein Build + * soll keinen Browser brauchen. + */ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "@playwright/test"; + +const ZIEL = path.join(process.cwd(), "public", "icons"); + +/** Die Flamme aus lucide-react, als eigenständiges SVG. */ +const FLAMME = ` + + +`; + +interface Symbolvorlage { + datei: string; + groesse: number; + /** Sicherheitsabstand für maskierbare Symbole (Android schneidet rund zu). */ + rand: number; + hintergrund: string; + farbe: string; + eckenRadius: string; +} + +const SYMBOLE: Symbolvorlage[] = [ + { + datei: "florian-192.png", + groesse: 192, + rand: 22, + hintergrund: "#b91c1c", + farbe: "#ffffff", + eckenRadius: "22%", + }, + { + datei: "florian-512.png", + groesse: 512, + rand: 60, + hintergrund: "#b91c1c", + farbe: "#ffffff", + eckenRadius: "22%", + }, + { + // Maskierbar: mehr Rand, voller Grund bis zum Rand — Android schneidet + // daraus je nach Gerät einen Kreis oder ein Rechteck. + datei: "florian-maskable.png", + groesse: 512, + rand: 110, + hintergrund: "#b91c1c", + farbe: "#ffffff", + eckenRadius: "0", + }, +]; + +function seite(vorlage: Symbolvorlage): string { + return ` + +
${FLAMME}
`; +} + +async function main(): Promise { + await mkdir(ZIEL, { recursive: true }); + + const browser = await chromium.launch(); + + for (const vorlage of SYMBOLE) { + const seiteImBrowser = await browser.newPage({ + viewport: { width: vorlage.groesse, height: vorlage.groesse }, + deviceScaleFactor: 1, + }); + + await seiteImBrowser.setContent(seite(vorlage)); + const ziel = path.join(ZIEL, vorlage.datei); + await seiteImBrowser.locator(".grund").screenshot({ path: ziel, omitBackground: false }); + await seiteImBrowser.close(); + + console.info(`[icons] ${vorlage.datei} (${vorlage.groesse}px) -> ${ziel}`); + } + + await browser.close(); + console.info("[icons] Fertig."); +} + +main().catch((fehler) => { + console.error("[icons] Abbruch:", fehler); + process.exit(1); +}); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 26eb2eb..34b13f4 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -15,7 +15,7 @@ import "dotenv/config"; import { mkdir } from "node:fs/promises"; import path from "node:path"; -import { chromium, type Page } from "@playwright/test"; +import { chromium, type Browser, type Page } from "@playwright/test"; const BASIS = process.env.E2E_BASIS_URL ?? "http://localhost:3100"; const ZIEL = path.join(process.cwd(), "docs", "screenshots"); @@ -138,6 +138,38 @@ const AUFNAHMEN: Aufnahme[] = [ beschreibung: "Übungen mit Thema und Teilnehmerzahl", angemeldet: true, }, + + // Phase 4 — Terminal im Gerätehaus + { + name: "25-kiosk-kacheln", + pfad: "/kiosk", + beschreibung: "Kachelschirm des Terminals", + angemeldet: true, + }, + { + name: "26-kiosk-mangel", + pfad: "/kiosk/mangel", + beschreibung: "Mangel melden in drei Schritten", + angemeldet: true, + }, + { + name: "27-kiosk-monitor", + pfad: "/kiosk/monitor", + beschreibung: "Hallenmonitor im Vollbild", + angemeldet: true, + }, + { + name: "28-terminal-uebersicht", + pfad: "/terminal", + beschreibung: "Was am Terminal zusammenkommt — Sicht der Verwaltung", + angemeldet: true, + }, + { + name: "29-anmeldemarke", + pfad: "/anmeldemarke", + beschreibung: "Anmeldemarke fürs Handy", + angemeldet: true, + }, ]; /** @@ -276,10 +308,61 @@ async function portalAufnehmen(seite: Page, darstellung: string): Promise } } +/** + * Der Anmeldeschirm des Terminals. + * + * Braucht das Gegenteil aller anderen Aufnahmen: **keine** Sitzung, dafür ein + * eingerichtetes Gerät. Beides zusammen gibt es nur in einem eigenen Kontext, + * in dem vor dem ersten Aufruf die Geräteangaben im localStorage stehen. + */ +async function kioskAnmeldungAufnehmen( + browser: Browser, + darstellung: "light" | "dark", +): Promise { + const kontext = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + locale: "de-DE", + timezoneId: "Europe/Berlin", + colorScheme: darstellung, + }); + + try { + await kontext.addInitScript( + (angaben: { schluessel: string; wehr: string }) => { + // Dieselben Schlüssel wie in lib/kiosk/geraet.ts. + window.localStorage.setItem("florian.kiosk.geraeteschluessel", angaben.schluessel); + window.localStorage.setItem("florian.kiosk.wehr", angaben.wehr); + }, + { + schluessel: process.env.KIOSK_DEVICE_SECRET ?? "bitte_aendern_kiosk_geheimnis", + wehr: "ff-musterhausen", + }, + ); + + const seite = await kontext.newPage(); + await seite.goto(`${BASIS}/kiosk`, { waitUntil: "networkidle" }); + await fertigGeladen(seite); + + const datei = path.join(ZIEL, `30-kiosk-anmeldung-${darstellung}.png`); + await seite.screenshot({ path: datei, fullPage: true }); + console.info(`[screenshots] Anmeldung am Terminal (${darstellung}) -> ${datei}`); + } catch (fehler) { + console.warn("[screenshots] Anmeldeschirm des Terminals fehlgeschlagen:", fehler); + } finally { + await kontext.close(); + } +} + async function main(): Promise { await mkdir(ZIEL, { recursive: true }); - const browser = await chromium.launch(); + const browser = await chromium.launch({ + // Der Mangel-Wizard startet die Kamera von selbst. Ohne Gerät stünde dort + // eine Fehlermeldung; mit dem Testbild von Chromium zeigt das Bild, was am + // Tablet zu sehen ist. + args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"], + }); for (const darstellung of ["light", "dark"] as const) { const kontext = await browser.newContext({ @@ -344,6 +427,9 @@ async function main(): Promise { await portalAufnehmen(seite, darstellung); await kontext.close(); + + // Eigener Kontext, weil dieser hier angemeldet ist. + await kioskAnmeldungAufnehmen(browser, darstellung); } await browser.close(); diff --git a/src/app/(app)/anmeldemarke/anzeige.tsx b/src/app/(app)/anmeldemarke/anzeige.tsx new file mode 100644 index 0000000..48451e2 --- /dev/null +++ b/src/app/(app)/anmeldemarke/anzeige.tsx @@ -0,0 +1,96 @@ +"use client"; + +import * as React from "react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +/** + * Die Anzeige der Anmeldemarke. + * + * Der Code läuft nach einer Minute ab — deshalb zählt die Seite herunter und + * holt sich rechtzeitig einen neuen. Wer davorsteht, soll nie einen abgelaufenen + * Code vor die Kamera halten und rätseln, warum nichts passiert. + */ +export function MarkenAnzeige({ + bild, + name, + gueltigSekunden, +}: { + bild: string; + name: string; + gueltigSekunden: number; +}) { + const router = useRouter(); + const [rest, setRest] = React.useState(gueltigSekunden); + + React.useEffect(() => { + setRest(gueltigSekunden); + + const zeitgeber = setInterval(() => { + setRest((wert) => { + if (wert <= 1) { + // Ein paar Sekunden vor Ablauf einen neuen holen: die Seite wird + // serverseitig gerendert, also genügt ein Aktualisieren. + router.refresh(); + return gueltigSekunden; + } + return wert - 1; + }); + }, 1000); + + return () => clearInterval(zeitgeber); + }, [bild, gueltigSekunden, router]); + + return ( +
+
+

Anmeldemarke

+

+ Am Terminal im Gerätehaus vor die Kamera halten. +

+
+ + + + {name} + + Gültig noch {rest} {rest === 1 ? "Sekunde" : "Sekunden"} — danach erneuert sich der + Code von selbst. + + + + + Anmeldemarke als QR-Code + +
+
+
+ + + + + +

+ Der Code enthält keine Daten über dich, sondern nur eine kurzlebige Unterschrift. Ein + abfotografierter Code nützt nach einer Minute nichts mehr. +

+
+ ); +} diff --git a/src/app/(app)/anmeldemarke/page.tsx b/src/app/(app)/anmeldemarke/page.tsx new file mode 100644 index 0000000..3b938d0 --- /dev/null +++ b/src/app/(app)/anmeldemarke/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { markeErzeugen, MARKE_GUELTIG_SEKUNDEN } from "@/lib/kiosk/anmeldemarke"; +import { qrTextAlsDataUri } from "@/lib/stammdaten/qr"; +import { MarkenAnzeige } from "@/app/(app)/anmeldemarke/anzeige"; + +export const metadata = { title: "Anmeldemarke" }; + +/** + * Die Anmeldemarke fürs Handy. + * + * Wer den Ausweis vergessen hat, öffnet diese Seite und hält den QR-Code vor + * die Kamera des Terminals. Der Code gilt eine Minute; die Seite erneuert ihn + * von selbst. + * + * Erzeugt wird er auf dem Server — die Signatur entsteht mit AUTH_SECRET, und + * das hat im Browser nichts verloren. + */ +export default async function AnmeldemarkeSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/anmelden"); + + const marke = markeErzeugen(sitzung.personId, sitzung.organisationId); + const bild = await qrTextAlsDataUri(marke, 420); + + return ( + + ); +} diff --git a/src/app/(app)/terminal/page.tsx b/src/app/(app)/terminal/page.tsx new file mode 100644 index 0000000..e1b2959 --- /dev/null +++ b/src/app/(app)/terminal/page.tsx @@ -0,0 +1,46 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { MonitorSmartphone } from "lucide-react"; +import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; +import { Button } from "@/components/ui/button"; +import { TerminalUebersicht } from "@/app/(app)/terminal/uebersicht"; + +export const metadata = { title: "Terminal" }; + +/** + * Was am Terminal zusammenkommt — aus Sicht der Verwaltung. + * + * Das Terminal selbst ist eine eigene Oberfläche für das Wandtablet. Hier steht + * das Ergebnis: wer im Haus ist, welche Fahrten laufen, welche Wäsche in der + * Reinigung ist und was sich beim Nachtragen nicht anwenden ließ. + */ +export default async function TerminalSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/anmelden"); + + return ( +
+
+
+

Terminal im Gerätehaus

+

+ Anwesenheit, Fahrtenbuch, Wäsche — und was beim Nachtragen hängen geblieben ist. +

+
+ + +
+ + +
+ ); +} diff --git a/src/app/(app)/terminal/uebersicht.tsx b/src/app/(app)/terminal/uebersicht.tsx new file mode 100644 index 0000000..00fc6a1 --- /dev/null +++ b/src/app/(app)/terminal/uebersicht.tsx @@ -0,0 +1,288 @@ +"use client"; + +import * as React from "react"; +import { Check } from "lucide-react"; +import { WaescheStatus } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige"; +import { alsDatumZeit } from "@/lib/utils"; + +/** + * Terminalübersicht für die Verwaltung. + * + * Vier Blöcke, in der Reihenfolge, in der sie Aufmerksamkeit brauchen: + * Konflikte zuerst — das sind Vorgänge, die ein Mensch anfassen muss —, danach + * das Laufende. + */ + +const WAESCHE_TEXT: Record = { + ABGEGEBEN: "abgegeben", + IN_REINIGUNG: "in Reinigung", + ZURUECK: "zurück", + VERSCHOLLEN: "verschollen", +}; + +const GRUND_TEXT: Record = { + ZIEL_FEHLT: "Ziel gibt es nicht", + ZIEL_GESPERRT: "Ziel ist festgeschrieben", + DOPPELT: "doppelt erfasst", + VERALTET: "Angaben veraltet", + SONSTIGES: "sonstiges", +}; + +export function TerminalUebersicht({ + darfKonflikte, + darfWaesche, +}: { + darfKonflikte: boolean; + darfWaesche: boolean; +}) { + const [meldung, setMeldung] = React.useState(null); + + const werkzeuge = trpc.useUtils(); + + const konflikte = trpc.kiosk.konflikte.useQuery( + { nurOffene: true }, + { enabled: darfKonflikte }, + ); + const anwesende = trpc.kiosk.anwesende.useQuery(); + const fahrten = trpc.kiosk.fahrten.useQuery({ nurOffene: false, anzahl: 25 }); + const waesche = trpc.kiosk.waesche.useQuery({ nurOffene: true }); + + const erledigen = trpc.kiosk.konfliktErledigen.useMutation({ + onSuccess: () => { + setMeldung("Konflikt als erledigt vermerkt."); + void werkzeuge.kiosk.konflikte.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const statusSetzen = trpc.kiosk.waescheStatusSetzen.useMutation({ + onSuccess: () => void werkzeuge.kiosk.waesche.invalidate(), + onError: (fehler) => setMeldung(fehler.message), + }); + + return ( +
+ {meldung && } + + {darfKonflikte && ( + + + Konflikte beim Nachtragen + + Vorgänge, die das Terminal offline erfasst hat und die sich beim Übertragen nicht + anwenden ließen. Sie sind nicht verloren — sie warten hier auf eine Entscheidung. + + + + {(konflikte.data?.length ?? 0) === 0 ? ( + + ) : ( + + + + Wann + Was + Grund + Wer + + + + + {konflikte.data?.map((konflikt) => ( + + + {alsDatumZeit(konflikt.entstandenAm)} + + +
{konflikt.art}
+
+ {konflikt.beschreibung} +
+
+ + + {GRUND_TEXT[konflikt.grund] ?? konflikt.grund} + + + + {konflikt.person + ? `${konflikt.person.nachname}, ${konflikt.person.vorname}` + : "—"} + + + + +
+ ))} +
+
+ )} +
+
+ )} + +
+ + + Im Haus + {anwesende.data?.length ?? 0} eingecheckt + + + {(anwesende.data?.length ?? 0) === 0 ? ( +

Gerade niemand.

+ ) : ( +
    + {anwesende.data?.map((eintrag) => ( +
  • + + {eintrag.person.nachname}, {eintrag.person.vorname} + + + {eintrag.anlass.toLowerCase()} · seit {alsDatumZeit(eintrag.angemeldetUm)} + +
  • + ))} +
+ )} +
+
+ + + + Wäsche + Schutzkleidung, die noch nicht zurück ist + + + {(waesche.data?.length ?? 0) === 0 ? ( +

Nichts offen.

+ ) : ( +
    + {waesche.data?.map((abgabe) => ( +
  • + + {abgabe.menge}× {abgabe.bezeichnung} + + + {abgabe.person.nachname}, {abgabe.person.vorname} ·{" "} + {alsDatumZeit(abgabe.abgegebenAm)} + + + {darfWaesche ? ( + + ) : ( + {WAESCHE_TEXT[abgabe.status]} + )} +
  • + ))} +
+ )} +
+
+
+ + + + Fahrtenbuch + Die letzten Fahrten, offene zuerst erkennbar + + + {(fahrten.data?.length ?? 0) === 0 ? ( + + ) : ( + + + + Fahrzeug + Fahrer + Zweck + Ziel + Abfahrt + km + + + + {fahrten.data?.map((fahrt) => ( + + {fahrt.fahrzeug.funkrufname} + + {fahrt.fahrer.nachname}, {fahrt.fahrer.vorname} + + {fahrt.zweck.toLowerCase()} + {fahrt.ziel ?? "—"} + + {alsDatumZeit(fahrt.abfahrtUm)} + + + {fahrt.kilometerRueckkehr + ? `${fahrt.kilometerRueckkehr - fahrt.kilometerAbfahrt} km` + : "unterwegs"} + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/app/kiosk/anwesenheit/page.tsx b/src/app/kiosk/anwesenheit/page.tsx new file mode 100644 index 0000000..6b0baab --- /dev/null +++ b/src/app/kiosk/anwesenheit/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { env } from "@/lib/env"; +import { AnwesenheitWizard } from "@/app/kiosk/anwesenheit/wizard"; + +export const metadata = { title: "Kommen und Gehen" }; + +export default async function AnwesenheitSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/kiosk"); + + return ; +} diff --git a/src/app/kiosk/anwesenheit/wizard.tsx b/src/app/kiosk/anwesenheit/wizard.tsx new file mode 100644 index 0000000..ce9f10f --- /dev/null +++ b/src/app/kiosk/anwesenheit/wizard.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import { AnwesenheitAnlass } from "@prisma/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Erledigt, GrosseAuswahl, KioskRahmen, WirdGeladen } from "@/components/kiosk/rahmen"; +import { useWarteschlange } from "@/components/kiosk/warteschlange"; +import { alsDatumZeit } from "@/lib/utils"; + +/** + * Kommen und Gehen. + * + * Der häufigste Vorgang am Terminal und der einfachste: zwei Klicks. Wer schon + * eingecheckt ist, sieht nur noch „Gehen" — die Auswahl des Anlasses hat er + * beim Kommen getroffen. + */ + +const ANLASS_TEXT: Record = { + DIENST: { titel: "Dienst", hinweis: "regulärer Dienstabend" }, + UEBUNG: { titel: "Übung", hinweis: "Ausbildung, Übungsdienst" }, + EINSATZ: { titel: "Einsatz", hinweis: "alarmiert oder nachgerückt" }, + ARBEITSDIENST: { titel: "Arbeitsdienst", hinweis: "Gerätehaus, Fahrzeugpflege" }, + SONSTIGES: { titel: "Sonstiges", hinweis: "alles andere" }, +}; + +export function AnwesenheitWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) { + const { erfassen, offline } = useWarteschlange(); + const stand = trpc.kiosk.eigenerStand.useQuery(undefined, { retry: false }); + + const [fertig, setFertig] = React.useState(null); + + const angemeldet = stand.data?.anwesenheit ?? null; + // stand.data ist erst nach dem ersten Abruf da. Bis dahin ist unbekannt, ob + // die Person schon eingecheckt ist — und damit auch, was hier stehen soll. + const standBekannt = stand.data !== undefined; + + async function kommen(anlass: AnwesenheitAnlass) { + await erfassen( + { art: "ANWESENHEIT_KOMMEN", daten: { anlass } }, + `Gekommen (${ANLASS_TEXT[anlass].titel})`, + ); + setFertig(`Willkommen. Anwesenheit erfasst: ${ANLASS_TEXT[anlass].titel}.`); + } + + async function gehen() { + await erfassen( + { art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: angemeldet?.id ?? null } }, + "Gegangen", + ); + setFertig("Bis zum nächsten Mal. Abwesenheit erfasst."); + } + + return ( + + {fertig ? ( + + ) : !standBekannt ? ( + + ) : angemeldet ? ( +
+ +

+ Anlass: {ANLASS_TEXT[angemeldet.anlass].titel} +

+
+ ) : ( +
+ {(Object.keys(ANLASS_TEXT) as AnwesenheitAnlass[]).map((anlass) => ( + void kommen(anlass)} + /> + ))} +
+ )} + + {offline && !fertig && standBekannt && ( +

+ Ohne Netz wird trotzdem erfasst — die Zeit ist die von jetzt, nicht die der Übertragung. +

+ )} +
+ ); +} diff --git a/src/app/kiosk/einrichten/formular.tsx b/src/app/kiosk/einrichten/formular.tsx new file mode 100644 index 0000000..50808ae --- /dev/null +++ b/src/app/kiosk/einrichten/formular.tsx @@ -0,0 +1,120 @@ +"use client"; + +import * as React from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { geraetEinrichten, geraeteangaben, geraetVergessen } from "@/lib/kiosk/geraet"; + +/** + * Einrichtung des Terminals. + * + * Einmal je Tablet: Gerätegeheimnis und Kurzname der Wehr. Beides steht in der + * `.env` des Servers (`KIOSK_DEVICE_SECRET`) beziehungsweise in den Stammdaten + * der Wehr. + * + * Damit das nicht abgetippt werden muss, nimmt die Seite beides auch aus der + * Adresse entgegen: + * + * /kiosk/einrichten?wehr=ff-musterhausen&schluessel=… + * + * Der Administrator schickt sich diesen Link einmal selbst — etwa als QR-Code + * vom eigenen Rechner — und ruft ihn am Tablet auf. + */ +export function EinrichtenFormular() { + const router = useRouter(); + const parameter = useSearchParams(); + + const [schluessel, setSchluessel] = React.useState(""); + const [wehr, setWehr] = React.useState(""); + const [bestehend, setBestehend] = React.useState(null); + + React.useEffect(() => { + const vorhanden = geraeteangaben(); + if (vorhanden) setBestehend(vorhanden.organisationSlug); + + const ausAdresse = parameter.get("schluessel"); + const wehrAusAdresse = parameter.get("wehr"); + if (ausAdresse) setSchluessel(ausAdresse); + if (wehrAusAdresse) setWehr(wehrAusAdresse); + }, [parameter]); + + return ( +
+
+

Terminal einrichten

+

+ Einmal je Tablet. Danach meldet sich jeder mit Ausweis, QR-Code oder PIN an. +

+
+ + {bestehend && ( +
+

+ Dieses Tablet ist bereits für {bestehend} eingerichtet. +

+ +
+ )} + +
{ + ereignis.preventDefault(); + geraetEinrichten({ + geraeteSchluessel: schluessel.trim(), + organisationSlug: wehr.trim(), + }); + router.push("/kiosk"); + router.refresh(); + }} + > +
+ + setWehr(ereignis.target.value)} + placeholder="ff-musterhausen" + required + /> +
+ +
+ + setSchluessel(ereignis.target.value)} + required + /> +

+ Steht als KIOSK_DEVICE_SECRET in der .env des Servers. Es + bleibt auf diesem Tablet und geht nur beim Anmelden mit. +

+
+ + +
+
+ ); +} diff --git a/src/app/kiosk/einrichten/page.tsx b/src/app/kiosk/einrichten/page.tsx new file mode 100644 index 0000000..0adc7f6 --- /dev/null +++ b/src/app/kiosk/einrichten/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; +import { EinrichtenFormular } from "@/app/kiosk/einrichten/formular"; + +export const metadata = { title: "Terminal einrichten" }; + +/** + * Einrichtung des Tablets. + * + * Bewusst ohne Anmeldung erreichbar: das Tablet muss sich ausweisen können, + * *bevor* sich jemand anmelden kann. Wer die Seite ohne Gerätegeheimnis + * aufruft, kann hier nichts anrichten — eingetragen wird nur, was ohnehin nur + * der Administrator kennt, und geprüft wird es beim ersten Anmeldeversuch. + * + * Das Formular liest Wehr und Geheimnis auch aus der Adresse, damit am Tablet + * nichts abgetippt werden muss. `useSearchParams()` verlangt dafür eine + * Suspense-Grenze — sonst ließe sich die Seite nicht vorab erzeugen. + */ +export default function EinrichtenSeite() { + return ( + Einrichtung wird geladen …

}> + +
+ ); +} diff --git a/src/app/kiosk/fahrt/page.tsx b/src/app/kiosk/fahrt/page.tsx new file mode 100644 index 0000000..720edbd --- /dev/null +++ b/src/app/kiosk/fahrt/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; +import { env } from "@/lib/env"; +import { FahrtWizard } from "@/app/kiosk/fahrt/wizard"; + +export const metadata = { title: "Fahrtenbuch" }; + +export default async function FahrtSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/kiosk"); + if (!hatRecht(sitzung, "fahrtenbuch.fuehren")) redirect("/kiosk"); + + return ; +} diff --git a/src/app/kiosk/fahrt/wizard.tsx b/src/app/kiosk/fahrt/wizard.tsx new file mode 100644 index 0000000..bbd8611 --- /dev/null +++ b/src/app/kiosk/fahrt/wizard.tsx @@ -0,0 +1,259 @@ +"use client"; + +import * as React from "react"; +import { FahrtZweck } 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 { Erledigt, GrosseAuswahl, KioskRahmen, WirdGeladen } from "@/components/kiosk/rahmen"; +import { useWarteschlange } from "@/components/kiosk/warteschlange"; +import { alsDatumZeit } from "@/lib/utils"; + +/** + * Das Fahrtenbuch am Terminal. + * + * Zwei Zustände, kein Formular: Wer nicht unterwegs ist, beginnt eine Fahrt. + * Wer unterwegs ist, beendet sie. Der Kilometerstand ist vorbelegt mit dem, + * was am Fahrzeug steht — meist stimmt er, und wenn nicht, wird er überschrieben. + * + * Warum das überhaupt am Terminal steht: das Fahrtenbuch wird sonst auf einem + * Zettel im Handschuhfach geführt, und der ist nach einem halben Jahr weg. + */ + +const ZWECK: Array<{ wert: FahrtZweck; titel: string; hinweis: string }> = [ + { wert: FahrtZweck.EINSATZ, titel: "Einsatz", hinweis: "alarmiert" }, + { wert: FahrtZweck.UEBUNG, titel: "Übung", hinweis: "Übungsdienst" }, + { wert: FahrtZweck.AUSBILDUNG, titel: "Ausbildung", hinweis: "Lehrgang, Schulung" }, + { wert: FahrtZweck.WERKSTATT, titel: "Werkstatt", hinweis: "Wartung, TÜV" }, + { wert: FahrtZweck.BESCHAFFUNG, titel: "Beschaffung", hinweis: "Material holen" }, + { wert: FahrtZweck.SONSTIGES, titel: "Sonstiges", hinweis: "Probefahrt, Überführung" }, +]; + +export function FahrtWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) { + const { erfassen } = useWarteschlange(); + const paket = trpc.kiosk.stammdatenpaket.useQuery(undefined, { retry: false }); + const stand = trpc.kiosk.eigenerStand.useQuery(undefined, { retry: false }); + + const [fahrzeugId, setFahrzeugId] = React.useState(null); + const [kilometer, setKilometer] = React.useState(""); + const [ziel, setZiel] = React.useState(""); + const [tankmenge, setTankmenge] = React.useState(""); + const [fertig, setFertig] = React.useState(null); + + const offeneFahrt = stand.data?.fahrt ?? null; + const fahrzeuge = paket.data?.fahrzeuge ?? []; + const gewaehlt = fahrzeuge.find((fahrzeug) => fahrzeug.id === fahrzeugId); + + // Kilometerstand vorbelegen, sobald ein Fahrzeug gewählt ist. + React.useEffect(() => { + if (gewaehlt?.kilometerstand && !kilometer) setKilometer(String(gewaehlt.kilometerstand)); + }, [gewaehlt, kilometer]); + + async function beginnen(zweck: FahrtZweck) { + if (!fahrzeugId || !kilometer) return; + + await erfassen( + { + art: "FAHRT_BEGINNEN", + daten: { + fahrzeugId, + kilometerAbfahrt: Number(kilometer), + zweck, + ziel: ziel.trim() || null, + }, + }, + `Fahrt begonnen (${gewaehlt?.funkrufname ?? "Fahrzeug"})`, + ); + + setFertig("Gute Fahrt. Die Fahrt ist eingetragen."); + } + + async function beenden() { + if (!offeneFahrt || !kilometer) return; + + await erfassen( + { + art: "FAHRT_BEENDEN", + daten: { + fahrtId: offeneFahrt.id, + kilometerRueckkehr: Number(kilometer), + tankmenge: tankmenge ? Number(tankmenge) : null, + }, + }, + `Fahrt beendet (${offeneFahrt.fahrzeug.funkrufname})`, + ); + + setFertig("Fahrt beendet. Kilometerstand übernommen."); + } + + // Bei einer offenen Fahrt ist der Kilometerstand der bei Abfahrt — als + // Anhaltspunkt, nicht als Vorgabe. + React.useEffect(() => { + if (offeneFahrt && !kilometer) setKilometer(String(offeneFahrt.kilometerAbfahrt)); + }, [offeneFahrt, kilometer]); + + if (fertig) { + return ( + + + + ); + } + + // Solange unbekannt ist, ob ein Fahrzeug unterwegs ist, wird nichts + // angeboten: sonst tauscht die Oberfläche gleich nach dem Öffnen zwischen + // „Fahrt beginnen" und „Fahrt beenden". + if (stand.data === undefined) { + return ( + + + + ); + } + + if (offeneFahrt) { + return ( + +
+
+ + setKilometer(ereignis.target.value)} + /> +

+ Bei Abfahrt: {offeneFahrt.kilometerAbfahrt} km +

+
+ +
+ + setTankmenge(ereignis.target.value)} + /> +
+ + + + {kilometer && Number(kilometer) < offeneFahrt.kilometerAbfahrt && ( +

+ Der Stand kann nicht kleiner sein als bei der Abfahrt. +

+ )} +
+
+ ); + } + + return ( + +
+
+

Welches Fahrzeug?

+
+ {fahrzeuge.map((fahrzeug) => ( + { + setFahrzeugId(fahrzeug.id); + setKilometer(fahrzeug.kilometerstand ? String(fahrzeug.kilometerstand) : ""); + }} + /> + ))} +
+ + {fahrzeuge.length === 0 && ( +

+ Keine Fahrzeuge im Zwischenspeicher. Einmal mit Netz öffnen, danach steht die Liste + auch offline. +

+ )} +
+ + {fahrzeugId && ( + <> +
+
+ + setKilometer(ereignis.target.value)} + /> +
+ +
+ + setZiel(ereignis.target.value)} + placeholder="Werkstatt Musterhausen" + /> +
+
+ +
+

Wozu?

+
+ {ZWECK.map((eintrag) => ( + void beginnen(eintrag.wert)} + /> + ))} +
+ {!kilometer && ( +

+ Der Kilometerstand fehlt noch. +

+ )} +
+ + )} +
+
+ ); +} diff --git a/src/app/kiosk/inventur/page.tsx b/src/app/kiosk/inventur/page.tsx new file mode 100644 index 0000000..c9a7a73 --- /dev/null +++ b/src/app/kiosk/inventur/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; +import { env } from "@/lib/env"; +import { InventurWizard } from "@/app/kiosk/inventur/wizard"; + +export const metadata = { title: "Inventur" }; + +export default async function InventurSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/kiosk"); + if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/kiosk"); + + return ; +} diff --git a/src/app/kiosk/inventur/wizard.tsx b/src/app/kiosk/inventur/wizard.tsx new file mode 100644 index 0000000..c8b0afc --- /dev/null +++ b/src/app/kiosk/inventur/wizard.tsx @@ -0,0 +1,206 @@ +"use client"; + +import * as React from "react"; +import { CheckCircle2, ScanLine } from "lucide-react"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; +import { KioskRahmen } from "@/components/kiosk/rahmen"; +import { QrLeser } from "@/components/kiosk/qr-leser"; +import { useWarteschlange } from "@/components/kiosk/warteschlange"; +import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser"; + +/** + * Inventur am Regal. + * + * Anders als die übrigen Wizards endet dieser nicht nach einem Vorgang: der + * Gerätewart geht mit dem Tablet durch die Halle und scannt der Reihe nach. + * Deshalb bleibt die Kamera an, und jeder Scan wandert sofort in die + * Warteschlange — auch ohne Netz, denn im hinteren Lager gibt es keins. + * + * Was zählt, ist die Liste am Ende: was nicht gescannt wurde, fehlt. + */ +export function InventurWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) { + const { erfassen } = useWarteschlange(); + const werkzeuge = trpc.useUtils(); + + const paket = trpc.kiosk.stammdatenpaket.useQuery(undefined, { retry: false }); + const [inventurId, setInventurId] = React.useState(null); + const [bezeichnung, setBezeichnung] = React.useState(""); + const [gescannt, setGescannt] = React.useState([]); + const [meldung, setMeldung] = React.useState(null); + + const laufende = paket.data?.laufendeInventur ?? null; + const aktiveId = inventurId ?? laufende?.id ?? null; + + const stand = trpc.kiosk.inventurStand.useQuery( + { id: aktiveId ?? "" }, + { enabled: Boolean(aktiveId), retry: false }, + ); + + const starten = trpc.kiosk.inventurStarten.useMutation({ + onSuccess: (inventur) => { + setInventurId(inventur.id); + void werkzeuge.kiosk.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + const abschliessen = trpc.kiosk.inventurAbschliessen.useMutation({ + onSuccess: () => { + setMeldung("Inventur abgeschlossen."); + setInventurId(null); + void werkzeuge.kiosk.invalidate(); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + async function scannen(inhalt: string) { + if (!aktiveId) return; + + const inventarnummer = inventarnummerAusScan(inhalt) ?? inhalt.trim(); + if (!inventarnummer) return; + + // Doppelte Scans sind bei einer Inventur der Normalfall — der Server + // behandelt sie ohnehin als denselben Posten. + if (gescannt.includes(inventarnummer)) { + setMeldung(`${inventarnummer} war schon dabei.`); + return; + } + + await erfassen( + { + art: "INVENTUR_SCANNEN", + daten: { inventurId: aktiveId, inventarnummer, gefunden: true }, + }, + `Inventur: ${inventarnummer}`, + ); + + setGescannt((alt) => [inventarnummer, ...alt]); + setMeldung(null); + } + + if (!aktiveId) { + return ( + +
+ {meldung && } + +
+ + setBezeichnung(ereignis.target.value)} + placeholder="Jahresinventur Gerätehaus" + /> +
+ + + +

+ Das Beginnen braucht Netz. Das Scannen danach nicht — die Scans warten notfalls in + der Warteschlange. +

+
+
+ ); + } + + return ( + +
+ {meldung && } + +
+
+
+ {(stand.data?.gescannt ?? 0) + gescannt.length} +
+
gescannt
+
+
+
+ {stand.data?.erwartet ?? "—"} +
+
erwartet
+
+
+
+ {stand.data ? stand.data.fehlend.length : "—"} +
+
fehlt noch
+
+
+ +
+

+ + Scannen +

+ +
+ + {gescannt.length > 0 && ( +
+

Gerade gescannt

+
    + {gescannt.slice(0, 12).map((nummer) => ( +
  • + + {nummer} +
  • + ))} +
+
+ )} + + {stand.data && stand.data.fehlend.length > 0 && ( +
+

Fehlt noch

+
    + {stand.data.fehlend.slice(0, 30).map((geraet) => ( +
  • + {geraet.inventarnummer} —{" "} + {geraet.bezeichnung} +
  • + ))} +
+
+ )} + + +
+
+ ); +} diff --git a/src/app/kiosk/layout.tsx b/src/app/kiosk/layout.tsx new file mode 100644 index 0000000..279bf33 --- /dev/null +++ b/src/app/kiosk/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata, Viewport } from "next"; +import { TrpcProvider } from "@/lib/trpc/provider"; +import { WarteschlangeProvider } from "@/components/kiosk/warteschlange"; +import { ServiceWorkerAnmeldung } from "@/components/kiosk/service-worker"; + +export const metadata: Metadata = { + title: "FLORIAN Terminal", + // Ein Terminal gehört nicht in eine Suchmaschine. + robots: { index: false, follow: false }, + appleWebApp: { capable: true, title: "FLORIAN", statusBarStyle: "black-translucent" }, +}; + +export const viewport: Viewport = { + themeColor: "#b91c1c", + // Kein Zoom auf dem Wandtablet: ein versehentliches Aufziehen mit dem + // Handschuh verschiebt sonst die halbe Oberfläche, und niemand findet zurück. + initialScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: "cover", +}; + +/** + * Layout des Terminals. + * + * Bewusst eigenständig, ohne die Seitenleiste der Verwaltungsoberfläche: + * + * * **Große Ziele.** Alles Bedienbare ist mindestens 64 Pixel hoch. Wer mit + * Feuerwehrhandschuhen tippt, trifft keine 32-Pixel-Knöpfe. + * * **Kein Hover.** Ein Touchgerät kennt kein Darüberfahren; alles, was + * wichtig ist, steht immer da. + * * **Kein Zoom, kein Scrollen in der Breite.** Sonst verrutscht die + * Oberfläche, sobald jemand sie mit dem Ärmel streift. + * + * Der Service Worker und die Warteschlange hängen hier — beide gelten für + * alles unterhalb von /kiosk. + */ +export default function KioskLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + +
+ {children} +
+
+
+ ); +} diff --git a/src/app/kiosk/mangel/page.tsx b/src/app/kiosk/mangel/page.tsx new file mode 100644 index 0000000..86ff2fa --- /dev/null +++ b/src/app/kiosk/mangel/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; +import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; +import { env } from "@/lib/env"; +import { MangelWizard } from "@/app/kiosk/mangel/wizard"; + +export const metadata = { title: "Mangel melden" }; + +export default async function MangelSeite() { + const sitzung = await aktuelleSitzung(); + if (!sitzung) redirect("/kiosk"); + if (!hatRecht(sitzung, "pruefung.lesen")) redirect("/kiosk"); + + return ; +} diff --git a/src/app/kiosk/mangel/wizard.tsx b/src/app/kiosk/mangel/wizard.tsx new file mode 100644 index 0000000..cb47910 --- /dev/null +++ b/src/app/kiosk/mangel/wizard.tsx @@ -0,0 +1,221 @@ +"use client"; + +import * as React from "react"; +import { Camera, ScanLine } from "lucide-react"; +import { MangelPrioritaet } 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 { Erledigt, GrosseAuswahl, KioskRahmen } from "@/components/kiosk/rahmen"; +import { QrLeser } from "@/components/kiosk/qr-leser"; +import { useWarteschlange } from "@/components/kiosk/warteschlange"; +import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser"; + +/** + * Mangel melden — der Vorgang, an dem sich das ganze Terminal misst. + * + * Anspruch aus der Anforderung: **unter dreißig Sekunden**. Deshalb drei + * Schritte und kein Formular: + * + * 1. QR-Code am Gerät scannen (oder Nummer eintippen) + * 2. Foto und ein Satz dazu + * 3. Dringlichkeit antippen — fertig + * + * Das Foto wandert samt Meldung in die Warteschlange. Ist gerade kein Netz da, + * wird es später hochgeladen; die Meldung geht in jedem Fall raus. + */ + +const PRIORITAET: Array<{ wert: MangelPrioritaet; titel: string; hinweis: string }> = [ + { wert: MangelPrioritaet.NIEDRIG, titel: "Kleinigkeit", hinweis: "kann warten" }, + { wert: MangelPrioritaet.MITTEL, titel: "Normal", hinweis: "beim nächsten Dienst" }, + { wert: MangelPrioritaet.HOCH, titel: "Dringend", hinweis: "vor dem nächsten Einsatz" }, + { + wert: MangelPrioritaet.KRITISCH, + titel: "Kritisch", + hinweis: "Gerät wird sofort gesperrt", + }, +]; + +export function MangelWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) { + const { erfassen } = useWarteschlange(); + const paket = trpc.kiosk.stammdatenpaket.useQuery(undefined, { retry: false }); + + const [inventarnummer, setInventarnummer] = React.useState(""); + const [beschreibung, setBeschreibung] = React.useState(""); + const [foto, setFoto] = React.useState<{ inhalt: Blob; name: string } | null>(null); + const [vorschau, setVorschau] = React.useState(null); + const [fertig, setFertig] = React.useState(null); + + const kamera = React.useRef(null); + + const geraet = paket.data?.geraete.find( + (eintrag) => eintrag.inventarnummer.toLowerCase() === inventarnummer.trim().toLowerCase(), + ); + + React.useEffect(() => { + return () => { + if (vorschau) URL.revokeObjectURL(vorschau); + }; + }, [vorschau]); + + async function melden(prioritaet: MangelPrioritaet) { + const nummer = inventarnummer.trim(); + if (!nummer || !beschreibung.trim()) return; + + await erfassen( + { + art: "MANGEL_MELDEN", + daten: { inventarnummer: nummer, beschreibung: beschreibung.trim(), prioritaet }, + }, + `Mangel an ${nummer}`, + foto ?? undefined, + ); + + setFertig( + prioritaet === MangelPrioritaet.KRITISCH + ? `Mangel gemeldet. ${nummer} wird gesperrt.` + : `Mangel an ${nummer} gemeldet.`, + ); + } + + return ( + + {fertig ? ( + + ) : ( +
+ {/* Schritt 1: Gerät */} +
+

+ + Welches Gerät? +

+ + {!inventarnummer && ( + setInventarnummer(inventarnummerAusScan(inhalt) ?? inhalt)} + /> + )} + +
+ + setInventarnummer(ereignis.target.value)} + placeholder="INV-0001" + /> + {inventarnummer && ( +

+ {geraet ? ( + <> + {geraet.bezeichnung}{" "} + ({geraet.status}) + + ) : paket.data ? ( + + Diese Nummer ist im Bestand nicht bekannt — die Meldung geht trotzdem raus + und landet notfalls in der Konfliktliste. + + ) : ( + + Bestand nicht geladen — die Nummer wird beim Übertragen geprüft. + + )} +

+ )} +
+
+ + {/* Schritt 2: Foto und Beschreibung */} +
+

Was ist kaputt?

+ +