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:
co-authored by
Claude Opus 5
parent
d8d692098d
commit
791523027f
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user