Files
FireAdministration/src/components/kiosk/warteschlange.tsx
T
Florian WildeandClaude Opus 5 791523027f 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>
2026-09-03 18:35:48 +02:00

335 lines
11 KiB
TypeScript

"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>
);
}