Terminal im Geraetehaus mit Offline-Betrieb ergaenzen
Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz weg ist. Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang, Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel; kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und verweigert UPDATE und DELETE auf kiosk_vorgang. Offline: von Hand geschriebener Service Worker, Warteschlange in der IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als Konflikt unter /terminal statt verworfen zu werden. Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy, Hallenmonitor mit optionalen DWD-Warnungen, Installation als App. Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal geschickt. Gefunden vom Offline-E2E-Test. 31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -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.
|
||||
|
||||
@@ -8,7 +8,126 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
`/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
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 415 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 733 KiB |
|
Before Width: | Height: | Size: 754 KiB After Width: | Height: | Size: 756 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 965 KiB After Width: | Height: | Size: 968 KiB |
|
Before Width: | Height: | Size: 1010 KiB After Width: | Height: | Size: 1013 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 555 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 389 KiB After Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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:");
|
||||
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
import { CloudOff } from "lucide-react";
|
||||
|
||||
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.
|
||||
*/
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Erledigt, GrosseAuswahl, KioskRahmen } from "@/components/kiosk/rahmen";
|
||||
import { useWarteschlange } from "@/components/kiosk/warteschlange";
|
||||
|
||||
/**
|
||||
* Wäsche abgeben.
|
||||
*
|
||||
* Steht am Wäschekorb im Gerätehaus. Wer seine Einsatzjacke einwirft, tippt
|
||||
* hier, was drin ist — sonst weiß später niemand, wem das Teil gehört, das aus
|
||||
* der Reinigung zurückkommt.
|
||||
*
|
||||
* Die Auswahl ist bewusst eine Liste der üblichen Stücke und kein Textfeld:
|
||||
* mit Handschuhen tippt man ungern, und „Einsatzjacke" schreibt jeder anders.
|
||||
*/
|
||||
|
||||
const STUECKE = [
|
||||
"Einsatzjacke",
|
||||
"Einsatzhose",
|
||||
"Überjacke",
|
||||
"Handschuhe",
|
||||
"Flammschutzhaube",
|
||||
"Sonstiges",
|
||||
];
|
||||
|
||||
const GRUENDE = [
|
||||
{ titel: "Einsatzverschmutzung", hinweis: "nach Brand oder THL" },
|
||||
{ titel: "Kontamination", hinweis: "Brandrauch, Gefahrstoff" },
|
||||
{ titel: "Routine", hinweis: "turnusmäßig" },
|
||||
];
|
||||
|
||||
export function WaescheWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) {
|
||||
const { erfassen } = useWarteschlange();
|
||||
|
||||
const [bezeichnung, setBezeichnung] = React.useState<string | null>(null);
|
||||
const [eigene, setEigene] = React.useState("");
|
||||
const [menge, setMenge] = React.useState(1);
|
||||
const [fertig, setFertig] = React.useState<string | null>(null);
|
||||
|
||||
const stueck = bezeichnung === "Sonstiges" ? eigene.trim() : bezeichnung;
|
||||
|
||||
async function abgeben(grund: string) {
|
||||
if (!stueck) return;
|
||||
|
||||
await erfassen(
|
||||
{ art: "WAESCHE_ABGEBEN", daten: { bezeichnung: stueck, menge, grund } },
|
||||
`Wäsche abgegeben (${stueck})`,
|
||||
);
|
||||
|
||||
setFertig(`${menge}× ${stueck} abgegeben. Der Gerätewart sieht es in der Liste.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<KioskRahmen
|
||||
titel="Wäsche abgeben"
|
||||
hinweis="Was kommt in den Korb?"
|
||||
abmeldenNachSekunden={abmeldenNachSekunden}
|
||||
>
|
||||
{fertig ? (
|
||||
<Erledigt text={fertig} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium">Kleidungsstück</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{STUECKE.map((eintrag) => (
|
||||
<GrosseAuswahl
|
||||
key={eintrag}
|
||||
titel={eintrag}
|
||||
gewaehlt={bezeichnung === eintrag}
|
||||
onClick={() => setBezeichnung(eintrag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{bezeichnung === "Sonstiges" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eigene" className="text-lg">
|
||||
Was genau?
|
||||
</Label>
|
||||
<Input
|
||||
id="eigene"
|
||||
className="h-16 text-xl"
|
||||
value={eigene}
|
||||
onChange={(ereignis) => setEigene(ereignis.target.value)}
|
||||
placeholder="Einsatzstiefel, Helmfutter …"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{stueck && (
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium">Wie viele?</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 w-20"
|
||||
onClick={() => setMenge((wert) => Math.max(1, wert - 1))}
|
||||
aria-label="weniger"
|
||||
>
|
||||
<Minus className="size-8" aria-hidden />
|
||||
</Button>
|
||||
<span className="tabellenzahlen w-20 text-center text-4xl font-semibold">
|
||||
{menge}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 w-20"
|
||||
onClick={() => setMenge((wert) => Math.min(50, wert + 1))}
|
||||
aria-label="mehr"
|
||||
>
|
||||
<Plus className="size-8" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-medium">Warum?</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{GRUENDE.map((grund) => (
|
||||
<GrosseAuswahl
|
||||
key={grund.titel}
|
||||
titel={grund.titel}
|
||||
hinweis={grund.hinweis}
|
||||
onClick={() => void abgeben(grund.titel)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</KioskRahmen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* Das Web-App-Manifest.
|
||||
*
|
||||
* Damit lässt sich FLORIAN auf dem Wandtablet als Anwendung ablegen: Vollbild,
|
||||
* kein Browserrahmen, eigenes Symbol. Startpunkt ist bewusst `/kiosk` — wer
|
||||
* das Tablet einschaltet, will das Terminal, nicht die Verwaltungsoberfläche.
|
||||
*
|
||||
* Keine externen Symbole, keine nachgeladenen Schriften: das Manifest verweist
|
||||
* nur auf Dateien aus dem eigenen Verzeichnis.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "FLORIAN — Feuerwehrverwaltung",
|
||||
short_name: "FLORIAN",
|
||||
description:
|
||||
"Terminal im Gerätehaus: Anwesenheit, Fahrtenbuch, Mängel, Inventur — auch ohne Netz.",
|
||||
start_url: "/kiosk",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "any",
|
||||
lang: "de",
|
||||
dir: "ltr",
|
||||
background_color: "#faf9f7",
|
||||
theme_color: "#b91c1c",
|
||||
categories: ["productivity", "utilities"],
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/florian-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/florian-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/florian-maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||