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
+96
View File
@@ -0,0 +1,96 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
/**
* Die Anzeige der Anmeldemarke.
*
* Der Code läuft nach einer Minute ab — deshalb zählt die Seite herunter und
* holt sich rechtzeitig einen neuen. Wer davorsteht, soll nie einen abgelaufenen
* Code vor die Kamera halten und rätseln, warum nichts passiert.
*/
export function MarkenAnzeige({
bild,
name,
gueltigSekunden,
}: {
bild: string;
name: string;
gueltigSekunden: number;
}) {
const router = useRouter();
const [rest, setRest] = React.useState(gueltigSekunden);
React.useEffect(() => {
setRest(gueltigSekunden);
const zeitgeber = setInterval(() => {
setRest((wert) => {
if (wert <= 1) {
// Ein paar Sekunden vor Ablauf einen neuen holen: die Seite wird
// serverseitig gerendert, also genügt ein Aktualisieren.
router.refresh();
return gueltigSekunden;
}
return wert - 1;
});
}, 1000);
return () => clearInterval(zeitgeber);
}, [bild, gueltigSekunden, router]);
return (
<div className="mx-auto max-w-md space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Anmeldemarke</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Am Terminal im Gerätehaus vor die Kamera halten.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{name}</CardTitle>
<CardDescription>
Gültig noch {rest} {rest === 1 ? "Sekunde" : "Sekunden"} — danach erneuert sich der
Code von selbst.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Image
src={bild}
alt="Anmeldemarke als QR-Code"
width={420}
height={420}
unoptimized
className="mx-auto h-auto w-full max-w-xs rounded-lg border border-[var(--border)] bg-white p-3"
/>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full bg-[var(--primary)] transition-all duration-1000"
style={{ width: `${(rest / gueltigSekunden) * 100}%` }}
role="presentation"
/>
</div>
<Button variant="outline" className="w-full" onClick={() => router.refresh()}>
<RefreshCw aria-hidden />
Neuen Code anzeigen
</Button>
</CardContent>
</Card>
<p className="text-sm text-[var(--muted-foreground)]">
Der Code enthält keine Daten über dich, sondern nur eine kurzlebige Unterschrift. Ein
abfotografierter Code nützt nach einer Minute nichts mehr.
</p>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { markeErzeugen, MARKE_GUELTIG_SEKUNDEN } from "@/lib/kiosk/anmeldemarke";
import { qrTextAlsDataUri } from "@/lib/stammdaten/qr";
import { MarkenAnzeige } from "@/app/(app)/anmeldemarke/anzeige";
export const metadata = { title: "Anmeldemarke" };
/**
* Die Anmeldemarke fürs Handy.
*
* Wer den Ausweis vergessen hat, öffnet diese Seite und hält den QR-Code vor
* die Kamera des Terminals. Der Code gilt eine Minute; die Seite erneuert ihn
* von selbst.
*
* Erzeugt wird er auf dem Server — die Signatur entsteht mit AUTH_SECRET, und
* das hat im Browser nichts verloren.
*/
export default async function AnmeldemarkeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
const bild = await qrTextAlsDataUri(marke, 420);
return (
<MarkenAnzeige
bild={bild}
name={sitzung.name}
gueltigSekunden={MARKE_GUELTIG_SEKUNDEN}
/>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { MonitorSmartphone } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { TerminalUebersicht } from "@/app/(app)/terminal/uebersicht";
export const metadata = { title: "Terminal" };
/**
* Was am Terminal zusammenkommt — aus Sicht der Verwaltung.
*
* Das Terminal selbst ist eine eigene Oberfläche für das Wandtablet. Hier steht
* das Ergebnis: wer im Haus ist, welche Fahrten laufen, welche Wäsche in der
* Reinigung ist und was sich beim Nachtragen nicht anwenden ließ.
*/
export default async function TerminalSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Terminal im Gerätehaus</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Anwesenheit, Fahrtenbuch, Wäsche — und was beim Nachtragen hängen geblieben ist.
</p>
</div>
<Button variant="outline" asChild>
<Link href="/kiosk">
<MonitorSmartphone aria-hidden />
Terminaloberfläche öffnen
</Link>
</Button>
</div>
<TerminalUebersicht
darfKonflikte={hatRecht(sitzung, "system.auditlog")}
darfWaesche={hatRecht(sitzung, "geraet.bearbeiten")}
/>
</div>
);
}
+288
View File
@@ -0,0 +1,288 @@
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { WaescheStatus } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { alsDatumZeit } from "@/lib/utils";
/**
* Terminalübersicht für die Verwaltung.
*
* Vier Blöcke, in der Reihenfolge, in der sie Aufmerksamkeit brauchen:
* Konflikte zuerst — das sind Vorgänge, die ein Mensch anfassen muss —, danach
* das Laufende.
*/
const WAESCHE_TEXT: Record<WaescheStatus, string> = {
ABGEGEBEN: "abgegeben",
IN_REINIGUNG: "in Reinigung",
ZURUECK: "zurück",
VERSCHOLLEN: "verschollen",
};
const GRUND_TEXT: Record<string, string> = {
ZIEL_FEHLT: "Ziel gibt es nicht",
ZIEL_GESPERRT: "Ziel ist festgeschrieben",
DOPPELT: "doppelt erfasst",
VERALTET: "Angaben veraltet",
SONSTIGES: "sonstiges",
};
export function TerminalUebersicht({
darfKonflikte,
darfWaesche,
}: {
darfKonflikte: boolean;
darfWaesche: boolean;
}) {
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const konflikte = trpc.kiosk.konflikte.useQuery(
{ nurOffene: true },
{ enabled: darfKonflikte },
);
const anwesende = trpc.kiosk.anwesende.useQuery();
const fahrten = trpc.kiosk.fahrten.useQuery({ nurOffene: false, anzahl: 25 });
const waesche = trpc.kiosk.waesche.useQuery({ nurOffene: true });
const erledigen = trpc.kiosk.konfliktErledigen.useMutation({
onSuccess: () => {
setMeldung("Konflikt als erledigt vermerkt.");
void werkzeuge.kiosk.konflikte.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const statusSetzen = trpc.kiosk.waescheStatusSetzen.useMutation({
onSuccess: () => void werkzeuge.kiosk.waesche.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="terminalmeldung" />}
{darfKonflikte && (
<Card>
<CardHeader>
<CardTitle>Konflikte beim Nachtragen</CardTitle>
<CardDescription>
Vorgänge, die das Terminal offline erfasst hat und die sich beim Übertragen nicht
anwenden ließen. Sie sind nicht verloren — sie warten hier auf eine Entscheidung.
</CardDescription>
</CardHeader>
<CardContent>
{(konflikte.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Keine offenen Konflikte"
hinweis="Alles, was vom Terminal kam, ließ sich anwenden."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Wann</TableHead>
<TableHead>Was</TableHead>
<TableHead>Grund</TableHead>
<TableHead>Wer</TableHead>
<TableHead className="w-32" aria-label="Aktion" />
</TableRow>
</TableHeader>
<TableBody>
{konflikte.data?.map((konflikt) => (
<TableRow key={konflikt.id}>
<TableCell className="tabellenzahlen whitespace-nowrap text-sm">
{alsDatumZeit(konflikt.entstandenAm)}
</TableCell>
<TableCell>
<div className="text-sm font-medium">{konflikt.art}</div>
<div className="text-xs text-[var(--muted-foreground)]">
{konflikt.beschreibung}
</div>
</TableCell>
<TableCell>
<Badge variant="pruefungFaellig">
{GRUND_TEXT[konflikt.grund] ?? konflikt.grund}
</Badge>
</TableCell>
<TableCell className="text-sm">
{konflikt.person
? `${konflikt.person.nachname}, ${konflikt.person.vorname}`
: "—"}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => {
const erledigung = window.prompt("Wie wurde es gelöst?");
if (erledigung === null) return;
erledigen.mutate({
id: konflikt.id,
erledigung: erledigung || undefined,
});
}}
>
<Check aria-hidden />
Erledigt
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Im Haus</CardTitle>
<CardDescription>{anwesende.data?.length ?? 0} eingecheckt</CardDescription>
</CardHeader>
<CardContent>
{(anwesende.data?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Gerade niemand.</p>
) : (
<ul className="space-y-1 text-sm">
{anwesende.data?.map((eintrag) => (
<li key={eintrag.id} className="flex justify-between gap-3">
<span>
{eintrag.person.nachname}, {eintrag.person.vorname}
</span>
<span className="text-[var(--muted-foreground)]">
{eintrag.anlass.toLowerCase()} · seit {alsDatumZeit(eintrag.angemeldetUm)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Wäsche</CardTitle>
<CardDescription>Schutzkleidung, die noch nicht zurück ist</CardDescription>
</CardHeader>
<CardContent>
{(waesche.data?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Nichts offen.</p>
) : (
<ul className="space-y-2 text-sm">
{waesche.data?.map((abgabe) => (
<li key={abgabe.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{abgabe.menge}× {abgabe.bezeichnung}
</span>
<span className="text-[var(--muted-foreground)]">
{abgabe.person.nachname}, {abgabe.person.vorname} ·{" "}
{alsDatumZeit(abgabe.abgegebenAm)}
</span>
{darfWaesche ? (
<Select
value={abgabe.status}
onValueChange={(wert) =>
statusSetzen.mutate({ id: abgabe.id, status: wert as WaescheStatus })
}
>
<SelectTrigger
className="ml-auto h-8 w-40"
aria-label={`Stand für ${abgabe.bezeichnung}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(WAESCHE_TEXT) as WaescheStatus[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{WAESCHE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Badge variant="secondary">{WAESCHE_TEXT[abgabe.status]}</Badge>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Fahrtenbuch</CardTitle>
<CardDescription>Die letzten Fahrten, offene zuerst erkennbar</CardDescription>
</CardHeader>
<CardContent>
{(fahrten.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Fahrt erfasst"
hinweis="Fahrten werden am Terminal eingetragen — Fahrzeug, Kilometerstand, Zweck."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Fahrzeug</TableHead>
<TableHead>Fahrer</TableHead>
<TableHead>Zweck</TableHead>
<TableHead>Ziel</TableHead>
<TableHead className="text-right">Abfahrt</TableHead>
<TableHead className="text-right">km</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fahrten.data?.map((fahrt) => (
<TableRow key={fahrt.id}>
<TableCell>{fahrt.fahrzeug.funkrufname}</TableCell>
<TableCell className="text-sm">
{fahrt.fahrer.nachname}, {fahrt.fahrer.vorname}
</TableCell>
<TableCell className="text-sm">{fahrt.zweck.toLowerCase()}</TableCell>
<TableCell className="text-sm">{fahrt.ziel ?? "—"}</TableCell>
<TableCell className="text-right tabellenzahlen whitespace-nowrap">
{alsDatumZeit(fahrt.abfahrtUm)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrt.kilometerRueckkehr
? `${fahrt.kilometerRueckkehr - fahrt.kilometerAbfahrt} km`
: "unterwegs"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}