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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* Das Web-App-Manifest.
|
||||
*
|
||||
* Damit lässt sich FLORIAN auf dem Wandtablet als Anwendung ablegen: Vollbild,
|
||||
* kein Browserrahmen, eigenes Symbol. Startpunkt ist bewusst `/kiosk` — wer
|
||||
* das Tablet einschaltet, will das Terminal, nicht die Verwaltungsoberfläche.
|
||||
*
|
||||
* Keine externen Symbole, keine nachgeladenen Schriften: das Manifest verweist
|
||||
* nur auf Dateien aus dem eigenen Verzeichnis.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "FLORIAN — Feuerwehrverwaltung",
|
||||
short_name: "FLORIAN",
|
||||
description:
|
||||
"Terminal im Gerätehaus: Anwesenheit, Fahrtenbuch, Mängel, Inventur — auch ohne Netz.",
|
||||
start_url: "/kiosk",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "any",
|
||||
lang: "de",
|
||||
dir: "ltr",
|
||||
background_color: "#faf9f7",
|
||||
theme_color: "#b91c1c",
|
||||
categories: ["productivity", "utilities"],
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/florian-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/florian-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/florian-maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useAbmelden } from "@/components/kiosk/warteschlange";
|
||||
|
||||
/**
|
||||
* Abmelden nach Untätigkeit.
|
||||
*
|
||||
* Ein Terminal in der Fahrzeughalle bleibt angemeldet stehen, wenn jemand
|
||||
* gerufen wird und weggeht. Der Nächste, der vorbeikommt, wäre dann unter
|
||||
* fremdem Namen unterwegs — im Fahrtenbuch und in jeder Mangelmeldung.
|
||||
*
|
||||
* Deshalb: nach `KIOSK_IDLE_TIMEOUT` Sekunden ohne Berührung wird abgemeldet.
|
||||
* Zehn Sekunden vorher erscheint eine Warnung, die sich mit einer Berührung
|
||||
* wegwischen lässt — mitten in einem Wizard soll niemand hinausfliegen, nur
|
||||
* weil er nachdenkt.
|
||||
*/
|
||||
|
||||
const WARNUNG_SEKUNDEN = 10;
|
||||
|
||||
export function Abmelduhr({ sekunden }: { sekunden: number }) {
|
||||
const [rest, setRest] = React.useState<number | null>(null);
|
||||
const letzteAktivitaet = React.useRef(Date.now());
|
||||
|
||||
// Meldet ab, nachdem die Warteschlange noch einmal versucht hat zu
|
||||
// übertragen — siehe useAbmelden().
|
||||
const abmelden = useAbmelden();
|
||||
const abmeldenRef = React.useRef(abmelden);
|
||||
abmeldenRef.current = abmelden;
|
||||
|
||||
React.useEffect(() => {
|
||||
const zuruecksetzen = () => {
|
||||
letzteAktivitaet.current = Date.now();
|
||||
setRest(null);
|
||||
};
|
||||
|
||||
// Alles, was ein Mensch am Tablet tut, zählt als Aktivität.
|
||||
const ereignisse = ["pointerdown", "keydown", "touchstart", "wheel"] as const;
|
||||
for (const ereignis of ereignisse) {
|
||||
window.addEventListener(ereignis, zuruecksetzen, { passive: true });
|
||||
}
|
||||
|
||||
const zeitgeber = setInterval(() => {
|
||||
const vergangen = Math.floor((Date.now() - letzteAktivitaet.current) / 1000);
|
||||
const verbleibend = sekunden - vergangen;
|
||||
|
||||
if (verbleibend <= 0) {
|
||||
void abmeldenRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
setRest(verbleibend <= WARNUNG_SEKUNDEN ? verbleibend : null);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(zeitgeber);
|
||||
for (const ereignis of ereignisse) {
|
||||
window.removeEventListener(ereignis, zuruecksetzen);
|
||||
}
|
||||
};
|
||||
}, [sekunden]);
|
||||
|
||||
if (rest === null) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 z-50 bg-[var(--gesperrt)] p-6 text-center text-white"
|
||||
role="alert"
|
||||
data-testid="abmeldewarnung"
|
||||
>
|
||||
<p className="text-2xl font-semibold">Abmeldung in {rest} Sekunden</p>
|
||||
<p className="text-lg opacity-90">Bildschirm berühren, um angemeldet zu bleiben.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { CreditCard, KeyRound, QrCode, Settings } from "lucide-react";
|
||||
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 { QrLeser } from "@/components/kiosk/qr-leser";
|
||||
import { geraeteangaben, type Geraeteangaben } from "@/lib/kiosk/geraet";
|
||||
|
||||
/**
|
||||
* Anmeldung am Terminal.
|
||||
*
|
||||
* Drei Wege, absichtlich in dieser Reihenfolge:
|
||||
*
|
||||
* 1. **Ausweis auflegen.** Der Regelfall. Der Leser am Tablet gibt die
|
||||
* Kartenkennung als Tastatureingabe aus — schnell und mit abschließendem
|
||||
* Enter. Deshalb liegt hier ein Zuhörer auf dem Fenster, kein Eingabefeld:
|
||||
* wer die Karte auflegt, soll nichts antippen müssen.
|
||||
* 2. **QR-Code vom Handy.** Wer den Ausweis vergessen hat.
|
||||
* 3. **Mitgliedsnummer und PIN.** Wenn beides fehlt.
|
||||
*
|
||||
* Vor allen dreien steht die erste Schranke: das Tablet muss eingerichtet sein
|
||||
* (siehe lib/kiosk/geraet.ts). Ohne Gerätegeheimnis kommt niemand hinein, auch
|
||||
* nicht mit richtiger PIN.
|
||||
*/
|
||||
|
||||
/** Wie schnell Zeichen kommen müssen, damit es ein Kartenleser ist und keine Hand. */
|
||||
const LESER_HOECHSTABSTAND_MS = 60;
|
||||
const LESER_MINDESTLAENGE = 6;
|
||||
|
||||
type Weg = "karte" | "marke" | "pin";
|
||||
|
||||
export function KioskAnmeldung() {
|
||||
const router = useRouter();
|
||||
|
||||
const [geraet, setGeraet] = React.useState<Geraeteangaben | null>(null);
|
||||
const [weg, setWeg] = React.useState<Weg>("karte");
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
const [laeuft, setLaeuft] = React.useState(false);
|
||||
|
||||
const [mitgliedsnummer, setMitgliedsnummer] = React.useState("");
|
||||
const [pin, setPin] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setGeraet(geraeteangaben());
|
||||
}, []);
|
||||
|
||||
const anmelden = React.useCallback(
|
||||
async (anbieter: string, daten: Record<string, string>) => {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
|
||||
try {
|
||||
const ergebnis = await signIn(anbieter, { ...daten, redirect: false });
|
||||
|
||||
if (ergebnis?.error) {
|
||||
setFehler(
|
||||
anbieter === "terminal-marke"
|
||||
? "Der Code wurde nicht erkannt. Auf dem Handy einen neuen anzeigen lassen."
|
||||
: "Anmeldung fehlgeschlagen. Stimmen Mitgliedsnummer und PIN?",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} catch {
|
||||
setFehler("Die Anmeldung ließ sich nicht durchführen. Ist das Terminal im Netz?");
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
setPin("");
|
||||
}
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
/**
|
||||
* Kartenleser: Tastatureingaben abfangen.
|
||||
*
|
||||
* Ein RFID-Leser tippt die Kennung in Millisekunden und schließt mit Enter
|
||||
* ab. Ein Mensch tippt langsamer — daran lassen sich beide unterscheiden,
|
||||
* ohne dass ein Eingabefeld den Fokus braucht.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (weg !== "karte" || !geraet) return;
|
||||
|
||||
// Festhalten, damit die Angaben auch im Zuhörer unten sicher vorhanden sind.
|
||||
const angaben = geraet;
|
||||
let puffer = "";
|
||||
let letzterAnschlag = 0;
|
||||
|
||||
function beiTaste(ereignis: KeyboardEvent) {
|
||||
// In einem Eingabefeld hat der Zuhörer nichts verloren.
|
||||
const ziel = ereignis.target as HTMLElement | null;
|
||||
if (ziel && (ziel.tagName === "INPUT" || ziel.tagName === "TEXTAREA")) return;
|
||||
|
||||
const jetzt = Date.now();
|
||||
if (jetzt - letzterAnschlag > LESER_HOECHSTABSTAND_MS) puffer = "";
|
||||
letzterAnschlag = jetzt;
|
||||
|
||||
if (ereignis.key === "Enter") {
|
||||
const kennung = puffer.trim();
|
||||
puffer = "";
|
||||
if (kennung.length < LESER_MINDESTLAENGE) return;
|
||||
|
||||
void anmelden("terminal", {
|
||||
geraeteSchluessel: angaben.geraeteSchluessel,
|
||||
organisationSlug: angaben.organisationSlug,
|
||||
art: "RFID",
|
||||
kennung,
|
||||
geheimnis: kennung,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ereignis.key.length === 1) puffer += ereignis.key;
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", beiTaste);
|
||||
return () => window.removeEventListener("keydown", beiTaste);
|
||||
}, [weg, geraet, anmelden]);
|
||||
|
||||
// Noch nicht eingerichtet: dann hilft nur die Einrichtung.
|
||||
if (geraet === null) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-6 p-6 text-center">
|
||||
<h1 className="text-3xl font-semibold">Terminal nicht eingerichtet</h1>
|
||||
<p className="text-lg text-[var(--muted-foreground)]">
|
||||
Dieses Tablet weiß noch nicht, zu welcher Wehr es gehört. Die Einrichtung braucht das
|
||||
Gerätegeheimnis aus der <code>.env</code> — sie macht der Administrator einmal.
|
||||
</p>
|
||||
<Button className="h-16 w-full text-lg" onClick={() => router.push("/kiosk/einrichten")}>
|
||||
<Settings aria-hidden />
|
||||
Terminal einrichten
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Anmelden</h1>
|
||||
<p className="mt-2 text-lg text-[var(--muted-foreground)]">
|
||||
Ausweis auflegen — oder einen anderen Weg wählen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} kennung="anmeldefehler" />}
|
||||
|
||||
{/* Wegwahl: große Schaltflächen, mit Handschuhen bedienbar. */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(
|
||||
[
|
||||
{ wert: "karte", text: "Ausweis", symbol: CreditCard },
|
||||
{ wert: "marke", text: "QR-Code", symbol: QrCode },
|
||||
{ wert: "pin", text: "PIN", symbol: KeyRound },
|
||||
] as const
|
||||
).map((eintrag) => {
|
||||
const Symbol = eintrag.symbol;
|
||||
return (
|
||||
<Button
|
||||
key={eintrag.wert}
|
||||
variant={weg === eintrag.wert ? "default" : "outline"}
|
||||
className="h-20 flex-col gap-1 text-base"
|
||||
onClick={() => {
|
||||
setWeg(eintrag.wert);
|
||||
setFehler(null);
|
||||
}}
|
||||
>
|
||||
<Symbol className="size-6" aria-hidden />
|
||||
{eintrag.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{weg === "karte" && (
|
||||
<div className="rounded-lg border-2 border-dashed border-[var(--border)] p-10 text-center">
|
||||
<CreditCard className="mx-auto size-16 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<p className="mt-4 text-xl font-medium">Ausweis auf den Leser legen</p>
|
||||
<p className="mt-1 text-[var(--muted-foreground)]">
|
||||
{laeuft ? "wird geprüft …" : "Das Terminal wartet."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{weg === "marke" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-lg">
|
||||
In FLORIAN auf dem Handy die Seite <strong>Anmeldemarke</strong> öffnen und den Code
|
||||
vor die Kamera halten.
|
||||
</p>
|
||||
<QrLeser
|
||||
automatischStarten
|
||||
beschriftung="Kamera starten"
|
||||
onGelesen={(inhalt) =>
|
||||
void anmelden("terminal-marke", {
|
||||
geraeteSchluessel: geraet.geraeteSchluessel,
|
||||
marke: inhalt,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{weg === "pin" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
void anmelden("terminal", {
|
||||
geraeteSchluessel: geraet.geraeteSchluessel,
|
||||
organisationSlug: geraet.organisationSlug,
|
||||
art: "PIN",
|
||||
kennung: mitgliedsnummer,
|
||||
geheimnis: pin,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mitgliedsnummer" className="text-lg">
|
||||
Mitgliedsnummer
|
||||
</Label>
|
||||
<Input
|
||||
id="mitgliedsnummer"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
className="h-16 text-2xl"
|
||||
value={mitgliedsnummer}
|
||||
onChange={(ereignis) => setMitgliedsnummer(ereignis.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pin" className="text-lg">
|
||||
PIN
|
||||
</Label>
|
||||
<Input
|
||||
id="pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
className="h-16 text-2xl tracking-[0.3em]"
|
||||
value={pin}
|
||||
onChange={(ereignis) => setPin(ereignis.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="h-16 w-full text-lg" disabled={laeuft}>
|
||||
{laeuft ? "wird geprüft …" : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
Wehr: {geraet.organisationSlug} ·{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() => router.push("/kiosk/einrichten")}
|
||||
>
|
||||
Terminal neu einrichten
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Monitor,
|
||||
ScanLine,
|
||||
Shirt,
|
||||
Truck,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Zustandsleiste, useAbmelden, useWarteschlange } from "@/components/kiosk/warteschlange";
|
||||
import { Abmelduhr } from "@/components/kiosk/abmelduhr";
|
||||
import { alsUhrzeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Der Kachelschirm.
|
||||
*
|
||||
* Was ein Wandtablet mit Handschuhen bedienbar macht, sind drei Dinge: große
|
||||
* Ziele, wenige davon, und eine sichtbare Rückmeldung. Deshalb sechs Kacheln
|
||||
* statt einer Navigationsleiste, jede mindestens 160 Pixel hoch, jede mit
|
||||
* Symbol **und** Text.
|
||||
*
|
||||
* Die Kacheln richten sich nach den Rechten der angemeldeten Person: der
|
||||
* Gerätewart sieht die Inventur, ein Mitglied nicht.
|
||||
*/
|
||||
|
||||
export interface KachelRechte {
|
||||
mangel: boolean;
|
||||
fahrtenbuch: boolean;
|
||||
inventur: boolean;
|
||||
pruefung: boolean;
|
||||
alleRechte: number;
|
||||
}
|
||||
|
||||
export function KioskKacheln({
|
||||
name,
|
||||
rechte,
|
||||
abmeldenNachSekunden,
|
||||
}: {
|
||||
name: string;
|
||||
rechte: KachelRechte;
|
||||
abmeldenNachSekunden: number;
|
||||
}) {
|
||||
const { offline } = useWarteschlange();
|
||||
const abmelden = useAbmelden();
|
||||
const stand = trpc.kiosk.eigenerStand.useQuery(undefined, { retry: false });
|
||||
|
||||
const angemeldet = stand.data?.anwesenheit ?? null;
|
||||
const fahrt = stand.data?.fahrt ?? null;
|
||||
|
||||
const kacheln = [
|
||||
{
|
||||
sichtbar: true,
|
||||
titel: angemeldet ? "Gehen" : "Kommen",
|
||||
hinweis: angemeldet
|
||||
? `da seit ${alsUhrzeit(angemeldet.angemeldetUm)}`
|
||||
: "Anwesenheit erfassen",
|
||||
symbol: angemeldet ? LogOut : LogIn,
|
||||
ziel: "/kiosk/anwesenheit",
|
||||
hervorgehoben: Boolean(angemeldet),
|
||||
},
|
||||
{
|
||||
sichtbar: rechte.mangel,
|
||||
titel: "Mangel melden",
|
||||
hinweis: "Gerät scannen, Foto, fertig",
|
||||
symbol: TriangleAlert,
|
||||
ziel: "/kiosk/mangel",
|
||||
},
|
||||
{
|
||||
sichtbar: rechte.fahrtenbuch,
|
||||
titel: fahrt ? "Fahrt beenden" : "Fahrt beginnen",
|
||||
hinweis: fahrt ? fahrt.fahrzeug.funkrufname : "Fahrtenbuch",
|
||||
symbol: Truck,
|
||||
ziel: "/kiosk/fahrt",
|
||||
hervorgehoben: Boolean(fahrt),
|
||||
},
|
||||
{
|
||||
sichtbar: true,
|
||||
titel: "Wäsche abgeben",
|
||||
hinweis:
|
||||
stand.data && stand.data.offeneWaesche > 0
|
||||
? `${stand.data.offeneWaesche} noch nicht zurück`
|
||||
: "Schutzkleidung zur Reinigung",
|
||||
symbol: Shirt,
|
||||
ziel: "/kiosk/waesche",
|
||||
},
|
||||
{
|
||||
sichtbar: rechte.inventur,
|
||||
titel: "Inventur",
|
||||
hinweis: "Geräte am Regal scannen",
|
||||
symbol: ScanLine,
|
||||
ziel: "/kiosk/inventur",
|
||||
},
|
||||
{
|
||||
sichtbar: rechte.pruefung,
|
||||
titel: "Prüfung durchführen",
|
||||
// Der Wizard aus Phase 2 mit Checkliste, Foto und Unterschrift braucht
|
||||
// Netz. Das steht dran, statt dass die Kachel ins Leere führt.
|
||||
hinweis: offline ? "braucht Netz" : "Wizard mit Checkliste",
|
||||
symbol: ClipboardCheck,
|
||||
ziel: "/pruefungen",
|
||||
gesperrt: offline,
|
||||
},
|
||||
{
|
||||
sichtbar: true,
|
||||
titel: "Hallenmonitor",
|
||||
hinweis: "Vollbild ohne Bedienung",
|
||||
symbol: Monitor,
|
||||
ziel: "/kiosk/monitor",
|
||||
},
|
||||
].filter((kachel) => kachel.sichtbar);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<Abmelduhr sekunden={abmeldenNachSekunden} />
|
||||
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-lg text-[var(--muted-foreground)]">Angemeldet als</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{name}</h1>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-16 px-6 text-lg"
|
||||
onClick={() => void abmelden()}
|
||||
>
|
||||
<LogOut aria-hidden />
|
||||
Abmelden
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Zustandsleiste />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{kacheln.map((kachel) => {
|
||||
const Symbol = kachel.symbol;
|
||||
|
||||
if (kachel.gesperrt) {
|
||||
return (
|
||||
<div
|
||||
key={kachel.titel}
|
||||
className="flex min-h-40 cursor-not-allowed flex-col justify-between rounded-xl border-2 border-dashed border-[var(--border)] p-6 opacity-60"
|
||||
>
|
||||
<Symbol className="size-10 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{kachel.titel}</p>
|
||||
<p className="text-base text-[var(--muted-foreground)]">{kachel.hinweis}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={kachel.titel}
|
||||
href={kachel.ziel}
|
||||
className={`flex min-h-40 flex-col justify-between rounded-xl border-2 p-6 transition-colors ${
|
||||
kachel.hervorgehoben
|
||||
? "border-[var(--primary)] bg-[var(--primary)]/10"
|
||||
: "border-[var(--border)] bg-[var(--card)]"
|
||||
} active:bg-[var(--accent)]`}
|
||||
>
|
||||
<Symbol className="size-10 text-[var(--primary)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{kachel.titel}</p>
|
||||
<p className="text-base text-[var(--muted-foreground)]">{kachel.hinweis}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
<ClipboardList className="mr-1 inline size-4" aria-hidden />
|
||||
Alles, was hier erfasst wird, geht zuerst in die Warteschlange des Terminals — auch ohne
|
||||
Netz.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { BrowserQRCodeReader } from "@zxing/browser";
|
||||
import type { Result } from "@zxing/library";
|
||||
import { Camera, CameraOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* QR-Leser für das Terminal.
|
||||
*
|
||||
* Dasselbe Verfahren wie beim Gerätescanner aus Phase 1 (@zxing/browser statt
|
||||
* BarcodeDetector, weil es die API auf dem Tablet im Gerätehaus nicht gibt),
|
||||
* aber ohne eigene Navigation: dieser Leser gibt das Gelesene weiter und
|
||||
* überlässt der aufrufenden Seite, was damit geschieht.
|
||||
*
|
||||
* Zwei Dinge, die im Betrieb zählen:
|
||||
* - Die Kamera wird beim Verlassen wirklich freigegeben. Sonst bleibt die
|
||||
* Leuchte an und der nächste Aufruf bekommt kein Bild.
|
||||
* - Es läuft eine Sperre gegen Doppellesungen: ein QR-Code im Sichtfeld wird
|
||||
* zwanzigmal je Sekunde erkannt, gemeldet werden soll er einmal.
|
||||
*/
|
||||
export function QrLeser({
|
||||
onGelesen,
|
||||
beschriftung = "Kamera starten",
|
||||
automatischStarten = false,
|
||||
}: {
|
||||
onGelesen: (inhalt: string) => void;
|
||||
beschriftung?: string;
|
||||
automatischStarten?: boolean;
|
||||
}) {
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||
const steuerungRef = React.useRef<{ stop: () => void } | null>(null);
|
||||
const letzterRef = React.useRef<{ inhalt: string; zeit: number } | null>(null);
|
||||
|
||||
const [laeuft, setLaeuft] = React.useState(false);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const stoppen = React.useCallback(() => {
|
||||
steuerungRef.current?.stop();
|
||||
steuerungRef.current = null;
|
||||
setLaeuft(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => stoppen, [stoppen]);
|
||||
|
||||
const starten = React.useCallback(async () => {
|
||||
setFehler(null);
|
||||
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setFehler("Dieser Browser gibt keine Kamera frei.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const leser = new BrowserQRCodeReader();
|
||||
steuerungRef.current = await leser.decodeFromVideoDevice(
|
||||
undefined,
|
||||
videoRef.current,
|
||||
(ergebnis: Result | undefined) => {
|
||||
if (!ergebnis) return;
|
||||
|
||||
const inhalt = ergebnis.getText();
|
||||
const jetzt = Date.now();
|
||||
|
||||
// Derselbe Code innerhalb von drei Sekunden zählt einmal.
|
||||
if (
|
||||
letzterRef.current &&
|
||||
letzterRef.current.inhalt === inhalt &&
|
||||
jetzt - letzterRef.current.zeit < 3000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
letzterRef.current = { inhalt, zeit: jetzt };
|
||||
onGelesen(inhalt);
|
||||
},
|
||||
);
|
||||
setLaeuft(true);
|
||||
} catch (problem) {
|
||||
setFehler(
|
||||
problem instanceof Error && problem.name === "NotAllowedError"
|
||||
? "Die Kamera wurde nicht freigegeben. Am Tablet in den Browsereinstellungen erlauben."
|
||||
: "Die Kamera ließ sich nicht starten.",
|
||||
);
|
||||
}
|
||||
}, [onGelesen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (automatischStarten) void starten();
|
||||
// Nur beim ersten Rendern — sonst würde jede Änderung die Kamera neu öffnen.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video ref={videoRef} className="aspect-video w-full object-cover" muted playsInline />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{laeuft ? (
|
||||
<Button type="button" variant="outline" className="h-14 text-base" onClick={stoppen}>
|
||||
<CameraOff aria-hidden />
|
||||
Kamera aus
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" className="h-14 text-base" onClick={() => void starten()}>
|
||||
<Camera aria-hidden />
|
||||
{beschriftung}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} kennung="kamerafehler" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Zustandsleiste } from "@/components/kiosk/warteschlange";
|
||||
import { Abmelduhr } from "@/components/kiosk/abmelduhr";
|
||||
|
||||
/**
|
||||
* Der Rahmen jeder Terminalseite.
|
||||
*
|
||||
* Immer dasselbe: zurück zu den Kacheln, der Zustand der Warteschlange, die
|
||||
* Abmeldeuhr. Alles groß genug für Handschuhe.
|
||||
*/
|
||||
export function KioskRahmen({
|
||||
titel,
|
||||
hinweis,
|
||||
abmeldenNachSekunden,
|
||||
children,
|
||||
}: {
|
||||
titel: string;
|
||||
hinweis?: string;
|
||||
abmeldenNachSekunden: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<Abmelduhr sekunden={abmeldenNachSekunden} />
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-16 w-16 shrink-0"
|
||||
onClick={() => router.push("/kiosk")}
|
||||
aria-label="Zurück zu den Kacheln"
|
||||
>
|
||||
<ArrowLeft className="size-7" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{titel}</h1>
|
||||
{hinweis && <p className="text-lg text-[var(--muted-foreground)]">{hinweis}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Zustandsleiste />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bestätigung nach einem erfassten Vorgang.
|
||||
*
|
||||
* Groß, grün, drei Sekunden — und dann zurück zu den Kacheln. Wer im
|
||||
* Gerätehaus etwas erfasst, will danach das Tablet weglegen und nicht noch
|
||||
* einen Knopf suchen.
|
||||
*/
|
||||
export function Erledigt({
|
||||
text,
|
||||
zurueckNachMs = 3000,
|
||||
}: {
|
||||
text: string;
|
||||
zurueckNachMs?: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
const zeitgeber = setTimeout(() => router.push("/kiosk"), zurueckNachMs);
|
||||
return () => clearTimeout(zeitgeber);
|
||||
}, [router, zurueckNachMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-4 rounded-xl border-2 border-[var(--einsatzbereit)] bg-[var(--einsatzbereit)]/10 p-10 text-center"
|
||||
role="status"
|
||||
data-testid="kiosk-erledigt"
|
||||
>
|
||||
<Check className="size-16 text-[var(--einsatzbereit)]" aria-hidden />
|
||||
<p className="text-2xl font-semibold">{text}</p>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Zurück zu den Kacheln in wenigen Sekunden.
|
||||
</p>
|
||||
<Button variant="outline" className="h-14 text-lg" onClick={() => router.push("/kiosk")}>
|
||||
Sofort zurück
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter, solange der eigene Stand noch nicht da ist.
|
||||
*
|
||||
* Zwei Wizards sehen verschieden aus, je nachdem ob jemand schon eingecheckt
|
||||
* oder mit einem Fahrzeug unterwegs ist. Würden sie schon vorher die eine
|
||||
* Fassung zeigen, tauschte die Oberfläche unter dem Finger — wer im
|
||||
* Gerätehaus zügig tippt, drückt dann ins Leere. Also erst warten, dann
|
||||
* anzeigen.
|
||||
*/
|
||||
export function WirdGeladen({ text = "Stand wird geladen …" }: { text?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border-2 border-dashed border-[var(--border)] p-10 text-center text-xl text-[var(--muted-foreground)]"
|
||||
role="status"
|
||||
data-testid="kiosk-laedt"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Eine große Auswahl-Schaltfläche, wie sie am Terminal überall vorkommt. */
|
||||
export function GrosseAuswahl({
|
||||
titel,
|
||||
hinweis,
|
||||
gewaehlt,
|
||||
onClick,
|
||||
}: {
|
||||
titel: string;
|
||||
hinweis?: string;
|
||||
gewaehlt?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={gewaehlt}
|
||||
className={`min-h-20 rounded-xl border-2 p-4 text-left transition-colors ${
|
||||
gewaehlt
|
||||
? "border-[var(--primary)] bg-[var(--primary)]/10"
|
||||
: "border-[var(--border)] bg-[var(--card)]"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xl font-medium">{titel}</span>
|
||||
{hinweis && (
|
||||
<span className="block text-base text-[var(--muted-foreground)]">{hinweis}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Meldet den Service Worker an.
|
||||
*
|
||||
* Bewusst eine eigene, winzige Komponente statt eines Pakets: `sw.js` liegt
|
||||
* fertig in `public/`, und alles, was es hier zu tun gibt, sind fünf Zeilen.
|
||||
*
|
||||
* Zwei Feinheiten:
|
||||
*
|
||||
* * Nur unter HTTPS oder auf localhost — anderswo lehnt der Browser die
|
||||
* Anmeldung ohnehin ab, und die Fehlermeldung in der Konsole wäre nur
|
||||
* Rauschen.
|
||||
* * Erst nach dem Laden der Seite. Die Anmeldung konkurriert sonst mit dem
|
||||
* ersten Rendern, und das Tablet in der Halle ist kein schnelles Gerät.
|
||||
*/
|
||||
export function ServiceWorkerAnmeldung() {
|
||||
React.useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const sicher = window.isSecureContext || window.location.hostname === "localhost";
|
||||
if (!sicher) return;
|
||||
|
||||
const anmelden = () => {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((fehler) => {
|
||||
console.warn("[kiosk] Service Worker ließ sich nicht anmelden:", fehler);
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
anmelden();
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("load", anmelden);
|
||||
return () => window.removeEventListener("load", anmelden);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
QrCode,
|
||||
ShieldAlert,
|
||||
Siren,
|
||||
TriangleAlert,
|
||||
@@ -90,10 +91,20 @@ const EINTRAEGE: Eintrag[] = [
|
||||
abPhase: 3,
|
||||
},
|
||||
{
|
||||
// Führt bewusst auf die Übersicht in der Verwaltung, nicht auf das
|
||||
// Terminal selbst: das läuft am Wandtablet unter /kiosk und hat eine eigene
|
||||
// Oberfläche. Wer hier klickt, will sehen, was dort zusammengekommen ist.
|
||||
titel: "Terminal",
|
||||
pfad: "/kiosk",
|
||||
pfad: "/terminal",
|
||||
symbol: MonitorSmartphone,
|
||||
rechte: ["kiosk.erfassen"],
|
||||
rechte: ["fahrtenbuch.lesen"],
|
||||
abPhase: 4,
|
||||
},
|
||||
{
|
||||
titel: "Anmeldemarke",
|
||||
pfad: "/anmeldemarke",
|
||||
symbol: QrCode,
|
||||
rechte: ["person.eigene"],
|
||||
abPhase: 4,
|
||||
},
|
||||
{
|
||||
@@ -123,7 +134,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 3;
|
||||
const AKTUELLE_PHASE = 4;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
+55
-1
@@ -9,6 +9,7 @@ import { pruefen } from "@/lib/passwort";
|
||||
import { rfidNormalisieren } from "@/lib/passwort";
|
||||
import { protokolliereAnmeldung } from "@/lib/audit";
|
||||
import { alsRolle, type Rolle, type Sitzung } from "@/lib/authz";
|
||||
import { markePruefen } from "@/lib/kiosk/anmeldemarke";
|
||||
|
||||
/**
|
||||
* Anmeldung — drei Wege, ein Sitzungsbegriff.
|
||||
@@ -297,6 +298,59 @@ const terminalAnbieter = Credentials({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Anmeldemarke — der QR-Code vom Handy.
|
||||
*
|
||||
* Dritter Weg an das Terminal. Auch hier zwei Schranken: erst das Gerät mit
|
||||
* KIOSK_DEVICE_SECRET, dann die Person über die signierte Marke. Die Marke gilt
|
||||
* eine Minute; ein abfotografierter Code nützt danach nichts mehr.
|
||||
*/
|
||||
const markenAnbieter = Credentials({
|
||||
id: "terminal-marke",
|
||||
name: "Anmeldemarke",
|
||||
credentials: {
|
||||
geraeteSchluessel: { label: "Gerät", type: "text" },
|
||||
marke: { label: "Anmeldemarke", type: "text" },
|
||||
},
|
||||
async authorize(eingabe) {
|
||||
if (!env.KIOSK_ENABLED) return null;
|
||||
|
||||
const geprueft = z
|
||||
.object({ geraeteSchluessel: z.string().min(1), marke: z.string().min(10).max(500) })
|
||||
.safeParse(eingabe);
|
||||
if (!geprueft.success) return null;
|
||||
|
||||
if (geprueft.data.geraeteSchluessel !== env.KIOSK_DEVICE_SECRET) return null;
|
||||
|
||||
const marke = markePruefen(geprueft.data.marke);
|
||||
if ("fehler" in marke) return null;
|
||||
|
||||
const person = await personMitRollen(marke.personId);
|
||||
if (!person || person.organisationId !== marke.organisationId) return null;
|
||||
if (!person.organisation.aktiv) return null;
|
||||
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: person.organisationId,
|
||||
personId: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
anmeldeArt: AnmeldeArt.KIOSK_RFID,
|
||||
erfolgreich: true,
|
||||
grund: "Anmeldemarke",
|
||||
});
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
email: person.email ?? undefined,
|
||||
organisationId: person.organisationId,
|
||||
rollen: rollenAus(person.rollen),
|
||||
// Fachlich dasselbe wie ein Ausweis: die Person weist sich mit etwas
|
||||
// aus, das sie bei sich trägt.
|
||||
anmeldeArt: "KIOSK_RFID" as const,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC gegen authentik. Wird nur eingehängt, wenn Issuer, Client-ID und Secret
|
||||
* gesetzt sind — sonst bliebe ein Knopf stehen, der ins Leere führt.
|
||||
@@ -497,7 +551,7 @@ function anbieterListe(): Provider[] {
|
||||
const liste: Provider[] = [];
|
||||
if (oidcVerfuegbar) liste.push(authentikAnbieter());
|
||||
if (env.AUTH_LOCAL_ENABLED) liste.push(passwortAnbieter);
|
||||
if (env.KIOSK_ENABLED) liste.push(terminalAnbieter);
|
||||
if (env.KIOSK_ENABLED) liste.push(terminalAnbieter, markenAnbieter);
|
||||
return liste;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ export const RECHTE = {
|
||||
// Fahrzeuge und Geräte
|
||||
"fahrzeug.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge ansehen" },
|
||||
"fahrzeug.bearbeiten": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge anlegen und bearbeiten" },
|
||||
// Fahrtenbuch (Phase 4). Eintragen darf jedes Mitglied, das fährt — die
|
||||
// Nachweispflicht trifft die Wehr, nicht den Gerätewart.
|
||||
"fahrtenbuch.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrtenbuch ansehen" },
|
||||
"fahrtenbuch.fuehren": { bereich: "Fahrzeuge", bezeichnung: "Fahrten eintragen" },
|
||||
"geraet.lesen": { bereich: "Geräte", bezeichnung: "Geräte ansehen" },
|
||||
"geraet.bearbeiten": { bereich: "Geräte", bezeichnung: "Geräte anlegen und bearbeiten" },
|
||||
"geraet.aussondern": { bereich: "Geräte", bezeichnung: "Geräte aussondern oder sperren" },
|
||||
@@ -143,6 +147,8 @@ const GRUNDRECHTE: Recht[] = [
|
||||
"organisation.lesen",
|
||||
"person.lesen",
|
||||
"fahrzeug.lesen",
|
||||
"fahrtenbuch.lesen",
|
||||
"fahrtenbuch.fuehren",
|
||||
"geraet.lesen",
|
||||
"pruefung.lesen",
|
||||
"einsatz.lesen",
|
||||
|
||||
@@ -106,6 +106,20 @@ const schema = z.object({
|
||||
LLM_API_KEY: z.string().optional(),
|
||||
LLM_MODELL: z.string().optional(),
|
||||
|
||||
// Terminal im Gerätehaus (Phase 4)
|
||||
/// Wetterwarnungen des DWD auf dem Hallenmonitor. Die einzige Verbindung,
|
||||
/// die FLORIAN von sich aus nach außen aufbaut — standardmäßig aus.
|
||||
WETTER_ENABLED: boolAusText(false),
|
||||
/// Warnzellen-Nummer der eigenen Gemeinde aus der DWD-Warnzellenliste.
|
||||
DWD_WARNCELL: z.string().optional(),
|
||||
DWD_WARNUNGEN_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json"),
|
||||
WETTER_TIMEOUT: zahl(5),
|
||||
/// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird.
|
||||
WETTER_ZWISCHENSPEICHER: zahl(300),
|
||||
|
||||
// Einsätze und Übungen (Phase 3)
|
||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||
GEOCODING_ENABLED: boolAusText(false),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Klartext zu den Warnstufen des DWD.
|
||||
*
|
||||
* Steht bewusst getrennt von wetter.ts: das Modul dort ist "server-only", weil
|
||||
* es Netzverbindungen aufbaut und die Konfiguration liest. Der Hallenmonitor
|
||||
* läuft im Browser und braucht nur die Beschriftung — die darf deshalb nicht
|
||||
* im selben Modul liegen.
|
||||
*
|
||||
* Dieselbe Trennung wie bei den QR-Codes (qr.ts / qr-parser.ts).
|
||||
*/
|
||||
export const WARNSTUFE_TEXT: Record<number, string> = {
|
||||
1: "Wetterwarnung",
|
||||
2: "markantes Wetter",
|
||||
3: "Unwetterwarnung",
|
||||
4: "extremes Unwetter",
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Wetterwarnungen des Deutschen Wetterdienstes.
|
||||
*
|
||||
* Das ist die **einzige** Verbindung, die FLORIAN von sich aus nach außen
|
||||
* aufbaut — und sie ist standardmäßig aus. Wer sie einschaltet, sollte wissen,
|
||||
* was dabei passiert:
|
||||
*
|
||||
* * Es geht eine Anfrage an opendata-Server des DWD, mehr nicht. Keine
|
||||
* Kennung, keine Daten der Wehr, kein Standort außer der Warnzellen-Nummer,
|
||||
* die in der Konfiguration steht.
|
||||
* * Die Antwort wird für ein paar Minuten zwischengespeichert, damit ein
|
||||
* Hallenmonitor, der jede Minute nachfragt, nicht jedes Mal hinausfunkt.
|
||||
* * Fällt die Abfrage aus, bleibt die Kachel leer. Eine Wetterwarnung ist
|
||||
* eine Zugabe; die Alarmierung läuft über die Leitstelle.
|
||||
*
|
||||
* Die Warnzellen-Nummer der eigenen Gemeinde steht in der Warnzellen-Liste des
|
||||
* DWD (`DWD_WARNCELL`). Ohne sie werden alle Warnungen ignoriert.
|
||||
*/
|
||||
|
||||
export interface Wetterwarnung {
|
||||
/** 1 = Wetterwarnung, 2 = markantes Wetter, 3 = Unwetter, 4 = extremes Unwetter. */
|
||||
stufe: number;
|
||||
ereignis: string;
|
||||
ueberschrift: string;
|
||||
beschreibung: string;
|
||||
von: Date | null;
|
||||
bis: Date | null;
|
||||
}
|
||||
|
||||
export interface WetterAnbieter {
|
||||
warnungen(): Promise<Wetterwarnung[]>;
|
||||
verfuegbar(): boolean;
|
||||
}
|
||||
|
||||
/** Ohne Konfiguration: keine Warnungen, kein Fehler. */
|
||||
class KeineWarnungen implements WetterAnbieter {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async warnungen(): Promise<Wetterwarnung[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface DwdWarnung {
|
||||
level?: number;
|
||||
event?: string;
|
||||
headline?: string;
|
||||
description?: string;
|
||||
start?: number;
|
||||
end?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der DWD liefert seine Warnungen als JavaScript-Aufruf aus, nicht als reines
|
||||
* JSON: `warnWetter.loadWarnings({...});`. Die Klammer außen muss weg, bevor
|
||||
* sich der Inhalt lesen lässt.
|
||||
*/
|
||||
function jsonAusJsonp(text: string): unknown {
|
||||
const anfang = text.indexOf("(");
|
||||
const ende = text.lastIndexOf(")");
|
||||
if (anfang < 0 || ende <= anfang) return JSON.parse(text);
|
||||
return JSON.parse(text.slice(anfang + 1, ende));
|
||||
}
|
||||
|
||||
class DwdWarnapp implements WetterAnbieter {
|
||||
/** Antwort und Zeitpunkt — der Monitor fragt oft, der DWD soll das nicht merken. */
|
||||
private zwischenspeicher: { zeit: number; warnungen: Wetterwarnung[] } | null = null;
|
||||
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.DWD_WARNCELL);
|
||||
}
|
||||
|
||||
async warnungen(): Promise<Wetterwarnung[]> {
|
||||
if (!this.verfuegbar()) return [];
|
||||
|
||||
const alter = this.zwischenspeicher ? Date.now() - this.zwischenspeicher.zeit : Infinity;
|
||||
if (this.zwischenspeicher && alter < env.WETTER_ZWISCHENSPEICHER * 1000) {
|
||||
return this.zwischenspeicher.warnungen;
|
||||
}
|
||||
|
||||
try {
|
||||
const antwort = await fetch(env.DWD_WARNUNGEN_URL, {
|
||||
signal: AbortSignal.timeout(env.WETTER_TIMEOUT * 1000),
|
||||
headers: { "User-Agent": "FLORIAN Feuerwehrverwaltung" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!antwort.ok) throw new Error(`DWD antwortete mit ${antwort.status}.`);
|
||||
|
||||
const daten = jsonAusJsonp(await antwort.text()) as {
|
||||
warnings?: Record<string, DwdWarnung[]>;
|
||||
vorabInformation?: Record<string, DwdWarnung[]>;
|
||||
};
|
||||
|
||||
const zelle = env.DWD_WARNCELL!;
|
||||
const roh = [...(daten.warnings?.[zelle] ?? []), ...(daten.vorabInformation?.[zelle] ?? [])];
|
||||
|
||||
const warnungen = roh.map((warnung) => ({
|
||||
stufe: warnung.level ?? 1,
|
||||
ereignis: warnung.event ?? "Wetterwarnung",
|
||||
ueberschrift: warnung.headline ?? "",
|
||||
beschreibung: warnung.description ?? "",
|
||||
von: warnung.start ? new Date(warnung.start) : null,
|
||||
bis: warnung.end ? new Date(warnung.end) : null,
|
||||
}));
|
||||
|
||||
this.zwischenspeicher = { zeit: Date.now(), warnungen };
|
||||
return warnungen;
|
||||
} catch (fehler) {
|
||||
console.error("[wetter] Warnungen nicht abrufbar:", fehler);
|
||||
// Lieber den alten Stand als gar nichts — eine Unwetterwarnung von vor
|
||||
// zehn Minuten ist immer noch die richtige Information.
|
||||
return this.zwischenspeicher?.warnungen ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let anbieterCache: WetterAnbieter | null = null;
|
||||
|
||||
export function wetter(): WetterAnbieter {
|
||||
if (!anbieterCache) {
|
||||
anbieterCache = env.WETTER_ENABLED ? new DwdWarnapp() : new KeineWarnungen();
|
||||
}
|
||||
return anbieterCache;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function _wetterErsetzen(ersatz: WetterAnbieter | null): void {
|
||||
anbieterCache = ersatz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Beschriftung der Warnstufen steht in wetter-texte.ts — der Hallenmonitor
|
||||
* ist eine Client-Komponente und dürfte dieses "server-only"-Modul nicht
|
||||
* anfassen.
|
||||
*/
|
||||
export { WARNSTUFE_TEXT } from "@/lib/integrations/wetter-texte";
|
||||
@@ -0,0 +1,80 @@
|
||||
import "server-only";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Anmeldemarke — der QR-Code vom Handy.
|
||||
*
|
||||
* Der dritte Weg an das Terminal, neben PIN und RFID-Karte: wer sein Handy
|
||||
* dabei hat, öffnet in FLORIAN die Seite „Anmeldemarke", hält den QR-Code vor
|
||||
* die Kamera des Tablets und ist angemeldet.
|
||||
*
|
||||
* Die Marke ist **signiert, nicht gespeichert**. Sie enthält Person, Wehr und
|
||||
* einen Zeitstempel, dazu einen HMAC über alles mit AUTH_SECRET. Damit braucht
|
||||
* es keine Tabelle, keine Aufräumjobs und keinen zweiten Ort, an dem ein
|
||||
* Geheimnis liegt — und eine abgelaufene Marke ist von selbst wertlos.
|
||||
*
|
||||
* Kurze Gültigkeit ist hier wichtiger als Bequemlichkeit: ein abfotografierter
|
||||
* QR-Code aus der Umkleide soll nach einer Minute nichts mehr nützen. Die
|
||||
* Seite auf dem Handy erneuert ihn deshalb laufend.
|
||||
*/
|
||||
|
||||
/** Wie lange eine Marke gilt. Kurz genug, dass ein Foto davon nichts bringt. */
|
||||
export const MARKE_GUELTIG_SEKUNDEN = 60;
|
||||
|
||||
const TRENNER = ".";
|
||||
|
||||
function signieren(nutzlast: string): string {
|
||||
return createHmac("sha256", env.AUTH_SECRET).update(nutzlast).digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine Marke für eine Person.
|
||||
*
|
||||
* Aufbau: `<personId>.<organisationId>.<zeitstempel>.<signatur>`
|
||||
*/
|
||||
export function markeErzeugen(personId: string, organisationId: string): string {
|
||||
const nutzlast = [personId, organisationId, String(Date.now())].join(TRENNER);
|
||||
return `${nutzlast}${TRENNER}${signieren(nutzlast)}`;
|
||||
}
|
||||
|
||||
export type MarkeFehler = "ungueltig" | "abgelaufen" | "verfaelscht";
|
||||
|
||||
/**
|
||||
* Prüft eine Marke.
|
||||
*
|
||||
* Der Vergleich der Signatur läuft über timingSafeEqual — sonst ließe sich aus
|
||||
* der Antwortzeit ablesen, wie viele Zeichen einer geratenen Signatur stimmen.
|
||||
*/
|
||||
export function markePruefen(
|
||||
marke: string,
|
||||
): { personId: string; organisationId: string } | { fehler: MarkeFehler } {
|
||||
const teile = marke.split(TRENNER);
|
||||
if (teile.length !== 4) return { fehler: "ungueltig" };
|
||||
|
||||
const [personId, organisationId, zeitstempel, signatur] = teile as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
|
||||
const erwartet = signieren([personId, organisationId, zeitstempel].join(TRENNER));
|
||||
|
||||
const a = Buffer.from(signatur);
|
||||
const b = Buffer.from(erwartet);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return { fehler: "verfaelscht" };
|
||||
|
||||
const alter = Date.now() - Number(zeitstempel);
|
||||
if (!Number.isFinite(alter) || alter < 0 || alter > MARKE_GUELTIG_SEKUNDEN * 1000) {
|
||||
return { fehler: "abgelaufen" };
|
||||
}
|
||||
|
||||
return { personId, organisationId };
|
||||
}
|
||||
|
||||
export const MARKE_FEHLER_TEXT: Record<MarkeFehler, string> = {
|
||||
ungueltig: "Dieser Code ist keine Anmeldemarke.",
|
||||
verfaelscht: "Dieser Code stammt nicht von FLORIAN.",
|
||||
abgelaufen: "Der Code ist abgelaufen. Bitte auf dem Handy einen neuen anzeigen lassen.",
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Was das Terminal über sich selbst weiß.
|
||||
*
|
||||
* Damit sich ein Tablet als Terminal ausweisen kann, braucht es zwei Angaben:
|
||||
* das Gerätegeheimnis (KIOSK_DEVICE_SECRET) und den Kurznamen der Wehr. Beides
|
||||
* wird einmal bei der Einrichtung hinterlegt und bleibt danach im Gerät.
|
||||
*
|
||||
* Warum localStorage und kein Cookie: das Geheimnis soll nicht bei jeder
|
||||
* Anfrage mitgehen, sondern nur beim Anmelden verwendet werden. Und es soll
|
||||
* einen Neustart des Tablets überleben, ohne dass jemand es erneut eintippt.
|
||||
*
|
||||
* Das ist bewusst **kein** Ersatz für die Anmeldung der Person. Es ist die
|
||||
* erste von zwei Schranken: eine erratene PIN nützt ohne das Gerätegeheimnis
|
||||
* nichts, und das Geheimnis allein meldet niemanden an.
|
||||
*/
|
||||
|
||||
const SCHLUESSEL_GEHEIMNIS = "florian.kiosk.geraeteschluessel";
|
||||
const SCHLUESSEL_WEHR = "florian.kiosk.wehr";
|
||||
|
||||
export interface Geraeteangaben {
|
||||
geraeteSchluessel: string;
|
||||
organisationSlug: string;
|
||||
}
|
||||
|
||||
export function geraeteangaben(): Geraeteangaben | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const geraeteSchluessel = window.localStorage.getItem(SCHLUESSEL_GEHEIMNIS);
|
||||
const organisationSlug = window.localStorage.getItem(SCHLUESSEL_WEHR);
|
||||
if (!geraeteSchluessel || !organisationSlug) return null;
|
||||
return { geraeteSchluessel, organisationSlug };
|
||||
} catch {
|
||||
// Privater Modus oder gesperrter Speicher: dann ist das Gerät eben nicht
|
||||
// eingerichtet.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function geraetEinrichten(angaben: Geraeteangaben): void {
|
||||
window.localStorage.setItem(SCHLUESSEL_GEHEIMNIS, angaben.geraeteSchluessel);
|
||||
window.localStorage.setItem(SCHLUESSEL_WEHR, angaben.organisationSlug);
|
||||
}
|
||||
|
||||
/** Für den Fall, dass ein Tablet die Wehr wechselt oder ausgemustert wird. */
|
||||
export function geraetVergessen(): void {
|
||||
window.localStorage.removeItem(SCHLUESSEL_GEHEIMNIS);
|
||||
window.localStorage.removeItem(SCHLUESSEL_WEHR);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import Dexie, { type Table } from "dexie";
|
||||
import type { Vorgang, VorgangArt, VorgangErgebnis } from "@/lib/kiosk/vorgaenge";
|
||||
|
||||
/**
|
||||
* Die Warteschlange des Terminals.
|
||||
*
|
||||
* Ein Wandtablet in einer Fahrzeughalle hat regelmäßig kein Netz — Stahlbeton,
|
||||
* Tore aus Metall, ein Repeater, der irgendwann ausfällt. Deshalb schreibt das
|
||||
* Terminal nichts direkt zum Server, sondern **immer zuerst hierher**: in eine
|
||||
* IndexedDB im Browser. Von dort gehen die Vorgänge los, sobald wieder Netz da
|
||||
* ist.
|
||||
*
|
||||
* Drei Eigenschaften machen das verlässlich:
|
||||
*
|
||||
* 1. **Immer über die Warteschlange.** Auch online. Sonst gäbe es zwei Wege
|
||||
* durch den Code, und der seltenere wäre der ungetestete.
|
||||
* 2. **Kennung vom Browser.** `crypto.randomUUID()` beim Einreihen, nicht
|
||||
* beim Senden. Bricht die Übertragung ab und wird wiederholt, erkennt der
|
||||
* Server den Vorgang an derselben Kennung wieder.
|
||||
* 3. **Erst löschen, wenn der Server bestätigt hat.** Bis dahin bleibt der
|
||||
* Vorgang liegen, auch über einen Neustart des Tablets hinweg.
|
||||
*/
|
||||
|
||||
export interface OffenerVorgang {
|
||||
/** Idempotenzschlüssel, im Browser erzeugt. */
|
||||
id: string;
|
||||
art: VorgangArt;
|
||||
daten: unknown;
|
||||
/**
|
||||
* Ein Foto, das noch nicht hochgeladen ist.
|
||||
*
|
||||
* Fotos gehören zum Mangelmelden dazu — und ausgerechnet dabei ist das Netz
|
||||
* oft weg. Deshalb liegt das Bild als Blob in derselben Warteschlange und
|
||||
* wird beim Übertragen zuerst hochgeladen; erst danach geht der Vorgang mit
|
||||
* der Dateikennung darin los. IndexedDB kann Blobs, das ist der Grund für
|
||||
* Dexie und gegen localStorage.
|
||||
*/
|
||||
foto?: Blob;
|
||||
fotoName?: string;
|
||||
/** Wann es am Terminal passiert ist. ISO-Zeichenkette, damit es in IndexedDB passt. */
|
||||
erfasstAm: string;
|
||||
versuche: number;
|
||||
letzterFehler?: string;
|
||||
/** Beschriftung für die Anzeige der Warteschlange. */
|
||||
beschriftung: string;
|
||||
}
|
||||
|
||||
class KioskDatenbank extends Dexie {
|
||||
vorgaenge!: Table<OffenerVorgang, string>;
|
||||
|
||||
constructor() {
|
||||
super("florian-kiosk");
|
||||
this.version(1).stores({
|
||||
// Nur die Kennung als Primärschlüssel und ein Index auf die Zeit —
|
||||
// gesucht wird hier nie, nur der Reihe nach abgearbeitet.
|
||||
vorgaenge: "id, erfasstAm",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let datenbankCache: KioskDatenbank | null = null;
|
||||
|
||||
/**
|
||||
* Die Datenbank wird erst beim ersten Zugriff geöffnet.
|
||||
*
|
||||
* Beim Rendern auf dem Server gibt es kein indexedDB; ein Modul, das sie im
|
||||
* Rumpf öffnet, ließe sich dort nicht einmal importieren.
|
||||
*/
|
||||
export function datenbank(): KioskDatenbank {
|
||||
if (!datenbankCache) datenbankCache = new KioskDatenbank();
|
||||
return datenbankCache;
|
||||
}
|
||||
|
||||
/** Ist überhaupt eine IndexedDB da? Im privaten Modus mancher Browser nicht. */
|
||||
export function speicherVerfuegbar(): boolean {
|
||||
return typeof window !== "undefined" && "indexedDB" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Vorgang einreihen.
|
||||
*
|
||||
* Gibt die Kennung zurück — das Terminal zeigt damit sofort „erfasst", ohne
|
||||
* auf den Server zu warten. Genau darum geht es: der Gerätewart soll das
|
||||
* Tablet weglegen können.
|
||||
*/
|
||||
export async function einreihen(
|
||||
vorgang: Vorgang,
|
||||
beschriftung: string,
|
||||
foto?: { inhalt: Blob; name: string },
|
||||
): Promise<OffenerVorgang> {
|
||||
const eintrag: OffenerVorgang = {
|
||||
id: crypto.randomUUID(),
|
||||
art: vorgang.art,
|
||||
daten: vorgang.daten,
|
||||
erfasstAm: new Date().toISOString(),
|
||||
versuche: 0,
|
||||
beschriftung,
|
||||
...(foto ? { foto: foto.inhalt, fotoName: foto.name } : {}),
|
||||
};
|
||||
|
||||
await datenbank().vorgaenge.add(eintrag);
|
||||
return eintrag;
|
||||
}
|
||||
|
||||
/** Was noch aussteht, in der Reihenfolge der Erfassung. */
|
||||
export async function warteschlange(): Promise<OffenerVorgang[]> {
|
||||
return datenbank().vorgaenge.orderBy("erfasstAm").toArray();
|
||||
}
|
||||
|
||||
export async function anzahlOffen(): Promise<number> {
|
||||
return datenbank().vorgaenge.count();
|
||||
}
|
||||
|
||||
/** Ein Vorgang, der nie durchgeht, lässt sich von Hand entfernen. */
|
||||
export async function verwerfen(id: string): Promise<void> {
|
||||
await datenbank().vorgaenge.delete(id);
|
||||
}
|
||||
|
||||
/** Die Form, in der ein Vorgang zum Server geht. */
|
||||
export interface SendeVorgang {
|
||||
id: string;
|
||||
art: VorgangArt;
|
||||
daten: unknown;
|
||||
erfasstAm: Date;
|
||||
}
|
||||
|
||||
export type Sender = (vorgaenge: SendeVorgang[]) => Promise<{ ergebnisse: VorgangErgebnis[] }>;
|
||||
|
||||
/** Lädt ein wartendes Foto hoch und gibt die Dateikennung zurück. */
|
||||
export type FotoSender = (foto: Blob, name: string) => Promise<string>;
|
||||
|
||||
export interface SyncErgebnis {
|
||||
gesendet: number;
|
||||
erledigt: number;
|
||||
konflikte: number;
|
||||
verblieben: number;
|
||||
fehler?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Warteschlange abarbeiten.
|
||||
*
|
||||
* Erledigt heißt: der Server hat den Vorgang angenommen **oder** ihn als
|
||||
* bereits verarbeitet erkannt **oder** ihn in die Konfliktliste gelegt. In
|
||||
* allen drei Fällen ist er beim Server angekommen und darf hier verschwinden —
|
||||
* liegen bleibt nur, was technisch nicht durchging.
|
||||
*/
|
||||
export async function synchronisieren(
|
||||
senden: Sender,
|
||||
fotoSenden?: FotoSender,
|
||||
hoechstzahl = 50,
|
||||
): Promise<SyncErgebnis> {
|
||||
if (!speicherVerfuegbar()) {
|
||||
return { gesendet: 0, erledigt: 0, konflikte: 0, verblieben: 0 };
|
||||
}
|
||||
|
||||
const offen = (await warteschlange()).slice(0, hoechstzahl);
|
||||
|
||||
if (offen.length === 0) {
|
||||
return { gesendet: 0, erledigt: 0, konflikte: 0, verblieben: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
// Erst die Fotos: ohne Dateikennung wäre die Meldung nur halb so viel wert.
|
||||
// Klappt ein Upload nicht, geht der Vorgang trotzdem los — eine Meldung
|
||||
// ohne Bild ist besser als gar keine.
|
||||
if (fotoSenden) {
|
||||
for (const eintrag of offen) {
|
||||
if (!eintrag.foto) continue;
|
||||
|
||||
try {
|
||||
const dateiId = await fotoSenden(eintrag.foto, eintrag.fotoName ?? "foto.jpg");
|
||||
eintrag.daten = { ...(eintrag.daten as Record<string, unknown>), fotoDateiId: dateiId };
|
||||
await datenbank().vorgaenge.update(eintrag.id, {
|
||||
daten: eintrag.daten,
|
||||
foto: undefined,
|
||||
fotoName: undefined,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.warn(`[outbox] Foto zu ${eintrag.id} noch nicht übertragen:`, fehler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const antwort = await senden(
|
||||
offen.map((eintrag) => ({
|
||||
id: eintrag.id,
|
||||
art: eintrag.art,
|
||||
daten: eintrag.daten,
|
||||
erfasstAm: new Date(eintrag.erfasstAm),
|
||||
})),
|
||||
);
|
||||
|
||||
let erledigt = 0;
|
||||
let konflikte = 0;
|
||||
|
||||
for (const ergebnis of antwort.ergebnisse) {
|
||||
if (ergebnis.erfolgreich || ergebnis.bereitsVerarbeitet || ergebnis.konflikt) {
|
||||
await datenbank().vorgaenge.delete(ergebnis.id);
|
||||
erledigt += 1;
|
||||
if (ergebnis.konflikt) konflikte += 1;
|
||||
} else {
|
||||
await datenbank().vorgaenge.update(ergebnis.id, {
|
||||
versuche: (offen.find((eintrag) => eintrag.id === ergebnis.id)?.versuche ?? 0) + 1,
|
||||
letzterFehler: ergebnis.meldung ?? "unbekannter Fehler",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gesendet: offen.length,
|
||||
erledigt,
|
||||
konflikte,
|
||||
verblieben: await anzahlOffen(),
|
||||
};
|
||||
} catch (fehler) {
|
||||
// Kein Netz oder Server nicht erreichbar: alles bleibt liegen. Das ist der
|
||||
// Normalfall und keine Ausnahme — deshalb wird nur gezählt, nicht gelöscht.
|
||||
for (const eintrag of offen) {
|
||||
await datenbank().vorgaenge.update(eintrag.id, { versuche: eintrag.versuche + 1 });
|
||||
}
|
||||
|
||||
return {
|
||||
gesendet: 0,
|
||||
erledigt: 0,
|
||||
konflikte: 0,
|
||||
verblieben: await anzahlOffen(),
|
||||
fehler: fehler instanceof Error ? fehler.message : String(fehler),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
import { AnwesenheitAnlass, FahrtZweck, MangelPrioritaet } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Der Vertrag zwischen Terminal und Server.
|
||||
*
|
||||
* Diese Datei kennen beide Seiten: der Browser legt danach seine Vorgänge in
|
||||
* die Warteschlange, der Server nimmt sie danach an. Sie enthält deshalb
|
||||
* bewusst nichts außer Typen und Prüfregeln — kein Datenbankzugriff, kein
|
||||
* "use client", kein "server-only".
|
||||
*
|
||||
* Jeder Vorgang trägt drei Dinge:
|
||||
*
|
||||
* id eine im Browser erzeugte Kennung (crypto.randomUUID). Sie ist
|
||||
* der Idempotenzschlüssel: kommt derselbe Vorgang nach einem
|
||||
* abgebrochenen Sendeversuch noch einmal an, erkennt ihn der
|
||||
* Server daran wieder.
|
||||
* art was passieren soll
|
||||
* erfasstAm wann es am Terminal passiert ist — nicht, wann es ankam. Bei
|
||||
* einem Tablet, das eine Stunde offline war, ist das ein
|
||||
* Unterschied, der im Fahrtenbuch zählt.
|
||||
*/
|
||||
|
||||
/** Ein Mangel, direkt am Gerät gemeldet. */
|
||||
export const mangelMelden = z.object({
|
||||
/** Über den QR-Code am Regal gescannt. */
|
||||
inventarnummer: z.string().trim().min(1).max(60),
|
||||
beschreibung: z.string().trim().min(1, "Was ist kaputt?").max(2000),
|
||||
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
|
||||
fotoDateiId: z.string().uuid().nullish(),
|
||||
});
|
||||
|
||||
/** Eine Fahrt beginnen. */
|
||||
export const fahrtBeginnen = z.object({
|
||||
fahrzeugId: z.string().uuid(),
|
||||
kilometerAbfahrt: z.number().int().min(0).max(9_999_999),
|
||||
zweck: z.nativeEnum(FahrtZweck).default(FahrtZweck.SONSTIGES),
|
||||
ziel: z.string().trim().max(200).nullish(),
|
||||
});
|
||||
|
||||
/** Eine Fahrt beenden. */
|
||||
export const fahrtBeenden = z.object({
|
||||
fahrtId: z.string().uuid(),
|
||||
kilometerRueckkehr: z.number().int().min(0).max(9_999_999),
|
||||
tankmenge: z.number().nonnegative().max(9999).nullish(),
|
||||
bemerkung: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
/** Kommen. */
|
||||
export const anwesenheitKommen = z.object({
|
||||
anlass: z.nativeEnum(AnwesenheitAnlass).default(AnwesenheitAnlass.DIENST),
|
||||
bemerkung: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
/** Gehen. Ohne Angabe wird die letzte offene Anwesenheit beendet. */
|
||||
export const anwesenheitGehen = z.object({
|
||||
anwesenheitId: z.string().uuid().nullish(),
|
||||
});
|
||||
|
||||
/** Schutzkleidung abgeben. */
|
||||
export const waescheAbgeben = z.object({
|
||||
bezeichnung: z.string().trim().min(1).max(200),
|
||||
menge: z.number().int().min(1).max(50).default(1),
|
||||
grund: z.string().trim().max(200).nullish(),
|
||||
inventarnummer: z.string().trim().max(60).nullish(),
|
||||
});
|
||||
|
||||
/** Ein Gerät im Inventurgang scannen. */
|
||||
export const inventurScannen = z.object({
|
||||
inventurId: z.string().uuid(),
|
||||
inventarnummer: z.string().trim().min(1).max(60),
|
||||
gefunden: z.boolean().default(true),
|
||||
vorgefundenAn: z.string().trim().max(200).nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Alle Vorgangsarten mit ihren Nutzlasten.
|
||||
*
|
||||
* PRUEFUNG_ERFASSEN fehlt hier bewusst: eine vollständige Prüfung mit
|
||||
* Checkliste, Fotos und Unterschrift lässt sich nicht sinnvoll in eine
|
||||
* Warteschlange legen. Das Terminal startet dafür den Wizard aus Phase 2 —
|
||||
* und zeigt die Kachel nur, solange Netz da ist. Der Wert steht trotzdem im
|
||||
* Datenmodell, weil die Atemschutzprüfung in Phase 5 genau das brauchen wird.
|
||||
*/
|
||||
export const vorgang = z.discriminatedUnion("art", [
|
||||
z.object({ art: z.literal("MANGEL_MELDEN"), daten: mangelMelden }),
|
||||
z.object({ art: z.literal("FAHRT_BEGINNEN"), daten: fahrtBeginnen }),
|
||||
z.object({ art: z.literal("FAHRT_BEENDEN"), daten: fahrtBeenden }),
|
||||
z.object({ art: z.literal("ANWESENHEIT_KOMMEN"), daten: anwesenheitKommen }),
|
||||
z.object({ art: z.literal("ANWESENHEIT_GEHEN"), daten: anwesenheitGehen }),
|
||||
z.object({ art: z.literal("WAESCHE_ABGEBEN"), daten: waescheAbgeben }),
|
||||
z.object({ art: z.literal("INVENTUR_SCANNEN"), daten: inventurScannen }),
|
||||
]);
|
||||
|
||||
/** Ein Vorgang, wie er über die Leitung geht. */
|
||||
export const vorgangEingabe = z
|
||||
.object({
|
||||
id: z.string().uuid(),
|
||||
erfasstAm: z.coerce.date(),
|
||||
})
|
||||
.and(vorgang);
|
||||
|
||||
export type Vorgang = z.infer<typeof vorgang>;
|
||||
export type VorgangEingabe = z.infer<typeof vorgangEingabe>;
|
||||
export type VorgangArt = Vorgang["art"];
|
||||
|
||||
/** Klartext für die Warteschlange auf dem Terminal. */
|
||||
export const VORGANG_TEXT: Record<VorgangArt, string> = {
|
||||
MANGEL_MELDEN: "Mangel gemeldet",
|
||||
FAHRT_BEGINNEN: "Fahrt begonnen",
|
||||
FAHRT_BEENDEN: "Fahrt beendet",
|
||||
ANWESENHEIT_KOMMEN: "gekommen",
|
||||
ANWESENHEIT_GEHEN: "gegangen",
|
||||
WAESCHE_ABGEBEN: "Wäsche abgegeben",
|
||||
INVENTUR_SCANNEN: "Gerät gescannt",
|
||||
};
|
||||
|
||||
/**
|
||||
* Antwort des Servers auf einen Vorgang.
|
||||
*
|
||||
* `bereitsVerarbeitet` ist der Normalfall bei einem zweiten Sendeversuch und
|
||||
* ausdrücklich kein Fehler — das Terminal darf den Vorgang dann aus seiner
|
||||
* Warteschlange nehmen.
|
||||
*/
|
||||
export interface VorgangErgebnis {
|
||||
id: string;
|
||||
erfolgreich: boolean;
|
||||
bereitsVerarbeitet: boolean;
|
||||
ergebnisId?: string | null;
|
||||
meldung?: string | null;
|
||||
/** Der Vorgang ließ sich nicht anwenden und liegt jetzt in der Konfliktliste. */
|
||||
konflikt?: boolean;
|
||||
}
|
||||
@@ -57,6 +57,23 @@ export async function qrAlsDataUri(inventarnummer: string, groessePx = 256): Pro
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* QR-Code aus beliebigem Text.
|
||||
*
|
||||
* Die Funktionen darüber setzen alle eine Inventarnummer voraus und bauen
|
||||
* daraus die Geräteadresse. Ab Phase 4 gibt es Codes, die keine Adresse
|
||||
* enthalten, sondern eine signierte Anmeldemarke — die soll unverändert im
|
||||
* Code stehen.
|
||||
*/
|
||||
export async function qrTextAlsDataUri(text: string, groessePx = 256): Promise<string> {
|
||||
return QRCode.toDataURL(text, {
|
||||
errorCorrectionLevel: FEHLERKORREKTUR,
|
||||
margin: 1,
|
||||
width: groessePx,
|
||||
color: { dark: "#000000ff", light: "#ffffffff" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Auswertung gescannter Codes steht in qr-parser.ts — sie läuft im
|
||||
* Browser und darf deshalb nicht in einem "server-only"-Modul liegen.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { einsatzRouter } from "@/server/routers/einsatz/einsatz";
|
||||
import { uebungRouter } from "@/server/routers/einsatz/uebung";
|
||||
import { statistikRouter } from "@/server/routers/einsatz/statistik";
|
||||
import { portalRouter } from "@/server/routers/einsatz/portal";
|
||||
import { kioskRouter } from "@/server/routers/kiosk/kiosk";
|
||||
|
||||
/**
|
||||
* Wurzel-Router.
|
||||
@@ -162,6 +163,9 @@ export const appRouter = router({
|
||||
statistik: statistikRouter,
|
||||
/** Öffentlich erreichbar, über einen signierten Link. Siehe lib/einsatz/portal.ts. */
|
||||
portal: portalRouter,
|
||||
|
||||
// Phase 4 — Terminal im Gerätehaus
|
||||
kiosk: kioskRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
AnwesenheitAnlass,
|
||||
InventurStatus,
|
||||
KioskVorgangArt,
|
||||
KonfliktGrund,
|
||||
MangelStatus,
|
||||
Prisma,
|
||||
WaescheStatus,
|
||||
} from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import type { MandantenClient } from "@/lib/db";
|
||||
import { wetter } from "@/lib/integrations/wetter";
|
||||
import {
|
||||
vorgangEingabe,
|
||||
VORGANG_TEXT,
|
||||
type VorgangEingabe,
|
||||
type VorgangErgebnis,
|
||||
} from "@/lib/kiosk/vorgaenge";
|
||||
|
||||
/**
|
||||
* Das Terminal im Gerätehaus.
|
||||
*
|
||||
* Der Unterschied zum Rest der Anwendung ist nicht die Fachlichkeit, sondern
|
||||
* die Lage: ein Wandtablet in einer Halle aus Stahlbeton hat regelmäßig kein
|
||||
* Netz. Erfasst wird trotzdem — der Browser legt den Vorgang in eine
|
||||
* Warteschlange und schickt ihn, sobald es wieder geht.
|
||||
*
|
||||
* Daraus folgt die wichtigste Eigenschaft dieses Routers: **jeder Vorgang darf
|
||||
* mehrfach ankommen**. Die Kennung erzeugt der Browser, und der Server merkt
|
||||
* sich, was er damit gemacht hat. Ein zweiter Sendeversuch bekommt dieselbe
|
||||
* Antwort wie der erste, statt einen zweiten Datensatz anzulegen.
|
||||
*/
|
||||
|
||||
/** Ein Gerät über die gescannte Inventarnummer finden. */
|
||||
async function geraetSuchen(db: MandantenClient, inventarnummer: string) {
|
||||
return db.geraet.findFirst({
|
||||
where: { inventarnummer, geloeschtAm: null },
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true, status: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Fehler, der zu einem Eintrag in der Konfliktliste führt. */
|
||||
class KonfliktFehler extends Error {
|
||||
constructor(
|
||||
readonly grund: KonfliktGrund,
|
||||
nachricht: string,
|
||||
) {
|
||||
super(nachricht);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet einen einzelnen Vorgang an.
|
||||
*
|
||||
* Gibt die Kennung des angelegten Datensatzes zurück. Wirft einen
|
||||
* KonfliktFehler, wenn sich der Vorgang nicht anwenden lässt — dann landet er
|
||||
* in der Konfliktliste statt verloren zu gehen.
|
||||
*/
|
||||
async function vorgangAnwenden(
|
||||
db: MandantenClient,
|
||||
organisationId: string,
|
||||
personId: string,
|
||||
personName: string,
|
||||
eingabe: VorgangEingabe,
|
||||
): Promise<string | null> {
|
||||
switch (eingabe.art) {
|
||||
case "MANGEL_MELDEN": {
|
||||
const geraet = await geraetSuchen(db, eingabe.daten.inventarnummer);
|
||||
if (!geraet) {
|
||||
throw new KonfliktFehler(
|
||||
KonfliktGrund.ZIEL_FEHLT,
|
||||
`Zur Inventarnummer ${eingabe.daten.inventarnummer} gibt es kein Gerät.`,
|
||||
);
|
||||
}
|
||||
|
||||
const kritisch = eingabe.daten.prioritaet === "KRITISCH";
|
||||
|
||||
const mangel = await db.mangel.create({
|
||||
data: {
|
||||
organisationId,
|
||||
geraetId: geraet.id,
|
||||
beschreibung: eingabe.daten.beschreibung,
|
||||
prioritaet: eingabe.daten.prioritaet,
|
||||
fotoDateiId: eingabe.daten.fotoDateiId ?? null,
|
||||
melderId: personId,
|
||||
melderName: personName,
|
||||
status: MangelStatus.GEMELDET,
|
||||
gemeldetAm: eingabe.erfasstAm,
|
||||
geraetGesperrt: kritisch && geraet.status !== "GESPERRT",
|
||||
},
|
||||
});
|
||||
|
||||
if (kritisch && geraet.status !== "GESPERRT") {
|
||||
await db.geraet.update({ where: { id: geraet.id }, data: { status: "GESPERRT" } });
|
||||
}
|
||||
|
||||
return mangel.id;
|
||||
}
|
||||
|
||||
case "FAHRT_BEGINNEN": {
|
||||
const fahrzeug = await db.fahrzeug.findFirst({
|
||||
where: { id: eingabe.daten.fahrzeugId, geloeschtAm: null },
|
||||
select: { id: true, funkrufname: true, kilometerstand: true },
|
||||
});
|
||||
if (!fahrzeug) {
|
||||
throw new KonfliktFehler(KonfliktGrund.ZIEL_FEHLT, "Dieses Fahrzeug gibt es nicht mehr.");
|
||||
}
|
||||
|
||||
const fahrt = await db.fahrt.create({
|
||||
data: {
|
||||
organisationId,
|
||||
fahrzeugId: fahrzeug.id,
|
||||
fahrerId: personId,
|
||||
zweck: eingabe.daten.zweck,
|
||||
ziel: eingabe.daten.ziel ?? null,
|
||||
kilometerAbfahrt: eingabe.daten.kilometerAbfahrt,
|
||||
abfahrtUm: eingabe.erfasstAm,
|
||||
},
|
||||
});
|
||||
|
||||
return fahrt.id;
|
||||
}
|
||||
|
||||
case "FAHRT_BEENDEN": {
|
||||
const fahrt = await db.fahrt.findUnique({
|
||||
where: { id: eingabe.daten.fahrtId },
|
||||
select: { id: true, kilometerAbfahrt: true, rueckkehrUm: true, fahrzeugId: true },
|
||||
});
|
||||
if (!fahrt) {
|
||||
throw new KonfliktFehler(KonfliktGrund.ZIEL_FEHLT, "Diese Fahrt gibt es nicht.");
|
||||
}
|
||||
if (fahrt.rueckkehrUm) {
|
||||
throw new KonfliktFehler(
|
||||
KonfliktGrund.DOPPELT,
|
||||
"Diese Fahrt wurde bereits beendet — vermutlich an einem zweiten Terminal.",
|
||||
);
|
||||
}
|
||||
if (eingabe.daten.kilometerRueckkehr < fahrt.kilometerAbfahrt) {
|
||||
throw new KonfliktFehler(
|
||||
KonfliktGrund.VERALTET,
|
||||
`Der Kilometerstand bei Rückkehr (${eingabe.daten.kilometerRueckkehr}) liegt unter dem bei Abfahrt (${fahrt.kilometerAbfahrt}).`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.fahrt.update({
|
||||
where: { id: fahrt.id },
|
||||
data: {
|
||||
kilometerRueckkehr: eingabe.daten.kilometerRueckkehr,
|
||||
tankmenge: eingabe.daten.tankmenge ?? null,
|
||||
bemerkung: eingabe.daten.bemerkung ?? null,
|
||||
rueckkehrUm: eingabe.erfasstAm,
|
||||
},
|
||||
});
|
||||
|
||||
// Der Kilometerstand am Fahrzeug zieht mit — er ist die Grundlage für
|
||||
// die nächste Wartung.
|
||||
await db.fahrzeug.update({
|
||||
where: { id: fahrt.fahrzeugId },
|
||||
data: { kilometerstand: eingabe.daten.kilometerRueckkehr },
|
||||
});
|
||||
|
||||
return fahrt.id;
|
||||
}
|
||||
|
||||
case "ANWESENHEIT_KOMMEN": {
|
||||
// Wer schon eingecheckt ist, checkt nicht zweimal ein. Das passiert
|
||||
// regelmäßig: einmal am Tablet, einmal per Handy.
|
||||
const offen = await db.anwesenheit.findFirst({
|
||||
where: { personId, abgemeldetUm: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (offen) return offen.id;
|
||||
|
||||
const anwesenheit = await db.anwesenheit.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId,
|
||||
anlass: eingabe.daten.anlass,
|
||||
bemerkung: eingabe.daten.bemerkung ?? null,
|
||||
angemeldetUm: eingabe.erfasstAm,
|
||||
},
|
||||
});
|
||||
|
||||
return anwesenheit.id;
|
||||
}
|
||||
|
||||
case "ANWESENHEIT_GEHEN": {
|
||||
const offen = eingabe.daten.anwesenheitId
|
||||
? await db.anwesenheit.findUnique({
|
||||
where: { id: eingabe.daten.anwesenheitId },
|
||||
select: { id: true, personId: true, abgemeldetUm: true, angemeldetUm: true },
|
||||
})
|
||||
: await db.anwesenheit.findFirst({
|
||||
where: { personId, abgemeldetUm: null },
|
||||
orderBy: { angemeldetUm: "desc" },
|
||||
select: { id: true, personId: true, abgemeldetUm: true, angemeldetUm: true },
|
||||
});
|
||||
|
||||
if (!offen) {
|
||||
throw new KonfliktFehler(
|
||||
KonfliktGrund.ZIEL_FEHLT,
|
||||
"Es gibt keine offene Anwesenheit zum Beenden.",
|
||||
);
|
||||
}
|
||||
if (offen.abgemeldetUm) return offen.id;
|
||||
|
||||
await db.anwesenheit.update({
|
||||
where: { id: offen.id },
|
||||
data: {
|
||||
// Nie vor dem Kommen — die Datenbank ließe es ohnehin nicht zu.
|
||||
abgemeldetUm:
|
||||
eingabe.erfasstAm < offen.angemeldetUm ? offen.angemeldetUm : eingabe.erfasstAm,
|
||||
},
|
||||
});
|
||||
|
||||
return offen.id;
|
||||
}
|
||||
|
||||
case "WAESCHE_ABGEBEN": {
|
||||
const geraet = eingabe.daten.inventarnummer
|
||||
? await geraetSuchen(db, eingabe.daten.inventarnummer)
|
||||
: null;
|
||||
|
||||
const abgabe = await db.waescheabgabe.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId,
|
||||
geraetId: geraet?.id ?? null,
|
||||
bezeichnung: eingabe.daten.bezeichnung,
|
||||
menge: eingabe.daten.menge,
|
||||
grund: eingabe.daten.grund ?? null,
|
||||
status: WaescheStatus.ABGEGEBEN,
|
||||
abgegebenAm: eingabe.erfasstAm,
|
||||
},
|
||||
});
|
||||
|
||||
return abgabe.id;
|
||||
}
|
||||
|
||||
case "INVENTUR_SCANNEN": {
|
||||
const inventur = await db.inventurgang.findUnique({
|
||||
where: { id: eingabe.daten.inventurId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!inventur) {
|
||||
throw new KonfliktFehler(KonfliktGrund.ZIEL_FEHLT, "Diesen Inventurgang gibt es nicht.");
|
||||
}
|
||||
if (inventur.status !== InventurStatus.LAEUFT) {
|
||||
throw new KonfliktFehler(
|
||||
KonfliktGrund.ZIEL_GESPERRT,
|
||||
"Der Inventurgang ist bereits abgeschlossen.",
|
||||
);
|
||||
}
|
||||
|
||||
const geraet = await geraetSuchen(db, eingabe.daten.inventarnummer);
|
||||
if (!geraet) {
|
||||
throw new KonfliktFehler(
|
||||
KonfliktGrund.ZIEL_FEHLT,
|
||||
`Zur Inventarnummer ${eingabe.daten.inventarnummer} gibt es kein Gerät.`,
|
||||
);
|
||||
}
|
||||
|
||||
const posten = await db.inventurposten.upsert({
|
||||
where: { inventurId_geraetId: { inventurId: inventur.id, geraetId: geraet.id } },
|
||||
create: {
|
||||
organisationId,
|
||||
inventurId: inventur.id,
|
||||
geraetId: geraet.id,
|
||||
gefunden: eingabe.daten.gefunden,
|
||||
vorgefundenAn: eingabe.daten.vorgefundenAn ?? null,
|
||||
gescanntAm: eingabe.erfasstAm,
|
||||
erfasstVonId: personId,
|
||||
},
|
||||
update: {
|
||||
gefunden: eingabe.daten.gefunden,
|
||||
vorgefundenAn: eingabe.daten.vorgefundenAn ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return posten.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const kioskRouter = router({
|
||||
/**
|
||||
* Vorgänge aus der Warteschlange des Terminals annehmen.
|
||||
*
|
||||
* Nimmt mehrere auf einmal: nach einer Stunde ohne Netz liegen dort
|
||||
* möglicherweise ein Dutzend. Jeder wird einzeln behandelt — ein Fehler in
|
||||
* einem Vorgang darf die anderen nicht aufhalten.
|
||||
*/
|
||||
vorgaengeSenden: angemeldet
|
||||
.input(z.object({ vorgaenge: z.array(vorgangEingabe).min(1).max(100) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const ergebnisse: VorgangErgebnis[] = [];
|
||||
|
||||
for (const eingabe of input.vorgaenge) {
|
||||
try {
|
||||
const ergebnis = await ctx.db(async (db) => {
|
||||
// Schon einmal angekommen? Dann die Antwort von damals.
|
||||
const bekannt = await db.kioskVorgang.findUnique({
|
||||
where: { id: eingabe.id },
|
||||
select: { id: true, ergebnisId: true, erfolgreich: true, meldung: true },
|
||||
});
|
||||
|
||||
if (bekannt) {
|
||||
return {
|
||||
id: eingabe.id,
|
||||
erfolgreich: bekannt.erfolgreich,
|
||||
bereitsVerarbeitet: true,
|
||||
ergebnisId: bekannt.ergebnisId,
|
||||
meldung: bekannt.meldung,
|
||||
} satisfies VorgangErgebnis;
|
||||
}
|
||||
|
||||
try {
|
||||
const ergebnisId = await vorgangAnwenden(
|
||||
db,
|
||||
ctx.sitzung.organisationId,
|
||||
ctx.sitzung.personId,
|
||||
ctx.sitzung.name,
|
||||
eingabe,
|
||||
);
|
||||
|
||||
await db.kioskVorgang.create({
|
||||
data: {
|
||||
id: eingabe.id,
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
art: eingabe.art as KioskVorgangArt,
|
||||
personId: ctx.sitzung.personId,
|
||||
erfasstAm: eingabe.erfasstAm,
|
||||
ergebnisId,
|
||||
nutzlast: eingabe.daten as Prisma.InputJsonValue,
|
||||
erfolgreich: true,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "KioskVorgang",
|
||||
entitaetId: eingabe.id,
|
||||
beschreibung: `Terminal: ${VORGANG_TEXT[eingabe.art]}`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { art: eingabe.art, ergebnisId, erfasstAm: eingabe.erfasstAm },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
id: eingabe.id,
|
||||
erfolgreich: true,
|
||||
bereitsVerarbeitet: false,
|
||||
ergebnisId,
|
||||
} satisfies VorgangErgebnis;
|
||||
} catch (fehler) {
|
||||
if (!(fehler instanceof KonfliktFehler)) throw fehler;
|
||||
|
||||
// Der Vorgang geht nicht verloren: er landet in der
|
||||
// Konfliktliste und wartet dort auf einen Menschen.
|
||||
await db.kioskVorgang.create({
|
||||
data: {
|
||||
id: eingabe.id,
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
art: eingabe.art as KioskVorgangArt,
|
||||
personId: ctx.sitzung.personId,
|
||||
erfasstAm: eingabe.erfasstAm,
|
||||
nutzlast: eingabe.daten as Prisma.InputJsonValue,
|
||||
erfolgreich: false,
|
||||
meldung: fehler.message.slice(0, 500),
|
||||
},
|
||||
});
|
||||
|
||||
await db.syncKonflikt.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
vorgangId: eingabe.id,
|
||||
art: eingabe.art as KioskVorgangArt,
|
||||
grund: fehler.grund,
|
||||
beschreibung: fehler.message.slice(0, 500),
|
||||
nutzlast: eingabe.daten as Prisma.InputJsonValue,
|
||||
personId: ctx.sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: eingabe.id,
|
||||
erfolgreich: false,
|
||||
bereitsVerarbeitet: false,
|
||||
konflikt: true,
|
||||
meldung: fehler.message,
|
||||
} satisfies VorgangErgebnis;
|
||||
}
|
||||
});
|
||||
|
||||
ergebnisse.push(ergebnis);
|
||||
} catch (fehler) {
|
||||
// Technischer Fehler — der Vorgang bleibt in der Warteschlange des
|
||||
// Terminals und wird später erneut versucht.
|
||||
console.error(`[kiosk] Vorgang ${eingabe.id} fehlgeschlagen:`, fehler);
|
||||
ergebnisse.push({
|
||||
id: eingabe.id,
|
||||
erfolgreich: false,
|
||||
bereitsVerarbeitet: false,
|
||||
meldung: "Der Vorgang ließ sich nicht verarbeiten und wird erneut versucht.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ergebnisse };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Was das Terminal zum Arbeiten braucht — in einem Aufruf.
|
||||
*
|
||||
* Der Service Worker legt die Antwort in seinen Zwischenspeicher, damit die
|
||||
* Wizards auch ohne Netz Fahrzeuge und Geräte kennen. Deshalb bewusst
|
||||
* schmal: Kennung, Nummer, Bezeichnung. Personendaten sind nicht dabei.
|
||||
*/
|
||||
stammdatenpaket: angemeldet.query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const [fahrzeuge, geraete, inventur] = await Promise.all([
|
||||
db.fahrzeug.findMany({
|
||||
where: { aktiv: true, geloeschtAm: null },
|
||||
select: {
|
||||
id: true,
|
||||
funkrufname: true,
|
||||
bezeichnung: true,
|
||||
dinTyp: true,
|
||||
kilometerstand: true,
|
||||
},
|
||||
orderBy: { funkrufname: "asc" },
|
||||
}),
|
||||
db.geraet.findMany({
|
||||
where: { geloeschtAm: null },
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true, status: true },
|
||||
orderBy: { inventarnummer: "asc" },
|
||||
take: 2000,
|
||||
}),
|
||||
db.inventurgang.findFirst({
|
||||
where: { status: InventurStatus.LAEUFT },
|
||||
select: { id: true, bezeichnung: true, begonnenAm: true },
|
||||
orderBy: { begonnenAm: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { fahrzeuge, geraete, laufendeInventur: inventur, stand: new Date() };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Der eigene Stand: bin ich da, fahre ich gerade, habe ich Wäsche offen? */
|
||||
eigenerStand: angemeldet.query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const [anwesenheit, fahrt, waesche] = await Promise.all([
|
||||
db.anwesenheit.findFirst({
|
||||
where: { personId: ctx.sitzung.personId, abgemeldetUm: null },
|
||||
orderBy: { angemeldetUm: "desc" },
|
||||
select: { id: true, anlass: true, angemeldetUm: true },
|
||||
}),
|
||||
db.fahrt.findFirst({
|
||||
where: { fahrerId: ctx.sitzung.personId, rueckkehrUm: null },
|
||||
orderBy: { abfahrtUm: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
abfahrtUm: true,
|
||||
kilometerAbfahrt: true,
|
||||
zweck: true,
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
},
|
||||
}),
|
||||
db.waescheabgabe.count({
|
||||
where: { personId: ctx.sitzung.personId, status: { not: WaescheStatus.ZURUECK } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { anwesenheit, fahrt, offeneWaesche: waesche };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Der Hallenmonitor.
|
||||
*
|
||||
* Läuft im Vollbild an der Wand und wird nicht bedient. Er zeigt, was beim
|
||||
* Vorbeigehen wichtig ist: wer da ist, welches Fahrzeug draußen ist, was
|
||||
* ansteht und was überfällig ist.
|
||||
*/
|
||||
monitor: angemeldet.query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const heute = new Date();
|
||||
const inSiebenTagen = new Date(heute.getTime() + 7 * 864e5);
|
||||
|
||||
const [anwesend, unterwegs, laufendeEinsaetze, ueberfaellig, naechsteTermine, uebungen, gesperrt] =
|
||||
await Promise.all([
|
||||
db.anwesenheit.findMany({
|
||||
where: { abgemeldetUm: null },
|
||||
orderBy: { angemeldetUm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
angemeldetUm: true,
|
||||
anlass: true,
|
||||
person: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
take: 40,
|
||||
}),
|
||||
db.fahrt.findMany({
|
||||
where: { rueckkehrUm: null },
|
||||
orderBy: { abfahrtUm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
abfahrtUm: true,
|
||||
zweck: true,
|
||||
ziel: true,
|
||||
fahrzeug: { select: { funkrufname: true } },
|
||||
fahrer: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
db.einsatz.findMany({
|
||||
where: { status: "LAUFEND", geloeschtAm: null },
|
||||
orderBy: { alarmiertUm: "desc" },
|
||||
select: { id: true, einsatznummer: true, stichwort: true, alarmiertUm: true, ort: true },
|
||||
take: 5,
|
||||
}),
|
||||
db.wartungstermin.count({
|
||||
where: { status: "OFFEN", geloeschtAm: null, faelligAm: { lt: heute } },
|
||||
}),
|
||||
db.wartungstermin.findMany({
|
||||
where: {
|
||||
status: "OFFEN",
|
||||
geloeschtAm: null,
|
||||
faelligAm: { gte: heute, lte: inSiebenTagen },
|
||||
},
|
||||
orderBy: { faelligAm: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
faelligAm: true,
|
||||
geraet: { select: { inventarnummer: true, bezeichnung: true } },
|
||||
vorlage: { select: { name: true } },
|
||||
},
|
||||
take: 8,
|
||||
}),
|
||||
db.uebung.findMany({
|
||||
where: { geloeschtAm: null, datum: { gte: heute, lte: inSiebenTagen } },
|
||||
orderBy: { datum: "asc" },
|
||||
select: { id: true, datum: true, thema: true, ort: true, beginnUm: true },
|
||||
take: 5,
|
||||
}),
|
||||
db.geraet.count({ where: { geloeschtAm: null, status: "GESPERRT" } }),
|
||||
]);
|
||||
|
||||
const fahrzeuge = await db.fahrzeug.findMany({
|
||||
where: { aktiv: true, geloeschtAm: null },
|
||||
select: { id: true, funkrufname: true, tuevBis: true },
|
||||
orderBy: { funkrufname: "asc" },
|
||||
});
|
||||
|
||||
const unterwegsIds = new Set(unterwegs.map((fahrt) => fahrt.fahrzeug.funkrufname));
|
||||
|
||||
return {
|
||||
stand: heute,
|
||||
anwesend,
|
||||
unterwegs,
|
||||
laufendeEinsaetze,
|
||||
fahrzeuge: fahrzeuge.map((fahrzeug) => ({
|
||||
...fahrzeug,
|
||||
unterwegs: unterwegsIds.has(fahrzeug.funkrufname),
|
||||
})),
|
||||
ueberfaelligePruefungen: ueberfaellig,
|
||||
gesperrteGeraete: gesperrt,
|
||||
naechsteTermine,
|
||||
uebungen,
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
inventurStarten: mitRecht("geraet.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
bezeichnung: z.string().trim().min(1).max(160),
|
||||
standortId: z.string().uuid().nullish(),
|
||||
fahrzeugId: z.string().uuid().nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const inventur = await db.inventurgang.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
bezeichnung: input.bezeichnung,
|
||||
standortId: input.standortId ?? null,
|
||||
fahrzeugId: input.fahrzeugId ?? null,
|
||||
begonnenVonId: ctx.sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Inventurgang",
|
||||
entitaetId: inventur.id,
|
||||
beschreibung: `Inventur "${inventur.bezeichnung}" begonnen`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: inventur,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return inventur;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Stand eines Inventurgangs: was gescannt wurde und was noch fehlt. */
|
||||
inventurStand: mitRecht("geraet.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const inventur = await db.inventurgang.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
standort: { select: { id: true, name: true } },
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
posten: {
|
||||
include: {
|
||||
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
|
||||
},
|
||||
orderBy: { gescanntAm: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!inventur) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Inventurgang gibt es nicht." });
|
||||
}
|
||||
|
||||
// Was erwartet wird: alle Geräte am Standort oder auf dem Fahrzeug.
|
||||
const erwartet = await db.geraet.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
status: { not: "AUSGESONDERT" },
|
||||
...(inventur.standortId ? { standortId: inventur.standortId } : {}),
|
||||
...(inventur.fahrzeugId ? { fahrzeugId: inventur.fahrzeugId } : {}),
|
||||
},
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true },
|
||||
orderBy: { inventarnummer: "asc" },
|
||||
});
|
||||
|
||||
const gescannt = new Set(inventur.posten.map((posten) => posten.geraetId));
|
||||
|
||||
return {
|
||||
...inventur,
|
||||
erwartet: erwartet.length,
|
||||
gescannt: inventur.posten.length,
|
||||
fehlend: erwartet.filter((geraet) => !gescannt.has(geraet.id)),
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
inventurAbschliessen: mitRecht("geraet.bearbeiten")
|
||||
.input(idEingabe.extend({ bemerkung: z.string().trim().max(1000).optional() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const inventur = await db.inventurgang.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: InventurStatus.ABGESCHLOSSEN,
|
||||
abgeschlossenAm: new Date(),
|
||||
bemerkung: input.bemerkung ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Inventurgang",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Inventur "${inventur.bezeichnung}" abgeschlossen`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: inventur,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return inventur;
|
||||
}),
|
||||
),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Konflikte
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Was sich beim Nachtragen nicht anwenden ließ. */
|
||||
konflikte: mitRecht("system.auditlog")
|
||||
.input(z.object({ nurOffene: z.boolean().default(true) }).default({ nurOffene: true }))
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.syncKonflikt.findMany({
|
||||
where: input.nurOffene ? { erledigtAm: null } : {},
|
||||
orderBy: { entstandenAm: "desc" },
|
||||
take: 200,
|
||||
include: {
|
||||
person: { select: { id: true, vorname: true, nachname: true } },
|
||||
erledigtVon: { select: { id: true, vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
konfliktErledigen: mitRecht("system.auditlog")
|
||||
.input(idEingabe.extend({ erledigung: z.string().trim().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const konflikt = await db.syncKonflikt.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
erledigtAm: new Date(),
|
||||
erledigtVonId: ctx.sitzung.personId,
|
||||
erledigung: input.erledigung ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "SyncKonflikt",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Konflikt erledigt: ${konflikt.beschreibung}`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: konflikt,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return konflikt;
|
||||
}),
|
||||
),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fahrtenbuch und Wäsche — die Listenansichten außerhalb des Terminals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fahrten: mitRecht("fahrtenbuch.lesen")
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
fahrzeugId: z.string().uuid().optional(),
|
||||
nurOffene: z.boolean().default(false),
|
||||
anzahl: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.default({ nurOffene: false, anzahl: 50 }),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.fahrt.findMany({
|
||||
where: {
|
||||
...(input.fahrzeugId ? { fahrzeugId: input.fahrzeugId } : {}),
|
||||
...(input.nurOffene ? { rueckkehrUm: null } : {}),
|
||||
},
|
||||
orderBy: { abfahrtUm: "desc" },
|
||||
take: input.anzahl,
|
||||
include: {
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
fahrer: { select: { id: true, vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
waesche: mitRecht("geraet.lesen")
|
||||
.input(
|
||||
z.object({ nurOffene: z.boolean().default(true) }).default({ nurOffene: true }),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.waescheabgabe.findMany({
|
||||
where: input.nurOffene ? { status: { not: WaescheStatus.ZURUECK } } : {},
|
||||
orderBy: { abgegebenAm: "desc" },
|
||||
take: 200,
|
||||
include: {
|
||||
person: { select: { id: true, vorname: true, nachname: true } },
|
||||
geraet: { select: { id: true, inventarnummer: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
waescheStatusSetzen: mitRecht("geraet.bearbeiten")
|
||||
.input(idEingabe.extend({ status: z.nativeEnum(WaescheStatus) }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const abgabe = await db.waescheabgabe.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: input.status,
|
||||
zurueckAm: input.status === WaescheStatus.ZURUECK ? new Date() : null,
|
||||
angenommenVonId:
|
||||
input.status === WaescheStatus.ZURUECK ? ctx.sitzung.personId : null,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Waescheabgabe",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Wäsche "${abgabe.bezeichnung}": ${input.status}`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: abgabe,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return abgabe;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Wer ist gerade im Haus — auch außerhalb des Monitors abrufbar. */
|
||||
anwesende: mitRecht("person.lesen").query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.anwesenheit.findMany({
|
||||
where: { abgemeldetUm: null },
|
||||
orderBy: { angemeldetUm: "asc" },
|
||||
include: {
|
||||
person: { select: { id: true, vorname: true, nachname: true, dienstgrad: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/**
|
||||
* Wetterwarnungen für den Hallenmonitor.
|
||||
*
|
||||
* Eigene Abfrage statt Teil von `monitor`: fällt der DWD aus oder antwortet
|
||||
* langsam, soll deswegen nicht die halbe Anzeige leer bleiben.
|
||||
*/
|
||||
wetterwarnungen: angemeldet.query(async () => ({
|
||||
verfuegbar: wetter().verfuegbar(),
|
||||
warnungen: await wetter().warnungen(),
|
||||
})),
|
||||
|
||||
/** Anlässe für die Auswahl am Terminal. */
|
||||
anlaesse: angemeldet.query(() => Object.values(AnwesenheitAnlass)),
|
||||
});
|
||||
Reference in New Issue
Block a user