Files
FireAdministration/src/app/kiosk/monitor/monitor.tsx
T
Florian WildeandClaude Opus 5 791523027f 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>
2026-09-03 18:35:48 +02:00

274 lines
11 KiB
TypeScript

"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>
);
}