Phase 4: Terminal im Gerätehaus mit Offline-Betrieb #5

Merged
florian.wilde merged 2 commits from feat/phase-4-kiosk into main 2026-09-03 18:25:36 +00:00
128 changed files with 7860 additions and 41 deletions
+15
View File
@@ -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.
+129 -1
View File
@@ -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.
---
+126 -1
View File
@@ -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
+5
View File
@@ -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}
+204 -11
View File
@@ -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:
+37 -1
View File
@@ -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**
+99 -1
View File
@@ -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 | `<Suspense>` 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
---
+11 -6
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 KiB

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 965 KiB

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1010 KiB

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+20 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+11 -1
View File
@@ -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
@@ -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;
@@ -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;
$$;
+364 -7
View File
@@ -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")
}
+233 -1
View File
@@ -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<void> {
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:");
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+163
View File
@@ -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(
"<!doctype html><meta charset=utf-8><title>Offline</title>" +
"<p style='font:16px system-ui;padding:2rem'>Kein Netz und nichts im Zwischenspeicher.</p>",
{ 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));
}
});
+116
View File
@@ -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 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>
</svg>`;
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 `<!doctype html>
<html><head><meta charset="utf-8"><style>
html, body { margin: 0; padding: 0; }
.grund {
width: ${vorlage.groesse}px;
height: ${vorlage.groesse}px;
background: ${vorlage.hintergrund};
border-radius: ${vorlage.eckenRadius};
display: flex;
align-items: center;
justify-content: center;
color: ${vorlage.farbe};
}
svg {
width: ${vorlage.groesse - vorlage.rand * 2}px;
height: ${vorlage.groesse - vorlage.rand * 2}px;
}
</style></head>
<body><div class="grund">${FLAMME}</div></body></html>`;
}
async function main(): Promise<void> {
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);
});
+88 -2
View File
@@ -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<void>
}
}
/**
* 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<void> {
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<void> {
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<void> {
await portalAufnehmen(seite, darstellung);
await kontext.close();
// Eigener Kontext, weil dieser hier angemeldet ist.
await kioskAnmeldungAufnehmen(browser, darstellung);
}
await browser.close();
+96
View File
@@ -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 (
<div className="mx-auto max-w-md space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Anmeldemarke</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Am Terminal im Gerätehaus vor die Kamera halten.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{name}</CardTitle>
<CardDescription>
Gültig noch {rest} {rest === 1 ? "Sekunde" : "Sekunden"} — danach erneuert sich der
Code von selbst.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Image
src={bild}
alt="Anmeldemarke als QR-Code"
width={420}
height={420}
unoptimized
className="mx-auto h-auto w-full max-w-xs rounded-lg border border-[var(--border)] bg-white p-3"
/>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full bg-[var(--primary)] transition-all duration-1000"
style={{ width: `${(rest / gueltigSekunden) * 100}%` }}
role="presentation"
/>
</div>
<Button variant="outline" className="w-full" onClick={() => router.refresh()}>
<RefreshCw aria-hidden />
Neuen Code anzeigen
</Button>
</CardContent>
</Card>
<p className="text-sm text-[var(--muted-foreground)]">
Der Code enthält keine Daten über dich, sondern nur eine kurzlebige Unterschrift. Ein
abfotografierter Code nützt nach einer Minute nichts mehr.
</p>
</div>
);
}
+33
View File
@@ -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 (
<MarkenAnzeige
bild={bild}
name={sitzung.name}
gueltigSekunden={MARKE_GUELTIG_SEKUNDEN}
/>
);
}
+46
View File
@@ -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 (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Terminal im Gerätehaus</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Anwesenheit, Fahrtenbuch, Wäsche — und was beim Nachtragen hängen geblieben ist.
</p>
</div>
<Button variant="outline" asChild>
<Link href="/kiosk">
<MonitorSmartphone aria-hidden />
Terminaloberfläche öffnen
</Link>
</Button>
</div>
<TerminalUebersicht
darfKonflikte={hatRecht(sitzung, "system.auditlog")}
darfWaesche={hatRecht(sitzung, "geraet.bearbeiten")}
/>
</div>
);
}
+288
View File
@@ -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<WaescheStatus, string> = {
ABGEGEBEN: "abgegeben",
IN_REINIGUNG: "in Reinigung",
ZURUECK: "zurück",
VERSCHOLLEN: "verschollen",
};
const GRUND_TEXT: Record<string, string> = {
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<string | null>(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 (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="terminalmeldung" />}
{darfKonflikte && (
<Card>
<CardHeader>
<CardTitle>Konflikte beim Nachtragen</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent>
{(konflikte.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Keine offenen Konflikte"
hinweis="Alles, was vom Terminal kam, ließ sich anwenden."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Wann</TableHead>
<TableHead>Was</TableHead>
<TableHead>Grund</TableHead>
<TableHead>Wer</TableHead>
<TableHead className="w-32" aria-label="Aktion" />
</TableRow>
</TableHeader>
<TableBody>
{konflikte.data?.map((konflikt) => (
<TableRow key={konflikt.id}>
<TableCell className="tabellenzahlen whitespace-nowrap text-sm">
{alsDatumZeit(konflikt.entstandenAm)}
</TableCell>
<TableCell>
<div className="text-sm font-medium">{konflikt.art}</div>
<div className="text-xs text-[var(--muted-foreground)]">
{konflikt.beschreibung}
</div>
</TableCell>
<TableCell>
<Badge variant="pruefungFaellig">
{GRUND_TEXT[konflikt.grund] ?? konflikt.grund}
</Badge>
</TableCell>
<TableCell className="text-sm">
{konflikt.person
? `${konflikt.person.nachname}, ${konflikt.person.vorname}`
: "—"}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => {
const erledigung = window.prompt("Wie wurde es gelöst?");
if (erledigung === null) return;
erledigen.mutate({
id: konflikt.id,
erledigung: erledigung || undefined,
});
}}
>
<Check aria-hidden />
Erledigt
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Im Haus</CardTitle>
<CardDescription>{anwesende.data?.length ?? 0} eingecheckt</CardDescription>
</CardHeader>
<CardContent>
{(anwesende.data?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Gerade niemand.</p>
) : (
<ul className="space-y-1 text-sm">
{anwesende.data?.map((eintrag) => (
<li key={eintrag.id} className="flex justify-between gap-3">
<span>
{eintrag.person.nachname}, {eintrag.person.vorname}
</span>
<span className="text-[var(--muted-foreground)]">
{eintrag.anlass.toLowerCase()} · seit {alsDatumZeit(eintrag.angemeldetUm)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Wäsche</CardTitle>
<CardDescription>Schutzkleidung, die noch nicht zurück ist</CardDescription>
</CardHeader>
<CardContent>
{(waesche.data?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Nichts offen.</p>
) : (
<ul className="space-y-2 text-sm">
{waesche.data?.map((abgabe) => (
<li key={abgabe.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{abgabe.menge}× {abgabe.bezeichnung}
</span>
<span className="text-[var(--muted-foreground)]">
{abgabe.person.nachname}, {abgabe.person.vorname} ·{" "}
{alsDatumZeit(abgabe.abgegebenAm)}
</span>
{darfWaesche ? (
<Select
value={abgabe.status}
onValueChange={(wert) =>
statusSetzen.mutate({ id: abgabe.id, status: wert as WaescheStatus })
}
>
<SelectTrigger
className="ml-auto h-8 w-40"
aria-label={`Stand für ${abgabe.bezeichnung}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(WAESCHE_TEXT) as WaescheStatus[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{WAESCHE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Badge variant="secondary">{WAESCHE_TEXT[abgabe.status]}</Badge>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Fahrtenbuch</CardTitle>
<CardDescription>Die letzten Fahrten, offene zuerst erkennbar</CardDescription>
</CardHeader>
<CardContent>
{(fahrten.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Fahrt erfasst"
hinweis="Fahrten werden am Terminal eingetragen — Fahrzeug, Kilometerstand, Zweck."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Fahrzeug</TableHead>
<TableHead>Fahrer</TableHead>
<TableHead>Zweck</TableHead>
<TableHead>Ziel</TableHead>
<TableHead className="text-right">Abfahrt</TableHead>
<TableHead className="text-right">km</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fahrten.data?.map((fahrt) => (
<TableRow key={fahrt.id}>
<TableCell>{fahrt.fahrzeug.funkrufname}</TableCell>
<TableCell className="text-sm">
{fahrt.fahrer.nachname}, {fahrt.fahrer.vorname}
</TableCell>
<TableCell className="text-sm">{fahrt.zweck.toLowerCase()}</TableCell>
<TableCell className="text-sm">{fahrt.ziel ?? "—"}</TableCell>
<TableCell className="text-right tabellenzahlen whitespace-nowrap">
{alsDatumZeit(fahrt.abfahrtUm)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrt.kilometerRueckkehr
? `${fahrt.kilometerRueckkehr - fahrt.kilometerAbfahrt} km`
: "unterwegs"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+13
View File
@@ -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 <AnwesenheitWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+97
View File
@@ -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<AnwesenheitAnlass, { titel: string; hinweis: string }> = {
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<string | null>(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 (
<KioskRahmen
titel={angemeldet ? "Gehen" : "Kommen"}
hinweis={
angemeldet
? `Angemeldet seit ${alsDatumZeit(angemeldet.angemeldetUm)}`
: "Wozu bist du da?"
}
abmeldenNachSekunden={abmeldenNachSekunden}
>
{fertig ? (
<Erledigt text={fertig} />
) : !standBekannt ? (
<WirdGeladen />
) : angemeldet ? (
<div className="space-y-4">
<Button className="h-24 w-full text-2xl" onClick={() => void gehen()}>
Jetzt gehen
</Button>
<p className="text-center text-[var(--muted-foreground)]">
Anlass: {ANLASS_TEXT[angemeldet.anlass].titel}
</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{(Object.keys(ANLASS_TEXT) as AnwesenheitAnlass[]).map((anlass) => (
<GrosseAuswahl
key={anlass}
titel={ANLASS_TEXT[anlass].titel}
hinweis={ANLASS_TEXT[anlass].hinweis}
onClick={() => void kommen(anlass)}
/>
))}
</div>
)}
{offline && !fertig && standBekannt && (
<p className="text-center text-[var(--muted-foreground)]">
Ohne Netz wird trotzdem erfasst — die Zeit ist die von jetzt, nicht die der Übertragung.
</p>
)}
</KioskRahmen>
);
}
+120
View File
@@ -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<string | null>(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 (
<div className="mx-auto max-w-xl space-y-6 p-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Terminal einrichten</h1>
<p className="mt-2 text-lg text-[var(--muted-foreground)]">
Einmal je Tablet. Danach meldet sich jeder mit Ausweis, QR-Code oder PIN an.
</p>
</div>
{bestehend && (
<div className="rounded-lg border border-[var(--border)] p-4">
<p className="text-lg">
Dieses Tablet ist bereits für <strong>{bestehend}</strong> eingerichtet.
</p>
<Button
variant="outline"
className="mt-3 h-14 text-lg"
onClick={() => {
geraetVergessen();
setBestehend(null);
}}
>
Einrichtung löschen
</Button>
</div>
)}
<form
className="space-y-4"
onSubmit={(ereignis) => {
ereignis.preventDefault();
geraetEinrichten({
geraeteSchluessel: schluessel.trim(),
organisationSlug: wehr.trim(),
});
router.push("/kiosk");
router.refresh();
}}
>
<div className="space-y-2">
<Label htmlFor="wehr" className="text-lg">
Kurzname der Wehr
</Label>
<Input
id="wehr"
className="h-16 text-xl"
value={wehr}
onChange={(ereignis) => setWehr(ereignis.target.value)}
placeholder="ff-musterhausen"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="schluessel" className="text-lg">
Gerätegeheimnis
</Label>
<Input
id="schluessel"
type="password"
className="h-16 text-xl"
value={schluessel}
onChange={(ereignis) => setSchluessel(ereignis.target.value)}
required
/>
<p className="text-[var(--muted-foreground)]">
Steht als <code>KIOSK_DEVICE_SECRET</code> in der <code>.env</code> des Servers. Es
bleibt auf diesem Tablet und geht nur beim Anmelden mit.
</p>
</div>
<Button type="submit" className="h-16 w-full text-lg">
Terminal einrichten
</Button>
</form>
</div>
);
}
+24
View File
@@ -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 (
<Suspense fallback={<p className="p-6 text-lg">Einrichtung wird geladen …</p>}>
<EinrichtenFormular />
</Suspense>
);
}
+15
View File
@@ -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 <FahrtWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+259
View File
@@ -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<string | null>(null);
const [kilometer, setKilometer] = React.useState("");
const [ziel, setZiel] = React.useState("");
const [tankmenge, setTankmenge] = React.useState("");
const [fertig, setFertig] = React.useState<string | null>(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 (
<KioskRahmen titel="Fahrtenbuch" abmeldenNachSekunden={abmeldenNachSekunden}>
<Erledigt text={fertig} />
</KioskRahmen>
);
}
// 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 (
<KioskRahmen titel="Fahrtenbuch" abmeldenNachSekunden={abmeldenNachSekunden}>
<WirdGeladen />
</KioskRahmen>
);
}
if (offeneFahrt) {
return (
<KioskRahmen
titel="Fahrt beenden"
hinweis={`${offeneFahrt.fahrzeug.funkrufname} · unterwegs seit ${alsDatumZeit(offeneFahrt.abfahrtUm)}`}
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="kmzurueck" className="text-lg">
Kilometerstand bei Rückkehr
</Label>
<Input
id="kmzurueck"
type="number"
inputMode="numeric"
className="h-20 text-3xl"
value={kilometer}
onChange={(ereignis) => setKilometer(ereignis.target.value)}
/>
<p className="text-[var(--muted-foreground)]">
Bei Abfahrt: {offeneFahrt.kilometerAbfahrt} km
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tank" className="text-lg">
Getankt (Liter, falls unterwegs getankt wurde)
</Label>
<Input
id="tank"
type="number"
inputMode="decimal"
step="0.01"
className="h-16 text-2xl"
value={tankmenge}
onChange={(ereignis) => setTankmenge(ereignis.target.value)}
/>
</div>
<Button
className="h-24 w-full text-2xl"
disabled={!kilometer || Number(kilometer) < offeneFahrt.kilometerAbfahrt}
onClick={() => void beenden()}
>
Fahrt beenden
</Button>
{kilometer && Number(kilometer) < offeneFahrt.kilometerAbfahrt && (
<p className="text-center text-[var(--gesperrt)]">
Der Stand kann nicht kleiner sein als bei der Abfahrt.
</p>
)}
</div>
</KioskRahmen>
);
}
return (
<KioskRahmen
titel="Fahrt beginnen"
hinweis="Fahrzeug, Kilometerstand, Zweck"
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-6">
<section className="space-y-3">
<h2 className="text-xl font-medium">Welches Fahrzeug?</h2>
<div className="grid gap-3 sm:grid-cols-2">
{fahrzeuge.map((fahrzeug) => (
<GrosseAuswahl
key={fahrzeug.id}
titel={fahrzeug.funkrufname}
hinweis={fahrzeug.dinTyp ?? fahrzeug.bezeichnung}
gewaehlt={fahrzeugId === fahrzeug.id}
onClick={() => {
setFahrzeugId(fahrzeug.id);
setKilometer(fahrzeug.kilometerstand ? String(fahrzeug.kilometerstand) : "");
}}
/>
))}
</div>
{fahrzeuge.length === 0 && (
<p className="text-[var(--muted-foreground)]">
Keine Fahrzeuge im Zwischenspeicher. Einmal mit Netz öffnen, danach steht die Liste
auch offline.
</p>
)}
</section>
{fahrzeugId && (
<>
<section className="space-y-3">
<div className="space-y-2">
<Label htmlFor="km" className="text-lg">
Kilometerstand bei Abfahrt
</Label>
<Input
id="km"
type="number"
inputMode="numeric"
className="h-20 text-3xl"
value={kilometer}
onChange={(ereignis) => setKilometer(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ziel" className="text-lg">
Ziel (kann leer bleiben)
</Label>
<Input
id="ziel"
className="h-16 text-xl"
value={ziel}
onChange={(ereignis) => setZiel(ereignis.target.value)}
placeholder="Werkstatt Musterhausen"
/>
</div>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium">Wozu?</h2>
<div className="grid gap-3 sm:grid-cols-2">
{ZWECK.map((eintrag) => (
<GrosseAuswahl
key={eintrag.wert}
titel={eintrag.titel}
hinweis={eintrag.hinweis}
onClick={() => void beginnen(eintrag.wert)}
/>
))}
</div>
{!kilometer && (
<p className="text-[var(--muted-foreground)]">
Der Kilometerstand fehlt noch.
</p>
)}
</section>
</>
)}
</div>
</KioskRahmen>
);
}
+15
View File
@@ -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 <InventurWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+206
View File
@@ -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<string | null>(null);
const [bezeichnung, setBezeichnung] = React.useState("");
const [gescannt, setGescannt] = React.useState<string[]>([]);
const [meldung, setMeldung] = React.useState<string | null>(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 (
<KioskRahmen
titel="Inventur"
hinweis="Kein Gang offen — einen neuen beginnen."
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-4">
{meldung && <Fehlermeldung meldung={meldung} kennung="inventurmeldung" />}
<div className="space-y-2">
<Label htmlFor="bezeichnung" className="text-lg">
Was wird gezählt?
</Label>
<Input
id="bezeichnung"
className="h-16 text-xl"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Jahresinventur Gerätehaus"
/>
</div>
<Button
className="h-20 w-full text-2xl"
disabled={!bezeichnung.trim() || starten.isPending}
onClick={() => starten.mutate({ bezeichnung: bezeichnung.trim() })}
>
Inventur beginnen
</Button>
<p className="text-[var(--muted-foreground)]">
Das Beginnen braucht Netz. Das Scannen danach nicht — die Scans warten notfalls in
der Warteschlange.
</p>
</div>
</KioskRahmen>
);
}
return (
<KioskRahmen
titel="Inventur läuft"
hinweis={stand.data?.bezeichnung ?? laufende?.bezeichnung ?? "Gerät für Gerät scannen"}
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="inventurmeldung" />}
<div className="grid grid-cols-3 gap-3 text-center">
<div className="rounded-xl border-2 border-[var(--border)] p-4">
<div className="tabellenzahlen text-3xl font-semibold">
{(stand.data?.gescannt ?? 0) + gescannt.length}
</div>
<div className="text-[var(--muted-foreground)]">gescannt</div>
</div>
<div className="rounded-xl border-2 border-[var(--border)] p-4">
<div className="tabellenzahlen text-3xl font-semibold">
{stand.data?.erwartet ?? "—"}
</div>
<div className="text-[var(--muted-foreground)]">erwartet</div>
</div>
<div className="rounded-xl border-2 border-[var(--border)] p-4">
<div className="tabellenzahlen text-3xl font-semibold text-[var(--gesperrt)]">
{stand.data ? stand.data.fehlend.length : "—"}
</div>
<div className="text-[var(--muted-foreground)]">fehlt noch</div>
</div>
</div>
<section className="space-y-3">
<h2 className="flex items-center gap-2 text-xl font-medium">
<ScanLine aria-hidden />
Scannen
</h2>
<QrLeser automatischStarten beschriftung="Kamera starten" onGelesen={scannen} />
</section>
{gescannt.length > 0 && (
<section className="space-y-2">
<h2 className="text-xl font-medium">Gerade gescannt</h2>
<ul className="space-y-1">
{gescannt.slice(0, 12).map((nummer) => (
<li key={nummer} className="flex items-center gap-2 text-lg">
<CheckCircle2 className="size-5 text-[var(--einsatzbereit)]" aria-hidden />
<span className="tabellenzahlen">{nummer}</span>
</li>
))}
</ul>
</section>
)}
{stand.data && stand.data.fehlend.length > 0 && (
<section className="space-y-2">
<h2 className="text-xl font-medium">Fehlt noch</h2>
<ul className="max-h-60 space-y-1 overflow-y-auto text-lg">
{stand.data.fehlend.slice(0, 30).map((geraet) => (
<li key={geraet.id}>
<span className="tabellenzahlen">{geraet.inventarnummer}</span> —{" "}
{geraet.bezeichnung}
</li>
))}
</ul>
</section>
)}
<Button
variant="outline"
className="h-16 w-full text-lg"
disabled={abschliessen.isPending}
onClick={() => {
if (window.confirm("Inventur abschließen? Danach lässt sich nichts mehr scannen.")) {
abschliessen.mutate({ id: aktiveId });
}
}}
>
Inventur abschließen
</Button>
</div>
</KioskRahmen>
);
}
+49
View File
@@ -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 (
<TrpcProvider>
<WarteschlangeProvider>
<ServiceWorkerAnmeldung />
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] [touch-action:manipulation]">
{children}
</div>
</WarteschlangeProvider>
</TrpcProvider>
);
}
+15
View File
@@ -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 <MangelWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+221
View File
@@ -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<string | null>(null);
const [fertig, setFertig] = React.useState<string | null>(null);
const kamera = React.useRef<HTMLInputElement | null>(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 (
<KioskRahmen
titel="Mangel melden"
hinweis="Gerät scannen, Foto, ein Satz dazu — fertig."
abmeldenNachSekunden={abmeldenNachSekunden}
>
{fertig ? (
<Erledigt text={fertig} />
) : (
<div className="space-y-6">
{/* Schritt 1: Gerät */}
<section className="space-y-3">
<h2 className="flex items-center gap-2 text-xl font-medium">
<ScanLine aria-hidden />
Welches Gerät?
</h2>
{!inventarnummer && (
<QrLeser
automatischStarten
beschriftung="Kamera für den QR-Code"
onGelesen={(inhalt) => setInventarnummer(inventarnummerAusScan(inhalt) ?? inhalt)}
/>
)}
<div className="space-y-2">
<Label htmlFor="inventarnummer" className="text-lg">
Inventarnummer
</Label>
<Input
id="inventarnummer"
className="h-16 text-2xl"
value={inventarnummer}
onChange={(ereignis) => setInventarnummer(ereignis.target.value)}
placeholder="INV-0001"
/>
{inventarnummer && (
<p className="text-lg">
{geraet ? (
<>
<strong>{geraet.bezeichnung}</strong>{" "}
<span className="text-[var(--muted-foreground)]">({geraet.status})</span>
</>
) : paket.data ? (
<span className="text-[var(--gesperrt)]">
Diese Nummer ist im Bestand nicht bekannt — die Meldung geht trotzdem raus
und landet notfalls in der Konfliktliste.
</span>
) : (
<span className="text-[var(--muted-foreground)]">
Bestand nicht geladen — die Nummer wird beim Übertragen geprüft.
</span>
)}
</p>
)}
</div>
</section>
{/* Schritt 2: Foto und Beschreibung */}
<section className="space-y-3">
<h2 className="text-xl font-medium">Was ist kaputt?</h2>
<Textarea
rows={3}
className="text-lg"
value={beschreibung}
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
placeholder="Riss im Schlauch, etwa 20 cm von der Kupplung"
aria-label="Beschreibung des Mangels"
/>
<input
ref={kamera}
type="file"
accept="image/*"
capture="environment"
className="hidden"
aria-label="Foto aufnehmen"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (!datei) return;
setFoto({ inhalt: datei, name: datei.name || "mangel.jpg" });
setVorschau(URL.createObjectURL(datei));
}}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
className="h-16 text-lg"
onClick={() => kamera.current?.click()}
>
<Camera aria-hidden />
{foto ? "Foto ersetzen" : "Foto aufnehmen"}
</Button>
{vorschau && (
// Objekt-URL aus der Kamera — die kann next/image nicht abrufen.
// eslint-disable-next-line @next/next/no-img-element
<img
src={vorschau}
alt="Aufgenommenes Foto"
className="h-24 rounded border border-[var(--border)]"
/>
)}
</div>
<p className="text-[var(--muted-foreground)]">
Ohne Netz wartet das Foto mit der Meldung in der Warteschlange und geht später raus.
</p>
</section>
{/* Schritt 3: Dringlichkeit — und damit abgeschickt */}
<section className="space-y-3">
<h2 className="text-xl font-medium">Wie dringend?</h2>
<div className="grid gap-3 sm:grid-cols-2">
{PRIORITAET.map((eintrag) => (
<GrosseAuswahl
key={eintrag.wert}
titel={eintrag.titel}
hinweis={eintrag.hinweis}
onClick={() => void melden(eintrag.wert)}
/>
))}
</div>
{(!inventarnummer.trim() || !beschreibung.trim()) && (
<p className="text-[var(--muted-foreground)]">
Gerät und Beschreibung fehlen noch — danach genügt ein Tipp auf die Dringlichkeit.
</p>
)}
</section>
</div>
)}
</KioskRahmen>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AlertTriangle, CloudRain, Flame, Truck, Users } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { WARNSTUFE_TEXT } from "@/lib/integrations/wetter-texte";
import { alsDatum, alsUhrzeit } from "@/lib/utils";
/**
* Hallenmonitor.
*
* Hängt an der Wand und wird im Vorbeigehen gelesen — aus drei, vier Metern
* Entfernung. Daraus folgt alles Weitere:
*
* * **Große Schrift, wenige Zahlen.** Wer stehen bleiben muss, um es zu
* lesen, liest es nicht.
* * **Keine Bedienung.** Kein Knopf, kein Menü, keine Abmeldeuhr.
* * **Was dringend ist, steht oben.** Ein laufender Einsatz verdrängt alles
* andere; danach kommen Warnungen, dann der Alltag.
*
* Die Daten kommen im Minutentakt nach. Fällt das Netz aus, bleibt der letzte
* Stand stehen — mit sichtbarer Uhrzeit, damit niemand einem alten Bild traut.
*/
const AKTUALISIERUNG_MS = 60_000;
/** Wie viele laufende Einsätze groß angezeigt werden, bevor der Rest zur Zeile wird. */
const GROSS_ANGEZEIGTE_EINSAETZE = 2;
export function Hallenmonitor() {
const [uhrzeit, setUhrzeit] = React.useState<Date>(new Date());
const daten = trpc.kiosk.monitor.useQuery(undefined, {
refetchInterval: AKTUALISIERUNG_MS,
retry: false,
});
const wetter = trpc.kiosk.wetterwarnungen.useQuery(undefined, {
refetchInterval: 5 * AKTUALISIERUNG_MS,
retry: false,
});
React.useEffect(() => {
const zeitgeber = setInterval(() => setUhrzeit(new Date()), 1000);
return () => clearInterval(zeitgeber);
}, []);
const stand = daten.data;
const warnungen = wetter.data?.warnungen ?? [];
const hoechsteWarnstufe = warnungen.reduce((hoechste, warnung) => Math.max(hoechste, warnung.stufe), 0);
return (
<div className="min-h-screen bg-[var(--background)] p-6">
{/* Kopfzeile: Uhr und Stand */}
<header className="flex flex-wrap items-baseline justify-between gap-4 border-b-2 border-[var(--border)] pb-4">
<div className="flex items-baseline gap-4">
<Flame className="size-10 text-[var(--primary)]" aria-hidden />
<span className="text-3xl font-semibold">Gerätehaus</span>
</div>
<div className="text-right">
<div className="tabellenzahlen text-6xl font-semibold leading-none">
{alsUhrzeit(uhrzeit)}
</div>
<div className="text-lg text-[var(--muted-foreground)]">{alsDatum(uhrzeit)}</div>
</div>
</header>
{/*
Laufender Einsatz verdrängt alles — aber höchstens zwei.
Mehr als zwei gleichzeitig laufende Einsätze gibt es selten, und wenn,
dann ist die Halle ohnehin leer. Ungebremst schöbe die Liste alles
Übrige aus dem Bild, und genau das darf auf einer Wandanzeige nicht
passieren: was nicht sichtbar ist, hilft niemandem. Der Rest steht als
Zeile darunter.
*/}
{stand?.laufendeEinsaetze.slice(0, GROSS_ANGEZEIGTE_EINSAETZE).map((einsatz) => (
<section
key={einsatz.id}
className="mt-6 rounded-2xl border-4 border-[var(--gesperrt)] bg-[var(--gesperrt)]/10 p-6"
>
<div className="flex items-center gap-4">
<AlertTriangle className="size-12 text-[var(--gesperrt)]" aria-hidden />
<div>
<p className="text-lg uppercase tracking-wide text-[var(--gesperrt)]">
Einsatz läuft
</p>
<p className="text-4xl font-semibold">{einsatz.stichwort}</p>
<p className="text-xl text-[var(--muted-foreground)]">
{einsatz.ort ?? "ohne Ortsangabe"} · alarmiert{" "}
{einsatz.alarmiertUm ? alsUhrzeit(einsatz.alarmiertUm) : "—"} ·{" "}
{einsatz.einsatznummer}
</p>
</div>
</div>
</section>
))}
{(stand?.laufendeEinsaetze.length ?? 0) > GROSS_ANGEZEIGTE_EINSAETZE && (
<p className="mt-3 text-xl text-[var(--muted-foreground)]">
{(stand?.laufendeEinsaetze.length ?? 0) - GROSS_ANGEZEIGTE_EINSAETZE} weitere Einsätze
laufen:{" "}
{stand?.laufendeEinsaetze
.slice(GROSS_ANGEZEIGTE_EINSAETZE)
.map((einsatz) => `${einsatz.einsatznummer} ${einsatz.stichwort}`)
.join(" · ")}
</p>
)}
{/* Wetterwarnung */}
{hoechsteWarnstufe >= 2 && (
<section className="mt-6 rounded-2xl border-4 border-[var(--pruefung-faellig)] bg-[var(--pruefung-faellig)]/10 p-6">
<div className="flex items-start gap-4">
<CloudRain className="size-12 text-[var(--pruefung-faellig)]" aria-hidden />
<div>
<p className="text-lg uppercase tracking-wide text-[var(--pruefung-faellig)]">
{WARNSTUFE_TEXT[hoechsteWarnstufe] ?? "Wetterwarnung"} des DWD
</p>
{warnungen
.filter((warnung) => warnung.stufe === hoechsteWarnstufe)
.slice(0, 2)
.map((warnung) => (
<p key={warnung.ueberschrift} className="text-2xl font-medium">
{warnung.ueberschrift || warnung.ereignis}
{warnung.bis && (
<span className="text-lg text-[var(--muted-foreground)]">
{" "}
bis {alsUhrzeit(warnung.bis)}
</span>
)}
</p>
))}
</div>
</div>
</section>
)}
<div className="mt-6 grid gap-6 lg:grid-cols-3">
{/* Anwesend */}
<section className="rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="flex items-center gap-2 text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
<Users className="size-5" aria-hidden />
Im Haus
</h2>
<p className="tabellenzahlen text-6xl font-semibold">{stand?.anwesend.length ?? 0}</p>
<ul className="mt-3 space-y-1 text-xl">
{stand?.anwesend.slice(0, 10).map((eintrag) => (
<li key={eintrag.id}>
{eintrag.person.nachname}, {eintrag.person.vorname}
<span className="ml-2 text-base text-[var(--muted-foreground)]">
seit {alsUhrzeit(eintrag.angemeldetUm)}
</span>
</li>
))}
</ul>
</section>
{/* Fahrzeuge */}
<section className="rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="flex items-center gap-2 text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
<Truck className="size-5" aria-hidden />
Fahrzeuge
</h2>
<ul className="mt-3 space-y-2 text-xl">
{stand?.fahrzeuge.map((fahrzeug) => (
<li key={fahrzeug.id} className="flex items-center justify-between gap-3">
<span>{fahrzeug.funkrufname}</span>
<span
className={
fahrzeug.unterwegs
? "rounded-full bg-[var(--gesperrt)]/15 px-3 py-1 text-base font-medium text-[var(--gesperrt)]"
: "rounded-full bg-[var(--einsatzbereit)]/15 px-3 py-1 text-base font-medium text-[var(--einsatzbereit)]"
}
>
{fahrzeug.unterwegs ? "unterwegs" : "im Haus"}
</span>
</li>
))}
</ul>
{stand && stand.unterwegs.length > 0 && (
<ul className="mt-3 space-y-1 text-base text-[var(--muted-foreground)]">
{stand.unterwegs.map((fahrt) => (
<li key={fahrt.id}>
{fahrt.fahrzeug.funkrufname}: {fahrt.fahrer.nachname} seit{" "}
{alsUhrzeit(fahrt.abfahrtUm)}
{fahrt.ziel ? ` → ${fahrt.ziel}` : ""}
</li>
))}
</ul>
)}
</section>
{/* Prüfungen und Termine */}
<section className="rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
Prüfungen
</h2>
<div className="mt-2 flex items-baseline gap-6">
<div>
<p
className={`tabellenzahlen text-6xl font-semibold ${
(stand?.ueberfaelligePruefungen ?? 0) > 0 ? "text-[var(--gesperrt)]" : ""
}`}
>
{stand?.ueberfaelligePruefungen ?? 0}
</p>
<p className="text-[var(--muted-foreground)]">überfällig</p>
</div>
<div>
<p className="tabellenzahlen text-4xl font-semibold">
{stand?.gesperrteGeraete ?? 0}
</p>
<p className="text-[var(--muted-foreground)]">gesperrt</p>
</div>
</div>
<ul className="mt-4 space-y-1 text-lg">
{stand?.naechsteTermine.slice(0, 5).map((termin) => (
<li key={termin.id}>
<span className="tabellenzahlen">{alsDatum(termin.faelligAm)}</span>{" "}
{termin.geraet.inventarnummer} — {termin.vorlage?.name ?? "Prüfung"}
</li>
))}
</ul>
</section>
</div>
{/* Übungen */}
{stand && stand.uebungen.length > 0 && (
<section className="mt-6 rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
Diese Woche
</h2>
<ul className="mt-2 space-y-1 text-2xl">
{stand.uebungen.map((uebung) => (
<li key={uebung.id}>
<span className="tabellenzahlen">{alsDatum(uebung.datum)}</span>
{uebung.beginnUm && (
<span className="tabellenzahlen"> {alsUhrzeit(uebung.beginnUm)}</span>
)}{" "}
{uebung.thema}
{uebung.ort && (
<span className="text-lg text-[var(--muted-foreground)]"> · {uebung.ort}</span>
)}
</li>
))}
</ul>
</section>
)}
<footer className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t-2 border-[var(--border)] pt-4 text-base text-[var(--muted-foreground)]">
<span>
Stand: {stand ? alsUhrzeit(stand.stand) : "—"}
{daten.isError && " · keine Verbindung, angezeigt wird der letzte Stand"}
{stand?.laufendeEinsaetze.length === 0 &&
stand.anwesend.length === 0 &&
" · ruhig im Haus"}
</span>
{/* Einziger Weg zurück — bewusst klein und unten. */}
<Link href="/kiosk" className="underline underline-offset-4">
zum Terminal
</Link>
</footer>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { Hallenmonitor } from "@/app/kiosk/monitor/monitor";
export const metadata = { title: "Hallenmonitor" };
/**
* Der Monitor in der Fahrzeughalle.
*
* Läuft im Vollbild und wird nicht bedient. Er braucht trotzdem eine Anmeldung
* — es stehen Namen darauf, und eine Halle ist kein abgeschlossener Raum. In
* der Praxis meldet sich das Anzeigegerät einmal an und bleibt es; die
* Abmeldeuhr läuft auf dieser Seite bewusst nicht mit.
*/
export default async function MonitorSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
return <Hallenmonitor />;
}
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { CloudOff } from "lucide-react";
import { ErneutVersuchen } from "@/app/kiosk/offline/wiederholen";
export const metadata = { title: "Offline" };
/**
* Die Seite, die der Service Worker zeigt, wenn nichts anderes mehr geht.
*
* Sie ist bewusst statisch und ohne Datenzugriff — sie muss auch dann noch
* erscheinen, wenn vom Server nichts mehr kommt. Und sie sagt das Wichtigste:
* das Erfasste ist nicht weg.
*
* Zurück findet sie von selbst: ein einzelner abgebrochener Aufruf genügt, um
* hier zu landen, und dann darf das Terminal nicht warten, bis jemand tippt.
*/
export default function OfflineSeite() {
return (
<div className="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center gap-6 p-6 text-center">
<CloudOff className="size-20 text-[var(--muted-foreground)]" aria-hidden />
<h1 className="text-3xl font-semibold tracking-tight">Kein Netz</h1>
<p className="text-lg text-[var(--muted-foreground)]">
Diese Seite liegt nicht im Zwischenspeicher des Terminals. Was bereits erfasst wurde,
wartet in der Warteschlange und geht raus, sobald das Netz wieder da ist — es ist nichts
verloren.
</p>
<ErneutVersuchen />
<Link
href="/kiosk"
className="flex h-16 items-center rounded-md border-2 border-[var(--border)] px-8 text-lg"
>
Zurück zu den Kacheln
</Link>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
/**
* Es allein noch einmal versuchen.
*
* Die Offline-Seite erscheint auch dann, wenn nur eine einzelne Anfrage
* unterwegs abgebrochen ist — ein kurzer Aussetzer des Repeaters genügt.
* Ohne diesen Baustein bliebe das Terminal danach auf dieser Seite stehen,
* bis jemand vorbeikommt und tippt. Am Hallenmonitor, den niemand bedient,
* hieße das: bis zum nächsten Dienstabend.
*
* Also versucht die Seite es selbst — sofort, wenn der Browser wieder Netz
* meldet, und sonst alle paar Sekunden.
*/
const ABSTAND_MS = 10_000;
export function ErneutVersuchen() {
const [versuche, setVersuche] = React.useState(0);
React.useEffect(() => {
const nochmal = () => {
setVersuche((wert) => wert + 1);
window.location.reload();
};
window.addEventListener("online", nochmal);
const zeitgeber = setInterval(() => {
// Ohne Netz spart der Versuch nichts — dann wartet die Seite auf das
// "online"-Ereignis darüber.
if (navigator.onLine) nochmal();
}, ABSTAND_MS);
return () => {
window.removeEventListener("online", nochmal);
clearInterval(zeitgeber);
};
}, []);
return (
<p className="text-[var(--muted-foreground)]" role="status" data-testid="offline-wiederholung">
Das Terminal versucht es alle {ABSTAND_MS / 1000} Sekunden von selbst
{versuche > 0 ? ` (${versuche}. Versuch)` : ""}.
</p>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht, rechteVon } from "@/lib/authz";
import { env } from "@/lib/env";
import { KioskAnmeldung } from "@/components/kiosk/anmeldung";
import { KioskKacheln } from "@/components/kiosk/kacheln";
export const metadata = { title: "Terminal" };
/**
* Die Startseite des Terminals.
*
* Ohne Anmeldung: der Anmeldeschirm. Mit Anmeldung: die Kacheln, und zwar nur
* die, für die die angemeldete Person auch das Recht hat. Die Prüfung passiert
* hier auf dem Server — die Kacheln bekommen nur das Ergebnis.
*/
export default async function KioskSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) return <KioskAnmeldung />;
return (
<KioskKacheln
name={sitzung.name}
abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT}
rechte={{
mangel: hatRecht(sitzung, "pruefung.lesen"),
fahrtenbuch: hatRecht(sitzung, "fahrtenbuch.fuehren"),
inventur: hatRecht(sitzung, "geraet.bearbeiten"),
pruefung: hatRecht(sitzung, "pruefung.durchfuehren"),
alleRechte: [...rechteVon(sitzung.rollen)].length,
}}
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { env } from "@/lib/env";
import { WaescheWizard } from "@/app/kiosk/waesche/wizard";
export const metadata = { title: "Wäsche abgeben" };
export default async function WaescheSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
return <WaescheWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}

Some files were not shown because too many files have changed in this diff Show More