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>
This commit is contained in:
Florian Wilde
2026-09-03 18:35:48 +02:00
co-authored by Claude Opus 5
parent d8d692098d
commit 791523027f
126 changed files with 7787 additions and 40 deletions
+75
View File
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import { useAbmelden } from "@/components/kiosk/warteschlange";
/**
* Abmelden nach Untätigkeit.
*
* Ein Terminal in der Fahrzeughalle bleibt angemeldet stehen, wenn jemand
* gerufen wird und weggeht. Der Nächste, der vorbeikommt, wäre dann unter
* fremdem Namen unterwegs — im Fahrtenbuch und in jeder Mangelmeldung.
*
* Deshalb: nach `KIOSK_IDLE_TIMEOUT` Sekunden ohne Berührung wird abgemeldet.
* Zehn Sekunden vorher erscheint eine Warnung, die sich mit einer Berührung
* wegwischen lässt — mitten in einem Wizard soll niemand hinausfliegen, nur
* weil er nachdenkt.
*/
const WARNUNG_SEKUNDEN = 10;
export function Abmelduhr({ sekunden }: { sekunden: number }) {
const [rest, setRest] = React.useState<number | null>(null);
const letzteAktivitaet = React.useRef(Date.now());
// Meldet ab, nachdem die Warteschlange noch einmal versucht hat zu
// übertragen — siehe useAbmelden().
const abmelden = useAbmelden();
const abmeldenRef = React.useRef(abmelden);
abmeldenRef.current = abmelden;
React.useEffect(() => {
const zuruecksetzen = () => {
letzteAktivitaet.current = Date.now();
setRest(null);
};
// Alles, was ein Mensch am Tablet tut, zählt als Aktivität.
const ereignisse = ["pointerdown", "keydown", "touchstart", "wheel"] as const;
for (const ereignis of ereignisse) {
window.addEventListener(ereignis, zuruecksetzen, { passive: true });
}
const zeitgeber = setInterval(() => {
const vergangen = Math.floor((Date.now() - letzteAktivitaet.current) / 1000);
const verbleibend = sekunden - vergangen;
if (verbleibend <= 0) {
void abmeldenRef.current();
return;
}
setRest(verbleibend <= WARNUNG_SEKUNDEN ? verbleibend : null);
}, 1000);
return () => {
clearInterval(zeitgeber);
for (const ereignis of ereignisse) {
window.removeEventListener(ereignis, zuruecksetzen);
}
};
}, [sekunden]);
if (rest === null) return null;
return (
<div
className="fixed inset-x-0 bottom-0 z-50 bg-[var(--gesperrt)] p-6 text-center text-white"
role="alert"
data-testid="abmeldewarnung"
>
<p className="text-2xl font-semibold">Abmeldung in {rest} Sekunden</p>
<p className="text-lg opacity-90">Bildschirm berühren, um angemeldet zu bleiben.</p>
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { CreditCard, KeyRound, QrCode, Settings } from "lucide-react";
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 { QrLeser } from "@/components/kiosk/qr-leser";
import { geraeteangaben, type Geraeteangaben } from "@/lib/kiosk/geraet";
/**
* Anmeldung am Terminal.
*
* Drei Wege, absichtlich in dieser Reihenfolge:
*
* 1. **Ausweis auflegen.** Der Regelfall. Der Leser am Tablet gibt die
* Kartenkennung als Tastatureingabe aus — schnell und mit abschließendem
* Enter. Deshalb liegt hier ein Zuhörer auf dem Fenster, kein Eingabefeld:
* wer die Karte auflegt, soll nichts antippen müssen.
* 2. **QR-Code vom Handy.** Wer den Ausweis vergessen hat.
* 3. **Mitgliedsnummer und PIN.** Wenn beides fehlt.
*
* Vor allen dreien steht die erste Schranke: das Tablet muss eingerichtet sein
* (siehe lib/kiosk/geraet.ts). Ohne Gerätegeheimnis kommt niemand hinein, auch
* nicht mit richtiger PIN.
*/
/** Wie schnell Zeichen kommen müssen, damit es ein Kartenleser ist und keine Hand. */
const LESER_HOECHSTABSTAND_MS = 60;
const LESER_MINDESTLAENGE = 6;
type Weg = "karte" | "marke" | "pin";
export function KioskAnmeldung() {
const router = useRouter();
const [geraet, setGeraet] = React.useState<Geraeteangaben | null>(null);
const [weg, setWeg] = React.useState<Weg>("karte");
const [fehler, setFehler] = React.useState<string | null>(null);
const [laeuft, setLaeuft] = React.useState(false);
const [mitgliedsnummer, setMitgliedsnummer] = React.useState("");
const [pin, setPin] = React.useState("");
React.useEffect(() => {
setGeraet(geraeteangaben());
}, []);
const anmelden = React.useCallback(
async (anbieter: string, daten: Record<string, string>) => {
setLaeuft(true);
setFehler(null);
try {
const ergebnis = await signIn(anbieter, { ...daten, redirect: false });
if (ergebnis?.error) {
setFehler(
anbieter === "terminal-marke"
? "Der Code wurde nicht erkannt. Auf dem Handy einen neuen anzeigen lassen."
: "Anmeldung fehlgeschlagen. Stimmen Mitgliedsnummer und PIN?",
);
return;
}
router.refresh();
} catch {
setFehler("Die Anmeldung ließ sich nicht durchführen. Ist das Terminal im Netz?");
} finally {
setLaeuft(false);
setPin("");
}
},
[router],
);
/**
* Kartenleser: Tastatureingaben abfangen.
*
* Ein RFID-Leser tippt die Kennung in Millisekunden und schließt mit Enter
* ab. Ein Mensch tippt langsamer — daran lassen sich beide unterscheiden,
* ohne dass ein Eingabefeld den Fokus braucht.
*/
React.useEffect(() => {
if (weg !== "karte" || !geraet) return;
// Festhalten, damit die Angaben auch im Zuhörer unten sicher vorhanden sind.
const angaben = geraet;
let puffer = "";
let letzterAnschlag = 0;
function beiTaste(ereignis: KeyboardEvent) {
// In einem Eingabefeld hat der Zuhörer nichts verloren.
const ziel = ereignis.target as HTMLElement | null;
if (ziel && (ziel.tagName === "INPUT" || ziel.tagName === "TEXTAREA")) return;
const jetzt = Date.now();
if (jetzt - letzterAnschlag > LESER_HOECHSTABSTAND_MS) puffer = "";
letzterAnschlag = jetzt;
if (ereignis.key === "Enter") {
const kennung = puffer.trim();
puffer = "";
if (kennung.length < LESER_MINDESTLAENGE) return;
void anmelden("terminal", {
geraeteSchluessel: angaben.geraeteSchluessel,
organisationSlug: angaben.organisationSlug,
art: "RFID",
kennung,
geheimnis: kennung,
});
return;
}
if (ereignis.key.length === 1) puffer += ereignis.key;
}
window.addEventListener("keydown", beiTaste);
return () => window.removeEventListener("keydown", beiTaste);
}, [weg, geraet, anmelden]);
// Noch nicht eingerichtet: dann hilft nur die Einrichtung.
if (geraet === null) {
return (
<div className="mx-auto max-w-xl space-y-6 p-6 text-center">
<h1 className="text-3xl font-semibold">Terminal nicht eingerichtet</h1>
<p className="text-lg text-[var(--muted-foreground)]">
Dieses Tablet weiß noch nicht, zu welcher Wehr es gehört. Die Einrichtung braucht das
Gerätegeheimnis aus der <code>.env</code> — sie macht der Administrator einmal.
</p>
<Button className="h-16 w-full text-lg" onClick={() => router.push("/kiosk/einrichten")}>
<Settings aria-hidden />
Terminal einrichten
</Button>
</div>
);
}
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="text-center">
<h1 className="text-4xl font-semibold tracking-tight">Anmelden</h1>
<p className="mt-2 text-lg text-[var(--muted-foreground)]">
Ausweis auflegen — oder einen anderen Weg wählen.
</p>
</div>
{fehler && <Fehlermeldung meldung={fehler} kennung="anmeldefehler" />}
{/* Wegwahl: große Schaltflächen, mit Handschuhen bedienbar. */}
<div className="grid grid-cols-3 gap-3">
{(
[
{ wert: "karte", text: "Ausweis", symbol: CreditCard },
{ wert: "marke", text: "QR-Code", symbol: QrCode },
{ wert: "pin", text: "PIN", symbol: KeyRound },
] as const
).map((eintrag) => {
const Symbol = eintrag.symbol;
return (
<Button
key={eintrag.wert}
variant={weg === eintrag.wert ? "default" : "outline"}
className="h-20 flex-col gap-1 text-base"
onClick={() => {
setWeg(eintrag.wert);
setFehler(null);
}}
>
<Symbol className="size-6" aria-hidden />
{eintrag.text}
</Button>
);
})}
</div>
{weg === "karte" && (
<div className="rounded-lg border-2 border-dashed border-[var(--border)] p-10 text-center">
<CreditCard className="mx-auto size-16 text-[var(--muted-foreground)]" aria-hidden />
<p className="mt-4 text-xl font-medium">Ausweis auf den Leser legen</p>
<p className="mt-1 text-[var(--muted-foreground)]">
{laeuft ? "wird geprüft …" : "Das Terminal wartet."}
</p>
</div>
)}
{weg === "marke" && (
<div className="space-y-3">
<p className="text-lg">
In FLORIAN auf dem Handy die Seite <strong>Anmeldemarke</strong> öffnen und den Code
vor die Kamera halten.
</p>
<QrLeser
automatischStarten
beschriftung="Kamera starten"
onGelesen={(inhalt) =>
void anmelden("terminal-marke", {
geraeteSchluessel: geraet.geraeteSchluessel,
marke: inhalt,
})
}
/>
</div>
)}
{weg === "pin" && (
<form
className="space-y-4"
onSubmit={(ereignis) => {
ereignis.preventDefault();
void anmelden("terminal", {
geraeteSchluessel: geraet.geraeteSchluessel,
organisationSlug: geraet.organisationSlug,
art: "PIN",
kennung: mitgliedsnummer,
geheimnis: pin,
});
}}
>
<div className="space-y-2">
<Label htmlFor="mitgliedsnummer" className="text-lg">
Mitgliedsnummer
</Label>
<Input
id="mitgliedsnummer"
inputMode="numeric"
autoComplete="off"
className="h-16 text-2xl"
value={mitgliedsnummer}
onChange={(ereignis) => setMitgliedsnummer(ereignis.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="pin" className="text-lg">
PIN
</Label>
<Input
id="pin"
type="password"
inputMode="numeric"
autoComplete="off"
className="h-16 text-2xl tracking-[0.3em]"
value={pin}
onChange={(ereignis) => setPin(ereignis.target.value)}
required
/>
</div>
<Button type="submit" className="h-16 w-full text-lg" disabled={laeuft}>
{laeuft ? "wird geprüft …" : "Anmelden"}
</Button>
</form>
)}
<p className="text-center text-sm text-[var(--muted-foreground)]">
Wehr: {geraet.organisationSlug} ·{" "}
<button
type="button"
className="underline underline-offset-4"
onClick={() => router.push("/kiosk/einrichten")}
>
Terminal neu einrichten
</button>
</p>
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
ClipboardCheck,
ClipboardList,
LogIn,
LogOut,
Monitor,
ScanLine,
Shirt,
Truck,
TriangleAlert,
} from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Zustandsleiste, useAbmelden, useWarteschlange } from "@/components/kiosk/warteschlange";
import { Abmelduhr } from "@/components/kiosk/abmelduhr";
import { alsUhrzeit } from "@/lib/utils";
/**
* Der Kachelschirm.
*
* Was ein Wandtablet mit Handschuhen bedienbar macht, sind drei Dinge: große
* Ziele, wenige davon, und eine sichtbare Rückmeldung. Deshalb sechs Kacheln
* statt einer Navigationsleiste, jede mindestens 160 Pixel hoch, jede mit
* Symbol **und** Text.
*
* Die Kacheln richten sich nach den Rechten der angemeldeten Person: der
* Gerätewart sieht die Inventur, ein Mitglied nicht.
*/
export interface KachelRechte {
mangel: boolean;
fahrtenbuch: boolean;
inventur: boolean;
pruefung: boolean;
alleRechte: number;
}
export function KioskKacheln({
name,
rechte,
abmeldenNachSekunden,
}: {
name: string;
rechte: KachelRechte;
abmeldenNachSekunden: number;
}) {
const { offline } = useWarteschlange();
const abmelden = useAbmelden();
const stand = trpc.kiosk.eigenerStand.useQuery(undefined, { retry: false });
const angemeldet = stand.data?.anwesenheit ?? null;
const fahrt = stand.data?.fahrt ?? null;
const kacheln = [
{
sichtbar: true,
titel: angemeldet ? "Gehen" : "Kommen",
hinweis: angemeldet
? `da seit ${alsUhrzeit(angemeldet.angemeldetUm)}`
: "Anwesenheit erfassen",
symbol: angemeldet ? LogOut : LogIn,
ziel: "/kiosk/anwesenheit",
hervorgehoben: Boolean(angemeldet),
},
{
sichtbar: rechte.mangel,
titel: "Mangel melden",
hinweis: "Gerät scannen, Foto, fertig",
symbol: TriangleAlert,
ziel: "/kiosk/mangel",
},
{
sichtbar: rechte.fahrtenbuch,
titel: fahrt ? "Fahrt beenden" : "Fahrt beginnen",
hinweis: fahrt ? fahrt.fahrzeug.funkrufname : "Fahrtenbuch",
symbol: Truck,
ziel: "/kiosk/fahrt",
hervorgehoben: Boolean(fahrt),
},
{
sichtbar: true,
titel: "Wäsche abgeben",
hinweis:
stand.data && stand.data.offeneWaesche > 0
? `${stand.data.offeneWaesche} noch nicht zurück`
: "Schutzkleidung zur Reinigung",
symbol: Shirt,
ziel: "/kiosk/waesche",
},
{
sichtbar: rechte.inventur,
titel: "Inventur",
hinweis: "Geräte am Regal scannen",
symbol: ScanLine,
ziel: "/kiosk/inventur",
},
{
sichtbar: rechte.pruefung,
titel: "Prüfung durchführen",
// Der Wizard aus Phase 2 mit Checkliste, Foto und Unterschrift braucht
// Netz. Das steht dran, statt dass die Kachel ins Leere führt.
hinweis: offline ? "braucht Netz" : "Wizard mit Checkliste",
symbol: ClipboardCheck,
ziel: "/pruefungen",
gesperrt: offline,
},
{
sichtbar: true,
titel: "Hallenmonitor",
hinweis: "Vollbild ohne Bedienung",
symbol: Monitor,
ziel: "/kiosk/monitor",
},
].filter((kachel) => kachel.sichtbar);
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<Abmelduhr sekunden={abmeldenNachSekunden} />
<header className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-lg text-[var(--muted-foreground)]">Angemeldet als</p>
<h1 className="text-3xl font-semibold tracking-tight">{name}</h1>
</div>
<Button
variant="outline"
className="h-16 px-6 text-lg"
onClick={() => void abmelden()}
>
<LogOut aria-hidden />
Abmelden
</Button>
</header>
<Zustandsleiste />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{kacheln.map((kachel) => {
const Symbol = kachel.symbol;
if (kachel.gesperrt) {
return (
<div
key={kachel.titel}
className="flex min-h-40 cursor-not-allowed flex-col justify-between rounded-xl border-2 border-dashed border-[var(--border)] p-6 opacity-60"
>
<Symbol className="size-10 text-[var(--muted-foreground)]" aria-hidden />
<div>
<p className="text-2xl font-semibold">{kachel.titel}</p>
<p className="text-base text-[var(--muted-foreground)]">{kachel.hinweis}</p>
</div>
</div>
);
}
return (
<Link
key={kachel.titel}
href={kachel.ziel}
className={`flex min-h-40 flex-col justify-between rounded-xl border-2 p-6 transition-colors ${
kachel.hervorgehoben
? "border-[var(--primary)] bg-[var(--primary)]/10"
: "border-[var(--border)] bg-[var(--card)]"
} active:bg-[var(--accent)]`}
>
<Symbol className="size-10 text-[var(--primary)]" aria-hidden />
<div>
<p className="text-2xl font-semibold">{kachel.titel}</p>
<p className="text-base text-[var(--muted-foreground)]">{kachel.hinweis}</p>
</div>
</Link>
);
})}
</div>
<p className="text-center text-sm text-[var(--muted-foreground)]">
<ClipboardList className="mr-1 inline size-4" aria-hidden />
Alles, was hier erfasst wird, geht zuerst in die Warteschlange des Terminals — auch ohne
Netz.
</p>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import { BrowserQRCodeReader } from "@zxing/browser";
import type { Result } from "@zxing/library";
import { Camera, CameraOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
/**
* QR-Leser für das Terminal.
*
* Dasselbe Verfahren wie beim Gerätescanner aus Phase 1 (@zxing/browser statt
* BarcodeDetector, weil es die API auf dem Tablet im Gerätehaus nicht gibt),
* aber ohne eigene Navigation: dieser Leser gibt das Gelesene weiter und
* überlässt der aufrufenden Seite, was damit geschieht.
*
* Zwei Dinge, die im Betrieb zählen:
* - Die Kamera wird beim Verlassen wirklich freigegeben. Sonst bleibt die
* Leuchte an und der nächste Aufruf bekommt kein Bild.
* - Es läuft eine Sperre gegen Doppellesungen: ein QR-Code im Sichtfeld wird
* zwanzigmal je Sekunde erkannt, gemeldet werden soll er einmal.
*/
export function QrLeser({
onGelesen,
beschriftung = "Kamera starten",
automatischStarten = false,
}: {
onGelesen: (inhalt: string) => void;
beschriftung?: string;
automatischStarten?: boolean;
}) {
const videoRef = React.useRef<HTMLVideoElement>(null);
const steuerungRef = React.useRef<{ stop: () => void } | null>(null);
const letzterRef = React.useRef<{ inhalt: string; zeit: number } | null>(null);
const [laeuft, setLaeuft] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const stoppen = React.useCallback(() => {
steuerungRef.current?.stop();
steuerungRef.current = null;
setLaeuft(false);
}, []);
React.useEffect(() => stoppen, [stoppen]);
const starten = React.useCallback(async () => {
setFehler(null);
if (!videoRef.current) return;
if (!navigator.mediaDevices?.getUserMedia) {
setFehler("Dieser Browser gibt keine Kamera frei.");
return;
}
try {
const leser = new BrowserQRCodeReader();
steuerungRef.current = await leser.decodeFromVideoDevice(
undefined,
videoRef.current,
(ergebnis: Result | undefined) => {
if (!ergebnis) return;
const inhalt = ergebnis.getText();
const jetzt = Date.now();
// Derselbe Code innerhalb von drei Sekunden zählt einmal.
if (
letzterRef.current &&
letzterRef.current.inhalt === inhalt &&
jetzt - letzterRef.current.zeit < 3000
) {
return;
}
letzterRef.current = { inhalt, zeit: jetzt };
onGelesen(inhalt);
},
);
setLaeuft(true);
} catch (problem) {
setFehler(
problem instanceof Error && problem.name === "NotAllowedError"
? "Die Kamera wurde nicht freigegeben. Am Tablet in den Browsereinstellungen erlauben."
: "Die Kamera ließ sich nicht starten.",
);
}
}, [onGelesen]);
React.useEffect(() => {
if (automatischStarten) void starten();
// Nur beim ersten Rendern — sonst würde jede Änderung die Kamera neu öffnen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video ref={videoRef} className="aspect-video w-full object-cover" muted playsInline />
</div>
<div className="flex gap-2">
{laeuft ? (
<Button type="button" variant="outline" className="h-14 text-base" onClick={stoppen}>
<CameraOff aria-hidden />
Kamera aus
</Button>
) : (
<Button type="button" className="h-14 text-base" onClick={() => void starten()}>
<Camera aria-hidden />
{beschriftung}
</Button>
)}
</div>
{fehler && <Fehlermeldung meldung={fehler} kennung="kamerafehler" />}
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Zustandsleiste } from "@/components/kiosk/warteschlange";
import { Abmelduhr } from "@/components/kiosk/abmelduhr";
/**
* Der Rahmen jeder Terminalseite.
*
* Immer dasselbe: zurück zu den Kacheln, der Zustand der Warteschlange, die
* Abmeldeuhr. Alles groß genug für Handschuhe.
*/
export function KioskRahmen({
titel,
hinweis,
abmeldenNachSekunden,
children,
}: {
titel: string;
hinweis?: string;
abmeldenNachSekunden: number;
children: React.ReactNode;
}) {
const router = useRouter();
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<Abmelduhr sekunden={abmeldenNachSekunden} />
<div className="flex items-start gap-4">
<Button
variant="outline"
className="h-16 w-16 shrink-0"
onClick={() => router.push("/kiosk")}
aria-label="Zurück zu den Kacheln"
>
<ArrowLeft className="size-7" aria-hidden />
</Button>
<div>
<h1 className="text-3xl font-semibold tracking-tight">{titel}</h1>
{hinweis && <p className="text-lg text-[var(--muted-foreground)]">{hinweis}</p>}
</div>
</div>
<Zustandsleiste />
{children}
</div>
);
}
/**
* Die Bestätigung nach einem erfassten Vorgang.
*
* Groß, grün, drei Sekunden — und dann zurück zu den Kacheln. Wer im
* Gerätehaus etwas erfasst, will danach das Tablet weglegen und nicht noch
* einen Knopf suchen.
*/
export function Erledigt({
text,
zurueckNachMs = 3000,
}: {
text: string;
zurueckNachMs?: number;
}) {
const router = useRouter();
React.useEffect(() => {
const zeitgeber = setTimeout(() => router.push("/kiosk"), zurueckNachMs);
return () => clearTimeout(zeitgeber);
}, [router, zurueckNachMs]);
return (
<div
className="flex flex-col items-center gap-4 rounded-xl border-2 border-[var(--einsatzbereit)] bg-[var(--einsatzbereit)]/10 p-10 text-center"
role="status"
data-testid="kiosk-erledigt"
>
<Check className="size-16 text-[var(--einsatzbereit)]" aria-hidden />
<p className="text-2xl font-semibold">{text}</p>
<p className="text-[var(--muted-foreground)]">
Zurück zu den Kacheln in wenigen Sekunden.
</p>
<Button variant="outline" className="h-14 text-lg" onClick={() => router.push("/kiosk")}>
Sofort zurück
</Button>
</div>
);
}
/**
* Platzhalter, solange der eigene Stand noch nicht da ist.
*
* Zwei Wizards sehen verschieden aus, je nachdem ob jemand schon eingecheckt
* oder mit einem Fahrzeug unterwegs ist. Würden sie schon vorher die eine
* Fassung zeigen, tauschte die Oberfläche unter dem Finger — wer im
* Gerätehaus zügig tippt, drückt dann ins Leere. Also erst warten, dann
* anzeigen.
*/
export function WirdGeladen({ text = "Stand wird geladen …" }: { text?: string }) {
return (
<div
className="rounded-xl border-2 border-dashed border-[var(--border)] p-10 text-center text-xl text-[var(--muted-foreground)]"
role="status"
data-testid="kiosk-laedt"
>
{text}
</div>
);
}
/** Eine große Auswahl-Schaltfläche, wie sie am Terminal überall vorkommt. */
export function GrosseAuswahl({
titel,
hinweis,
gewaehlt,
onClick,
}: {
titel: string;
hinweis?: string;
gewaehlt?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={gewaehlt}
className={`min-h-20 rounded-xl border-2 p-4 text-left transition-colors ${
gewaehlt
? "border-[var(--primary)] bg-[var(--primary)]/10"
: "border-[var(--border)] bg-[var(--card)]"
}`}
>
<span className="block text-xl font-medium">{titel}</span>
{hinweis && (
<span className="block text-base text-[var(--muted-foreground)]">{hinweis}</span>
)}
</button>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
/**
* Meldet den Service Worker an.
*
* Bewusst eine eigene, winzige Komponente statt eines Pakets: `sw.js` liegt
* fertig in `public/`, und alles, was es hier zu tun gibt, sind fünf Zeilen.
*
* Zwei Feinheiten:
*
* * Nur unter HTTPS oder auf localhost — anderswo lehnt der Browser die
* Anmeldung ohnehin ab, und die Fehlermeldung in der Konsole wäre nur
* Rauschen.
* * Erst nach dem Laden der Seite. Die Anmeldung konkurriert sonst mit dem
* ersten Rendern, und das Tablet in der Halle ist kein schnelles Gerät.
*/
export function ServiceWorkerAnmeldung() {
React.useEffect(() => {
if (!("serviceWorker" in navigator)) return;
const sicher = window.isSecureContext || window.location.hostname === "localhost";
if (!sicher) return;
const anmelden = () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((fehler) => {
console.warn("[kiosk] Service Worker ließ sich nicht anmelden:", fehler);
});
};
if (document.readyState === "complete") {
anmelden();
return;
}
window.addEventListener("load", anmelden);
return () => window.removeEventListener("load", anmelden);
}, []);
return null;
}
+334
View File
@@ -0,0 +1,334 @@
"use client";
import * as React from "react";
import { signOut } from "next-auth/react";
import { CloudOff, RefreshCw, Trash2, Wifi } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import {
anzahlOffen,
einreihen,
speicherVerfuegbar,
synchronisieren,
verwerfen,
warteschlange,
type OffenerVorgang,
} from "@/lib/kiosk/outbox";
import type { Vorgang, VorgangEingabe } from "@/lib/kiosk/vorgaenge";
import { alsDatumZeit } from "@/lib/utils";
/**
* Die Warteschlange als sichtbarer Zustand.
*
* Im Gerätehaus muss ohne Nachdenken erkennbar sein, ob das Erfasste schon
* beim Server ist. Deshalb steht der Zustand nicht in einer Ecke, sondern als
* Leiste über allem — und zwar dauerhaft, nicht nur im Fehlerfall.
*
* Der Kontext hier gibt allen Kacheln denselben Weg: `einreihen()` schreibt in
* die IndexedDB, der Rest passiert von selbst.
*/
interface WarteschlangeKontext {
offline: boolean;
offen: number;
vorgaenge: OffenerVorgang[];
/** Einen Vorgang erfassen — funktioniert immer, mit und ohne Netz. */
erfassen: (
vorgang: Vorgang,
beschriftung: string,
foto?: { inhalt: Blob; name: string },
) => Promise<void>;
jetztSenden: () => Promise<void>;
laeuft: boolean;
letzteMeldung: string | null;
}
const Kontext = React.createContext<WarteschlangeKontext | null>(null);
export function useWarteschlange(): WarteschlangeKontext {
const kontext = React.useContext(Kontext);
if (!kontext) {
throw new Error("useWarteschlange braucht den WarteschlangeProvider darüber.");
}
return kontext;
}
/** Wie oft ohne Ereignis nachgesehen wird, ob etwas liegen geblieben ist. */
const SYNC_ABSTAND_MS = 20_000;
export function WarteschlangeProvider({ children }: { children: React.ReactNode }) {
const [offline, setOffline] = React.useState(false);
const [offen, setOffen] = React.useState(0);
const [vorgaenge, setVorgaenge] = React.useState<OffenerVorgang[]>([]);
const [laeuft, setLaeuft] = React.useState(false);
const [letzteMeldung, setLetzteMeldung] = React.useState<string | null>(null);
const senden = trpc.kiosk.vorgaengeSenden.useMutation();
const werkzeuge = trpc.useUtils();
/**
* Beides über eine Referenz statt über die Abhängigkeiten.
*
* `useMutation` gibt bei jedem Rendern ein neues Objekt zurück. Stünde es in
* den Abhängigkeiten von `jetztSenden`, bekäme auch diese Funktion bei jedem
* Rendern eine neue Identität — und der Effekt weiter unten würde den
* Zeitgeber jedes Mal abräumen und neu aufziehen. Der wäre dann nie
* abgelaufen, und die Warteschlange hätte ohne fremdes Zutun nie einen
* zweiten Versuch unternommen. Genau das ist beim Offline-Test passiert.
*/
const sendenRef = React.useRef(senden);
sendenRef.current = senden;
const werkzeugeRef = React.useRef(werkzeuge);
werkzeugeRef.current = werkzeuge;
/**
* Läuft gerade eine Übertragung?
*
* Als Referenz, nicht als Zustand: der Wert wird beim Aufruf gebraucht, nicht
* beim Rendern. `laeuft` daneben ist nur für die Anzeige da.
*/
const laeuftRef = React.useRef(false);
/**
* Ein wartendes Foto hochladen.
*
* Geht über denselben Endpunkt wie überall sonst (/api/dateien) — hier nur
* mit dem Unterschied, dass der Zeitpunkt nicht der der Aufnahme ist.
*/
const fotoHochladen = React.useCallback(async (foto: Blob, name: string) => {
const formular = new FormData();
formular.append("datei", foto, name);
formular.append("entitaet", "Mangel");
formular.append("kategorie", "foto");
const antwort = await fetch("/api/dateien", { method: "POST", body: formular });
if (!antwort.ok) throw new Error("Foto ließ sich nicht übertragen.");
const inhalt = (await antwort.json()) as { id: string };
return inhalt.id;
}, []);
const standAktualisieren = React.useCallback(async () => {
if (!speicherVerfuegbar()) return;
setOffen(await anzahlOffen());
setVorgaenge(await warteschlange());
}, []);
const jetztSenden = React.useCallback(async () => {
if (!speicherVerfuegbar() || laeuftRef.current) return;
laeuftRef.current = true;
setLaeuft(true);
try {
const ergebnis = await synchronisieren(
(liste) =>
sendenRef.current.mutateAsync({
// Die Nutzlast kommt aus der IndexedDB und trägt dort keine
// Typinformation mehr. Geprüft wird sie ohnehin auf dem Server,
// mit denselben zod-Schemata aus lib/kiosk/vorgaenge.ts.
vorgaenge: liste as unknown as VorgangEingabe[],
}),
fotoHochladen,
);
if (ergebnis.erledigt > 0) {
setLetzteMeldung(
ergebnis.konflikte > 0
? `${ergebnis.erledigt} Vorgänge übertragen, ${ergebnis.konflikte} davon mit Konflikt — der Gerätewart sieht sie in der Konfliktliste.`
: `${ergebnis.erledigt} Vorgänge übertragen.`,
);
// Was die Kacheln anzeigen, stimmt danach nicht mehr.
void werkzeugeRef.current.kiosk.invalidate();
} else if (ergebnis.fehler) {
setLetzteMeldung(null);
}
} finally {
laeuftRef.current = false;
setLaeuft(false);
await standAktualisieren();
}
}, [fotoHochladen, standAktualisieren]);
const erfassen = React.useCallback(
async (vorgang: Vorgang, beschriftung: string, foto?: { inhalt: Blob; name: string }) => {
await einreihen(vorgang, beschriftung, foto);
await standAktualisieren();
// Sofort versuchen. Klappt es nicht, bleibt der Vorgang liegen — der
// Anwender merkt davon nichts außer der Zahl in der Leiste.
void jetztSenden();
},
[jetztSenden, standAktualisieren],
);
// Netzzustand beobachten. navigator.onLine ist nicht verlässlich für „das
// Internet geht", aber sehr wohl für „dieses Gerät hat gerade kein Netz" —
// und genau das ist der Fall im Gerätehaus.
React.useEffect(() => {
const setzen = () => setOffline(!navigator.onLine);
setzen();
const wiederDa = () => {
setzen();
void jetztSenden();
};
window.addEventListener("online", wiederDa);
window.addEventListener("offline", setzen);
return () => {
window.removeEventListener("online", wiederDa);
window.removeEventListener("offline", setzen);
};
}, [jetztSenden]);
React.useEffect(() => {
void standAktualisieren();
const zeitgeber = setInterval(() => void jetztSenden(), SYNC_ABSTAND_MS);
return () => clearInterval(zeitgeber);
}, [jetztSenden, standAktualisieren]);
const wert = React.useMemo(
() => ({ offline, offen, vorgaenge, erfassen, jetztSenden, laeuft, letzteMeldung }),
[offline, offen, vorgaenge, erfassen, jetztSenden, laeuft, letzteMeldung],
);
return <Kontext.Provider value={wert}>{children}</Kontext.Provider>;
}
/**
* Abmelden — aber erst, wenn die Warteschlange leer ist.
*
* Der Server schreibt einen Vorgang der Person zu, die beim **Empfang**
* angemeldet ist, nicht der beim Erfassen. Das ist im Regelfall dieselbe: wer
* offline erfasst, bleibt angemeldet, weil auch das Abmelden Netz braucht.
*
* Es bleibt aber ein Fenster: kommt das Netz zurück und meldet sich in den
* Sekunden danach jemand anderes an, bevor die Warteschlange von selbst
* losläuft, stünde die Mangelmeldung unter dem falschen Namen. Deshalb wird vor
* jedem Abmelden — von Hand wie durch die Abmeldeuhr — noch einmal übertragen.
*
* Klappt es nicht, wird trotzdem abgemeldet: die Vorgänge bleiben liegen, und
* ein Terminal, das sich nicht abmelden lässt, wäre das größere Problem.
*/
export function useAbmelden(): () => Promise<void> {
const { jetztSenden } = useWarteschlange();
return React.useCallback(async () => {
try {
await jetztSenden();
} catch {
// Absicht: das Abmelden darf daran nicht scheitern.
}
await signOut({ redirectTo: "/kiosk" });
}, [jetztSenden]);
}
/**
* Die Zustandsleiste.
*
* Grün heißt: alles ist beim Server. Gelb heißt: es liegt noch etwas hier.
* Rot heißt: kein Netz. Alle drei tragen Klartext — Farbe allein reicht nicht.
*/
export function Zustandsleiste() {
const { offline, offen, laeuft, jetztSenden, letzteMeldung } = useWarteschlange();
const [detailsOffen, setDetailsOffen] = React.useState(false);
const farbe = offline
? "bg-[var(--gesperrt)]/15 text-[var(--gesperrt)] border-[var(--gesperrt)]/40"
: offen > 0
? "bg-[var(--pruefung-faellig)]/15 text-[var(--pruefung-faellig)] border-[var(--pruefung-faellig)]/40"
: "bg-[var(--einsatzbereit)]/15 text-[var(--einsatzbereit)] border-[var(--einsatzbereit)]/40";
return (
<div className="space-y-2">
<div
className={`flex flex-wrap items-center gap-3 rounded-lg border px-4 py-3 text-base font-medium ${farbe}`}
role="status"
data-testid="kiosk-zustand"
data-offline={offline}
data-offen={offen}
>
{offline ? <CloudOff aria-hidden /> : <Wifi aria-hidden />}
<span>
{offline ? "Kein Netz" : "Verbunden"}
{offen > 0
? ` · ${offen} ${offen === 1 ? "Änderung wartet" : "Änderungen warten"}`
: " · alles übertragen"}
</span>
{offen > 0 && (
<>
<Button
variant="ghost"
size="sm"
className="h-10"
onClick={() => setDetailsOffen((wert) => !wert)}
>
{detailsOffen ? "verbergen" : "anzeigen"}
</Button>
<Button
variant="outline"
size="sm"
className="ml-auto h-10"
onClick={() => void jetztSenden()}
disabled={laeuft || offline}
>
<RefreshCw aria-hidden />
{laeuft ? "überträgt …" : "Jetzt übertragen"}
</Button>
</>
)}
</div>
{letzteMeldung && (
<p className="px-1 text-sm text-[var(--muted-foreground)]">{letzteMeldung}</p>
)}
{detailsOffen && <WarteschlangeListe />}
</div>
);
}
/** Was genau noch aussteht — für den Fall, dass etwas hängen bleibt. */
function WarteschlangeListe() {
const { vorgaenge, jetztSenden } = useWarteschlange();
if (vorgaenge.length === 0) return null;
return (
<ul className="space-y-1 rounded-lg border border-[var(--border)] p-3">
{vorgaenge.map((vorgang) => (
<li key={vorgang.id} className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-medium">{vorgang.beschriftung}</span>
<span className="text-[var(--muted-foreground)]">
erfasst {alsDatumZeit(vorgang.erfasstAm)}
</span>
{vorgang.versuche > 0 && (
<span className="text-[var(--muted-foreground)]">
{vorgang.versuche} Versuche
{vorgang.letzterFehler ? ` · ${vorgang.letzterFehler}` : ""}
</span>
)}
{vorgang.versuche >= 5 && (
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={async () => {
if (window.confirm("Diesen Vorgang verwerfen? Er geht damit verloren.")) {
await verwerfen(vorgang.id);
void jetztSenden();
}
}}
>
<Trash2 aria-hidden />
verwerfen
</Button>
)}
</li>
))}
</ul>
);
}
+14 -3
View File
@@ -14,6 +14,7 @@ import {
Map,
Menu,
MonitorSmartphone,
QrCode,
ShieldAlert,
Siren,
TriangleAlert,
@@ -90,10 +91,20 @@ const EINTRAEGE: Eintrag[] = [
abPhase: 3,
},
{
// Führt bewusst auf die Übersicht in der Verwaltung, nicht auf das
// Terminal selbst: das läuft am Wandtablet unter /kiosk und hat eine eigene
// Oberfläche. Wer hier klickt, will sehen, was dort zusammengekommen ist.
titel: "Terminal",
pfad: "/kiosk",
pfad: "/terminal",
symbol: MonitorSmartphone,
rechte: ["kiosk.erfassen"],
rechte: ["fahrtenbuch.lesen"],
abPhase: 4,
},
{
titel: "Anmeldemarke",
pfad: "/anmeldemarke",
symbol: QrCode,
rechte: ["person.eigene"],
abPhase: 4,
},
{
@@ -123,7 +134,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 3;
const AKTUELLE_PHASE = 4;
export interface SeitenleisteProps {
rechte: Recht[];