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
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { env } from "@/lib/env";
import { AnwesenheitWizard } from "@/app/kiosk/anwesenheit/wizard";
export const metadata = { title: "Kommen und Gehen" };
export default async function AnwesenheitSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
return <AnwesenheitWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import * as React from "react";
import { AnwesenheitAnlass } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Erledigt, GrosseAuswahl, KioskRahmen, WirdGeladen } from "@/components/kiosk/rahmen";
import { useWarteschlange } from "@/components/kiosk/warteschlange";
import { alsDatumZeit } from "@/lib/utils";
/**
* Kommen und Gehen.
*
* Der häufigste Vorgang am Terminal und der einfachste: zwei Klicks. Wer schon
* eingecheckt ist, sieht nur noch „Gehen" — die Auswahl des Anlasses hat er
* beim Kommen getroffen.
*/
const ANLASS_TEXT: Record<AnwesenheitAnlass, { titel: string; hinweis: string }> = {
DIENST: { titel: "Dienst", hinweis: "regulärer Dienstabend" },
UEBUNG: { titel: "Übung", hinweis: "Ausbildung, Übungsdienst" },
EINSATZ: { titel: "Einsatz", hinweis: "alarmiert oder nachgerückt" },
ARBEITSDIENST: { titel: "Arbeitsdienst", hinweis: "Gerätehaus, Fahrzeugpflege" },
SONSTIGES: { titel: "Sonstiges", hinweis: "alles andere" },
};
export function AnwesenheitWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) {
const { erfassen, offline } = useWarteschlange();
const stand = trpc.kiosk.eigenerStand.useQuery(undefined, { retry: false });
const [fertig, setFertig] = React.useState<string | null>(null);
const angemeldet = stand.data?.anwesenheit ?? null;
// stand.data ist erst nach dem ersten Abruf da. Bis dahin ist unbekannt, ob
// die Person schon eingecheckt ist — und damit auch, was hier stehen soll.
const standBekannt = stand.data !== undefined;
async function kommen(anlass: AnwesenheitAnlass) {
await erfassen(
{ art: "ANWESENHEIT_KOMMEN", daten: { anlass } },
`Gekommen (${ANLASS_TEXT[anlass].titel})`,
);
setFertig(`Willkommen. Anwesenheit erfasst: ${ANLASS_TEXT[anlass].titel}.`);
}
async function gehen() {
await erfassen(
{ art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: angemeldet?.id ?? null } },
"Gegangen",
);
setFertig("Bis zum nächsten Mal. Abwesenheit erfasst.");
}
return (
<KioskRahmen
titel={angemeldet ? "Gehen" : "Kommen"}
hinweis={
angemeldet
? `Angemeldet seit ${alsDatumZeit(angemeldet.angemeldetUm)}`
: "Wozu bist du da?"
}
abmeldenNachSekunden={abmeldenNachSekunden}
>
{fertig ? (
<Erledigt text={fertig} />
) : !standBekannt ? (
<WirdGeladen />
) : angemeldet ? (
<div className="space-y-4">
<Button className="h-24 w-full text-2xl" onClick={() => void gehen()}>
Jetzt gehen
</Button>
<p className="text-center text-[var(--muted-foreground)]">
Anlass: {ANLASS_TEXT[angemeldet.anlass].titel}
</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{(Object.keys(ANLASS_TEXT) as AnwesenheitAnlass[]).map((anlass) => (
<GrosseAuswahl
key={anlass}
titel={ANLASS_TEXT[anlass].titel}
hinweis={ANLASS_TEXT[anlass].hinweis}
onClick={() => void kommen(anlass)}
/>
))}
</div>
)}
{offline && !fertig && standBekannt && (
<p className="text-center text-[var(--muted-foreground)]">
Ohne Netz wird trotzdem erfasst — die Zeit ist die von jetzt, nicht die der Übertragung.
</p>
)}
</KioskRahmen>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { geraetEinrichten, geraeteangaben, geraetVergessen } from "@/lib/kiosk/geraet";
/**
* Einrichtung des Terminals.
*
* Einmal je Tablet: Gerätegeheimnis und Kurzname der Wehr. Beides steht in der
* `.env` des Servers (`KIOSK_DEVICE_SECRET`) beziehungsweise in den Stammdaten
* der Wehr.
*
* Damit das nicht abgetippt werden muss, nimmt die Seite beides auch aus der
* Adresse entgegen:
*
* /kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
*
* Der Administrator schickt sich diesen Link einmal selbst — etwa als QR-Code
* vom eigenen Rechner — und ruft ihn am Tablet auf.
*/
export function EinrichtenFormular() {
const router = useRouter();
const parameter = useSearchParams();
const [schluessel, setSchluessel] = React.useState("");
const [wehr, setWehr] = React.useState("");
const [bestehend, setBestehend] = React.useState<string | null>(null);
React.useEffect(() => {
const vorhanden = geraeteangaben();
if (vorhanden) setBestehend(vorhanden.organisationSlug);
const ausAdresse = parameter.get("schluessel");
const wehrAusAdresse = parameter.get("wehr");
if (ausAdresse) setSchluessel(ausAdresse);
if (wehrAusAdresse) setWehr(wehrAusAdresse);
}, [parameter]);
return (
<div className="mx-auto max-w-xl space-y-6 p-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Terminal einrichten</h1>
<p className="mt-2 text-lg text-[var(--muted-foreground)]">
Einmal je Tablet. Danach meldet sich jeder mit Ausweis, QR-Code oder PIN an.
</p>
</div>
{bestehend && (
<div className="rounded-lg border border-[var(--border)] p-4">
<p className="text-lg">
Dieses Tablet ist bereits für <strong>{bestehend}</strong> eingerichtet.
</p>
<Button
variant="outline"
className="mt-3 h-14 text-lg"
onClick={() => {
geraetVergessen();
setBestehend(null);
}}
>
Einrichtung löschen
</Button>
</div>
)}
<form
className="space-y-4"
onSubmit={(ereignis) => {
ereignis.preventDefault();
geraetEinrichten({
geraeteSchluessel: schluessel.trim(),
organisationSlug: wehr.trim(),
});
router.push("/kiosk");
router.refresh();
}}
>
<div className="space-y-2">
<Label htmlFor="wehr" className="text-lg">
Kurzname der Wehr
</Label>
<Input
id="wehr"
className="h-16 text-xl"
value={wehr}
onChange={(ereignis) => setWehr(ereignis.target.value)}
placeholder="ff-musterhausen"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="schluessel" className="text-lg">
Gerätegeheimnis
</Label>
<Input
id="schluessel"
type="password"
className="h-16 text-xl"
value={schluessel}
onChange={(ereignis) => setSchluessel(ereignis.target.value)}
required
/>
<p className="text-[var(--muted-foreground)]">
Steht als <code>KIOSK_DEVICE_SECRET</code> in der <code>.env</code> des Servers. Es
bleibt auf diesem Tablet und geht nur beim Anmelden mit.
</p>
</div>
<Button type="submit" className="h-16 w-full text-lg">
Terminal einrichten
</Button>
</form>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { Suspense } from "react";
import { EinrichtenFormular } from "@/app/kiosk/einrichten/formular";
export const metadata = { title: "Terminal einrichten" };
/**
* Einrichtung des Tablets.
*
* Bewusst ohne Anmeldung erreichbar: das Tablet muss sich ausweisen können,
* *bevor* sich jemand anmelden kann. Wer die Seite ohne Gerätegeheimnis
* aufruft, kann hier nichts anrichten — eingetragen wird nur, was ohnehin nur
* der Administrator kennt, und geprüft wird es beim ersten Anmeldeversuch.
*
* Das Formular liest Wehr und Geheimnis auch aus der Adresse, damit am Tablet
* nichts abgetippt werden muss. `useSearchParams()` verlangt dafür eine
* Suspense-Grenze — sonst ließe sich die Seite nicht vorab erzeugen.
*/
export default function EinrichtenSeite() {
return (
<Suspense fallback={<p className="p-6 text-lg">Einrichtung wird geladen …</p>}>
<EinrichtenFormular />
</Suspense>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { env } from "@/lib/env";
import { FahrtWizard } from "@/app/kiosk/fahrt/wizard";
export const metadata = { title: "Fahrtenbuch" };
export default async function FahrtSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
if (!hatRecht(sitzung, "fahrtenbuch.fuehren")) redirect("/kiosk");
return <FahrtWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+259
View File
@@ -0,0 +1,259 @@
"use client";
import * as React from "react";
import { FahrtZweck } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Erledigt, GrosseAuswahl, KioskRahmen, WirdGeladen } from "@/components/kiosk/rahmen";
import { useWarteschlange } from "@/components/kiosk/warteschlange";
import { alsDatumZeit } from "@/lib/utils";
/**
* Das Fahrtenbuch am Terminal.
*
* Zwei Zustände, kein Formular: Wer nicht unterwegs ist, beginnt eine Fahrt.
* Wer unterwegs ist, beendet sie. Der Kilometerstand ist vorbelegt mit dem,
* was am Fahrzeug steht — meist stimmt er, und wenn nicht, wird er überschrieben.
*
* Warum das überhaupt am Terminal steht: das Fahrtenbuch wird sonst auf einem
* Zettel im Handschuhfach geführt, und der ist nach einem halben Jahr weg.
*/
const ZWECK: Array<{ wert: FahrtZweck; titel: string; hinweis: string }> = [
{ wert: FahrtZweck.EINSATZ, titel: "Einsatz", hinweis: "alarmiert" },
{ wert: FahrtZweck.UEBUNG, titel: "Übung", hinweis: "Übungsdienst" },
{ wert: FahrtZweck.AUSBILDUNG, titel: "Ausbildung", hinweis: "Lehrgang, Schulung" },
{ wert: FahrtZweck.WERKSTATT, titel: "Werkstatt", hinweis: "Wartung, TÜV" },
{ wert: FahrtZweck.BESCHAFFUNG, titel: "Beschaffung", hinweis: "Material holen" },
{ wert: FahrtZweck.SONSTIGES, titel: "Sonstiges", hinweis: "Probefahrt, Überführung" },
];
export function FahrtWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) {
const { erfassen } = useWarteschlange();
const paket = trpc.kiosk.stammdatenpaket.useQuery(undefined, { retry: false });
const stand = trpc.kiosk.eigenerStand.useQuery(undefined, { retry: false });
const [fahrzeugId, setFahrzeugId] = React.useState<string | null>(null);
const [kilometer, setKilometer] = React.useState("");
const [ziel, setZiel] = React.useState("");
const [tankmenge, setTankmenge] = React.useState("");
const [fertig, setFertig] = React.useState<string | null>(null);
const offeneFahrt = stand.data?.fahrt ?? null;
const fahrzeuge = paket.data?.fahrzeuge ?? [];
const gewaehlt = fahrzeuge.find((fahrzeug) => fahrzeug.id === fahrzeugId);
// Kilometerstand vorbelegen, sobald ein Fahrzeug gewählt ist.
React.useEffect(() => {
if (gewaehlt?.kilometerstand && !kilometer) setKilometer(String(gewaehlt.kilometerstand));
}, [gewaehlt, kilometer]);
async function beginnen(zweck: FahrtZweck) {
if (!fahrzeugId || !kilometer) return;
await erfassen(
{
art: "FAHRT_BEGINNEN",
daten: {
fahrzeugId,
kilometerAbfahrt: Number(kilometer),
zweck,
ziel: ziel.trim() || null,
},
},
`Fahrt begonnen (${gewaehlt?.funkrufname ?? "Fahrzeug"})`,
);
setFertig("Gute Fahrt. Die Fahrt ist eingetragen.");
}
async function beenden() {
if (!offeneFahrt || !kilometer) return;
await erfassen(
{
art: "FAHRT_BEENDEN",
daten: {
fahrtId: offeneFahrt.id,
kilometerRueckkehr: Number(kilometer),
tankmenge: tankmenge ? Number(tankmenge) : null,
},
},
`Fahrt beendet (${offeneFahrt.fahrzeug.funkrufname})`,
);
setFertig("Fahrt beendet. Kilometerstand übernommen.");
}
// Bei einer offenen Fahrt ist der Kilometerstand der bei Abfahrt — als
// Anhaltspunkt, nicht als Vorgabe.
React.useEffect(() => {
if (offeneFahrt && !kilometer) setKilometer(String(offeneFahrt.kilometerAbfahrt));
}, [offeneFahrt, kilometer]);
if (fertig) {
return (
<KioskRahmen titel="Fahrtenbuch" abmeldenNachSekunden={abmeldenNachSekunden}>
<Erledigt text={fertig} />
</KioskRahmen>
);
}
// Solange unbekannt ist, ob ein Fahrzeug unterwegs ist, wird nichts
// angeboten: sonst tauscht die Oberfläche gleich nach dem Öffnen zwischen
// „Fahrt beginnen" und „Fahrt beenden".
if (stand.data === undefined) {
return (
<KioskRahmen titel="Fahrtenbuch" abmeldenNachSekunden={abmeldenNachSekunden}>
<WirdGeladen />
</KioskRahmen>
);
}
if (offeneFahrt) {
return (
<KioskRahmen
titel="Fahrt beenden"
hinweis={`${offeneFahrt.fahrzeug.funkrufname} · unterwegs seit ${alsDatumZeit(offeneFahrt.abfahrtUm)}`}
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="kmzurueck" className="text-lg">
Kilometerstand bei Rückkehr
</Label>
<Input
id="kmzurueck"
type="number"
inputMode="numeric"
className="h-20 text-3xl"
value={kilometer}
onChange={(ereignis) => setKilometer(ereignis.target.value)}
/>
<p className="text-[var(--muted-foreground)]">
Bei Abfahrt: {offeneFahrt.kilometerAbfahrt} km
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tank" className="text-lg">
Getankt (Liter, falls unterwegs getankt wurde)
</Label>
<Input
id="tank"
type="number"
inputMode="decimal"
step="0.01"
className="h-16 text-2xl"
value={tankmenge}
onChange={(ereignis) => setTankmenge(ereignis.target.value)}
/>
</div>
<Button
className="h-24 w-full text-2xl"
disabled={!kilometer || Number(kilometer) < offeneFahrt.kilometerAbfahrt}
onClick={() => void beenden()}
>
Fahrt beenden
</Button>
{kilometer && Number(kilometer) < offeneFahrt.kilometerAbfahrt && (
<p className="text-center text-[var(--gesperrt)]">
Der Stand kann nicht kleiner sein als bei der Abfahrt.
</p>
)}
</div>
</KioskRahmen>
);
}
return (
<KioskRahmen
titel="Fahrt beginnen"
hinweis="Fahrzeug, Kilometerstand, Zweck"
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-6">
<section className="space-y-3">
<h2 className="text-xl font-medium">Welches Fahrzeug?</h2>
<div className="grid gap-3 sm:grid-cols-2">
{fahrzeuge.map((fahrzeug) => (
<GrosseAuswahl
key={fahrzeug.id}
titel={fahrzeug.funkrufname}
hinweis={fahrzeug.dinTyp ?? fahrzeug.bezeichnung}
gewaehlt={fahrzeugId === fahrzeug.id}
onClick={() => {
setFahrzeugId(fahrzeug.id);
setKilometer(fahrzeug.kilometerstand ? String(fahrzeug.kilometerstand) : "");
}}
/>
))}
</div>
{fahrzeuge.length === 0 && (
<p className="text-[var(--muted-foreground)]">
Keine Fahrzeuge im Zwischenspeicher. Einmal mit Netz öffnen, danach steht die Liste
auch offline.
</p>
)}
</section>
{fahrzeugId && (
<>
<section className="space-y-3">
<div className="space-y-2">
<Label htmlFor="km" className="text-lg">
Kilometerstand bei Abfahrt
</Label>
<Input
id="km"
type="number"
inputMode="numeric"
className="h-20 text-3xl"
value={kilometer}
onChange={(ereignis) => setKilometer(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ziel" className="text-lg">
Ziel (kann leer bleiben)
</Label>
<Input
id="ziel"
className="h-16 text-xl"
value={ziel}
onChange={(ereignis) => setZiel(ereignis.target.value)}
placeholder="Werkstatt Musterhausen"
/>
</div>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium">Wozu?</h2>
<div className="grid gap-3 sm:grid-cols-2">
{ZWECK.map((eintrag) => (
<GrosseAuswahl
key={eintrag.wert}
titel={eintrag.titel}
hinweis={eintrag.hinweis}
onClick={() => void beginnen(eintrag.wert)}
/>
))}
</div>
{!kilometer && (
<p className="text-[var(--muted-foreground)]">
Der Kilometerstand fehlt noch.
</p>
)}
</section>
</>
)}
</div>
</KioskRahmen>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { env } from "@/lib/env";
import { InventurWizard } from "@/app/kiosk/inventur/wizard";
export const metadata = { title: "Inventur" };
export default async function InventurSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/kiosk");
return <InventurWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import * as React from "react";
import { CheckCircle2, ScanLine } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
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 { KioskRahmen } from "@/components/kiosk/rahmen";
import { QrLeser } from "@/components/kiosk/qr-leser";
import { useWarteschlange } from "@/components/kiosk/warteschlange";
import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
/**
* Inventur am Regal.
*
* Anders als die übrigen Wizards endet dieser nicht nach einem Vorgang: der
* Gerätewart geht mit dem Tablet durch die Halle und scannt der Reihe nach.
* Deshalb bleibt die Kamera an, und jeder Scan wandert sofort in die
* Warteschlange — auch ohne Netz, denn im hinteren Lager gibt es keins.
*
* Was zählt, ist die Liste am Ende: was nicht gescannt wurde, fehlt.
*/
export function InventurWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) {
const { erfassen } = useWarteschlange();
const werkzeuge = trpc.useUtils();
const paket = trpc.kiosk.stammdatenpaket.useQuery(undefined, { retry: false });
const [inventurId, setInventurId] = React.useState<string | null>(null);
const [bezeichnung, setBezeichnung] = React.useState("");
const [gescannt, setGescannt] = React.useState<string[]>([]);
const [meldung, setMeldung] = React.useState<string | null>(null);
const laufende = paket.data?.laufendeInventur ?? null;
const aktiveId = inventurId ?? laufende?.id ?? null;
const stand = trpc.kiosk.inventurStand.useQuery(
{ id: aktiveId ?? "" },
{ enabled: Boolean(aktiveId), retry: false },
);
const starten = trpc.kiosk.inventurStarten.useMutation({
onSuccess: (inventur) => {
setInventurId(inventur.id);
void werkzeuge.kiosk.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const abschliessen = trpc.kiosk.inventurAbschliessen.useMutation({
onSuccess: () => {
setMeldung("Inventur abgeschlossen.");
setInventurId(null);
void werkzeuge.kiosk.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
async function scannen(inhalt: string) {
if (!aktiveId) return;
const inventarnummer = inventarnummerAusScan(inhalt) ?? inhalt.trim();
if (!inventarnummer) return;
// Doppelte Scans sind bei einer Inventur der Normalfall — der Server
// behandelt sie ohnehin als denselben Posten.
if (gescannt.includes(inventarnummer)) {
setMeldung(`${inventarnummer} war schon dabei.`);
return;
}
await erfassen(
{
art: "INVENTUR_SCANNEN",
daten: { inventurId: aktiveId, inventarnummer, gefunden: true },
},
`Inventur: ${inventarnummer}`,
);
setGescannt((alt) => [inventarnummer, ...alt]);
setMeldung(null);
}
if (!aktiveId) {
return (
<KioskRahmen
titel="Inventur"
hinweis="Kein Gang offen — einen neuen beginnen."
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-4">
{meldung && <Fehlermeldung meldung={meldung} kennung="inventurmeldung" />}
<div className="space-y-2">
<Label htmlFor="bezeichnung" className="text-lg">
Was wird gezählt?
</Label>
<Input
id="bezeichnung"
className="h-16 text-xl"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Jahresinventur Gerätehaus"
/>
</div>
<Button
className="h-20 w-full text-2xl"
disabled={!bezeichnung.trim() || starten.isPending}
onClick={() => starten.mutate({ bezeichnung: bezeichnung.trim() })}
>
Inventur beginnen
</Button>
<p className="text-[var(--muted-foreground)]">
Das Beginnen braucht Netz. Das Scannen danach nicht — die Scans warten notfalls in
der Warteschlange.
</p>
</div>
</KioskRahmen>
);
}
return (
<KioskRahmen
titel="Inventur läuft"
hinweis={stand.data?.bezeichnung ?? laufende?.bezeichnung ?? "Gerät für Gerät scannen"}
abmeldenNachSekunden={abmeldenNachSekunden}
>
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="inventurmeldung" />}
<div className="grid grid-cols-3 gap-3 text-center">
<div className="rounded-xl border-2 border-[var(--border)] p-4">
<div className="tabellenzahlen text-3xl font-semibold">
{(stand.data?.gescannt ?? 0) + gescannt.length}
</div>
<div className="text-[var(--muted-foreground)]">gescannt</div>
</div>
<div className="rounded-xl border-2 border-[var(--border)] p-4">
<div className="tabellenzahlen text-3xl font-semibold">
{stand.data?.erwartet ?? "—"}
</div>
<div className="text-[var(--muted-foreground)]">erwartet</div>
</div>
<div className="rounded-xl border-2 border-[var(--border)] p-4">
<div className="tabellenzahlen text-3xl font-semibold text-[var(--gesperrt)]">
{stand.data ? stand.data.fehlend.length : "—"}
</div>
<div className="text-[var(--muted-foreground)]">fehlt noch</div>
</div>
</div>
<section className="space-y-3">
<h2 className="flex items-center gap-2 text-xl font-medium">
<ScanLine aria-hidden />
Scannen
</h2>
<QrLeser automatischStarten beschriftung="Kamera starten" onGelesen={scannen} />
</section>
{gescannt.length > 0 && (
<section className="space-y-2">
<h2 className="text-xl font-medium">Gerade gescannt</h2>
<ul className="space-y-1">
{gescannt.slice(0, 12).map((nummer) => (
<li key={nummer} className="flex items-center gap-2 text-lg">
<CheckCircle2 className="size-5 text-[var(--einsatzbereit)]" aria-hidden />
<span className="tabellenzahlen">{nummer}</span>
</li>
))}
</ul>
</section>
)}
{stand.data && stand.data.fehlend.length > 0 && (
<section className="space-y-2">
<h2 className="text-xl font-medium">Fehlt noch</h2>
<ul className="max-h-60 space-y-1 overflow-y-auto text-lg">
{stand.data.fehlend.slice(0, 30).map((geraet) => (
<li key={geraet.id}>
<span className="tabellenzahlen">{geraet.inventarnummer}</span> —{" "}
{geraet.bezeichnung}
</li>
))}
</ul>
</section>
)}
<Button
variant="outline"
className="h-16 w-full text-lg"
disabled={abschliessen.isPending}
onClick={() => {
if (window.confirm("Inventur abschließen? Danach lässt sich nichts mehr scannen.")) {
abschliessen.mutate({ id: aktiveId });
}
}}
>
Inventur abschließen
</Button>
</div>
</KioskRahmen>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata, Viewport } from "next";
import { TrpcProvider } from "@/lib/trpc/provider";
import { WarteschlangeProvider } from "@/components/kiosk/warteschlange";
import { ServiceWorkerAnmeldung } from "@/components/kiosk/service-worker";
export const metadata: Metadata = {
title: "FLORIAN Terminal",
// Ein Terminal gehört nicht in eine Suchmaschine.
robots: { index: false, follow: false },
appleWebApp: { capable: true, title: "FLORIAN", statusBarStyle: "black-translucent" },
};
export const viewport: Viewport = {
themeColor: "#b91c1c",
// Kein Zoom auf dem Wandtablet: ein versehentliches Aufziehen mit dem
// Handschuh verschiebt sonst die halbe Oberfläche, und niemand findet zurück.
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
};
/**
* Layout des Terminals.
*
* Bewusst eigenständig, ohne die Seitenleiste der Verwaltungsoberfläche:
*
* * **Große Ziele.** Alles Bedienbare ist mindestens 64 Pixel hoch. Wer mit
* Feuerwehrhandschuhen tippt, trifft keine 32-Pixel-Knöpfe.
* * **Kein Hover.** Ein Touchgerät kennt kein Darüberfahren; alles, was
* wichtig ist, steht immer da.
* * **Kein Zoom, kein Scrollen in der Breite.** Sonst verrutscht die
* Oberfläche, sobald jemand sie mit dem Ärmel streift.
*
* Der Service Worker und die Warteschlange hängen hier — beide gelten für
* alles unterhalb von /kiosk.
*/
export default function KioskLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<TrpcProvider>
<WarteschlangeProvider>
<ServiceWorkerAnmeldung />
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)] [touch-action:manipulation]">
{children}
</div>
</WarteschlangeProvider>
</TrpcProvider>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { env } from "@/lib/env";
import { MangelWizard } from "@/app/kiosk/mangel/wizard";
export const metadata = { title: "Mangel melden" };
export default async function MangelSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
if (!hatRecht(sitzung, "pruefung.lesen")) redirect("/kiosk");
return <MangelWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+221
View File
@@ -0,0 +1,221 @@
"use client";
import * as React from "react";
import { Camera, ScanLine } from "lucide-react";
import { MangelPrioritaet } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Erledigt, GrosseAuswahl, KioskRahmen } from "@/components/kiosk/rahmen";
import { QrLeser } from "@/components/kiosk/qr-leser";
import { useWarteschlange } from "@/components/kiosk/warteschlange";
import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
/**
* Mangel melden — der Vorgang, an dem sich das ganze Terminal misst.
*
* Anspruch aus der Anforderung: **unter dreißig Sekunden**. Deshalb drei
* Schritte und kein Formular:
*
* 1. QR-Code am Gerät scannen (oder Nummer eintippen)
* 2. Foto und ein Satz dazu
* 3. Dringlichkeit antippen — fertig
*
* Das Foto wandert samt Meldung in die Warteschlange. Ist gerade kein Netz da,
* wird es später hochgeladen; die Meldung geht in jedem Fall raus.
*/
const PRIORITAET: Array<{ wert: MangelPrioritaet; titel: string; hinweis: string }> = [
{ wert: MangelPrioritaet.NIEDRIG, titel: "Kleinigkeit", hinweis: "kann warten" },
{ wert: MangelPrioritaet.MITTEL, titel: "Normal", hinweis: "beim nächsten Dienst" },
{ wert: MangelPrioritaet.HOCH, titel: "Dringend", hinweis: "vor dem nächsten Einsatz" },
{
wert: MangelPrioritaet.KRITISCH,
titel: "Kritisch",
hinweis: "Gerät wird sofort gesperrt",
},
];
export function MangelWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) {
const { erfassen } = useWarteschlange();
const paket = trpc.kiosk.stammdatenpaket.useQuery(undefined, { retry: false });
const [inventarnummer, setInventarnummer] = React.useState("");
const [beschreibung, setBeschreibung] = React.useState("");
const [foto, setFoto] = React.useState<{ inhalt: Blob; name: string } | null>(null);
const [vorschau, setVorschau] = React.useState<string | null>(null);
const [fertig, setFertig] = React.useState<string | null>(null);
const kamera = React.useRef<HTMLInputElement | null>(null);
const geraet = paket.data?.geraete.find(
(eintrag) => eintrag.inventarnummer.toLowerCase() === inventarnummer.trim().toLowerCase(),
);
React.useEffect(() => {
return () => {
if (vorschau) URL.revokeObjectURL(vorschau);
};
}, [vorschau]);
async function melden(prioritaet: MangelPrioritaet) {
const nummer = inventarnummer.trim();
if (!nummer || !beschreibung.trim()) return;
await erfassen(
{
art: "MANGEL_MELDEN",
daten: { inventarnummer: nummer, beschreibung: beschreibung.trim(), prioritaet },
},
`Mangel an ${nummer}`,
foto ?? undefined,
);
setFertig(
prioritaet === MangelPrioritaet.KRITISCH
? `Mangel gemeldet. ${nummer} wird gesperrt.`
: `Mangel an ${nummer} gemeldet.`,
);
}
return (
<KioskRahmen
titel="Mangel melden"
hinweis="Gerät scannen, Foto, ein Satz dazu — fertig."
abmeldenNachSekunden={abmeldenNachSekunden}
>
{fertig ? (
<Erledigt text={fertig} />
) : (
<div className="space-y-6">
{/* Schritt 1: Gerät */}
<section className="space-y-3">
<h2 className="flex items-center gap-2 text-xl font-medium">
<ScanLine aria-hidden />
Welches Gerät?
</h2>
{!inventarnummer && (
<QrLeser
automatischStarten
beschriftung="Kamera für den QR-Code"
onGelesen={(inhalt) => setInventarnummer(inventarnummerAusScan(inhalt) ?? inhalt)}
/>
)}
<div className="space-y-2">
<Label htmlFor="inventarnummer" className="text-lg">
Inventarnummer
</Label>
<Input
id="inventarnummer"
className="h-16 text-2xl"
value={inventarnummer}
onChange={(ereignis) => setInventarnummer(ereignis.target.value)}
placeholder="INV-0001"
/>
{inventarnummer && (
<p className="text-lg">
{geraet ? (
<>
<strong>{geraet.bezeichnung}</strong>{" "}
<span className="text-[var(--muted-foreground)]">({geraet.status})</span>
</>
) : paket.data ? (
<span className="text-[var(--gesperrt)]">
Diese Nummer ist im Bestand nicht bekannt — die Meldung geht trotzdem raus
und landet notfalls in der Konfliktliste.
</span>
) : (
<span className="text-[var(--muted-foreground)]">
Bestand nicht geladen — die Nummer wird beim Übertragen geprüft.
</span>
)}
</p>
)}
</div>
</section>
{/* Schritt 2: Foto und Beschreibung */}
<section className="space-y-3">
<h2 className="text-xl font-medium">Was ist kaputt?</h2>
<Textarea
rows={3}
className="text-lg"
value={beschreibung}
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
placeholder="Riss im Schlauch, etwa 20 cm von der Kupplung"
aria-label="Beschreibung des Mangels"
/>
<input
ref={kamera}
type="file"
accept="image/*"
capture="environment"
className="hidden"
aria-label="Foto aufnehmen"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (!datei) return;
setFoto({ inhalt: datei, name: datei.name || "mangel.jpg" });
setVorschau(URL.createObjectURL(datei));
}}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
className="h-16 text-lg"
onClick={() => kamera.current?.click()}
>
<Camera aria-hidden />
{foto ? "Foto ersetzen" : "Foto aufnehmen"}
</Button>
{vorschau && (
// Objekt-URL aus der Kamera — die kann next/image nicht abrufen.
// eslint-disable-next-line @next/next/no-img-element
<img
src={vorschau}
alt="Aufgenommenes Foto"
className="h-24 rounded border border-[var(--border)]"
/>
)}
</div>
<p className="text-[var(--muted-foreground)]">
Ohne Netz wartet das Foto mit der Meldung in der Warteschlange und geht später raus.
</p>
</section>
{/* Schritt 3: Dringlichkeit — und damit abgeschickt */}
<section className="space-y-3">
<h2 className="text-xl font-medium">Wie dringend?</h2>
<div className="grid gap-3 sm:grid-cols-2">
{PRIORITAET.map((eintrag) => (
<GrosseAuswahl
key={eintrag.wert}
titel={eintrag.titel}
hinweis={eintrag.hinweis}
onClick={() => void melden(eintrag.wert)}
/>
))}
</div>
{(!inventarnummer.trim() || !beschreibung.trim()) && (
<p className="text-[var(--muted-foreground)]">
Gerät und Beschreibung fehlen noch — danach genügt ein Tipp auf die Dringlichkeit.
</p>
)}
</section>
</div>
)}
</KioskRahmen>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AlertTriangle, CloudRain, Flame, Truck, Users } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { WARNSTUFE_TEXT } from "@/lib/integrations/wetter-texte";
import { alsDatum, alsUhrzeit } from "@/lib/utils";
/**
* Hallenmonitor.
*
* Hängt an der Wand und wird im Vorbeigehen gelesen — aus drei, vier Metern
* Entfernung. Daraus folgt alles Weitere:
*
* * **Große Schrift, wenige Zahlen.** Wer stehen bleiben muss, um es zu
* lesen, liest es nicht.
* * **Keine Bedienung.** Kein Knopf, kein Menü, keine Abmeldeuhr.
* * **Was dringend ist, steht oben.** Ein laufender Einsatz verdrängt alles
* andere; danach kommen Warnungen, dann der Alltag.
*
* Die Daten kommen im Minutentakt nach. Fällt das Netz aus, bleibt der letzte
* Stand stehen — mit sichtbarer Uhrzeit, damit niemand einem alten Bild traut.
*/
const AKTUALISIERUNG_MS = 60_000;
/** Wie viele laufende Einsätze groß angezeigt werden, bevor der Rest zur Zeile wird. */
const GROSS_ANGEZEIGTE_EINSAETZE = 2;
export function Hallenmonitor() {
const [uhrzeit, setUhrzeit] = React.useState<Date>(new Date());
const daten = trpc.kiosk.monitor.useQuery(undefined, {
refetchInterval: AKTUALISIERUNG_MS,
retry: false,
});
const wetter = trpc.kiosk.wetterwarnungen.useQuery(undefined, {
refetchInterval: 5 * AKTUALISIERUNG_MS,
retry: false,
});
React.useEffect(() => {
const zeitgeber = setInterval(() => setUhrzeit(new Date()), 1000);
return () => clearInterval(zeitgeber);
}, []);
const stand = daten.data;
const warnungen = wetter.data?.warnungen ?? [];
const hoechsteWarnstufe = warnungen.reduce((hoechste, warnung) => Math.max(hoechste, warnung.stufe), 0);
return (
<div className="min-h-screen bg-[var(--background)] p-6">
{/* Kopfzeile: Uhr und Stand */}
<header className="flex flex-wrap items-baseline justify-between gap-4 border-b-2 border-[var(--border)] pb-4">
<div className="flex items-baseline gap-4">
<Flame className="size-10 text-[var(--primary)]" aria-hidden />
<span className="text-3xl font-semibold">Gerätehaus</span>
</div>
<div className="text-right">
<div className="tabellenzahlen text-6xl font-semibold leading-none">
{alsUhrzeit(uhrzeit)}
</div>
<div className="text-lg text-[var(--muted-foreground)]">{alsDatum(uhrzeit)}</div>
</div>
</header>
{/*
Laufender Einsatz verdrängt alles — aber höchstens zwei.
Mehr als zwei gleichzeitig laufende Einsätze gibt es selten, und wenn,
dann ist die Halle ohnehin leer. Ungebremst schöbe die Liste alles
Übrige aus dem Bild, und genau das darf auf einer Wandanzeige nicht
passieren: was nicht sichtbar ist, hilft niemandem. Der Rest steht als
Zeile darunter.
*/}
{stand?.laufendeEinsaetze.slice(0, GROSS_ANGEZEIGTE_EINSAETZE).map((einsatz) => (
<section
key={einsatz.id}
className="mt-6 rounded-2xl border-4 border-[var(--gesperrt)] bg-[var(--gesperrt)]/10 p-6"
>
<div className="flex items-center gap-4">
<AlertTriangle className="size-12 text-[var(--gesperrt)]" aria-hidden />
<div>
<p className="text-lg uppercase tracking-wide text-[var(--gesperrt)]">
Einsatz läuft
</p>
<p className="text-4xl font-semibold">{einsatz.stichwort}</p>
<p className="text-xl text-[var(--muted-foreground)]">
{einsatz.ort ?? "ohne Ortsangabe"} · alarmiert{" "}
{einsatz.alarmiertUm ? alsUhrzeit(einsatz.alarmiertUm) : "—"} ·{" "}
{einsatz.einsatznummer}
</p>
</div>
</div>
</section>
))}
{(stand?.laufendeEinsaetze.length ?? 0) > GROSS_ANGEZEIGTE_EINSAETZE && (
<p className="mt-3 text-xl text-[var(--muted-foreground)]">
{(stand?.laufendeEinsaetze.length ?? 0) - GROSS_ANGEZEIGTE_EINSAETZE} weitere Einsätze
laufen:{" "}
{stand?.laufendeEinsaetze
.slice(GROSS_ANGEZEIGTE_EINSAETZE)
.map((einsatz) => `${einsatz.einsatznummer} ${einsatz.stichwort}`)
.join(" · ")}
</p>
)}
{/* Wetterwarnung */}
{hoechsteWarnstufe >= 2 && (
<section className="mt-6 rounded-2xl border-4 border-[var(--pruefung-faellig)] bg-[var(--pruefung-faellig)]/10 p-6">
<div className="flex items-start gap-4">
<CloudRain className="size-12 text-[var(--pruefung-faellig)]" aria-hidden />
<div>
<p className="text-lg uppercase tracking-wide text-[var(--pruefung-faellig)]">
{WARNSTUFE_TEXT[hoechsteWarnstufe] ?? "Wetterwarnung"} des DWD
</p>
{warnungen
.filter((warnung) => warnung.stufe === hoechsteWarnstufe)
.slice(0, 2)
.map((warnung) => (
<p key={warnung.ueberschrift} className="text-2xl font-medium">
{warnung.ueberschrift || warnung.ereignis}
{warnung.bis && (
<span className="text-lg text-[var(--muted-foreground)]">
{" "}
bis {alsUhrzeit(warnung.bis)}
</span>
)}
</p>
))}
</div>
</div>
</section>
)}
<div className="mt-6 grid gap-6 lg:grid-cols-3">
{/* Anwesend */}
<section className="rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="flex items-center gap-2 text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
<Users className="size-5" aria-hidden />
Im Haus
</h2>
<p className="tabellenzahlen text-6xl font-semibold">{stand?.anwesend.length ?? 0}</p>
<ul className="mt-3 space-y-1 text-xl">
{stand?.anwesend.slice(0, 10).map((eintrag) => (
<li key={eintrag.id}>
{eintrag.person.nachname}, {eintrag.person.vorname}
<span className="ml-2 text-base text-[var(--muted-foreground)]">
seit {alsUhrzeit(eintrag.angemeldetUm)}
</span>
</li>
))}
</ul>
</section>
{/* Fahrzeuge */}
<section className="rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="flex items-center gap-2 text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
<Truck className="size-5" aria-hidden />
Fahrzeuge
</h2>
<ul className="mt-3 space-y-2 text-xl">
{stand?.fahrzeuge.map((fahrzeug) => (
<li key={fahrzeug.id} className="flex items-center justify-between gap-3">
<span>{fahrzeug.funkrufname}</span>
<span
className={
fahrzeug.unterwegs
? "rounded-full bg-[var(--gesperrt)]/15 px-3 py-1 text-base font-medium text-[var(--gesperrt)]"
: "rounded-full bg-[var(--einsatzbereit)]/15 px-3 py-1 text-base font-medium text-[var(--einsatzbereit)]"
}
>
{fahrzeug.unterwegs ? "unterwegs" : "im Haus"}
</span>
</li>
))}
</ul>
{stand && stand.unterwegs.length > 0 && (
<ul className="mt-3 space-y-1 text-base text-[var(--muted-foreground)]">
{stand.unterwegs.map((fahrt) => (
<li key={fahrt.id}>
{fahrt.fahrzeug.funkrufname}: {fahrt.fahrer.nachname} seit{" "}
{alsUhrzeit(fahrt.abfahrtUm)}
{fahrt.ziel ? ` → ${fahrt.ziel}` : ""}
</li>
))}
</ul>
)}
</section>
{/* Prüfungen und Termine */}
<section className="rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
Prüfungen
</h2>
<div className="mt-2 flex items-baseline gap-6">
<div>
<p
className={`tabellenzahlen text-6xl font-semibold ${
(stand?.ueberfaelligePruefungen ?? 0) > 0 ? "text-[var(--gesperrt)]" : ""
}`}
>
{stand?.ueberfaelligePruefungen ?? 0}
</p>
<p className="text-[var(--muted-foreground)]">überfällig</p>
</div>
<div>
<p className="tabellenzahlen text-4xl font-semibold">
{stand?.gesperrteGeraete ?? 0}
</p>
<p className="text-[var(--muted-foreground)]">gesperrt</p>
</div>
</div>
<ul className="mt-4 space-y-1 text-lg">
{stand?.naechsteTermine.slice(0, 5).map((termin) => (
<li key={termin.id}>
<span className="tabellenzahlen">{alsDatum(termin.faelligAm)}</span>{" "}
{termin.geraet.inventarnummer} — {termin.vorlage?.name ?? "Prüfung"}
</li>
))}
</ul>
</section>
</div>
{/* Übungen */}
{stand && stand.uebungen.length > 0 && (
<section className="mt-6 rounded-2xl border-2 border-[var(--border)] p-6">
<h2 className="text-xl uppercase tracking-wide text-[var(--muted-foreground)]">
Diese Woche
</h2>
<ul className="mt-2 space-y-1 text-2xl">
{stand.uebungen.map((uebung) => (
<li key={uebung.id}>
<span className="tabellenzahlen">{alsDatum(uebung.datum)}</span>
{uebung.beginnUm && (
<span className="tabellenzahlen"> {alsUhrzeit(uebung.beginnUm)}</span>
)}{" "}
{uebung.thema}
{uebung.ort && (
<span className="text-lg text-[var(--muted-foreground)]"> · {uebung.ort}</span>
)}
</li>
))}
</ul>
</section>
)}
<footer className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t-2 border-[var(--border)] pt-4 text-base text-[var(--muted-foreground)]">
<span>
Stand: {stand ? alsUhrzeit(stand.stand) : "—"}
{daten.isError && " · keine Verbindung, angezeigt wird der letzte Stand"}
{stand?.laufendeEinsaetze.length === 0 &&
stand.anwesend.length === 0 &&
" · ruhig im Haus"}
</span>
{/* Einziger Weg zurück — bewusst klein und unten. */}
<Link href="/kiosk" className="underline underline-offset-4">
zum Terminal
</Link>
</footer>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { Hallenmonitor } from "@/app/kiosk/monitor/monitor";
export const metadata = { title: "Hallenmonitor" };
/**
* Der Monitor in der Fahrzeughalle.
*
* Läuft im Vollbild und wird nicht bedient. Er braucht trotzdem eine Anmeldung
* — es stehen Namen darauf, und eine Halle ist kein abgeschlossener Raum. In
* der Praxis meldet sich das Anzeigegerät einmal an und bleibt es; die
* Abmeldeuhr läuft auf dieser Seite bewusst nicht mit.
*/
export default async function MonitorSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
return <Hallenmonitor />;
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link";
import { CloudOff } from "lucide-react";
export const metadata = { title: "Offline" };
/**
* Die Seite, die der Service Worker zeigt, wenn nichts anderes mehr geht.
*
* Sie ist bewusst statisch und ohne Datenzugriff — sie muss auch dann noch
* erscheinen, wenn vom Server nichts mehr kommt. Und sie sagt das Wichtigste:
* das Erfasste ist nicht weg.
*/
export default function OfflineSeite() {
return (
<div className="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center gap-6 p-6 text-center">
<CloudOff className="size-20 text-[var(--muted-foreground)]" aria-hidden />
<h1 className="text-3xl font-semibold tracking-tight">Kein Netz</h1>
<p className="text-lg text-[var(--muted-foreground)]">
Diese Seite liegt nicht im Zwischenspeicher des Terminals. Was bereits erfasst wurde,
wartet in der Warteschlange und geht raus, sobald das Netz wieder da ist — es ist nichts
verloren.
</p>
<Link
href="/kiosk"
className="flex h-16 items-center rounded-md border-2 border-[var(--border)] px-8 text-lg"
>
Zurück zu den Kacheln
</Link>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht, rechteVon } from "@/lib/authz";
import { env } from "@/lib/env";
import { KioskAnmeldung } from "@/components/kiosk/anmeldung";
import { KioskKacheln } from "@/components/kiosk/kacheln";
export const metadata = { title: "Terminal" };
/**
* Die Startseite des Terminals.
*
* Ohne Anmeldung: der Anmeldeschirm. Mit Anmeldung: die Kacheln, und zwar nur
* die, für die die angemeldete Person auch das Recht hat. Die Prüfung passiert
* hier auf dem Server — die Kacheln bekommen nur das Ergebnis.
*/
export default async function KioskSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) return <KioskAnmeldung />;
return (
<KioskKacheln
name={sitzung.name}
abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT}
rechte={{
mangel: hatRecht(sitzung, "pruefung.lesen"),
fahrtenbuch: hatRecht(sitzung, "fahrtenbuch.fuehren"),
inventur: hatRecht(sitzung, "geraet.bearbeiten"),
pruefung: hatRecht(sitzung, "pruefung.durchfuehren"),
alleRechte: [...rechteVon(sitzung.rollen)].length,
}}
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { env } from "@/lib/env";
import { WaescheWizard } from "@/app/kiosk/waesche/wizard";
export const metadata = { title: "Wäsche abgeben" };
export default async function WaescheSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/kiosk");
return <WaescheWizard abmeldenNachSekunden={env.KIOSK_IDLE_TIMEOUT} />;
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { Minus, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Erledigt, GrosseAuswahl, KioskRahmen } from "@/components/kiosk/rahmen";
import { useWarteschlange } from "@/components/kiosk/warteschlange";
/**
* Wäsche abgeben.
*
* Steht am Wäschekorb im Gerätehaus. Wer seine Einsatzjacke einwirft, tippt
* hier, was drin ist — sonst weiß später niemand, wem das Teil gehört, das aus
* der Reinigung zurückkommt.
*
* Die Auswahl ist bewusst eine Liste der üblichen Stücke und kein Textfeld:
* mit Handschuhen tippt man ungern, und „Einsatzjacke" schreibt jeder anders.
*/
const STUECKE = [
"Einsatzjacke",
"Einsatzhose",
"Überjacke",
"Handschuhe",
"Flammschutzhaube",
"Sonstiges",
];
const GRUENDE = [
{ titel: "Einsatzverschmutzung", hinweis: "nach Brand oder THL" },
{ titel: "Kontamination", hinweis: "Brandrauch, Gefahrstoff" },
{ titel: "Routine", hinweis: "turnusmäßig" },
];
export function WaescheWizard({ abmeldenNachSekunden }: { abmeldenNachSekunden: number }) {
const { erfassen } = useWarteschlange();
const [bezeichnung, setBezeichnung] = React.useState<string | null>(null);
const [eigene, setEigene] = React.useState("");
const [menge, setMenge] = React.useState(1);
const [fertig, setFertig] = React.useState<string | null>(null);
const stueck = bezeichnung === "Sonstiges" ? eigene.trim() : bezeichnung;
async function abgeben(grund: string) {
if (!stueck) return;
await erfassen(
{ art: "WAESCHE_ABGEBEN", daten: { bezeichnung: stueck, menge, grund } },
`Wäsche abgegeben (${stueck})`,
);
setFertig(`${menge}× ${stueck} abgegeben. Der Gerätewart sieht es in der Liste.`);
}
return (
<KioskRahmen
titel="Wäsche abgeben"
hinweis="Was kommt in den Korb?"
abmeldenNachSekunden={abmeldenNachSekunden}
>
{fertig ? (
<Erledigt text={fertig} />
) : (
<div className="space-y-6">
<section className="space-y-3">
<h2 className="text-xl font-medium">Kleidungsstück</h2>
<div className="grid gap-3 sm:grid-cols-2">
{STUECKE.map((eintrag) => (
<GrosseAuswahl
key={eintrag}
titel={eintrag}
gewaehlt={bezeichnung === eintrag}
onClick={() => setBezeichnung(eintrag)}
/>
))}
</div>
{bezeichnung === "Sonstiges" && (
<div className="space-y-2">
<Label htmlFor="eigene" className="text-lg">
Was genau?
</Label>
<Input
id="eigene"
className="h-16 text-xl"
value={eigene}
onChange={(ereignis) => setEigene(ereignis.target.value)}
placeholder="Einsatzstiefel, Helmfutter …"
/>
</div>
)}
</section>
{stueck && (
<>
<section className="space-y-3">
<h2 className="text-xl font-medium">Wie viele?</h2>
<div className="flex items-center gap-4">
<Button
variant="outline"
className="h-20 w-20"
onClick={() => setMenge((wert) => Math.max(1, wert - 1))}
aria-label="weniger"
>
<Minus className="size-8" aria-hidden />
</Button>
<span className="tabellenzahlen w-20 text-center text-4xl font-semibold">
{menge}
</span>
<Button
variant="outline"
className="h-20 w-20"
onClick={() => setMenge((wert) => Math.min(50, wert + 1))}
aria-label="mehr"
>
<Plus className="size-8" aria-hidden />
</Button>
</div>
</section>
<section className="space-y-3">
<h2 className="text-xl font-medium">Warum?</h2>
<div className="grid gap-3 sm:grid-cols-3">
{GRUENDE.map((grund) => (
<GrosseAuswahl
key={grund.titel}
titel={grund.titel}
hinweis={grund.hinweis}
onClick={() => void abgeben(grund.titel)}
/>
))}
</div>
</section>
</>
)}
</div>
)}
</KioskRahmen>
);
}