Atemschutzueberwachung nach FwDV 7 ergaenzen

Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht
steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt —
abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten.

Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein
Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus.
Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine
Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht
verlieren laesst. Server und Browser rechnen mit denselben Funktionen.

Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein
gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das
Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist
damit die vorsichtigere Annahme.

Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf
druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen
Eintrag richtiggestellt, nicht durch Aendern.

Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine
Richtung, und so bleibt das Deployment bei "next start".

Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette
weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille
von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag
"Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1.

68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 23:19:05 +02:00
co-authored by Claude Opus 5
parent b2b31ed00a
commit 4e2daaafd4
107 changed files with 7214 additions and 40 deletions
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Sicherheitshinweis } from "@/components/atemschutz/hinweis";
import { Ueberwachungsschirm } from "@/app/(app)/atemschutz/[id]/ueberwachung";
export const metadata = { title: "Atemschutzüberwachung" };
/**
* Eine laufende Überwachung.
*
* Der Hinweis steht ganz oben und bleibt beim Scrollen im Blick — er ist der
* erste und der letzte Satz dieser Seite.
*/
export default async function UeberwachungSeite({
params,
}: {
params: Promise<{ id: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "atemschutz.lesen")) redirect("/");
const { id } = await params;
return (
<div className="space-y-6">
<Sicherheitshinweis />
<Ueberwachungsschirm id={id} darfBedienen={hatRecht(sitzung, "atemschutz.ueberwachung")} />
</div>
);
}
@@ -0,0 +1,316 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, FileText, Plus, Radio, WifiOff } from "lucide-react";
import { DruckArt, TruppStatus } 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 { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { TruppKarte } from "@/components/atemschutz/trupp-karte";
import { useEreignisstrom, useJetzt } from "@/components/atemschutz/stand";
import { TruppAnlegen } from "@/components/atemschutz/trupp-anlegen";
import { alsDatumZeit } from "@/lib/utils";
import { alsUhr } from "@/lib/atemschutz/berechnung";
/**
* Der Überwachungsschirm.
*
* Was diese Ansicht aushalten muss, steht in Phase-5.md als wichtigste
* Testanforderung: **ein Neuladen und ein Verbindungsabbruch dürfen ihr nichts
* anhaben.** Drei Entscheidungen sorgen dafür:
*
* 1. **Kein Zustand, der nur hier lebt.** Die Uhr eines Trupps wird nicht
* hochgezählt, sondern jede Sekunde neu aus zwei Zeitstempeln gerechnet.
* Nach einem Neuladen kommen dieselben Zeitstempel zurück, also steht
* dieselbe Zahl da.
* 2. **Zwei Wege zum Stand.** Der Ereignisstrom meldet Änderungen sofort;
* unabhängig davon fragt die Ansicht alle paar Sekunden nach. Fällt der
* Strom aus, wird die Anzeige langsamer — nicht falsch.
* 3. **Die Verbindung ist sichtbar.** Wer nicht sieht, dass der Draht weg
* ist, hält einen alten Stand für den aktuellen. Das wäre der
* gefährlichste Fehler, den diese Ansicht machen könnte.
*/
/** Wie oft ohne Ereignis nachgefragt wird. Netz vorausgesetzt, ist das die Rückfallebene. */
const NACHFRAGE_MS = 5000;
export function Ueberwachungsschirm({
id,
darfBedienen,
}: {
id: string;
darfBedienen: boolean;
}) {
const jetzt = useJetzt();
const [meldung, setMeldung] = React.useState<string | null>(null);
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [notiz, setNotiz] = React.useState("");
const werkzeuge = trpc.useUtils();
const stand = trpc.atemschutz.stand.useQuery(
{ id },
{
refetchInterval: NACHFRAGE_MS,
// Beim Zurückkehren an das Tablet soll sofort der echte Stand da sein.
refetchOnWindowFocus: true,
retry: 2,
},
);
const neuLaden = React.useCallback(() => {
void werkzeuge.atemschutz.stand.invalidate({ id });
}, [id, werkzeuge]);
const { verbunden, letzteMeldung } = useEreignisstrom(id, neuLaden);
const beiFehler = (fehler: { message: string }) => setMeldung(fehler.message);
const beiErfolg = () => {
setMeldung(null);
neuLaden();
};
const druck = trpc.atemschutz.druckErfassen.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const status = trpc.atemschutz.statusSetzen.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const mayday = trpc.atemschutz.mayday.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const rueckzug = trpc.atemschutz.rueckzugAnordnen.useMutation({
onSuccess: beiErfolg,
onError: beiFehler,
});
const notizSenden = trpc.atemschutz.notiz.useMutation({
onSuccess: () => {
setNotiz("");
beiErfolg();
},
onError: beiFehler,
});
const beenden = trpc.atemschutz.beenden.useMutation({ onSuccess: beiErfolg, onError: beiFehler });
const daten = stand.data;
const laeuft = daten && !daten.endeUm;
const trupps = daten?.trupps ?? [];
const notfall = trupps.some((trupp) => trupp.maydayUm && !trupp.maydayAufgehobenUm);
/**
* Der Ton beim Notfall.
*
* Kein Tonfile, sondern ein erzeugter Ton: eine Datei müsste geladen werden,
* und im Zweifel ist sie genau dann nicht im Zwischenspeicher, wenn sie
* gebraucht wird. Der Ton ist ausdrücklich eine Zugabe — er ersetzt nicht,
* dass jemand hinsieht.
*/
React.useEffect(() => {
if (!notfall || typeof window === "undefined") return;
let abgebrochen = false;
const zeitgeber = setInterval(() => {
if (abgebrochen) return;
try {
const AudioKontext =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioKontext) return;
const kontext = new AudioKontext();
const ton = kontext.createOscillator();
const lautstaerke = kontext.createGain();
ton.frequency.value = 880;
lautstaerke.gain.value = 0.1;
ton.connect(lautstaerke).connect(kontext.destination);
ton.start();
ton.stop(kontext.currentTime + 0.4);
setTimeout(() => void kontext.close(), 600);
} catch {
// Kein Ton möglich (Browserregel, kein Ausgabegerät) — die Anzeige
// bleibt trotzdem eindeutig.
}
}, 2000);
return () => {
abgebrochen = true;
clearInterval(zeitgeber);
};
}, [notfall]);
if (stand.isPending) {
return <p className="text-[var(--muted-foreground)]">Stand wird geladen …</p>;
}
if (stand.isError || !daten) {
return <Fehlermeldung meldung={stand.error?.message ?? "Unbekannter Fehler"} kennung="standfehler" />;
}
return (
<div className="space-y-6" data-testid="ueberwachung">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/atemschutz">
<ArrowLeft aria-hidden />
Alle Überwachungen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{daten.bezeichnung}</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Überwacher: {daten.ueberwacher.vorname} {daten.ueberwacher.nachname} · Beginn{" "}
{alsDatumZeit(daten.beginnUm)}
{daten.einsatz ? ` · Einsatz ${daten.einsatz.einsatznummer}` : ""}
{daten.endeUm ? ` · beendet ${alsDatumZeit(daten.endeUm)}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Zustand der Verbindung — sichtbar, nicht versteckt. */}
<span
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
verbunden
? "border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 text-[var(--einsatzbereit)]"
: "border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 text-[var(--gesperrt)]"
}`}
role="status"
data-testid="verbindung"
data-verbunden={verbunden}
>
{verbunden ? <Radio className="size-4" aria-hidden /> : <WifiOff className="size-4" aria-hidden />}
{verbunden ? "Verbunden" : "Verbindung unterbrochen"}
{letzteMeldung && (
<span className="text-xs opacity-80">
zuletzt{" "}
{letzteMeldung.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
)}
</span>
{/*
Sobald die Überwachung beendet ist — nicht erst, wenn die Datei
schon liegt: der Endpunkt erzeugt sie bei Bedarf. Ein Nachweis, den
man nicht bekommt, weil ein Hintergrunddienst einen schlechten Tag
hatte, wäre keiner.
*/}
{daten.endeUm && (
<Button variant="outline" asChild>
<a href={`/api/atemschutz/protokoll?id=${daten.id}`} target="_blank" rel="noreferrer">
<FileText aria-hidden />
Protokoll
</a>
</Button>
)}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="ueberwachungsfehler" />}
{!verbunden && (
<p className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 px-4 py-3 text-sm">
Der Ereignisstrom ist unterbrochen. Die Ansicht fragt weiter im Takt nach und rechnet
die Zeiten aus den Zeitstempeln des Servers — sie bleibt also richtig, nur nicht mehr
sekundenaktuell. <strong>Die analoge Überwachung bleibt ohnehin führend.</strong>
</p>
)}
{laeuft && darfBedienen && (
<div className="flex flex-wrap gap-2">
<Button onClick={() => setAnlegenOffen((wert) => !wert)}>
<Plus aria-hidden />
Trupp anlegen
</Button>
<Button
variant="outline"
onClick={() => {
if (
window.confirm(
"Überwachung beenden? Das geht nur, wenn alle Trupps als draußen gemeldet sind.",
)
) {
beenden.mutate({ id });
}
}}
>
Überwachung beenden
</Button>
</div>
)}
{anlegenOffen && laeuft && (
<TruppAnlegen
atemschutzeinsatzId={id}
onFertig={() => {
setAnlegenOffen(false);
beiErfolg();
}}
onFehler={setMeldung}
/>
)}
{trupps.length === 0 ? (
<NichtsGefunden
titel="Noch kein Trupp angelegt"
hinweis="Ein Trupp braucht mindestens zwei Personen und für jede den Eingangsdruck."
/>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{trupps.map((trupp) => (
<TruppKarte
key={trupp.id}
trupp={trupp}
jetzt={jetzt}
intervallSekunden={daten.abfrageIntervall}
darfBedienen={darfBedienen && Boolean(laeuft)}
onDruck={(messungen, art) =>
druck.mutate({ truppId: trupp.id, messungen, art: art as DruckArt })
}
onStatus={(neuerStatus, ausgangsdruecke) =>
status.mutate({
truppId: trupp.id,
status: neuerStatus as TruppStatus,
ausgangsdruecke:
neuerStatus === TruppStatus.DRAUSSEN ? ausgangsdruecke : undefined,
})
}
onMayday={(aufheben) => mayday.mutate({ truppId: trupp.id, aufheben })}
onRueckzug={() => rueckzug.mutate({ truppId: trupp.id })}
/>
))}
</div>
)}
{laeuft && darfBedienen && (
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (notiz.trim()) {
notizSenden.mutate({ atemschutzeinsatzId: id, text: notiz.trim() });
}
}}
>
<div className="flex-1 space-y-1">
<Label htmlFor="notiz">Notiz ins Protokoll</Label>
<Input
id="notiz"
value={notiz}
onChange={(ereignis) => setNotiz(ereignis.target.value)}
placeholder="Funkverkehr, Beobachtung, Anweisung …"
/>
</div>
<Button type="submit" variant="outline">
Eintragen
</Button>
</form>
)}
<p className="text-xs text-[var(--muted-foreground)]">
Stand vom Server: {alsDatumZeit(daten.stand)} · Anzeige läuft seit{" "}
{alsUhr(Math.floor((jetzt.getTime() - daten.stand.getTime()) / 1000))} weiter
</p>
</div>
);
}
@@ -0,0 +1,321 @@
"use client";
import * as React from "react";
import { G26Ergebnis, UebungsartAtemschutz } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { G26_ERGEBNIS_TEXT, UEBUNGSART_TEXT } from "@/lib/atemschutz/tauglichkeit";
import { alsDatum } from "@/lib/utils";
/**
* Nachweise pflegen.
*
* Zwei Formulare, die im Jahreslauf zusammengehören: die G26 kommt einzeln
* vom Arzt, die Belastungsübung macht die halbe Wehr an einem Abend. Deshalb
* ist das eine ein Formular für eine Person und das andere eines für viele.
*/
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
export function Nachweispflege() {
const [meldung, setMeldung] = React.useState<string | null>(null);
const [erfolg, setErfolg] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const traeger = trpc.atemschutzTraeger.uebersicht.useQuery();
// --- G26 -----------------------------------------------------------------
const [personId, setPersonId] = React.useState("");
const [untersuchtAm, setUntersuchtAm] = React.useState(heute());
const [gueltigBis, setGueltigBis] = React.useState("");
const [ergebnis, setErgebnis] = React.useState<G26Ergebnis>(G26Ergebnis.TAUGLICH);
const [arzt, setArzt] = React.useState("");
const [bemerkung, setBemerkung] = React.useState("");
// Der Vorschlag richtet sich nach dem Alter — drei Jahre, ab fünfzig eines.
const vorschlag = trpc.atemschutzTraeger.fristVorschlag.useQuery(
{ personId, untersuchtAm: new Date(untersuchtAm) },
{ enabled: personId !== "" && untersuchtAm !== "" },
);
React.useEffect(() => {
if (vorschlag.data?.gueltigBis) {
setGueltigBis(vorschlag.data.gueltigBis.toISOString().slice(0, 10));
}
}, [vorschlag.data]);
const g26 = trpc.atemschutzTraeger.g26Erfassen.useMutation({
onSuccess: () => {
setErfolg("Untersuchung erfasst.");
setMeldung(null);
setArzt("");
setBemerkung("");
void werkzeuge.atemschutzTraeger.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
// --- Belastungsübung -----------------------------------------------------
const [uebungsdatum, setUebungsdatum] = React.useState(heute());
const [art, setArt] = React.useState<UebungsartAtemschutz>(UebungsartAtemschutz.UEBUNGSSTRECKE);
const [ort, setOrt] = React.useState("");
const [dauer, setDauer] = React.useState("");
const [teilnehmer, setTeilnehmer] = React.useState<string[]>([]);
const uebung = trpc.atemschutzTraeger.uebungErfassen.useMutation({
onSuccess: (ergebnisDaten) => {
setErfolg(`Für ${ergebnisDaten.erfasst} Personen erfasst.`);
setMeldung(null);
setTeilnehmer([]);
void werkzeuge.atemschutzTraeger.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="nachweisfehler" />}
{erfolg && (
<p
className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 px-4 py-3 text-sm"
role="status"
data-testid="nachweis-erfolg"
>
{erfolg}
</p>
)}
<Card>
<CardHeader>
<CardTitle>G26.3-Untersuchung</CardTitle>
<CardDescription>
Die harte Voraussetzung. Das Ablaufdatum wird nach dem Alter vorgeschlagen — drei
Jahre, ab fünfzig eines. Was auf der Bescheinigung steht, gilt.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (!personId || !gueltigBis) return;
g26.mutate({
personId,
untersuchtAm: new Date(untersuchtAm),
gueltigBis: new Date(gueltigBis),
ergebnis,
arzt: arzt.trim() || null,
bemerkung: bemerkung.trim() || null,
});
}}
>
<div className="space-y-1">
<Label htmlFor="g26-person">Person</Label>
<Select value={personId} onValueChange={setPersonId}>
<SelectTrigger id="g26-person">
<SelectValue placeholder="Mitglied wählen" />
</SelectTrigger>
<SelectContent>
{traeger.data?.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="g26-ergebnis">Ergebnis</Label>
<Select value={ergebnis} onValueChange={(wert) => setErgebnis(wert as G26Ergebnis)}>
<SelectTrigger id="g26-ergebnis">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(G26_ERGEBNIS_TEXT) as G26Ergebnis[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{G26_ERGEBNIS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="g26-datum">Untersucht am</Label>
<Input
id="g26-datum"
type="date"
value={untersuchtAm}
onChange={(ereignis) => setUntersuchtAm(ereignis.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="g26-bis">Gültig bis</Label>
<Input
id="g26-bis"
type="date"
value={gueltigBis}
onChange={(ereignis) => setGueltigBis(ereignis.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="g26-arzt">Arzt oder Stelle</Label>
<Input
id="g26-arzt"
value={arzt}
onChange={(ereignis) => setArzt(ereignis.target.value)}
placeholder="Betriebsarzt Dr. Meier, Landshut"
/>
</div>
<div className="space-y-1">
<Label htmlFor="g26-bemerkung">Bemerkung</Label>
<Textarea
id="g26-bemerkung"
rows={2}
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
/>
</div>
<div className="sm:col-span-2">
<Button type="submit" disabled={!personId || g26.isPending}>
{g26.isPending ? "wird gespeichert …" : "Untersuchung erfassen"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Belastungsübung oder Einsatzübung</CardTitle>
<CardDescription>
Für alle auf einmal — eine Übungsstrecke macht selten eine Person allein. Beides ist
jährlich nötig; ein Einsatz unter Atemschutz ersetzt die Einsatzübung.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-4"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (teilnehmer.length === 0) return;
uebung.mutate({
personIds: teilnehmer,
datum: new Date(uebungsdatum),
art,
ort: ort.trim() || null,
dauerMinuten: dauer ? Number(dauer) : null,
});
}}
>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="ue-datum">Datum</Label>
<Input
id="ue-datum"
type="date"
value={uebungsdatum}
onChange={(ereignis) => setUebungsdatum(ereignis.target.value)}
required
/>
</div>
<div className="space-y-1 sm:col-span-2">
<Label htmlFor="ue-art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as UebungsartAtemschutz)}>
<SelectTrigger id="ue-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(UEBUNGSART_TEXT) as UebungsartAtemschutz[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{UEBUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="ue-dauer">Dauer (Minuten)</Label>
<Input
id="ue-dauer"
type="number"
inputMode="numeric"
value={dauer}
onChange={(ereignis) => setDauer(ereignis.target.value)}
placeholder="45"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="ue-ort">Ort</Label>
<Input
id="ue-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
placeholder="Atemschutzübungsstrecke Landshut"
/>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
Teilnehmer ({teilnehmer.length} gewählt)
</legend>
<div className="grid gap-1 sm:grid-cols-3">
{traeger.data?.map((person) => (
<label key={person.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={teilnehmer.includes(person.id)}
onChange={(ereignis) =>
setTeilnehmer((bisher) =>
ereignis.target.checked
? [...bisher, person.id]
: bisher.filter((eintrag) => eintrag !== person.id),
)
}
/>
{person.nachname}, {person.vorname}
{person.belastungsuebungAm && (
<span className="text-xs text-[var(--muted-foreground)]">
zuletzt {alsDatum(person.belastungsuebungAm)}
</span>
)}
</label>
))}
</div>
</fieldset>
<Button type="submit" disabled={teilnehmer.length === 0 || uebung.isPending}>
{uebung.isPending ? "wird gespeichert …" : `Für ${teilnehmer.length} erfassen`}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,43 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { Sicherheitshinweis } from "@/components/atemschutz/hinweis";
import { Nachweispflege } from "@/app/(app)/atemschutz/nachweise/nachweise";
export const metadata = { title: "Atemschutz-Nachweise" };
/**
* G26-Untersuchungen und Übungen pflegen.
*
* Der ruhige Teil des Moduls — und der, an dem sich später entscheidet, ob im
* Einsatz jemand fehlt.
*/
export default async function NachweiseSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "atemschutz.bearbeiten")) redirect("/atemschutz");
return (
<div className="space-y-6">
<Sicherheitshinweis />
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/atemschutz">
<ArrowLeft aria-hidden />
Atemschutz
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nachweise</h1>
<p className="text-sm text-[var(--muted-foreground)]">
G26.3-Untersuchungen, Belastungs- und Einsatzübungen.
</p>
</div>
<Nachweispflege />
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ClipboardList } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { Sicherheitshinweis } from "@/components/atemschutz/hinweis";
import { AtemschutzUebersicht } from "@/app/(app)/atemschutz/uebersicht";
export const metadata = { title: "Atemschutz" };
/**
* Atemschutz — die Übersicht.
*
* Die Seite beantwortet zwei Fragen: was läuft gerade, und wen hätten wir.
* Alles Weitere hängt an den Verweisen.
*/
export default async function AtemschutzSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "atemschutz.lesen")) redirect("/");
return (
<div className="space-y-6">
<Sicherheitshinweis />
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Atemschutz</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Überwachung nach FwDV 7, Tauglichkeit der Geräteträger, Nachweise.
</p>
</div>
{hatRecht(sitzung, "atemschutz.bearbeiten") && (
<Button variant="outline" asChild>
<Link href="/atemschutz/nachweise">
<ClipboardList aria-hidden />
Nachweise pflegen
</Link>
</Button>
)}
</div>
<AtemschutzUebersicht darfUeberwachen={hatRecht(sitzung, "atemschutz.ueberwachung")} />
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { CircleAlert, CircleCheck, CircleDot, Play, Radio } 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 { Badge } from "@/components/ui/badge";
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 { STUFE_TEXT, type Tauglichkeitsstufe } from "@/lib/atemschutz/tauglichkeit";
import { alsDatum, alsDatumZeit } from "@/lib/utils";
/**
* Die Übersicht.
*
* Zwei Fragen, in dieser Reihenfolge: **Läuft gerade eine Überwachung?** —
* wenn ja, gehört sie ganz nach oben, alles andere kann warten. Und: **Wen
* hätten wir überhaupt?** Die zweite Frage stellt sich am Dienstabend, die
* erste im Einsatz.
*/
const STUFE_SYMBOL: Record<Tauglichkeitsstufe, typeof CircleCheck> = {
tauglich: CircleCheck,
laeuft_ab: CircleDot,
nicht_tauglich: CircleAlert,
};
const STUFE_FARBE: Record<Tauglichkeitsstufe, string> = {
tauglich: "text-[var(--einsatzbereit)]",
laeuft_ab: "text-[var(--pruefung-faellig)]",
nicht_tauglich: "text-[var(--gesperrt)]",
};
export function AtemschutzUebersicht({ darfUeberwachen }: { darfUeberwachen: boolean }) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
// Der laufende Betrieb wird nachgeführt, die Stammdaten nicht: eine
// Überwachung kann sich jederzeit öffnen, eine G26 nicht.
const laufende = trpc.atemschutz.laufende.useQuery(undefined, { refetchInterval: 10_000 });
const liste = trpc.atemschutz.liste.useQuery({ nurLaufende: false, anzahl: 20 });
const traeger = trpc.atemschutzTraeger.uebersicht.useQuery();
const eroeffnen = trpc.atemschutz.eroeffnen.useMutation({
onSuccess: () => {
setBezeichnung("");
void werkzeuge.atemschutz.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const einsetzbar = traeger.data?.filter((person) => person.stufe !== "nicht_tauglich").length ?? 0;
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="atemschutzfehler" />}
{/* Was gerade läuft. */}
{(laufende.data?.length ?? 0) > 0 && (
<section className="space-y-3" data-testid="laufende-ueberwachungen">
<h2 className="text-lg font-semibold">Läuft gerade</h2>
<div className="grid gap-3 sm:grid-cols-2">
{laufende.data?.map((ueberwachung) => (
<Link
key={ueberwachung.id}
href={`/atemschutz/${ueberwachung.id}`}
className="rounded-xl border-2 border-[var(--gesperrt)] bg-[var(--gesperrt)]/10 p-4 transition-colors hover:bg-[var(--gesperrt)]/20"
>
<p className="flex items-center gap-2 text-sm font-medium text-[var(--gesperrt)]">
<Radio className="size-4" aria-hidden />
Überwachung läuft
</p>
<p className="text-xl font-semibold">{ueberwachung.bezeichnung}</p>
<p className="text-sm text-[var(--muted-foreground)]">
seit {alsDatumZeit(ueberwachung.beginnUm)} ·{" "}
{ueberwachung.ueberwacher.nachname} ·{" "}
{ueberwachung.truppsDrinnen === 0
? "kein Trupp unter Atemschutz"
: `${ueberwachung.truppsDrinnen} ${ueberwachung.truppsDrinnen === 1 ? "Trupp" : "Trupps"} unter Atemschutz`}
</p>
</Link>
))}
</div>
</section>
)}
{darfUeberwachen && (
<Card>
<CardHeader>
<CardTitle>Überwachung eröffnen</CardTitle>
<CardDescription>
Zuerst eröffnen, dann Trupps anlegen. Die Zuordnung zu einem Einsatz lässt sich
nachtragen — im Ernstfall wird zuerst überwacht.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (bezeichnung.trim()) eroeffnen.mutate({ bezeichnung: bezeichnung.trim() });
}}
>
<div className="flex-1 space-y-1">
<Label htmlFor="bezeichnung">Bezeichnung</Label>
<Input
id="bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Zimmerbrand Hauptstraße 12"
required
/>
</div>
<Button type="submit" disabled={eroeffnen.isPending}>
<Play aria-hidden />
{eroeffnen.isPending ? "wird eröffnet …" : "Eröffnen"}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Wer einsetzbar ist. */}
<Card>
<CardHeader>
<CardTitle>Atemschutzgeräteträger</CardTitle>
<CardDescription>
{einsetzbar} von {traeger.data?.length ?? 0} Mitgliedern sind nach FwDV 7 einsetzbar —
G26.3 gültig, Belastungsübung und Einsatzübung im laufenden Jahr.
</CardDescription>
</CardHeader>
<CardContent>
{(traeger.data?.length ?? 0) === 0 ? (
<NichtsGefunden titel="Keine Mitglieder" hinweis="Ohne Mannschaft kein Atemschutz." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Stand</TableHead>
<TableHead>G26.3 bis</TableHead>
<TableHead>Belastungsübung</TableHead>
<TableHead>Einsatzübung</TableHead>
<TableHead className="text-right">Einsätze {new Date().getFullYear()}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{traeger.data
?.filter((person) => person.stufe !== "nicht_tauglich" || person.g26GueltigBis)
.map((person) => {
const Symbol = STUFE_SYMBOL[person.stufe];
return (
<TableRow key={person.id} data-testid={`traeger-${person.id}`}>
<TableCell>
<Link href={`/mitglieder/${person.id}`} className="hover:underline">
{person.nachname}, {person.vorname}
</Link>
</TableCell>
<TableCell>
<span className={`flex items-center gap-2 text-sm ${STUFE_FARBE[person.stufe]}`}>
<Symbol className="size-4" aria-hidden />
{STUFE_TEXT[person.stufe]}
</span>
{person.gruende.length > 0 && (
<span className="text-xs text-[var(--muted-foreground)]">
{person.gruende.join(" · ")}
</span>
)}
</TableCell>
<TableCell className="tabellenzahlen text-sm">
{person.g26GueltigBis ? alsDatum(person.g26GueltigBis) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-sm">
{person.belastungsuebungAm ? alsDatum(person.belastungsuebungAm) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-sm">
{person.einsatzuebungAm ? alsDatum(person.einsatzuebungAm) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-right">
{person.einsaetzeImJahr}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Die Nachschau. */}
<Card>
<CardHeader>
<CardTitle>Frühere Überwachungen</CardTitle>
<CardDescription>Mit Protokoll, sobald die Überwachung beendet ist.</CardDescription>
</CardHeader>
<CardContent>
{(liste.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Überwachung"
hinweis="Die erste entsteht beim nächsten Einsatz unter Atemschutz — oder bei der nächsten Übung."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Bezeichnung</TableHead>
<TableHead>Überwacher</TableHead>
<TableHead>Beginn</TableHead>
<TableHead>Ende</TableHead>
<TableHead className="text-right">Trupps</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{liste.data?.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell>
<Link href={`/atemschutz/${eintrag.id}`} className="font-medium hover:underline">
{eintrag.bezeichnung}
</Link>
{!eintrag.endeUm && (
<Badge variant="gesperrt" className="ml-2">
läuft
</Badge>
)}
</TableCell>
<TableCell className="text-sm">{eintrag.ueberwacher.nachname}</TableCell>
<TableCell className="tabellenzahlen whitespace-nowrap text-sm">
{alsDatumZeit(eintrag.beginnUm)}
</TableCell>
<TableCell className="tabellenzahlen whitespace-nowrap text-sm">
{eintrag.endeUm ? alsDatumZeit(eintrag.endeUm) : "—"}
</TableCell>
<TableCell className="tabellenzahlen text-right">
{eintrag._count.trupps}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+35 -10
View File
@@ -1,9 +1,10 @@
import { AlertTriangle, Truck, Users, Wrench } from "lucide-react";
import { AlertTriangle, ShieldCheck, Truck, Users, Wrench } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { verfuegbarkeit } from "@/lib/atemschutz/verfuegbarkeit";
import { alsDatumZeit } from "@/lib/utils";
export const metadata = { title: "Übersicht" };
@@ -14,7 +15,7 @@ export default async function UebersichtSeite() {
if (!sitzung) return null;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt] =
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt, atemschutz] =
await Promise.all([
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
@@ -22,8 +23,17 @@ export default async function UebersichtSeite() {
db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
db.geraet.count({ where: { status: "GESPERRT", geloeschtAm: null } }),
verfuegbarkeit(db),
]);
return { organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt };
return {
organisation,
mitglieder,
fahrzeuge,
geraete,
pruefungFaellig,
gesperrt,
atemschutz,
};
});
const kacheln = [
@@ -31,6 +41,18 @@ export default async function UebersichtSeite() {
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
{ titel: "Prüfung fällig", wert: daten.pruefungFaellig, symbol: AlertTriangle },
{
// Die Zahl, nach der im Einsatz als Erstes gefragt wird: wie viele
// Atemschutzgeräteträger hat die Wehr heute wirklich? Der Zusatz nennt,
// wie viele davon knapp davor sind, es nicht mehr zu sein.
titel: "Atemschutz einsetzbar",
wert: daten.atemschutz.einsetzbar,
symbol: ShieldCheck,
zusatz:
daten.atemschutz.laeuftAb > 0
? `${daten.atemschutz.laeuftAb} davon mit ablaufender G26.3`
: null,
},
];
return (
@@ -80,6 +102,9 @@ export default async function UebersichtSeite() {
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold tabellenzahlen">{kachel.wert}</p>
{"zusatz" in kachel && kachel.zusatz && (
<p className="text-xs text-[var(--pruefung-faellig)]">{kachel.zusatz}</p>
)}
</CardContent>
</Card>
);
@@ -90,19 +115,19 @@ export default async function UebersichtSeite() {
<CardHeader>
<CardTitle>Stand des Aufbaus</CardTitle>
<CardDescription>
Phase 1 ist abgeschlossen: Standorte, Fahrzeuge mit Geräteräumen,
Gerätetypen, Geräte mit QR-Etiketten, Qualifikationen und der Import aus
vorhandenen Tabellen stehen. Als Nächstes kommt das Prüfmanagement.
Stammdaten, Prüfmanagement, Einsätze und Übungen, das Terminal im Gerätehaus und
die Atemschutzüberwachung stehen. Als Nächstes kommen die Kartenmodule.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
<Badge variant="einsatzbereit">Phase 1 — Stammdaten</Badge>
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
<Badge variant="outline">Phase 3 — Einsätze</Badge>
<Badge variant="outline">Phase 4 — Terminal</Badge>
<Badge variant="outline">Phase 5 — Atemschutz</Badge>
<Badge variant="einsatzbereit">Phase 2 — Wartung und Prüfung</Badge>
<Badge variant="einsatzbereit">Phase 3 — Einsätze</Badge>
<Badge variant="einsatzbereit">Phase 4 — Terminal</Badge>
<Badge variant="einsatzbereit">Phase 5 — Atemschutz</Badge>
<Badge variant="outline">Phase 6 — Karten</Badge>
</div>
<dl className="grid gap-2 sm:grid-cols-2">
<div>