"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; jetztSenden: () => Promise; laeuft: boolean; letzteMeldung: string | null; } const Kontext = React.createContext(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([]); const [laeuft, setLaeuft] = React.useState(false); const [letzteMeldung, setLetzteMeldung] = React.useState(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 {children}; } /** * 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 { 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 (
{offline ? : } {offline ? "Kein Netz" : "Verbunden"} {offen > 0 ? ` · ${offen} ${offen === 1 ? "Änderung wartet" : "Änderungen warten"}` : " · alles übertragen"} {offen > 0 && ( <> )}
{letzteMeldung && (

{letzteMeldung}

)} {detailsOffen && }
); } /** 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 (
    {vorgaenge.map((vorgang) => (
  • {vorgang.beschriftung} erfasst {alsDatumZeit(vorgang.erfasstAm)} {vorgang.versuche > 0 && ( {vorgang.versuche} Versuche {vorgang.letzterFehler ? ` · ${vorgang.letzterFehler}` : ""} )} {vorgang.versuche >= 5 && ( )}
  • ))}
); }