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:
co-authored by
Claude Opus 5
parent
b2b31ed00a
commit
4e2daaafd4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { herunterladen } from "@/lib/storage";
|
||||
import { AuditAktion, protokolliere } from "@/lib/audit";
|
||||
import { atemschutzprotokollErzeugen } from "@/lib/atemschutz/protokoll-erzeugen";
|
||||
|
||||
/**
|
||||
* Das Atemschutzprotokoll herunterladen.
|
||||
*
|
||||
* Liegt es schon, kommt es aus MinIO. Liegt es noch nicht — etwa weil der
|
||||
* Hintergrundprozess gerade nicht lief, als die Überwachung beendet wurde —,
|
||||
* wird es hier erzeugt. Ein Nachweis, den man nicht bekommt, weil ein Dienst
|
||||
* einen schlechten Tag hatte, wäre kein Nachweis.
|
||||
*
|
||||
* Jeder Abruf steht im Audit-Log: wer ein Atemschutzprotokoll ansieht, ist
|
||||
* eine Information, die im Ernstfall zählt.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(anfrage: Request) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "atemschutz.lesen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = new URL(anfrage.url).searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ fehler: "Keine Überwachung angegeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const vorhanden = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.atemschutzeinsatz.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
endeUm: true,
|
||||
protokollDatei: { select: { objektSchluessel: true, dateiname: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!vorhanden) {
|
||||
return NextResponse.json({ fehler: "Diese Überwachung gibt es nicht." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!vorhanden.endeUm) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler:
|
||||
"Die Überwachung läuft noch. Das Protokoll entsteht, wenn sie beendet ist — vorher wäre es unvollständig und sähe vollständig aus.",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
let schluessel = vorhanden.protokollDatei?.objektSchluessel;
|
||||
let dateiname = vorhanden.protokollDatei?.dateiname;
|
||||
|
||||
if (!schluessel) {
|
||||
const erzeugt = await atemschutzprotokollErzeugen(sitzung.organisationId, id);
|
||||
if (!erzeugt) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Das Protokoll ließ sich nicht erzeugen." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const datei = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.datei.findUnique({
|
||||
where: { id: erzeugt.dateiId },
|
||||
select: { objektSchluessel: true, dateiname: true },
|
||||
}),
|
||||
);
|
||||
|
||||
schluessel = datei?.objektSchluessel;
|
||||
dateiname = datei?.dateiname;
|
||||
}
|
||||
|
||||
if (!schluessel) {
|
||||
return NextResponse.json({ fehler: "Das Protokoll fehlt im Speicher." }, { status: 500 });
|
||||
}
|
||||
|
||||
const inhalt = await herunterladen(schluessel);
|
||||
|
||||
await protokolliere({
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_ABGERUFEN,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: id,
|
||||
sitzung,
|
||||
beschreibung: "Atemschutzprotokoll abgerufen",
|
||||
});
|
||||
|
||||
return new NextResponse(new Uint8Array(inhalt), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${dateiname ?? "Atemschutzprotokoll.pdf"}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { TruppStatus } from "@prisma/client";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Der Ereignisstrom der Atemschutzüberwachung.
|
||||
*
|
||||
* Server-Sent Events statt WebSocket: es geht nur in eine Richtung. Der
|
||||
* Browser meldet nichts über diesen Kanal — Druckwerte, Statuswechsel und
|
||||
* Mayday laufen wie alles andere über tRPC. Was hier zurückkommt, ist nur die
|
||||
* Nachricht „es hat sich etwas geändert, hol den Stand neu".
|
||||
*
|
||||
* Drei Gründe für diese Bauart:
|
||||
*
|
||||
* 1. **Sie überlebt Abbrüche.** `EventSource` verbindet sich von selbst neu,
|
||||
* ohne dass eine Zeile Code dafür nötig wäre. Ein Repeater, der kurz
|
||||
* ausfällt, unterbricht die Anzeige für Sekunden — danach läuft sie
|
||||
* weiter, weil die Zeit ohnehin aus Zeitstempeln gerechnet wird und nicht
|
||||
* aus einem Zähler, der hätte weiterlaufen müssen.
|
||||
* 2. **Sie braucht keinen eigenen Server.** Ein Route Handler genügt; das
|
||||
* Deployment bleibt `next start`.
|
||||
* 3. **Sie überträgt nichts Wichtiges.** Fiele der Strom ganz aus, bliebe
|
||||
* die Ansicht bedienbar: sie fragt den Stand zusätzlich im festen Takt
|
||||
* ab. Der Strom macht sie schneller, nicht erst funktionsfähig.
|
||||
*
|
||||
* Erkannt wird eine Änderung an einem Zählwert aus der Datenbank — Trupps,
|
||||
* Messungen, Ereignisse, Notfälle. Das ist eine Abfrage alle zwei Sekunden je
|
||||
* offener Ansicht, und in einem Gerätehaus stehen davon zwei bis drei.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Wie oft nachgesehen wird, ob sich etwas geändert hat. */
|
||||
const TAKT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Nach dieser Zeit endet der Strom von selbst.
|
||||
*
|
||||
* Nicht als Sparmaßnahme, sondern damit kein Handler unbemerkt für immer
|
||||
* weiterläuft, wenn ein Tablet in der Ecke vergessen wird. Der Browser
|
||||
* verbindet sich danach neu — davon merkt niemand etwas.
|
||||
*/
|
||||
const HOECHSTDAUER_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Ein kurzer Fingerabdruck des Zustands. Ändert er sich, holt der Browser den Stand. */
|
||||
async function fingerabdruck(organisationId: string, id: string): Promise<string> {
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const [trupps, messungen, ereignisse, ueberwachung] = await Promise.all([
|
||||
db.atemschutztrupp.findMany({
|
||||
where: { atemschutzeinsatzId: id },
|
||||
select: { id: true, status: true, maydayUm: true, maydayAufgehobenUm: true },
|
||||
orderBy: { angelegtUm: "asc" },
|
||||
}),
|
||||
db.druckmessung.count({ where: { trupp: { atemschutzeinsatzId: id } } }),
|
||||
db.atemschutzEreignis.count({ where: { atemschutzeinsatzId: id } }),
|
||||
db.atemschutzeinsatz.findUnique({ where: { id }, select: { endeUm: true } }),
|
||||
]);
|
||||
|
||||
const truppTeil = trupps
|
||||
.map(
|
||||
(trupp) =>
|
||||
`${trupp.id}:${trupp.status}:${trupp.maydayUm?.getTime() ?? 0}:${trupp.maydayAufgehobenUm?.getTime() ?? 0}`,
|
||||
)
|
||||
.join("|");
|
||||
|
||||
return `${truppTeil}#${messungen}#${ereignisse}#${ueberwachung?.endeUm?.getTime() ?? 0}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Läuft für einen Trupp gerade ein Notfall? Das geht sofort raus, nicht erst beim Takt. */
|
||||
async function notfallLaeuft(organisationId: string, id: string): Promise<boolean> {
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const offen = await db.atemschutztrupp.count({
|
||||
where: {
|
||||
atemschutzeinsatzId: id,
|
||||
maydayUm: { not: null },
|
||||
maydayAufgehobenUm: null,
|
||||
status: { not: TruppStatus.DRAUSSEN },
|
||||
},
|
||||
});
|
||||
return offen > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(anfrage: Request): Promise<Response> {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "atemschutz.lesen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = new URL(anfrage.url).searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ fehler: "Keine Überwachung angegeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const organisationId = sitzung.organisationId;
|
||||
const kodierer = new TextEncoder();
|
||||
|
||||
const strom = new ReadableStream<Uint8Array>({
|
||||
async start(steuerung) {
|
||||
let offen = true;
|
||||
let letzter = "";
|
||||
|
||||
const senden = (art: string, daten: unknown) => {
|
||||
if (!offen) return;
|
||||
try {
|
||||
steuerung.enqueue(kodierer.encode(`event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`));
|
||||
} catch {
|
||||
offen = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Der Browser soll nach einem Abbruch zügig wiederkommen, aber nicht im
|
||||
// Sekundentakt anklopfen, wenn der Server gerade nicht da ist.
|
||||
steuerung.enqueue(kodierer.encode("retry: 3000\n\n"));
|
||||
|
||||
const beenden = () => {
|
||||
offen = false;
|
||||
clearInterval(takt);
|
||||
clearTimeout(schluss);
|
||||
try {
|
||||
steuerung.close();
|
||||
} catch {
|
||||
// Schon geschlossen — das ist der Normalfall beim Wegklicken.
|
||||
}
|
||||
};
|
||||
|
||||
anfrage.signal.addEventListener("abort", beenden);
|
||||
|
||||
const nachsehen = async () => {
|
||||
if (!offen) return;
|
||||
|
||||
try {
|
||||
const jetzt = await fingerabdruck(organisationId, id);
|
||||
|
||||
if (jetzt !== letzter) {
|
||||
letzter = jetzt;
|
||||
senden("aenderung", {
|
||||
zeitpunkt: new Date().toISOString(),
|
||||
notfall: await notfallLaeuft(organisationId, id),
|
||||
});
|
||||
} else {
|
||||
// Ein Lebenszeichen, damit Zwischenstationen die Verbindung nicht
|
||||
// für tot halten und schließen.
|
||||
senden("takt", { zeitpunkt: new Date().toISOString() });
|
||||
}
|
||||
} catch (fehler) {
|
||||
senden("fehler", {
|
||||
meldung: fehler instanceof Error ? fehler.message : "unbekannter Fehler",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const takt = setInterval(() => void nachsehen(), TAKT_MS);
|
||||
const schluss = setTimeout(beenden, HOECHSTDAUER_MS);
|
||||
|
||||
// Der erste Stand geht sofort raus, nicht erst nach zwei Sekunden.
|
||||
await nachsehen();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(strom, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
// Verhindert, dass ein vorgeschalteter Proxy den Strom puffert.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Der Hinweis, der über jeder Ansicht dieses Moduls steht.
|
||||
*
|
||||
* Er ist keine Verzierung und keine Voreinstellung, die man wegklickt: FLORIAN
|
||||
* ist ein ergänzendes System. Die Überwachung, die im Ernstfall zählt, hängt
|
||||
* am Fahrzeug und wird mit Stift geführt. Solange das so ist, muss es dort
|
||||
* stehen, wo jemand hinsieht — und zwar dauerhaft, nicht als Meldung, die
|
||||
* nach drei Sekunden verschwindet.
|
||||
*
|
||||
* Abschalten kann ihn nur, wer beides tut: `ATEMSCHUTZ_HINWEIS=false` setzen
|
||||
* **und** unter `ATEMSCHUTZ_FREIGABE` eintragen, worauf sich die Freigabe
|
||||
* stützt. Fehlt die Fundstelle, bleibt der Hinweis stehen. Das ist Absicht:
|
||||
* eine Freigabe ohne Beschluss ist keine.
|
||||
*
|
||||
* Ist er abgeschaltet, verschwindet er nicht spurlos — dann steht dort, seit
|
||||
* wann und worauf gestützt die Wehr digital überwacht. Auch das gehört auf den
|
||||
* Bildschirm.
|
||||
*/
|
||||
export function Sicherheitshinweis() {
|
||||
const freigabe = env.ATEMSCHUTZ_FREIGABE?.trim();
|
||||
const abgeschaltet = !env.ATEMSCHUTZ_HINWEIS && Boolean(freigabe);
|
||||
|
||||
if (abgeschaltet) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--muted-foreground)]" role="note">
|
||||
Digitale Atemschutzüberwachung freigegeben — {freigabe}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
data-testid="atemschutz-hinweis"
|
||||
className="flex items-start gap-3 rounded-lg border-2 border-[var(--gesperrt)]/50 bg-[var(--gesperrt)]/10 p-4"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-[var(--gesperrt)]" aria-hidden />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">
|
||||
Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.
|
||||
</p>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Was hier steht, ersetzt weder die Überwachungstafel am Fahrzeug noch den
|
||||
Atemschutzüberwacher. Fällt dieses Gerät aus, läuft der Einsatz unverändert weiter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Die laufende Uhr der Überwachung.
|
||||
*
|
||||
* Ein Hook, der einmal je Sekunde einen neuen Zeitpunkt liefert — mehr nicht.
|
||||
* **Er zählt nichts herunter und merkt sich nichts.** Alle Dauern entstehen in
|
||||
* den Ansichten aus zwei Zeitstempeln: dem vom Server und diesem hier.
|
||||
*
|
||||
* Genau daran hängt die wichtigste Anforderung dieser Phase: ein Neuladen des
|
||||
* Browsers und ein Verbindungsabbruch dürfen die Anzeige nicht verfälschen.
|
||||
* Ein heruntergezählter Wert wäre nach einem Neuladen verloren; ein
|
||||
* Zeitstempel aus der Datenbank ist es nicht.
|
||||
*/
|
||||
export function useJetzt(taktMs = 1000): Date {
|
||||
const [jetzt, setJetzt] = React.useState<Date>(() => new Date());
|
||||
|
||||
React.useEffect(() => {
|
||||
const zeitgeber = setInterval(() => setJetzt(new Date()), taktMs);
|
||||
return () => clearInterval(zeitgeber);
|
||||
}, [taktMs]);
|
||||
|
||||
return jetzt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Ereignisstrom der Überwachung.
|
||||
*
|
||||
* Verbindet sich mit /api/atemschutz/strom und ruft `beiAenderung` auf, sobald
|
||||
* sich am Serverstand etwas getan hat. Bricht die Verbindung ab, verbindet
|
||||
* `EventSource` von selbst neu — deshalb steht hier keine Zeile
|
||||
* Wiederverbindungslogik.
|
||||
*
|
||||
* Zusätzlich meldet der Hook, ob die Verbindung gerade steht. Das ist kein
|
||||
* Beiwerk: wer am Bildschirm nicht sieht, dass die Verbindung weg ist, hält
|
||||
* einen alten Stand für den aktuellen. Steht sie nicht, fragt die Ansicht in
|
||||
* einem festen Takt nach — der Strom macht sie schneller, nicht erst
|
||||
* funktionsfähig.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nach dieser Stille gilt die Verbindung als tot.
|
||||
*
|
||||
* Der Server schickt alle zwei Sekunden ein Lebenszeichen. Bleibt es aus, ist
|
||||
* der Draht weg — auch wenn der Browser das nicht meldet. Genau das ist der
|
||||
* Fall, den ein E2E-Test aufgedeckt hat: wird das Netz gekappt, während der
|
||||
* Strom offen ist, schweigt `EventSource` und meldet **keinen** Fehler. Ohne
|
||||
* diese Frist sähe eine tote Verbindung aus wie eine gesunde, und das ist der
|
||||
* gefährlichste Zustand, den diese Ansicht haben kann.
|
||||
*/
|
||||
const STILLE_MS = 8000;
|
||||
|
||||
export function useEreignisstrom(
|
||||
ueberwachungId: string | null,
|
||||
beiAenderung: () => void,
|
||||
): { verbunden: boolean; letzteMeldung: Date | null } {
|
||||
const [verbunden, setVerbunden] = React.useState(false);
|
||||
const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null);
|
||||
|
||||
// Als Referenz, damit die Wache unten nicht bei jeder Meldung neu aufgezogen
|
||||
// wird — dieselbe Falle wie in der Warteschlange des Terminals.
|
||||
const letzteRef = React.useRef<number>(0);
|
||||
|
||||
// Über eine Referenz, damit ein neuer Rückruf nicht die Verbindung neu
|
||||
// aufbaut — sonst risse der Strom bei jedem Rendern ab.
|
||||
const rueckruf = React.useRef(beiAenderung);
|
||||
rueckruf.current = beiAenderung;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ueberwachungId || typeof window === "undefined") return;
|
||||
|
||||
const quelle = new EventSource(
|
||||
`/api/atemschutz/strom?id=${encodeURIComponent(ueberwachungId)}`,
|
||||
);
|
||||
|
||||
const gehoert = () => {
|
||||
letzteRef.current = Date.now();
|
||||
setVerbunden(true);
|
||||
setLetzteMeldung(new Date());
|
||||
};
|
||||
|
||||
quelle.onopen = gehoert;
|
||||
|
||||
quelle.addEventListener("aenderung", () => {
|
||||
gehoert();
|
||||
rueckruf.current();
|
||||
});
|
||||
|
||||
quelle.addEventListener("takt", gehoert);
|
||||
|
||||
quelle.onerror = () => {
|
||||
// EventSource versucht es von selbst wieder. Hier wird nur vermerkt,
|
||||
// dass der Stand gerade nicht nachgeführt wird.
|
||||
setVerbunden(false);
|
||||
};
|
||||
|
||||
// Die Wache: bleibt das Lebenszeichen aus, gilt die Verbindung als tot —
|
||||
// unabhaengig davon, was EventSource meldet.
|
||||
const wache = setInterval(() => {
|
||||
if (letzteRef.current > 0 && Date.now() - letzteRef.current > STILLE_MS) {
|
||||
setVerbunden(false);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(wache);
|
||||
quelle.close();
|
||||
};
|
||||
}, [ueberwachungId]);
|
||||
|
||||
return { verbunden, letzteMeldung };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckCircle2, CircleAlert } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { STUFE_TEXT } from "@/lib/atemschutz/tauglichkeit";
|
||||
|
||||
/**
|
||||
* Einen Trupp anlegen.
|
||||
*
|
||||
* Zwei oder drei Personen, für jede der Eingangsdruck, eine davon ist
|
||||
* Truppführer. Mehr braucht es nicht — und weniger geht nicht: ohne
|
||||
* Eingangsdruck lässt sich kein Rückzugsdruck rechnen, und ein Trupp ohne
|
||||
* benannten Führer steht später nicht im Protokoll.
|
||||
*
|
||||
* Die Liste zeigt neben jedem Namen, ob die Person überhaupt einsetzbar ist.
|
||||
* **Sie sperrt nicht.** Im Einsatz entscheidet der Einheitsführer, und ein
|
||||
* Programm, das ihm im entscheidenden Moment eine Person verweigert, wird
|
||||
* umgangen — dann steht am Ende gar nichts im Protokoll. Was hier steht, ist
|
||||
* der Hinweis, den er kennen sollte.
|
||||
*/
|
||||
export function TruppAnlegen({
|
||||
atemschutzeinsatzId,
|
||||
onFertig,
|
||||
onFehler,
|
||||
}: {
|
||||
atemschutzeinsatzId: string;
|
||||
onFertig: () => void;
|
||||
onFehler: (meldung: string) => void;
|
||||
}) {
|
||||
const [funkrufname, setFunkrufname] = React.useState("Angriffstrupp");
|
||||
const [auftrag, setAuftrag] = React.useState("");
|
||||
const [gewaehlt, setGewaehlt] = React.useState<string[]>([]);
|
||||
const [fuehrer, setFuehrer] = React.useState<string | null>(null);
|
||||
const [druecke, setDruecke] = React.useState<Record<string, string>>({});
|
||||
|
||||
const traeger = trpc.atemschutzTraeger.uebersicht.useQuery();
|
||||
const anlegen = trpc.atemschutz.truppAnlegen.useMutation({
|
||||
onSuccess: onFertig,
|
||||
onError: (fehler) => onFehler(fehler.message),
|
||||
});
|
||||
|
||||
function umschalten(personId: string) {
|
||||
setGewaehlt((bisher) => {
|
||||
if (bisher.includes(personId)) {
|
||||
if (fuehrer === personId) setFuehrer(null);
|
||||
return bisher.filter((eintrag) => eintrag !== personId);
|
||||
}
|
||||
if (bisher.length >= 3) return bisher;
|
||||
// Der Erste ist der Vorschlag für den Truppführer — änderbar.
|
||||
if (bisher.length === 0) setFuehrer(personId);
|
||||
return [...bisher, personId];
|
||||
});
|
||||
}
|
||||
|
||||
const vollstaendig =
|
||||
gewaehlt.length >= 2 &&
|
||||
fuehrer !== null &&
|
||||
gewaehlt.every((personId) => Number(druecke[personId]) > 0);
|
||||
|
||||
return (
|
||||
<Card data-testid="trupp-anlegen">
|
||||
<CardHeader>
|
||||
<CardTitle>Trupp anlegen</CardTitle>
|
||||
<CardDescription>
|
||||
Zwei oder drei Personen, je Person der Eingangsdruck vom Manometer. Der Rückzugsdruck
|
||||
errechnet sich daraus.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="funkrufname">Funkrufname</Label>
|
||||
<Input
|
||||
id="funkrufname"
|
||||
value={funkrufname}
|
||||
onChange={(ereignis) => setFunkrufname(ereignis.target.value)}
|
||||
placeholder="Angriffstrupp"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="auftrag">Auftrag</Label>
|
||||
<Input
|
||||
id="auftrag"
|
||||
value={auftrag}
|
||||
onChange={(ereignis) => setAuftrag(ereignis.target.value)}
|
||||
placeholder="Menschenrettung 1. OG links"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Wer geht?</p>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{traeger.data?.map((person) => {
|
||||
const ausgewaehlt = gewaehlt.includes(person.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={person.id}
|
||||
className={`rounded-lg border p-3 ${
|
||||
ausgewaehlt ? "border-[var(--primary)] bg-[var(--primary)]/5" : "border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left"
|
||||
onClick={() => umschalten(person.id)}
|
||||
aria-pressed={ausgewaehlt}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{person.nachname}, {person.vorname}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-0.5 flex items-center gap-1 text-xs ${
|
||||
person.stufe === "nicht_tauglich"
|
||||
? "text-[var(--gesperrt)]"
|
||||
: person.stufe === "laeuft_ab"
|
||||
? "text-[var(--pruefung-faellig)]"
|
||||
: "text-[var(--einsatzbereit)]"
|
||||
}`}
|
||||
>
|
||||
{person.stufe === "tauglich" ? (
|
||||
<CheckCircle2 className="size-3" aria-hidden />
|
||||
) : (
|
||||
<CircleAlert className="size-3" aria-hidden />
|
||||
)}
|
||||
{STUFE_TEXT[person.stufe]}
|
||||
{person.gruende.length > 0 ? ` — ${person.gruende[0]}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{ausgewaehlt && (
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor={`eingang-${person.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
Eingangsdruck
|
||||
</Label>
|
||||
<Input
|
||||
id={`eingang-${person.id}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
className="h-10 w-24"
|
||||
placeholder="300"
|
||||
value={druecke[person.id] ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
setDruecke((bisher) => ({
|
||||
...bisher,
|
||||
[person.id]: ereignis.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ausgewaehlt && (
|
||||
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="truppfuehrer"
|
||||
checked={fuehrer === person.id}
|
||||
onChange={() => setFuehrer(person.id)}
|
||||
/>
|
||||
Truppführer
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!vollstaendig || anlegen.isPending}
|
||||
onClick={() =>
|
||||
anlegen.mutate({
|
||||
atemschutzeinsatzId,
|
||||
funkrufname: funkrufname.trim() || "Trupp",
|
||||
auftrag: auftrag.trim() || null,
|
||||
mitglieder: gewaehlt.map((personId) => ({
|
||||
personId,
|
||||
truppfuehrer: personId === fuehrer,
|
||||
eingangsdruck: Number(druecke[personId]),
|
||||
})),
|
||||
})
|
||||
}
|
||||
>
|
||||
{anlegen.isPending ? "wird angelegt …" : "Trupp anlegen"}
|
||||
</Button>
|
||||
|
||||
{!vollstaendig && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Es fehlen noch: mindestens zwei Personen, ein Truppführer und je ein
|
||||
Eingangsdruck.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AlertTriangle, Bell, Gauge, LogIn, LogOut, Undo2 } from "lucide-react";
|
||||
import { DruckArt, TruppStatus } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
AMPEL_TEXT,
|
||||
STATUS_TEXT,
|
||||
alsUhr,
|
||||
ampel,
|
||||
einsatzdauer,
|
||||
abfrageUeberfaellig,
|
||||
niedrigsterDruck,
|
||||
type Ampel,
|
||||
} from "@/lib/atemschutz/berechnung";
|
||||
|
||||
/**
|
||||
* Ein Trupp auf dem Überwachungsschirm.
|
||||
*
|
||||
* Die Karte ist so gebaut, dass sie aus zwei Metern lesbar ist: die Uhr groß,
|
||||
* der niedrigste Druck groß, alles andere klein. Im Einsatz steht jemand
|
||||
* daneben und hat eine Hand am Funkgerät.
|
||||
*
|
||||
* **Die Zeit läuft hier, die Wahrheit kommt vom Server.** Die Karte bekommt
|
||||
* Zeitstempel und rechnet daraus die Anzeige — bei jedem Rendern neu. Nach
|
||||
* einem Neuladen sieht sie deshalb genauso aus wie vorher.
|
||||
*/
|
||||
|
||||
type Messung = { id: string; personId: string; druck: number; art: DruckArt; zeitpunkt: Date };
|
||||
|
||||
export interface TruppAnsicht {
|
||||
id: string;
|
||||
funkrufname: string;
|
||||
auftrag: string | null;
|
||||
status: TruppStatus;
|
||||
angelegtUm: Date;
|
||||
einstiegUm: Date | null;
|
||||
rueckzugUm: Date | null;
|
||||
drausenUm: Date | null;
|
||||
rueckzugsdruck: number | null;
|
||||
maydayUm: Date | null;
|
||||
maydayAufgehobenUm: Date | null;
|
||||
mitglieder: Array<{
|
||||
id: string;
|
||||
truppfuehrer: boolean;
|
||||
person: { id: string; vorname: string; nachname: string };
|
||||
}>;
|
||||
druckmessungen: Messung[];
|
||||
}
|
||||
|
||||
const AMPEL_FARBE: Record<Ampel, string> = {
|
||||
gruen: "border-[var(--einsatzbereit)] bg-[var(--einsatzbereit)]/10",
|
||||
gelb: "border-[var(--pruefung-faellig)] bg-[var(--pruefung-faellig)]/10",
|
||||
rot: "border-[var(--gesperrt)] bg-[var(--gesperrt)]/10",
|
||||
};
|
||||
|
||||
const AMPEL_SCHRIFT: Record<Ampel, string> = {
|
||||
gruen: "text-[var(--einsatzbereit)]",
|
||||
gelb: "text-[var(--pruefung-faellig)]",
|
||||
rot: "text-[var(--gesperrt)]",
|
||||
};
|
||||
|
||||
export function TruppKarte({
|
||||
trupp,
|
||||
jetzt,
|
||||
intervallSekunden,
|
||||
darfBedienen,
|
||||
onDruck,
|
||||
onStatus,
|
||||
onMayday,
|
||||
onRueckzug,
|
||||
}: {
|
||||
trupp: TruppAnsicht;
|
||||
jetzt: Date;
|
||||
intervallSekunden: number;
|
||||
darfBedienen: boolean;
|
||||
onDruck: (messungen: Array<{ personId: string; druck: number }>, art: DruckArt) => void;
|
||||
onStatus: (
|
||||
status: TruppStatus,
|
||||
ausgangsdruecke?: Array<{ personId: string; druck: number }>,
|
||||
) => void;
|
||||
onMayday: (aufheben: boolean) => void;
|
||||
onRueckzug: () => void;
|
||||
}) {
|
||||
const [eingaben, setEingaben] = React.useState<Record<string, string>>({});
|
||||
|
||||
const laufende = trupp.druckmessungen.filter((messung) => messung.art !== DruckArt.EINGANG);
|
||||
const letzteMessung = laufende.at(-1) ?? null;
|
||||
const niedrigste = niedrigsterDruck(trupp.druckmessungen);
|
||||
|
||||
const dauer = einsatzdauer(trupp, jetzt);
|
||||
const stufe = ampel({
|
||||
aktuellerDruck: niedrigste?.druck ?? null,
|
||||
rueckzugsdruck: trupp.rueckzugsdruck,
|
||||
status: trupp.status,
|
||||
});
|
||||
|
||||
const ueberfaellig = abfrageUeberfaellig({
|
||||
einstiegUm: trupp.einstiegUm,
|
||||
letzteMessungUm: letzteMessung?.zeitpunkt ?? null,
|
||||
intervallSekunden,
|
||||
status: trupp.status,
|
||||
jetzt,
|
||||
});
|
||||
|
||||
const notfall = Boolean(trupp.maydayUm) && !trupp.maydayAufgehobenUm;
|
||||
const draussen = trupp.status === TruppStatus.DRAUSSEN;
|
||||
const zielGemeldet = trupp.druckmessungen.some((messung) => messung.art === DruckArt.ZIEL);
|
||||
|
||||
/** Sammelt die eingetippten Werte ein und leert die Felder. */
|
||||
function werteEinsammeln(): Array<{ personId: string; druck: number }> {
|
||||
const werte = trupp.mitglieder
|
||||
.map((mitglied) => ({
|
||||
personId: mitglied.person.id,
|
||||
druck: Number(eingaben[mitglied.person.id]),
|
||||
}))
|
||||
.filter((wert) => Number.isFinite(wert.druck) && wert.druck >= 0);
|
||||
|
||||
if (werte.length > 0) setEingaben({});
|
||||
return werte;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`rounded-xl border-2 p-5 ${notfall ? "border-[var(--gesperrt)] bg-[var(--gesperrt)]/20 ring-4 ring-[var(--gesperrt)]/30" : AMPEL_FARBE[stufe]}`}
|
||||
data-testid={`trupp-${trupp.id}`}
|
||||
data-ampel={stufe}
|
||||
data-status={trupp.status}
|
||||
data-mayday={notfall}
|
||||
>
|
||||
{notfall && (
|
||||
<p
|
||||
className="mb-3 flex items-center gap-2 text-2xl font-bold text-[var(--gesperrt)]"
|
||||
role="alert"
|
||||
data-testid="mayday-anzeige"
|
||||
>
|
||||
<AlertTriangle className="size-7" aria-hidden />
|
||||
MAYDAY seit {alsUhr(Math.floor((jetzt.getTime() - trupp.maydayUm!.getTime()) / 1000))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">{trupp.funkrufname}</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{trupp.mitglieder
|
||||
.map(
|
||||
(mitglied) =>
|
||||
`${mitglied.person.nachname}${mitglied.truppfuehrer ? " (TF)" : ""}`,
|
||||
)
|
||||
.join(", ")}
|
||||
{trupp.auftrag ? ` · ${trupp.auftrag}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className={`text-sm font-medium ${AMPEL_SCHRIFT[stufe]}`}>
|
||||
{STATUS_TEXT[trupp.status]} · {AMPEL_TEXT[stufe]}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Die beiden Zahlen, auf die es ankommt. */}
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
unter Atemschutz
|
||||
</p>
|
||||
<p className="tabellenzahlen text-4xl font-semibold" data-testid="trupp-dauer">
|
||||
{alsUhr(dauer)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
niedrigster Druck
|
||||
</p>
|
||||
<p className={`tabellenzahlen text-4xl font-semibold ${AMPEL_SCHRIFT[stufe]}`}>
|
||||
{niedrigste ? `${niedrigste.druck}` : "—"}
|
||||
<span className="ml-1 text-lg font-normal">bar</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Rückzug bei
|
||||
</p>
|
||||
<p className="tabellenzahlen text-4xl font-semibold">
|
||||
{trupp.rueckzugsdruck ?? "—"}
|
||||
<span className="ml-1 text-lg font-normal">bar</span>
|
||||
</p>
|
||||
{/* Nur solange es etwas zu erwarten gibt: bei einem Trupp, der
|
||||
draußen ist, wäre „vorläufig" bloß noch verwirrend. */}
|
||||
{!zielGemeldet && !draussen && trupp.rueckzugsdruck !== null && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
vorläufig — Zwei-Drittel-Regel, bis das Einsatzziel gemeldet ist
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ueberfaellig && (
|
||||
<p
|
||||
className="mt-3 flex items-center gap-2 rounded-md bg-[var(--pruefung-faellig)]/20 px-3 py-2 text-sm font-medium text-[var(--pruefung-faellig)]"
|
||||
role="status"
|
||||
data-testid="abfrage-faellig"
|
||||
>
|
||||
<Bell className="size-4" aria-hidden />
|
||||
Druckabfrage überfällig — letzte Meldung vor{" "}
|
||||
{letzteMessung
|
||||
? alsUhr(Math.floor((jetzt.getTime() - letzteMessung.zeitpunkt.getTime()) / 1000))
|
||||
: alsUhr(dauer)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{darfBedienen && !draussen && (
|
||||
<div className="mt-4 space-y-3 border-t border-[var(--border)] pt-4">
|
||||
{/* Druckeingabe je Person: eine Zeile, drei Felder, ein Knopf. */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{trupp.mitglieder.map((mitglied) => (
|
||||
<div key={mitglied.id} className="space-y-1">
|
||||
<Label htmlFor={`druck-${trupp.id}-${mitglied.person.id}`} className="text-xs">
|
||||
{mitglied.person.nachname}
|
||||
</Label>
|
||||
<Input
|
||||
id={`druck-${trupp.id}-${mitglied.person.id}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
className="h-12 w-24 text-xl"
|
||||
placeholder="bar"
|
||||
value={eingaben[mitglied.person.id] ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
setEingaben((bisher) => ({
|
||||
...bisher,
|
||||
[mitglied.person.id]: ereignis.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-12"
|
||||
onClick={() => {
|
||||
const werte = werteEinsammeln();
|
||||
if (werte.length > 0) onDruck(werte, DruckArt.ZWISCHEN);
|
||||
}}
|
||||
>
|
||||
<Gauge aria-hidden />
|
||||
Druck melden
|
||||
</Button>
|
||||
|
||||
{trupp.status === TruppStatus.IM_EINSATZ && !zielGemeldet && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => {
|
||||
const werte = werteEinsammeln();
|
||||
if (werte.length > 0) onDruck(werte, DruckArt.ZIEL);
|
||||
}}
|
||||
>
|
||||
Einsatzziel erreicht
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Statuswechsel und Notfall. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trupp.status === TruppStatus.BEREIT && (
|
||||
<Button type="button" className="h-12" onClick={() => onStatus(TruppStatus.IM_EINSATZ)}>
|
||||
<LogIn aria-hidden />
|
||||
Einstieg
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trupp.status === TruppStatus.IM_EINSATZ && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => onRueckzug()}
|
||||
>
|
||||
<Undo2 aria-hidden />
|
||||
Rückzug anordnen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => onStatus(TruppStatus.RUECKWEG)}
|
||||
>
|
||||
Rückweg angetreten
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(trupp.status === TruppStatus.IM_EINSATZ ||
|
||||
trupp.status === TruppStatus.RUECKWEG) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => onStatus(TruppStatus.DRAUSSEN, werteEinsammeln())}
|
||||
>
|
||||
<LogOut aria-hidden />
|
||||
Trupp ist draußen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={notfall ? "outline" : "destructive"}
|
||||
className="ml-auto h-12"
|
||||
data-testid={`mayday-${trupp.id}`}
|
||||
onClick={() => onMayday(notfall)}
|
||||
>
|
||||
<AlertTriangle aria-hidden />
|
||||
{notfall ? "Notfall aufheben" : "Mayday"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Der Verlauf. Klein, aber vollständig — das ist der Nachweis. */}
|
||||
{laufende.length > 0 && (
|
||||
<ul className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--muted-foreground)]">
|
||||
{laufende.slice(-8).map((messung) => (
|
||||
<li key={messung.id} className="tabellenzahlen">
|
||||
{messung.zeitpunkt.toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}{" "}
|
||||
{messung.druck} bar
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -124,17 +124,20 @@ const EINTRAEGE: Eintrag[] = [
|
||||
},
|
||||
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
|
||||
{
|
||||
// Das Audit-Log wird seit Phase 0 geschrieben, eine Ansicht dafür gibt es
|
||||
// noch nicht — sie gehört zum Löschkonzept in Phase 8. Bis dahin steht der
|
||||
// Eintrag ausgegraut da statt ins Leere zu führen.
|
||||
titel: "Protokoll",
|
||||
pfad: "/protokoll",
|
||||
symbol: Album,
|
||||
rechte: ["system.auditlog"],
|
||||
abPhase: 1,
|
||||
abPhase: 8,
|
||||
},
|
||||
];
|
||||
|
||||
/** 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 = 4;
|
||||
const AKTUELLE_PHASE = 5;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { DruckArt, TruppStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Die Rechenregeln der Atemschutzüberwachung.
|
||||
*
|
||||
* Diese Datei hat bewusst keinen Datenbankzugriff und kein `server-only`: sie
|
||||
* wird auf dem Server für die verbindlichen Werte gebraucht und im Browser für
|
||||
* die laufende Anzeige zwischen zwei Aktualisierungen. Beide Seiten rechnen
|
||||
* mit demselben Code — sonst zeigte das Tablet etwas anderes an als das
|
||||
* Protokoll später ausweist.
|
||||
*
|
||||
* **Die Zeit gehört dem Server.** Nirgends wird eine Restzeit heruntergezählt.
|
||||
* Jede Angabe entsteht aus zwei Zeitstempeln, und den späteren liefert der
|
||||
* Aufrufer. Damit übersteht jede Anzeige einen Neustart, einen zweiten
|
||||
* Bildschirm und einen Verbindungsabbruch — es gibt schlicht nichts, das
|
||||
* verloren gehen könnte.
|
||||
*
|
||||
* **Und über allem:** was hier gerechnet wird, ist eine Hilfe, keine
|
||||
* Überwachung. Die analoge Überwachung bleibt führend.
|
||||
*/
|
||||
|
||||
/** Ein Pressluftatmer hält 300 bar; darüber liegt ein Ablesefehler. */
|
||||
export const DRUCK_HOECHSTWERT = 400;
|
||||
|
||||
/**
|
||||
* Die Regel für den Rückzugsdruck: **doppelter Anmarschverbrauch**.
|
||||
*
|
||||
* Was der Weg hinein an Luft gekostet hat, kostet der Weg zurück noch einmal.
|
||||
* Dazu kommt der Druck, bei dem die Warneinrichtung anspricht, und ein
|
||||
* Sicherheitszuschlag, den der Kommandant festlegt:
|
||||
*
|
||||
* Rückzug = 2 × (Eingangsdruck − Druck am Ziel) + Warndruck + Zuschlag
|
||||
*
|
||||
* Solange der Trupp das Einsatzziel noch nicht gemeldet hat, gibt es keinen
|
||||
* Anmarschverbrauch — dann greift ersatzweise die Zwei-Drittel-Regel: Rückzug,
|
||||
* sobald ein Drittel verbraucht ist. Sie liegt in aller Regel höher als die
|
||||
* spätere Rechnung und ist damit die vorsichtigere Annahme.
|
||||
*/
|
||||
export function rueckzugsdruck(angaben: {
|
||||
eingangsdruck: number;
|
||||
/** Druck beim Erreichen des Einsatzziels, falls schon gemeldet. */
|
||||
zieldruck?: number | null;
|
||||
warndruck: number;
|
||||
sicherheitszuschlag: number;
|
||||
}): { druck: number; regel: "anmarschverbrauch" | "zweidrittel" } {
|
||||
const { eingangsdruck, zieldruck, warndruck, sicherheitszuschlag } = angaben;
|
||||
|
||||
if (typeof zieldruck === "number" && zieldruck > 0 && zieldruck <= eingangsdruck) {
|
||||
const anmarsch = eingangsdruck - zieldruck;
|
||||
return {
|
||||
druck: Math.min(eingangsdruck, 2 * anmarsch + warndruck + sicherheitszuschlag),
|
||||
regel: "anmarschverbrauch",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
druck: Math.round((eingangsdruck * 2) / 3) + sicherheitszuschlag,
|
||||
regel: "zweidrittel",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Der niedrigste Druck im Trupp.
|
||||
*
|
||||
* Ein Trupp geht gemeinsam zurück, und zwar wenn der **erste** den
|
||||
* Rückzugsdruck erreicht. Deshalb zählt für jede Bewertung der kleinste Wert,
|
||||
* nie der Durchschnitt.
|
||||
*/
|
||||
export function niedrigsterDruck(
|
||||
messungen: Array<{ personId: string; druck: number; zeitpunkt: Date }>,
|
||||
): { personId: string; druck: number; zeitpunkt: Date } | null {
|
||||
const letzteJePerson = new Map<string, { personId: string; druck: number; zeitpunkt: Date }>();
|
||||
|
||||
for (const messung of messungen) {
|
||||
const bisher = letzteJePerson.get(messung.personId);
|
||||
if (!bisher || messung.zeitpunkt.getTime() > bisher.zeitpunkt.getTime()) {
|
||||
letzteJePerson.set(messung.personId, messung);
|
||||
}
|
||||
}
|
||||
|
||||
let niedrigste: { personId: string; druck: number; zeitpunkt: Date } | null = null;
|
||||
for (const messung of letzteJePerson.values()) {
|
||||
if (!niedrigste || messung.druck < niedrigste.druck) niedrigste = messung;
|
||||
}
|
||||
|
||||
return niedrigste;
|
||||
}
|
||||
|
||||
/** Wie ein Trupp gerade dasteht. Farbe **und** Text — Farbe allein reicht nicht. */
|
||||
export type Ampel = "gruen" | "gelb" | "rot";
|
||||
|
||||
export const AMPEL_TEXT: Record<Ampel, string> = {
|
||||
gruen: "im Rahmen",
|
||||
gelb: "Rückzugsdruck bald erreicht",
|
||||
rot: "Rückzugsdruck erreicht — Trupp zurückziehen",
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Ampel.
|
||||
*
|
||||
* Gelb ab zehn Prozent über dem Rückzugsdruck: der Trupp soll den Rückweg
|
||||
* antreten, bevor der Wert erreicht ist, nicht wenn er erreicht ist.
|
||||
*/
|
||||
export function ampel(angaben: {
|
||||
aktuellerDruck: number | null;
|
||||
rueckzugsdruck: number | null;
|
||||
status: TruppStatus;
|
||||
}): Ampel {
|
||||
const { aktuellerDruck, rueckzugsdruck: grenze, status } = angaben;
|
||||
|
||||
// Wer draußen ist, ist draußen.
|
||||
if (status === TruppStatus.DRAUSSEN) return "gruen";
|
||||
if (aktuellerDruck === null || grenze === null) return "gruen";
|
||||
|
||||
if (aktuellerDruck <= grenze) return "rot";
|
||||
if (aktuellerDruck <= grenze * 1.1) return "gelb";
|
||||
return "gruen";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange ein Trupp schon unter Atemschutz ist, in Sekunden.
|
||||
*
|
||||
* Aus zwei Zeitstempeln, nicht aus einem laufenden Zähler. `jetzt` gibt der
|
||||
* Aufrufer mit — im Test ein fester Zeitpunkt, im Betrieb die Uhr.
|
||||
*/
|
||||
export function einsatzdauer(
|
||||
trupp: { einstiegUm: Date | null; drausenUm: Date | null },
|
||||
jetzt: Date,
|
||||
): number | null {
|
||||
if (!trupp.einstiegUm) return null;
|
||||
const ende = trupp.drausenUm ?? jetzt;
|
||||
return Math.max(0, Math.floor((ende.getTime() - trupp.einstiegUm.getTime()) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wann die nächste Druckabfrage fällig ist.
|
||||
*
|
||||
* Gerechnet ab der letzten Meldung, nicht ab dem Einstieg — sonst käme die
|
||||
* Erinnerung nach einer verspäteten Meldung sofort wieder.
|
||||
*/
|
||||
export function naechsteAbfrage(angaben: {
|
||||
einstiegUm: Date | null;
|
||||
letzteMessungUm: Date | null;
|
||||
intervallSekunden: number;
|
||||
}): Date | null {
|
||||
const basis = angaben.letzteMessungUm ?? angaben.einstiegUm;
|
||||
if (!basis) return null;
|
||||
return new Date(basis.getTime() + angaben.intervallSekunden * 1000);
|
||||
}
|
||||
|
||||
/** Ist die Druckabfrage überfällig? */
|
||||
export function abfrageUeberfaellig(angaben: {
|
||||
einstiegUm: Date | null;
|
||||
letzteMessungUm: Date | null;
|
||||
intervallSekunden: number;
|
||||
status: TruppStatus;
|
||||
jetzt: Date;
|
||||
}): boolean {
|
||||
if (angaben.status !== TruppStatus.IM_EINSATZ && angaben.status !== TruppStatus.RUECKWEG) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const faellig = naechsteAbfrage(angaben);
|
||||
return faellig !== null && angaben.jetzt.getTime() >= faellig.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Luftverbrauch je Minute, aus den bisherigen Messungen.
|
||||
*
|
||||
* Nur eine Schätzung, und sie steht auch so in der Oberfläche: sie taugt für
|
||||
* einen Anhaltspunkt, nicht für eine Entscheidung. Unter zwei Messungen oder
|
||||
* unter einer Minute Abstand wird gar nichts gerechnet — eine Hochrechnung aus
|
||||
* zwei Werten im Sekundenabstand wäre eine erfundene Zahl.
|
||||
*/
|
||||
export function verbrauchProMinute(
|
||||
messungen: Array<{ druck: number; zeitpunkt: Date }>,
|
||||
): number | null {
|
||||
if (messungen.length < 2) return null;
|
||||
|
||||
const sortiert = [...messungen].sort((a, b) => a.zeitpunkt.getTime() - b.zeitpunkt.getTime());
|
||||
const erste = sortiert[0]!;
|
||||
const letzte = sortiert[sortiert.length - 1]!;
|
||||
|
||||
const minuten = (letzte.zeitpunkt.getTime() - erste.zeitpunkt.getTime()) / 60_000;
|
||||
if (minuten < 1) return null;
|
||||
|
||||
const verbraucht = erste.druck - letzte.druck;
|
||||
if (verbraucht <= 0) return null;
|
||||
|
||||
return Math.round((verbraucht / minuten) * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geschätzte Restzeit bis zum Rückzugsdruck, in Minuten.
|
||||
*
|
||||
* Ausdrücklich eine Schätzung: der Verbrauch steigt unter Belastung, und
|
||||
* niemand soll sich darauf verlassen. Deshalb gibt die Funktion null zurück,
|
||||
* sobald die Grundlage dünn wird — lieber keine Zahl als eine falsche.
|
||||
*/
|
||||
export function restzeitMinuten(angaben: {
|
||||
aktuellerDruck: number | null;
|
||||
rueckzugsdruck: number | null;
|
||||
verbrauchProMinute: number | null;
|
||||
}): number | null {
|
||||
const { aktuellerDruck, rueckzugsdruck: grenze, verbrauchProMinute: rate } = angaben;
|
||||
|
||||
if (aktuellerDruck === null || grenze === null || rate === null || rate <= 0) return null;
|
||||
if (aktuellerDruck <= grenze) return 0;
|
||||
|
||||
return Math.floor((aktuellerDruck - grenze) / rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der nächste erlaubte Statusschritt.
|
||||
*
|
||||
* Ein Trupp geht immer denselben Weg: bereit, im Einsatz, Rückweg, draußen.
|
||||
* Zurück geht es nicht — ein Trupp, der schon draußen war, ist ein neuer
|
||||
* Trupp. Das hält das Protokoll gerade.
|
||||
*/
|
||||
export const STATUSFOLGE: Record<TruppStatus, TruppStatus[]> = {
|
||||
[TruppStatus.BEREIT]: [TruppStatus.IM_EINSATZ],
|
||||
[TruppStatus.IM_EINSATZ]: [TruppStatus.RUECKWEG, TruppStatus.DRAUSSEN],
|
||||
[TruppStatus.RUECKWEG]: [TruppStatus.DRAUSSEN],
|
||||
[TruppStatus.DRAUSSEN]: [],
|
||||
};
|
||||
|
||||
export function statuswechselErlaubt(von: TruppStatus, nach: TruppStatus): boolean {
|
||||
return STATUSFOLGE[von].includes(nach);
|
||||
}
|
||||
|
||||
export const STATUS_TEXT: Record<TruppStatus, string> = {
|
||||
BEREIT: "bereit",
|
||||
IM_EINSATZ: "im Einsatz",
|
||||
RUECKWEG: "auf dem Rückweg",
|
||||
DRAUSSEN: "draußen",
|
||||
};
|
||||
|
||||
export const DRUCKART_TEXT: Record<DruckArt, string> = {
|
||||
EINGANG: "Eingangsdruck",
|
||||
ZIEL: "am Einsatzziel",
|
||||
ZWISCHEN: "Zwischenmeldung",
|
||||
AUSGANG: "Ausgangsdruck",
|
||||
};
|
||||
|
||||
/**
|
||||
* Eine Sekundenzahl als `mm:ss` beziehungsweise `h:mm:ss`.
|
||||
*
|
||||
* Eigene Funktion statt der allgemeinen Formatierung: hier zählt jede Sekunde,
|
||||
* und die Anzeige darf nicht runden.
|
||||
*/
|
||||
export function alsUhr(sekunden: number | null): string {
|
||||
if (sekunden === null) return "—";
|
||||
|
||||
const stunden = Math.floor(sekunden / 3600);
|
||||
const minuten = Math.floor((sekunden % 3600) / 60);
|
||||
const rest = sekunden % 60;
|
||||
|
||||
const zweistellig = (wert: number) => String(wert).padStart(2, "0");
|
||||
|
||||
return stunden > 0
|
||||
? `${stunden}:${zweistellig(minuten)}:${zweistellig(rest)}`
|
||||
: `${zweistellig(minuten)}:${zweistellig(rest)}`;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import "server-only";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { env } from "@/lib/env";
|
||||
import { protokollPdf, type ProtokollDaten, type ProtokollZeile } from "@/lib/atemschutz/protokoll";
|
||||
import { DRUCKART_TEXT } from "@/lib/atemschutz/berechnung";
|
||||
|
||||
/**
|
||||
* Von der Überwachung zum abgelegten Protokoll.
|
||||
*
|
||||
* Dreigeteilt wie beim Prüfprotokoll und beim Einsatzbericht — lesen, rendern,
|
||||
* schreiben. Dazwischen liegt der Aufruf an Gotenberg, und der darf dauern;
|
||||
* eine offene Transaktion darf das nicht.
|
||||
*
|
||||
* Zwei Besonderheiten gegenüber den anderen Erzeugern:
|
||||
*
|
||||
* * **Erst nach dem Ende.** Ein Protokoll einer laufenden Überwachung wäre
|
||||
* unvollständig und würde als vollständig aussehen. Das ist genau der
|
||||
* Fehler, den ein Nachweis nicht machen darf.
|
||||
* * **Der Verlauf entsteht aus zwei Quellen.** Ereignisse und Druckmessungen
|
||||
* werden getrennt gespeichert, gehören im Protokoll aber in eine einzige
|
||||
* Reihenfolge — sonst müsste der Leser zwei Spalten gleichzeitig verfolgen.
|
||||
*
|
||||
* Wiederholbar: gibt es das Protokoll schon, passiert nichts.
|
||||
*/
|
||||
export async function atemschutzprotokollErzeugen(
|
||||
organisationId: string,
|
||||
ueberwachungId: string,
|
||||
): Promise<{ dateiId: string; neu: boolean } | null> {
|
||||
// ---------------------------------------------------------------- lesen
|
||||
const geladen = await fuerOrganisation(organisationId, async (db) => {
|
||||
const ueberwachung = await db.atemschutzeinsatz.findUnique({
|
||||
where: { id: ueberwachungId },
|
||||
include: {
|
||||
organisation: { select: { name: true, slug: true, ort: true } },
|
||||
ueberwacher: { select: { vorname: true, nachname: true } },
|
||||
einsatz: { select: { einsatznummer: true, stichwort: true } },
|
||||
uebung: { select: { nummer: true, thema: true } },
|
||||
trupps: {
|
||||
orderBy: { angelegtUm: "asc" },
|
||||
include: {
|
||||
mitglieder: {
|
||||
include: {
|
||||
person: { select: { id: true, vorname: true, nachname: true } },
|
||||
geraet: { select: { inventarnummer: true } },
|
||||
},
|
||||
},
|
||||
druckmessungen: {
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
include: { person: { select: { id: true, vorname: true, nachname: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
ereignisse: {
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
include: {
|
||||
trupp: { select: { funkrufname: true } },
|
||||
person: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!ueberwachung) return null;
|
||||
// Ein Protokoll gibt es erst, wenn nichts mehr dazukommt.
|
||||
if (!ueberwachung.endeUm) return null;
|
||||
|
||||
return ueberwachung;
|
||||
});
|
||||
|
||||
if (!geladen) return null;
|
||||
if (geladen.protokollDateiId) return { dateiId: geladen.protokollDateiId, neu: false };
|
||||
|
||||
// --------------------------------------------------------------- rendern
|
||||
const name = (person: { vorname: string; nachname: string }) =>
|
||||
`${person.nachname}, ${person.vorname}`;
|
||||
|
||||
/** Drücke einer Art als „Huber 300, Maier 290" — kurz genug für die Spalte. */
|
||||
const drueckeAlsText = (
|
||||
messungen: Array<{ art: string; druck: number; person: { vorname: string; nachname: string } }>,
|
||||
art: string,
|
||||
) =>
|
||||
messungen
|
||||
.filter((messung) => messung.art === art)
|
||||
.map((messung) => `${messung.person.nachname} ${messung.druck}`)
|
||||
.join(", ");
|
||||
|
||||
const verlauf: ProtokollZeile[] = [
|
||||
...geladen.ereignisse.map((ereignis) => ({
|
||||
zeitpunkt: ereignis.zeitpunkt,
|
||||
trupp: ereignis.trupp?.funkrufname ?? null,
|
||||
was: ereignis.text,
|
||||
wer: ereignis.person ? name(ereignis.person) : null,
|
||||
})),
|
||||
...geladen.trupps.flatMap((trupp) =>
|
||||
trupp.druckmessungen.map((messung) => ({
|
||||
zeitpunkt: messung.zeitpunkt,
|
||||
trupp: trupp.funkrufname,
|
||||
was: `${DRUCKART_TEXT[messung.art]}: ${name(messung.person)} ${messung.druck} bar`,
|
||||
wer: null,
|
||||
})),
|
||||
),
|
||||
].sort((a, b) => a.zeitpunkt.getTime() - b.zeitpunkt.getTime());
|
||||
|
||||
const freigabe = env.ATEMSCHUTZ_FREIGABE?.trim();
|
||||
|
||||
const daten: ProtokollDaten = {
|
||||
organisation: { name: geladen.organisation.name, ort: geladen.organisation.ort },
|
||||
ueberwachung: {
|
||||
bezeichnung: geladen.bezeichnung,
|
||||
ueberwacher: name(geladen.ueberwacher),
|
||||
beginnUm: geladen.beginnUm,
|
||||
endeUm: geladen.endeUm,
|
||||
abfrageIntervall: geladen.abfrageIntervall,
|
||||
warndruck: geladen.warndruck,
|
||||
sicherheitszuschlag: geladen.sicherheitszuschlag,
|
||||
einsatz: geladen.einsatz
|
||||
? `Einsatz ${geladen.einsatz.einsatznummer} — ${geladen.einsatz.stichwort}`
|
||||
: null,
|
||||
uebung: geladen.uebung ? `Übung ${geladen.uebung.nummer} — ${geladen.uebung.thema}` : null,
|
||||
bemerkung: geladen.bemerkung,
|
||||
},
|
||||
trupps: geladen.trupps.map((trupp) => ({
|
||||
funkrufname: trupp.funkrufname,
|
||||
auftrag: trupp.auftrag,
|
||||
status: trupp.status,
|
||||
mitglieder: trupp.mitglieder.map((mitglied) => ({
|
||||
name: name(mitglied.person),
|
||||
truppfuehrer: mitglied.truppfuehrer,
|
||||
geraet: mitglied.geraet?.inventarnummer ?? null,
|
||||
})),
|
||||
einstiegUm: trupp.einstiegUm,
|
||||
rueckzugUm: trupp.rueckzugUm,
|
||||
drausenUm: trupp.drausenUm,
|
||||
rueckzugsdruck: trupp.rueckzugsdruck,
|
||||
maydayUm: trupp.maydayUm,
|
||||
maydayAufgehobenUm: trupp.maydayAufgehobenUm,
|
||||
eingangsdruecke: drueckeAlsText(trupp.druckmessungen, "EINGANG"),
|
||||
ausgangsdruecke: drueckeAlsText(trupp.druckmessungen, "AUSGANG"),
|
||||
})),
|
||||
verlauf,
|
||||
// Nur wenn der Hinweis tatsächlich abgeschaltet ist, war die digitale
|
||||
// Überwachung führend — sonst steht der ergänzende Charakter im Protokoll.
|
||||
freigabe: !env.ATEMSCHUTZ_HINWEIS && freigabe ? freigabe : null,
|
||||
erzeugtAm: new Date(),
|
||||
};
|
||||
|
||||
const pdf = await protokollPdf(daten);
|
||||
|
||||
const dateiname = `Atemschutzprotokoll-${geladen.beginnUm.toISOString().slice(0, 10)}-${geladen.id.slice(0, 8)}.pdf`;
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: geladen.organisation.slug,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: geladen.id,
|
||||
dateiname,
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
|
||||
atemschutz: geladen.id,
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- schreiben
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const stand = await db.atemschutzeinsatz.findUnique({
|
||||
where: { id: geladen.id },
|
||||
select: { protokollDateiId: true },
|
||||
});
|
||||
|
||||
// Zwei gleichzeitige Anfragen: die zweite nimmt, was die erste abgelegt hat.
|
||||
if (stand?.protokollDateiId) return { dateiId: stand.protokollDateiId, neu: false };
|
||||
|
||||
const datei = await db.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname,
|
||||
mimeTyp: "application/pdf",
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: geladen.id,
|
||||
kategorie: "atemschutzprotokoll",
|
||||
hochgeladenVonId: geladen.ueberwacherId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.atemschutzeinsatz.update({
|
||||
where: { id: geladen.id },
|
||||
data: { protokollDateiId: datei.id },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: geladen.id,
|
||||
beschreibung: `Atemschutzprotokoll erzeugt, ${verlauf.length} Einträge (${Math.round(
|
||||
abgelegt.groesseBytes / 1024,
|
||||
)} kB)`,
|
||||
akteurTyp: AkteurTyp.SYSTEM,
|
||||
akteurName: "FLORIAN",
|
||||
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
|
||||
});
|
||||
|
||||
return { dateiId: datei.id, neu: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import "server-only";
|
||||
import { htmlZuPdf, html } from "@/lib/pdf";
|
||||
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
|
||||
import { DRUCKART_TEXT, STATUS_TEXT, alsUhr } from "@/lib/atemschutz/berechnung";
|
||||
|
||||
/**
|
||||
* Das Atemschutzprotokoll als PDF.
|
||||
*
|
||||
* Es ist der Nachweis, den FwDV 7 verlangt — und im Schadensfall das Papier,
|
||||
* nach dem die Unfallkasse fragt. Daraus folgt der Aufbau:
|
||||
*
|
||||
* Kopf Wehr, Bezeichnung, Überwacher, Beginn und Ende
|
||||
* Trupps wer, wann eingestiegen, wann draußen, mit welchen Drücken
|
||||
* Verlauf **jede** Zeile in zeitlicher Reihenfolge, Ereignisse und
|
||||
* Druckmeldungen gemeinsam — das ist der Kern
|
||||
* Fuß wann erstellt, und der Hinweis, dass die analoge Überwachung
|
||||
* führend war
|
||||
*
|
||||
* Der letzte Satz steht dort nicht aus Bescheidenheit, sondern weil er
|
||||
* stimmen muss: solange die Wehr die digitale Überwachung nicht freigegeben
|
||||
* hat, ist dieses Blatt eine Mitschrift und keine Überwachungsdokumentation.
|
||||
* Wer das später liest, soll wissen, was er in der Hand hält.
|
||||
*/
|
||||
|
||||
export interface ProtokollTrupp {
|
||||
funkrufname: string;
|
||||
auftrag: string | null;
|
||||
status: string;
|
||||
mitglieder: Array<{ name: string; truppfuehrer: boolean; geraet: string | null }>;
|
||||
einstiegUm: Date | null;
|
||||
rueckzugUm: Date | null;
|
||||
drausenUm: Date | null;
|
||||
rueckzugsdruck: number | null;
|
||||
maydayUm: Date | null;
|
||||
maydayAufgehobenUm: Date | null;
|
||||
eingangsdruecke: string;
|
||||
ausgangsdruecke: string;
|
||||
}
|
||||
|
||||
export interface ProtokollZeile {
|
||||
zeitpunkt: Date;
|
||||
trupp: string | null;
|
||||
was: string;
|
||||
wer: string | null;
|
||||
}
|
||||
|
||||
export interface ProtokollDaten {
|
||||
organisation: { name: string; ort?: string | null };
|
||||
ueberwachung: {
|
||||
bezeichnung: string;
|
||||
ueberwacher: string;
|
||||
beginnUm: Date;
|
||||
endeUm: Date | null;
|
||||
abfrageIntervall: number;
|
||||
warndruck: number;
|
||||
sicherheitszuschlag: number;
|
||||
einsatz?: string | null;
|
||||
uebung?: string | null;
|
||||
bemerkung?: string | null;
|
||||
};
|
||||
trupps: ProtokollTrupp[];
|
||||
verlauf: ProtokollZeile[];
|
||||
/** Steht im Fuß: ob die Wehr die digitale Überwachung freigegeben hat. */
|
||||
freigabe: string | null;
|
||||
erzeugtAm: Date;
|
||||
}
|
||||
|
||||
const STIL = `
|
||||
@page { size: A4; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: #111;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
h1 { font-size: 15pt; margin: 0 0 1mm; }
|
||||
h2 { font-size: 11pt; margin: 6mm 0 2mm; border-bottom: 0.4mm solid #111; padding-bottom: 1mm; }
|
||||
|
||||
.kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: 6mm; }
|
||||
.wehr { text-align: right; font-size: 8.5pt; color: #444; }
|
||||
|
||||
.hinweis {
|
||||
margin: 3mm 0 0;
|
||||
border: 0.5mm solid #b91c1c;
|
||||
background: #fdecec;
|
||||
padding: 3mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 2mm; }
|
||||
th, td { border: 0.2mm solid #bbb; padding: 1.2mm 1.6mm; text-align: left; vertical-align: top; }
|
||||
th { background: #eee; font-size: 8.5pt; }
|
||||
td.zahl, th.zahl { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
td.zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
|
||||
tr.mayday td { background: #fdecec; font-weight: bold; }
|
||||
|
||||
.fuss { margin-top: 6mm; font-size: 8pt; color: #444; border-top: 0.2mm solid #bbb; padding-top: 2mm; }
|
||||
`;
|
||||
|
||||
function protokollHtml(daten: ProtokollDaten): string {
|
||||
const { ueberwachung, organisation } = daten;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>Atemschutzprotokoll</title><style>${STIL}</style></head>
|
||||
<body>
|
||||
<div class="kopf">
|
||||
<div>
|
||||
<h1>Atemschutzüberwachung</h1>
|
||||
<div>${html(ueberwachung.bezeichnung)}</div>
|
||||
</div>
|
||||
<div class="wehr">
|
||||
<div><strong>${html(organisation.name)}</strong></div>
|
||||
${organisation.ort ? `<div>${html(organisation.ort)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hinweis">
|
||||
${
|
||||
daten.freigabe
|
||||
? `Digitale Atemschutzüberwachung freigegeben — ${html(daten.freigabe)}.`
|
||||
: "<strong>Ergänzendes System.</strong> Während dieser Überwachung war die analoge Atemschutzüberwachung führend. Dieses Blatt ist die Mitschrift des ergänzenden Systems."
|
||||
}
|
||||
</div>
|
||||
|
||||
<h2>Rahmen</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width:28%">Überwacher</th><td>${html(ueberwachung.ueberwacher)}</td>
|
||||
<th style="width:20%">Beginn</th><td class="zeit">${html(alsDatumZeit(ueberwachung.beginnUm))}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Ende</th>
|
||||
<td class="zeit">${ueberwachung.endeUm ? html(alsDatumZeit(ueberwachung.endeUm)) : "— (nicht beendet)"}</td>
|
||||
<th>Abfrageintervall</th><td>${Math.round(ueberwachung.abfrageIntervall / 60)} Minuten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Rückzugsregel</th>
|
||||
<td>doppelter Anmarschverbrauch + ${ueberwachung.warndruck} bar Warndruck + ${ueberwachung.sicherheitszuschlag} bar Zuschlag</td>
|
||||
<th>Zuordnung</th>
|
||||
<td>${html(ueberwachung.einsatz ?? ueberwachung.uebung ?? "keine")}</td>
|
||||
</tr>
|
||||
${
|
||||
ueberwachung.bemerkung
|
||||
? `<tr><th>Bemerkung</th><td colspan="3">${html(ueberwachung.bemerkung)}</td></tr>`
|
||||
: ""
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Trupps</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Trupp</th>
|
||||
<th>Besetzung</th>
|
||||
<th class="zeit">Einstieg</th>
|
||||
<th class="zeit">Rückweg</th>
|
||||
<th class="zeit">draußen</th>
|
||||
<th class="zahl">Dauer</th>
|
||||
<th class="zahl">Rückzug bei</th>
|
||||
<th>Drücke ein/aus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${daten.trupps
|
||||
.map((trupp) => {
|
||||
const dauer =
|
||||
trupp.einstiegUm && trupp.drausenUm
|
||||
? alsUhr(
|
||||
Math.floor((trupp.drausenUm.getTime() - trupp.einstiegUm.getTime()) / 1000),
|
||||
)
|
||||
: "—";
|
||||
|
||||
return `
|
||||
<tr${trupp.maydayUm ? ' class="mayday"' : ""}>
|
||||
<td>
|
||||
<strong>${html(trupp.funkrufname)}</strong>
|
||||
${trupp.auftrag ? `<div>${html(trupp.auftrag)}</div>` : ""}
|
||||
${trupp.maydayUm ? `<div>MAYDAY ${html(alsUhrzeit(trupp.maydayUm))}${trupp.maydayAufgehobenUm ? ` bis ${html(alsUhrzeit(trupp.maydayAufgehobenUm))}` : ""}</div>` : ""}
|
||||
</td>
|
||||
<td>
|
||||
${trupp.mitglieder
|
||||
.map(
|
||||
(mitglied) =>
|
||||
`${html(mitglied.name)}${mitglied.truppfuehrer ? " (TF)" : ""}${mitglied.geraet ? ` — ${html(mitglied.geraet)}` : ""}`,
|
||||
)
|
||||
.join("<br>")}
|
||||
</td>
|
||||
<td class="zeit">${trupp.einstiegUm ? html(alsUhrzeit(trupp.einstiegUm)) : "—"}</td>
|
||||
<td class="zeit">${trupp.rueckzugUm ? html(alsUhrzeit(trupp.rueckzugUm)) : "—"}</td>
|
||||
<td class="zeit">${trupp.drausenUm ? html(alsUhrzeit(trupp.drausenUm)) : "—"}</td>
|
||||
<td class="zahl">${html(dauer)}</td>
|
||||
<td class="zahl">${trupp.rueckzugsdruck ?? "—"} bar</td>
|
||||
<td>${html(trupp.eingangsdruecke)} / ${html(trupp.ausgangsdruecke || "—")}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Verlauf</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="zeit" style="width:18mm">Uhrzeit</th>
|
||||
<th style="width:30mm">Trupp</th>
|
||||
<th>Vorgang</th>
|
||||
<th style="width:35mm">erfasst von</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${daten.verlauf
|
||||
.map(
|
||||
(zeile) => `
|
||||
<tr${zeile.was.startsWith("MAYDAY") ? ' class="mayday"' : ""}>
|
||||
<td class="zeit">${html(alsUhrzeit(zeile.zeitpunkt))}</td>
|
||||
<td>${html(zeile.trupp ?? "—")}</td>
|
||||
<td>${html(zeile.was)}</td>
|
||||
<td>${html(zeile.wer ?? "—")}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="fuss">
|
||||
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Das Protokoll umfasst
|
||||
${daten.verlauf.length} Einträge und ist nachträglich nicht veränderbar — Druckmessungen und
|
||||
Ereignisse lassen sich in der Datenbank weder ändern noch löschen.
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Erzeugt das Protokoll als PDF. */
|
||||
export async function protokollPdf(daten: ProtokollDaten): Promise<Buffer> {
|
||||
return htmlZuPdf(protokollHtml(daten), {
|
||||
randOben: 0.6,
|
||||
randUnten: 0.5,
|
||||
randLinks: 0.5,
|
||||
randRechts: 0.5,
|
||||
hintergrundDrucken: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Klartext für eine Druckmessung im Verlauf. */
|
||||
export function druckZeile(art: string, druck: number, person: string): string {
|
||||
return `${DRUCKART_TEXT[art as keyof typeof DRUCKART_TEXT] ?? art}: ${person} ${druck} bar`;
|
||||
}
|
||||
|
||||
/** Klartext für einen Status im Trupp-Block. */
|
||||
export function statusText(status: string): string {
|
||||
return STATUS_TEXT[status as keyof typeof STATUS_TEXT] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { G26Ergebnis, UebungsartAtemschutz } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Wer darf unter Atemschutz?
|
||||
*
|
||||
* FwDV 7 verlangt dreierlei, und alle drei müssen gleichzeitig gelten:
|
||||
*
|
||||
* 1. **Gültige G26.3.** Die Untersuchung beim Arbeitsmediziner. Ohne sie
|
||||
* geht gar nichts, auch nicht „einmalig ausnahmsweise".
|
||||
* 2. **Belastungsübung im laufenden Jahr.** Übungsstrecke oder eine
|
||||
* gleichwertige Belastung.
|
||||
* 3. **Einsatzübung unter Atemschutz im laufenden Jahr.** Ein Einsatz unter
|
||||
* PA zählt dafür ebenso — wer im Ernstfall drin war, hat mehr geleistet
|
||||
* als in jeder Übung.
|
||||
*
|
||||
* Dazu die Qualifikation als Atemschutzgeräteträger, die aus Phase 1 kommt.
|
||||
*
|
||||
* Diese Datei rechnet nur. Sie entscheidet nicht, ob jemand eingesetzt wird —
|
||||
* das tut der Einheitsführer, und das Ergebnis hier ist der Hinweis darauf,
|
||||
* was ihm dabei bekannt sein sollte.
|
||||
*/
|
||||
|
||||
/** Wie lange vor Ablauf gewarnt wird. Drei Monate reichen für einen Arzttermin. */
|
||||
export const VORWARNUNG_TAGE = 90;
|
||||
|
||||
export type Tauglichkeitsstufe = "tauglich" | "laeuft_ab" | "nicht_tauglich";
|
||||
|
||||
export const STUFE_TEXT: Record<Tauglichkeitsstufe, string> = {
|
||||
tauglich: "einsetzbar",
|
||||
laeuft_ab: "einsetzbar, läuft bald ab",
|
||||
nicht_tauglich: "nicht einsetzbar",
|
||||
};
|
||||
|
||||
export interface Tauglichkeit {
|
||||
stufe: Tauglichkeitsstufe;
|
||||
/** Klartext, was fehlt — die Farbe allein sagt niemandem, was zu tun ist. */
|
||||
gruende: string[];
|
||||
g26GueltigBis: Date | null;
|
||||
belastungsuebungAm: Date | null;
|
||||
einsatzuebungAm: Date | null;
|
||||
}
|
||||
|
||||
function tageBis(ziel: Date, jetzt: Date): number {
|
||||
return Math.floor((ziel.getTime() - jetzt.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Fällt ein Datum in dasselbe Kalenderjahr wie der Stichtag? */
|
||||
function imSelbenJahr(datum: Date, jetzt: Date): boolean {
|
||||
return datum.getUTCFullYear() === jetzt.getUTCFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet eine Person.
|
||||
*
|
||||
* Bewusst ohne Datenbankzugriff: die Aufrufer holen die Daten so, wie es an
|
||||
* ihrer Stelle günstig ist — einzeln für eine Person, gebündelt für die
|
||||
* Übersicht der ganzen Mannschaft.
|
||||
*/
|
||||
export function tauglichkeit(angaben: {
|
||||
/** Untersuchungen der Person, beliebige Reihenfolge. */
|
||||
untersuchungen: Array<{ gueltigBis: Date; ergebnis: G26Ergebnis }>;
|
||||
/** Übungen und Unterweisungen, beliebige Reihenfolge. */
|
||||
uebungen: Array<{ datum: Date; art: UebungsartAtemschutz }>;
|
||||
/** Einsätze unter Atemschutz im laufenden Jahr — sie ersetzen die Einsatzübung. */
|
||||
einsaetzeUnterPa: Date[];
|
||||
/** Ist die Person überhaupt als Atemschutzgeräteträger qualifiziert? */
|
||||
istAtemschutzgeraetetraeger: boolean;
|
||||
jetzt: Date;
|
||||
}): Tauglichkeit {
|
||||
const { untersuchungen, uebungen, einsaetzeUnterPa, jetzt } = angaben;
|
||||
|
||||
const gruende: string[] = [];
|
||||
|
||||
// --- G26.3 ---------------------------------------------------------------
|
||||
const brauchbar = untersuchungen.filter(
|
||||
(untersuchung) =>
|
||||
untersuchung.ergebnis === G26Ergebnis.TAUGLICH ||
|
||||
untersuchung.ergebnis === G26Ergebnis.BEFRISTET_TAUGLICH,
|
||||
);
|
||||
|
||||
const neueste = brauchbar.reduce<Date | null>(
|
||||
(bisher, untersuchung) =>
|
||||
!bisher || untersuchung.gueltigBis.getTime() > bisher.getTime()
|
||||
? untersuchung.gueltigBis
|
||||
: bisher,
|
||||
null,
|
||||
);
|
||||
|
||||
const abgelehnt = untersuchungen.some(
|
||||
(untersuchung) => untersuchung.ergebnis === G26Ergebnis.NICHT_TAUGLICH,
|
||||
);
|
||||
|
||||
let g26Laeuft = false;
|
||||
|
||||
if (!neueste) {
|
||||
gruende.push(
|
||||
abgelehnt ? "G26.3 ergab „nicht tauglich“" : "Keine G26.3-Untersuchung hinterlegt",
|
||||
);
|
||||
} else if (tageBis(neueste, jetzt) < 0) {
|
||||
gruende.push("G26.3 ist abgelaufen");
|
||||
} else if (tageBis(neueste, jetzt) <= VORWARNUNG_TAGE) {
|
||||
g26Laeuft = true;
|
||||
}
|
||||
|
||||
// --- Belastungsübung -----------------------------------------------------
|
||||
const belastung = uebungen
|
||||
.filter(
|
||||
(uebung) =>
|
||||
uebung.art === UebungsartAtemschutz.UEBUNGSSTRECKE && imSelbenJahr(uebung.datum, jetzt),
|
||||
)
|
||||
.sort((a, b) => b.datum.getTime() - a.datum.getTime())[0];
|
||||
|
||||
if (!belastung) gruende.push("Belastungsübung in diesem Jahr fehlt");
|
||||
|
||||
// --- Einsatzübung oder Einsatz unter PA ----------------------------------
|
||||
const einsatzuebung = uebungen
|
||||
.filter(
|
||||
(uebung) =>
|
||||
uebung.art === UebungsartAtemschutz.EINSATZUEBUNG && imSelbenJahr(uebung.datum, jetzt),
|
||||
)
|
||||
.sort((a, b) => b.datum.getTime() - a.datum.getTime())[0];
|
||||
|
||||
const echterEinsatz = einsaetzeUnterPa
|
||||
.filter((datum) => imSelbenJahr(datum, jetzt))
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0];
|
||||
|
||||
if (!einsatzuebung && !echterEinsatz) {
|
||||
gruende.push("Einsatzübung unter Atemschutz in diesem Jahr fehlt");
|
||||
}
|
||||
|
||||
// --- Qualifikation -------------------------------------------------------
|
||||
if (!angaben.istAtemschutzgeraetetraeger) {
|
||||
gruende.push("Keine Qualifikation als Atemschutzgeräteträger");
|
||||
}
|
||||
|
||||
const stufe: Tauglichkeitsstufe =
|
||||
gruende.length > 0 ? "nicht_tauglich" : g26Laeuft ? "laeuft_ab" : "tauglich";
|
||||
|
||||
if (g26Laeuft && neueste) {
|
||||
gruende.push(`G26.3 läuft in ${tageBis(neueste, jetzt)} Tagen ab`);
|
||||
}
|
||||
|
||||
return {
|
||||
stufe,
|
||||
gruende,
|
||||
g26GueltigBis: neueste,
|
||||
belastungsuebungAm: belastung?.datum ?? null,
|
||||
einsatzuebungAm: einsatzuebung?.datum ?? echterEinsatz ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Frist einer G26.3.
|
||||
*
|
||||
* Die Regel richtet sich nach dem Alter: bis 50 Jahre drei Jahre, danach ein
|
||||
* Jahr. Der Arzt kann kürzer befristen — deshalb ist das hier nur der
|
||||
* Vorschlag für das Eingabefeld, überschreibbar wie das Ablaufdatum einer
|
||||
* Qualifikation aus Phase 1.
|
||||
*/
|
||||
export function g26FristVorschlag(geburtsdatum: Date | null, untersuchtAm: Date): Date {
|
||||
const vorschlag = new Date(untersuchtAm);
|
||||
|
||||
const alter = geburtsdatum
|
||||
? Math.floor((untersuchtAm.getTime() - geburtsdatum.getTime()) / (365.25 * 86_400_000))
|
||||
: 0;
|
||||
|
||||
vorschlag.setUTCFullYear(vorschlag.getUTCFullYear() + (alter >= 50 ? 1 : 3));
|
||||
return vorschlag;
|
||||
}
|
||||
|
||||
export const G26_ERGEBNIS_TEXT: Record<G26Ergebnis, string> = {
|
||||
TAUGLICH: "tauglich",
|
||||
BEFRISTET_TAUGLICH: "befristet tauglich",
|
||||
NACHUNTERSUCHUNG: "Nachuntersuchung nötig",
|
||||
NICHT_TAUGLICH: "nicht tauglich",
|
||||
};
|
||||
|
||||
export const UEBUNGSART_TEXT: Record<UebungsartAtemschutz, string> = {
|
||||
UEBUNGSSTRECKE: "Belastungsübung (Übungsstrecke)",
|
||||
EINSATZUEBUNG: "Einsatzübung unter Atemschutz",
|
||||
UNTERWEISUNG: "Unterweisung",
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import "server-only";
|
||||
import type { MandantenClient } from "@/lib/db";
|
||||
import { tauglichkeit, type Tauglichkeit } from "@/lib/atemschutz/tauglichkeit";
|
||||
|
||||
/**
|
||||
* Wer ist heute einsetzbar?
|
||||
*
|
||||
* Die Frage stellt sich an zwei Stellen — auf der Startseite als Zahl und im
|
||||
* Atemschutzmodul als Liste. Damit beide dasselbe sagen, steht die Beschaffung
|
||||
* hier und nicht zweimal nebeneinander.
|
||||
*
|
||||
* Die Bewertung selbst liegt in lib/atemschutz/tauglichkeit.ts und ist dort
|
||||
* ohne Datenbank getestet. Diese Datei holt nur zusammen, was sie braucht.
|
||||
*/
|
||||
|
||||
/** Woran ein Atemschutzgeräteträger in den Qualifikationen erkannt wird. */
|
||||
const AGT_MERKMALE = ["atemschutz", "agt", "atemschutzgeräteträger", "atemschutzgeraetetraeger"];
|
||||
|
||||
export function istAgtTyp(name: string): boolean {
|
||||
const klein = name.toLowerCase();
|
||||
return AGT_MERKMALE.some((merkmal) => klein.includes(merkmal));
|
||||
}
|
||||
|
||||
export interface TraegerBewertung extends Tauglichkeit {
|
||||
id: string;
|
||||
vorname: string;
|
||||
nachname: string;
|
||||
dienstgrad: string | null;
|
||||
einsaetzeImJahr: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet die ganze Mannschaft.
|
||||
*
|
||||
* Eine Abfrage für alle statt einer je Person: bei fünfzig Mitgliedern wären
|
||||
* das sonst fünfzig Rundreisen zur Datenbank für eine Ansicht, die im
|
||||
* Gerätehaus schnell dastehen soll.
|
||||
*/
|
||||
export async function traegerBewerten(
|
||||
db: MandantenClient,
|
||||
jetzt = new Date(),
|
||||
): Promise<TraegerBewertung[]> {
|
||||
const jahresbeginn = new Date(Date.UTC(jetzt.getUTCFullYear(), 0, 1));
|
||||
|
||||
const personen = await db.person.findMany({
|
||||
where: { aktiv: true, geloeschtAm: null },
|
||||
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
dienstgrad: true,
|
||||
qualifikationen: {
|
||||
where: { geloeschtAm: null },
|
||||
select: { gueltigBis: true, typ: { select: { name: true } } },
|
||||
},
|
||||
g26Untersuchungen: {
|
||||
where: { geloeschtAm: null },
|
||||
select: { gueltigBis: true, ergebnis: true },
|
||||
},
|
||||
belastungsuebungen: {
|
||||
where: { geloeschtAm: null },
|
||||
select: { datum: true, art: true },
|
||||
},
|
||||
truppeinsaetze: {
|
||||
where: { trupp: { einstiegUm: { gte: jahresbeginn } } },
|
||||
select: { trupp: { select: { einstiegUm: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return personen.map((person) => ({
|
||||
id: person.id,
|
||||
vorname: person.vorname,
|
||||
nachname: person.nachname,
|
||||
dienstgrad: person.dienstgrad,
|
||||
einsaetzeImJahr: person.truppeinsaetze.length,
|
||||
...tauglichkeit({
|
||||
untersuchungen: person.g26Untersuchungen,
|
||||
uebungen: person.belastungsuebungen,
|
||||
einsaetzeUnterPa: person.truppeinsaetze
|
||||
.map((eintrag) => eintrag.trupp.einstiegUm)
|
||||
.filter((datum): datum is Date => datum !== null),
|
||||
istAtemschutzgeraetetraeger: person.qualifikationen.some(
|
||||
(qualifikation) =>
|
||||
istAgtTyp(qualifikation.typ.name) &&
|
||||
(!qualifikation.gueltigBis || qualifikation.gueltigBis >= jetzt),
|
||||
),
|
||||
jetzt,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Nur die Zahlen — für die Kachel auf der Startseite. */
|
||||
export async function verfuegbarkeit(
|
||||
db: MandantenClient,
|
||||
jetzt = new Date(),
|
||||
): Promise<{ einsetzbar: number; laeuftAb: number; nichtEinsetzbar: number }> {
|
||||
const alle = await traegerBewerten(db, jetzt);
|
||||
|
||||
return {
|
||||
einsetzbar: alle.filter((eintrag) => eintrag.stufe !== "nicht_tauglich").length,
|
||||
laeuftAb: alle.filter((eintrag) => eintrag.stufe === "laeuft_ab").length,
|
||||
nichtEinsetzbar: alle.filter((eintrag) => eintrag.stufe === "nicht_tauglich").length,
|
||||
};
|
||||
}
|
||||
@@ -120,6 +120,28 @@ const schema = z.object({
|
||||
/// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird.
|
||||
WETTER_ZWISCHENSPEICHER: zahl(300),
|
||||
|
||||
// Atemschutz nach FwDV 7 (Phase 5)
|
||||
/**
|
||||
* Der Sicherheitshinweis über jeder Ansicht der Atemschutzüberwachung.
|
||||
*
|
||||
* Er ist standardmäßig an und **gehört an**: FLORIAN ist ein ergänzendes
|
||||
* System, die analoge Überwachung bleibt führend. Wer ihn abschaltet,
|
||||
* erklärt damit, dass die Wehr die digitale Überwachung freigegeben hat —
|
||||
* das ist eine Entscheidung des Kommandanten, keine des Administrators.
|
||||
* Deshalb verlangt das Abschalten zusätzlich einen Verweis auf die
|
||||
* schriftliche Freigabe (ATEMSCHUTZ_FREIGABE), der in der Oberfläche und im
|
||||
* Protokoll steht.
|
||||
*/
|
||||
ATEMSCHUTZ_HINWEIS: boolAusText(true),
|
||||
/// Aktenzeichen oder Fundstelle der Freigabe durch den Kommandanten.
|
||||
ATEMSCHUTZ_FREIGABE: z.string().optional(),
|
||||
/// Vorgabe für den Abstand der Druckabfrage, in Sekunden.
|
||||
ATEMSCHUTZ_ABFRAGE_INTERVALL: zahl(600),
|
||||
/// Ansprechdruck der Warneinrichtung in bar, Vorgabe für neue Überwachungen.
|
||||
ATEMSCHUTZ_WARNDRUCK: zahl(55),
|
||||
/// Sicherheitszuschlag auf den Rückzugsdruck in bar.
|
||||
ATEMSCHUTZ_ZUSCHLAG: zahl(10),
|
||||
|
||||
// Einsätze und Übungen (Phase 3)
|
||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||
GEOCODING_ENABLED: boolAusText(false),
|
||||
|
||||
@@ -28,6 +28,8 @@ export const AUFTRAG = {
|
||||
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
|
||||
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
|
||||
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
||||
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
|
||||
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
|
||||
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||
} as const;
|
||||
|
||||
@@ -19,6 +19,8 @@ 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";
|
||||
import { atemschutzRouter } from "@/server/routers/atemschutz/ueberwachung";
|
||||
import { atemschutzTraegerRouter } from "@/server/routers/atemschutz/traeger";
|
||||
|
||||
/**
|
||||
* Wurzel-Router.
|
||||
@@ -166,6 +168,10 @@ export const appRouter = router({
|
||||
|
||||
// Phase 4 — Terminal im Gerätehaus
|
||||
kiosk: kioskRouter,
|
||||
|
||||
// Phase 5 — Atemschutz nach FwDV 7
|
||||
atemschutz: atemschutzRouter,
|
||||
atemschutzTraeger: atemschutzTraegerRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { z } from "zod";
|
||||
import { G26Ergebnis, UebungsartAtemschutz } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { g26FristVorschlag } from "@/lib/atemschutz/tauglichkeit";
|
||||
import { traegerBewerten, verfuegbarkeit } from "@/lib/atemschutz/verfuegbarkeit";
|
||||
|
||||
/**
|
||||
* Die Atemschutzgeräteträger.
|
||||
*
|
||||
* Der ruhige Teil des Moduls: G26-Untersuchungen, Belastungsübungen und die
|
||||
* Frage, wer daraus folgend einsetzbar ist. Anders als die Überwachung läuft
|
||||
* das nicht im Einsatz, sondern am Schreibtisch — und trotzdem hängt daran
|
||||
* dasselbe: wer hier durchrutscht, steht im Ernstfall unter Atemschutz, ohne
|
||||
* es zu dürfen.
|
||||
*
|
||||
* Die Bewertung steht in lib/atemschutz/tauglichkeit.ts und ist dort ohne
|
||||
* Datenbank getestet; die Beschaffung in lib/atemschutz/verfuegbarkeit.ts,
|
||||
* damit die Startseite dieselbe Zahl nennt wie diese Ansicht.
|
||||
*/
|
||||
|
||||
export const atemschutzTraegerRouter = router({
|
||||
/**
|
||||
* Die Tauglichkeitsübersicht.
|
||||
*
|
||||
* Eine Zeile je Person mit Ampel und Klartext. Das ist die Ansicht, die der
|
||||
* Atemschutzgerätewart vor einer Übung aufruft — und die dem Kommandanten
|
||||
* sagt, wie viele Träger die Wehr an diesem Tag tatsächlich hat.
|
||||
*/
|
||||
uebersicht: mitRecht("atemschutz.lesen").query(async ({ ctx }) =>
|
||||
ctx.db((db) => traegerBewerten(db)),
|
||||
),
|
||||
|
||||
/**
|
||||
* Wie viele Träger stehen heute zur Verfügung?
|
||||
*
|
||||
* Für die Kachel auf der Startseite. Die Zahl allein sagt wenig — deshalb
|
||||
* steht daneben, wie viele knapp davor sind, es nicht mehr zu sein.
|
||||
*/
|
||||
verfuegbarkeit: mitRecht("atemschutz.lesen").query(async ({ ctx }) =>
|
||||
ctx.db((db) => verfuegbarkeit(db)),
|
||||
),
|
||||
|
||||
/** Alles zu einer Person: Untersuchungen, Übungen, Einsatzstunden. */
|
||||
person: mitRecht("atemschutz.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const [untersuchungen, uebungen, trupps] = await Promise.all([
|
||||
db.g26Untersuchung.findMany({
|
||||
where: { personId: input.id, geloeschtAm: null },
|
||||
orderBy: { untersuchtAm: "desc" },
|
||||
}),
|
||||
db.belastungsuebung.findMany({
|
||||
where: { personId: input.id, geloeschtAm: null },
|
||||
orderBy: { datum: "desc" },
|
||||
}),
|
||||
db.truppmitglied.findMany({
|
||||
where: { personId: input.id, trupp: { einstiegUm: { not: null } } },
|
||||
orderBy: { trupp: { einstiegUm: "desc" } },
|
||||
take: 50,
|
||||
select: {
|
||||
trupp: {
|
||||
select: {
|
||||
id: true,
|
||||
funkrufname: true,
|
||||
einstiegUm: true,
|
||||
drausenUm: true,
|
||||
atemschutzeinsatz: { select: { id: true, bezeichnung: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const einsatzminuten = trupps.reduce((summe, eintrag) => {
|
||||
const { einstiegUm, drausenUm } = eintrag.trupp;
|
||||
if (!einstiegUm || !drausenUm) return summe;
|
||||
return summe + Math.round((drausenUm.getTime() - einstiegUm.getTime()) / 60_000);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
untersuchungen,
|
||||
uebungen,
|
||||
einsaetze: trupps.map((eintrag) => eintrag.trupp),
|
||||
einsatzminuten,
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
/** Vorschlag für das Ablaufdatum einer G26 — abhängig vom Alter. */
|
||||
fristVorschlag: mitRecht("atemschutz.bearbeiten")
|
||||
.input(z.object({ personId: z.string().uuid(), untersuchtAm: z.coerce.date() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: input.personId },
|
||||
select: { geburtsdatum: true },
|
||||
}),
|
||||
);
|
||||
|
||||
return { gueltigBis: g26FristVorschlag(person?.geburtsdatum ?? null, input.untersuchtAm) };
|
||||
}),
|
||||
|
||||
/** Eine Untersuchung erfassen. */
|
||||
g26Erfassen: mitRecht("atemschutz.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
personId: z.string().uuid(),
|
||||
untersuchtAm: z.coerce.date(),
|
||||
gueltigBis: z.coerce.date(),
|
||||
ergebnis: z.nativeEnum(G26Ergebnis).default(G26Ergebnis.TAUGLICH),
|
||||
arzt: z.string().trim().max(200).nullish(),
|
||||
bemerkung: z.string().trim().max(1000).nullish(),
|
||||
nachweisDateiId: z.string().uuid().nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const untersuchung = await db.g26Untersuchung.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: input.personId,
|
||||
untersuchtAm: input.untersuchtAm,
|
||||
gueltigBis: input.gueltigBis,
|
||||
ergebnis: input.ergebnis,
|
||||
arzt: input.arzt ?? null,
|
||||
bemerkung: input.bemerkung ?? null,
|
||||
nachweisDateiId: input.nachweisDateiId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "G26Untersuchung",
|
||||
entitaetId: untersuchung.id,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: {
|
||||
personId: input.personId,
|
||||
ergebnis: input.ergebnis,
|
||||
gueltigBis: input.gueltigBis,
|
||||
},
|
||||
});
|
||||
|
||||
return untersuchung;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Eine Untersuchung zurückziehen.
|
||||
*
|
||||
* Soft-Delete wie überall bei Nachweisen: eine gelöschte G26 ist ein
|
||||
* fehlender Nachweis, und der Vorgang selbst gehört ins Protokoll.
|
||||
*/
|
||||
g26Entfernen: mitRecht("atemschutz.bearbeiten")
|
||||
.input(z.object({ id: z.string().uuid(), grund: z.string().trim().max(300) }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.g26Untersuchung.findUniqueOrThrow({ where: { id: input.id } });
|
||||
|
||||
await db.g26Untersuchung.update({
|
||||
where: { id: input.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "G26Untersuchung",
|
||||
entitaetId: input.id,
|
||||
sitzung: ctx.sitzung,
|
||||
beschreibung: input.grund,
|
||||
vorher: { gueltigBis: vorher.gueltigBis, ergebnis: vorher.ergebnis },
|
||||
});
|
||||
|
||||
return { entfernt: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Eine Belastungsübung, Einsatzübung oder Unterweisung erfassen. */
|
||||
uebungErfassen: mitRecht("atemschutz.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
personIds: z.array(z.string().uuid()).min(1).max(60),
|
||||
datum: z.coerce.date(),
|
||||
art: z.nativeEnum(UebungsartAtemschutz).default(UebungsartAtemschutz.UEBUNGSSTRECKE),
|
||||
ort: z.string().trim().max(200).nullish(),
|
||||
dauerMinuten: z.number().int().min(1).max(600).nullish(),
|
||||
uebungId: z.string().uuid().nullish(),
|
||||
bemerkung: z.string().trim().max(1000).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
// Für eine ganze Gruppe auf einmal: eine Belastungsübung machen
|
||||
// selten Einzelne, sondern die halbe Wehr an einem Abend.
|
||||
await db.belastungsuebung.createMany({
|
||||
data: input.personIds.map((personId) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId,
|
||||
datum: input.datum,
|
||||
art: input.art,
|
||||
ort: input.ort ?? null,
|
||||
dauerMinuten: input.dauerMinuten ?? null,
|
||||
uebungId: input.uebungId ?? null,
|
||||
bemerkung: input.bemerkung ?? null,
|
||||
})),
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Belastungsuebung",
|
||||
sitzung: ctx.sitzung,
|
||||
beschreibung: `${input.personIds.length} Teilnehmer, ${input.art}`,
|
||||
nachher: { datum: input.datum, art: input.art },
|
||||
});
|
||||
|
||||
return { erfasst: input.personIds.length };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Eine Übung zurückziehen. */
|
||||
uebungEntfernen: mitRecht("atemschutz.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
await db.belastungsuebung.update({
|
||||
where: { id: input.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Belastungsuebung",
|
||||
entitaetId: input.id,
|
||||
sitzung: ctx.sitzung,
|
||||
});
|
||||
|
||||
return { entfernt: true };
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,905 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { DruckArt, EreignisArt, TruppStatus } from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { env } from "@/lib/env";
|
||||
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
|
||||
import type { MandantenClient } from "@/lib/db";
|
||||
import {
|
||||
DRUCK_HOECHSTWERT,
|
||||
ampel,
|
||||
einsatzdauer,
|
||||
niedrigsterDruck,
|
||||
restzeitMinuten,
|
||||
rueckzugsdruck,
|
||||
statuswechselErlaubt,
|
||||
verbrauchProMinute,
|
||||
abfrageUeberfaellig,
|
||||
naechsteAbfrage,
|
||||
} from "@/lib/atemschutz/berechnung";
|
||||
|
||||
/**
|
||||
* Die Atemschutzüberwachung.
|
||||
*
|
||||
* Der Router hat eine Eigenschaft, die ihn vom Rest des Projekts unterscheidet:
|
||||
* **er führt keine Zeit**. Es gibt keinen Timer, kein Intervall, keinen
|
||||
* Zähler. Jede Aktion setzt einen Zeitstempel, jede Abfrage liefert
|
||||
* Zeitstempel — und wie viel Zeit vergangen ist, rechnet der Empfänger aus.
|
||||
*
|
||||
* Das ist der Grund, warum die Anzeige einen Neustart des Browsers, einen
|
||||
* Verbindungsabbruch und einen zweiten Bildschirm übersteht: es gibt nichts,
|
||||
* das dabei verloren gehen könnte. Wer die Seite neu lädt, fragt den Stand ab
|
||||
* und rechnet weiter, als wäre nichts gewesen.
|
||||
*
|
||||
* Die zweite Eigenschaft: **das Protokoll wächst nur**. Jede Druckmessung und
|
||||
* jedes Ereignis wird angelegt und nie wieder angefasst — die Datenbank
|
||||
* verweigert dem Anwendungszugang Änderung und Löschung (siehe Migration
|
||||
* 20260903190300_atemschutz_rls).
|
||||
*/
|
||||
|
||||
/** Schreibt einen Protokolleintrag. Immer in derselben Transaktion wie die Änderung. */
|
||||
async function protokollieren(
|
||||
db: MandantenClient,
|
||||
angaben: {
|
||||
organisationId: string;
|
||||
atemschutzeinsatzId: string;
|
||||
truppId?: string | null;
|
||||
art: EreignisArt;
|
||||
text: string;
|
||||
personId?: string | null;
|
||||
zeitpunkt?: Date;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db.atemschutzEreignis.create({
|
||||
data: {
|
||||
organisationId: angaben.organisationId,
|
||||
atemschutzeinsatzId: angaben.atemschutzeinsatzId,
|
||||
truppId: angaben.truppId ?? null,
|
||||
art: angaben.art,
|
||||
text: angaben.text,
|
||||
personId: angaben.personId ?? null,
|
||||
...(angaben.zeitpunkt ? { zeitpunkt: angaben.zeitpunkt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Alles, was eine Ansicht über einen Trupp wissen muss. */
|
||||
const truppAuswahl = {
|
||||
id: true,
|
||||
funkrufname: true,
|
||||
auftrag: true,
|
||||
status: true,
|
||||
angelegtUm: true,
|
||||
einstiegUm: true,
|
||||
rueckzugUm: true,
|
||||
drausenUm: true,
|
||||
rueckzugsdruck: true,
|
||||
maydayUm: true,
|
||||
maydayAufgehobenUm: true,
|
||||
bemerkung: true,
|
||||
mitglieder: {
|
||||
select: {
|
||||
id: true,
|
||||
truppfuehrer: true,
|
||||
person: { select: { id: true, vorname: true, nachname: true } },
|
||||
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
|
||||
},
|
||||
},
|
||||
druckmessungen: {
|
||||
select: { id: true, personId: true, druck: true, art: true, zeitpunkt: true },
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Rechnet die abgeleiteten Werte zu einem Trupp aus.
|
||||
*
|
||||
* Bewusst auf dem Server **und** noch einmal im Browser: der Server liefert
|
||||
* den Stand zum Zeitpunkt der Antwort, der Browser rechnet die Sekunden
|
||||
* dazwischen weiter. Beide benutzen dieselben Funktionen aus
|
||||
* lib/atemschutz/berechnung.ts.
|
||||
*/
|
||||
function truppAufbereiten(
|
||||
trupp: {
|
||||
id: string;
|
||||
status: TruppStatus;
|
||||
einstiegUm: Date | null;
|
||||
drausenUm: Date | null;
|
||||
rueckzugsdruck: number | null;
|
||||
druckmessungen: Array<{ personId: string; druck: number; art: DruckArt; zeitpunkt: Date }>;
|
||||
},
|
||||
intervallSekunden: number,
|
||||
jetzt: Date,
|
||||
) {
|
||||
const laufende = trupp.druckmessungen.filter((messung) => messung.art !== DruckArt.EINGANG);
|
||||
const niedrigste = niedrigsterDruck(trupp.druckmessungen);
|
||||
const letzteMessung = laufende.at(-1) ?? null;
|
||||
|
||||
const rate = verbrauchProMinute(
|
||||
trupp.druckmessungen.map((messung) => ({ druck: messung.druck, zeitpunkt: messung.zeitpunkt })),
|
||||
);
|
||||
|
||||
return {
|
||||
dauerSekunden: einsatzdauer(trupp, jetzt),
|
||||
niedrigsterDruck: niedrigste?.druck ?? null,
|
||||
niedrigstePersonId: niedrigste?.personId ?? null,
|
||||
letzteMessungUm: letzteMessung?.zeitpunkt ?? null,
|
||||
naechsteAbfrageUm: naechsteAbfrage({
|
||||
einstiegUm: trupp.einstiegUm,
|
||||
letzteMessungUm: letzteMessung?.zeitpunkt ?? null,
|
||||
intervallSekunden,
|
||||
}),
|
||||
abfrageUeberfaellig: abfrageUeberfaellig({
|
||||
einstiegUm: trupp.einstiegUm,
|
||||
letzteMessungUm: letzteMessung?.zeitpunkt ?? null,
|
||||
intervallSekunden,
|
||||
status: trupp.status,
|
||||
jetzt,
|
||||
}),
|
||||
verbrauchProMinute: rate,
|
||||
restzeitMinuten: restzeitMinuten({
|
||||
aktuellerDruck: niedrigste?.druck ?? null,
|
||||
rueckzugsdruck: trupp.rueckzugsdruck,
|
||||
verbrauchProMinute: rate,
|
||||
}),
|
||||
ampel: ampel({
|
||||
aktuellerDruck: niedrigste?.druck ?? null,
|
||||
rueckzugsdruck: trupp.rueckzugsdruck,
|
||||
status: trupp.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Holt eine Überwachung samt Trupps und rechnet den Stand aus. */
|
||||
async function standLesen(db: MandantenClient, id: string, jetzt: Date) {
|
||||
const ueberwachung = await db.atemschutzeinsatz.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
bezeichnung: true,
|
||||
beginnUm: true,
|
||||
endeUm: true,
|
||||
abfrageIntervall: true,
|
||||
sicherheitszuschlag: true,
|
||||
warndruck: true,
|
||||
bemerkung: true,
|
||||
protokollDateiId: true,
|
||||
ueberwacher: { select: { id: true, vorname: true, nachname: true } },
|
||||
einsatz: { select: { id: true, einsatznummer: true, stichwort: true } },
|
||||
uebung: { select: { id: true, nummer: true, thema: true } },
|
||||
trupps: { select: truppAuswahl, orderBy: { angelegtUm: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!ueberwachung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Überwachung gibt es nicht." });
|
||||
}
|
||||
|
||||
return {
|
||||
...ueberwachung,
|
||||
/** Der Zeitpunkt der Antwort. Der Browser rechnet ab hier weiter. */
|
||||
stand: jetzt,
|
||||
trupps: ueberwachung.trupps.map((trupp) => ({
|
||||
...trupp,
|
||||
...truppAufbereiten(trupp, ueberwachung.abfrageIntervall, jetzt),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type Ueberwachungsstand = Awaited<ReturnType<typeof standLesen>>;
|
||||
|
||||
/** Der Druck, den eine Person zuletzt gemeldet hat. */
|
||||
function letzterDruckJePerson(
|
||||
messungen: Array<{ personId: string; druck: number; art: DruckArt; zeitpunkt: Date }>,
|
||||
art?: DruckArt,
|
||||
): Map<string, number> {
|
||||
const karte = new Map<string, { druck: number; zeitpunkt: Date }>();
|
||||
|
||||
for (const messung of messungen) {
|
||||
if (art && messung.art !== art) continue;
|
||||
const bisher = karte.get(messung.personId);
|
||||
if (!bisher || messung.zeitpunkt.getTime() >= bisher.zeitpunkt.getTime()) {
|
||||
karte.set(messung.personId, { druck: messung.druck, zeitpunkt: messung.zeitpunkt });
|
||||
}
|
||||
}
|
||||
|
||||
return new Map([...karte].map(([personId, wert]) => [personId, wert.druck]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Errechnet den Rückzugsdruck eines Trupps neu.
|
||||
*
|
||||
* Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht
|
||||
* gemeinsam zurück, also zählt, wer am wenigsten Luft hat.
|
||||
*/
|
||||
function truppRueckzugsdruck(
|
||||
messungen: Array<{ personId: string; druck: number; art: DruckArt; zeitpunkt: Date }>,
|
||||
warndruck: number,
|
||||
zuschlag: number,
|
||||
): number | null {
|
||||
const eingang = letzterDruckJePerson(messungen, DruckArt.EINGANG);
|
||||
if (eingang.size === 0) return null;
|
||||
|
||||
const ziel = letzterDruckJePerson(messungen, DruckArt.ZIEL);
|
||||
|
||||
let hoechster: number | null = null;
|
||||
|
||||
for (const [personId, eingangsdruck] of eingang) {
|
||||
const { druck } = rueckzugsdruck({
|
||||
eingangsdruck,
|
||||
zieldruck: ziel.get(personId) ?? null,
|
||||
warndruck,
|
||||
sicherheitszuschlag: zuschlag,
|
||||
});
|
||||
if (hoechster === null || druck > hoechster) hoechster = druck;
|
||||
}
|
||||
|
||||
return hoechster;
|
||||
}
|
||||
|
||||
export const atemschutzRouter = router({
|
||||
/**
|
||||
* Der Sicherheitshinweis.
|
||||
*
|
||||
* Steht bewusst im Server und nicht als Konstante im Browser: ob er
|
||||
* abgeschaltet ist, entscheidet die Konfiguration der Wehr, und die
|
||||
* Fundstelle der Freigabe gehört daneben. Ohne Freigabe bleibt der Hinweis
|
||||
* stehen, egal was in ATEMSCHUTZ_HINWEIS steht.
|
||||
*/
|
||||
hinweis: angemeldet.query(() => {
|
||||
const freigabe = env.ATEMSCHUTZ_FREIGABE?.trim();
|
||||
const abgeschaltet = !env.ATEMSCHUTZ_HINWEIS && Boolean(freigabe);
|
||||
|
||||
return {
|
||||
anzeigen: !abgeschaltet,
|
||||
freigabe: freigabe || null,
|
||||
text: "Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.",
|
||||
};
|
||||
}),
|
||||
|
||||
/** Vorgabewerte für eine neue Überwachung. */
|
||||
vorgaben: mitRecht("atemschutz.ueberwachung").query(() => ({
|
||||
abfrageIntervall: env.ATEMSCHUTZ_ABFRAGE_INTERVALL,
|
||||
warndruck: env.ATEMSCHUTZ_WARNDRUCK,
|
||||
sicherheitszuschlag: env.ATEMSCHUTZ_ZUSCHLAG,
|
||||
})),
|
||||
|
||||
/** Die laufenden Überwachungen. Für den Einstieg und das Dashboard. */
|
||||
laufende: mitRecht("atemschutz.lesen").query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const offene = await db.atemschutzeinsatz.findMany({
|
||||
where: { endeUm: null },
|
||||
orderBy: { beginnUm: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
bezeichnung: true,
|
||||
beginnUm: true,
|
||||
ueberwacher: { select: { vorname: true, nachname: true } },
|
||||
_count: { select: { trupps: true } },
|
||||
trupps: {
|
||||
where: { status: { in: [TruppStatus.IM_EINSATZ, TruppStatus.RUECKWEG] } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return offene.map((eintrag) => ({
|
||||
...eintrag,
|
||||
truppsDrinnen: eintrag.trupps.length,
|
||||
}));
|
||||
}),
|
||||
),
|
||||
|
||||
/** Die Liste aller Überwachungen, für die Nachschau. */
|
||||
liste: mitRecht("atemschutz.lesen")
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
nurLaufende: z.boolean().default(false),
|
||||
anzahl: z.number().int().min(1).max(100).default(30),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.atemschutzeinsatz.findMany({
|
||||
where: input?.nurLaufende ? { endeUm: null } : {},
|
||||
orderBy: { beginnUm: "desc" },
|
||||
take: input?.anzahl ?? 30,
|
||||
select: {
|
||||
id: true,
|
||||
bezeichnung: true,
|
||||
beginnUm: true,
|
||||
endeUm: true,
|
||||
protokollDateiId: true,
|
||||
ueberwacher: { select: { vorname: true, nachname: true } },
|
||||
einsatz: { select: { id: true, einsatznummer: true } },
|
||||
_count: { select: { trupps: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/**
|
||||
* Der vollständige Stand einer Überwachung.
|
||||
*
|
||||
* Das ist die Abfrage, die alles trägt: die Ansicht ruft sie beim Laden auf,
|
||||
* nach jeder Aktion und nach jedem Hinweis aus dem Ereignisstrom. Sie ist
|
||||
* bewusst nicht sparsam — lieber eine Antwort mit allem als drei, die
|
||||
* auseinanderlaufen können.
|
||||
*/
|
||||
stand: mitRecht("atemschutz.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) => ctx.db((db) => standLesen(db, input.id, new Date()))),
|
||||
|
||||
/** Das Protokoll einer Überwachung, in der Reihenfolge der Ereignisse. */
|
||||
protokoll: mitRecht("atemschutz.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const [ereignisse, messungen] = await Promise.all([
|
||||
db.atemschutzEreignis.findMany({
|
||||
where: { atemschutzeinsatzId: input.id },
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
zeitpunkt: true,
|
||||
art: true,
|
||||
text: true,
|
||||
trupp: { select: { id: true, funkrufname: true } },
|
||||
person: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
db.druckmessung.findMany({
|
||||
where: { trupp: { atemschutzeinsatzId: input.id } },
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
zeitpunkt: true,
|
||||
druck: true,
|
||||
art: true,
|
||||
trupp: { select: { id: true, funkrufname: true } },
|
||||
person: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { ereignisse, messungen };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Eine Überwachung eröffnen. */
|
||||
eroeffnen: mitRecht("atemschutz.ueberwachung")
|
||||
.input(
|
||||
z.object({
|
||||
bezeichnung: z.string().trim().min(1).max(200),
|
||||
einsatzId: z.string().uuid().nullish(),
|
||||
uebungId: z.string().uuid().nullish(),
|
||||
abfrageIntervall: z.number().int().min(60).max(3600).optional(),
|
||||
sicherheitszuschlag: z.number().int().min(0).max(100).optional(),
|
||||
warndruck: z.number().int().min(0).max(120).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const ueberwachung = await db.atemschutzeinsatz.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
bezeichnung: input.bezeichnung,
|
||||
einsatzId: input.einsatzId ?? null,
|
||||
uebungId: input.uebungId ?? null,
|
||||
ueberwacherId: ctx.sitzung.personId,
|
||||
abfrageIntervall: input.abfrageIntervall ?? env.ATEMSCHUTZ_ABFRAGE_INTERVALL,
|
||||
sicherheitszuschlag: input.sicherheitszuschlag ?? env.ATEMSCHUTZ_ZUSCHLAG,
|
||||
warndruck: input.warndruck ?? env.ATEMSCHUTZ_WARNDRUCK,
|
||||
},
|
||||
});
|
||||
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: ueberwachung.id,
|
||||
art: EreignisArt.HINWEIS,
|
||||
text: `Überwachung eröffnet durch ${ctx.sitzung.name}.`,
|
||||
personId: ctx.sitzung.personId,
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: ueberwachung.id,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { bezeichnung: ueberwachung.bezeichnung },
|
||||
});
|
||||
|
||||
return ueberwachung;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Einen Trupp anlegen.
|
||||
*
|
||||
* Der Eingangsdruck gehört dazu: ein Trupp ohne Eingangsdruck lässt sich
|
||||
* nicht überwachen, und ihn nachträglich zu erfassen hieße, ihn zu raten.
|
||||
*/
|
||||
truppAnlegen: mitRecht("atemschutz.ueberwachung")
|
||||
.input(
|
||||
z.object({
|
||||
atemschutzeinsatzId: z.string().uuid(),
|
||||
funkrufname: z.string().trim().min(1).max(60),
|
||||
auftrag: z.string().trim().max(500).nullish(),
|
||||
mitglieder: z
|
||||
.array(
|
||||
z.object({
|
||||
personId: z.string().uuid(),
|
||||
truppfuehrer: z.boolean().default(false),
|
||||
eingangsdruck: z.number().int().min(1).max(DRUCK_HOECHSTWERT),
|
||||
geraetId: z.string().uuid().nullish(),
|
||||
}),
|
||||
)
|
||||
.min(2, "Ein Trupp besteht aus mindestens zwei Personen.")
|
||||
.max(3, "Mehr als drei Personen sind kein Trupp mehr."),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const fuehrer = input.mitglieder.filter((mitglied) => mitglied.truppfuehrer);
|
||||
if (fuehrer.length !== 1) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Genau eine Person im Trupp ist Truppführer.",
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db(async (db) => {
|
||||
const ueberwachung = await db.atemschutzeinsatz.findUnique({
|
||||
where: { id: input.atemschutzeinsatzId },
|
||||
select: { id: true, endeUm: true, warndruck: true, sicherheitszuschlag: true },
|
||||
});
|
||||
|
||||
if (!ueberwachung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Überwachung gibt es nicht." });
|
||||
}
|
||||
if (ueberwachung.endeUm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Diese Überwachung ist beendet. Für einen neuen Trupp eine neue eröffnen.",
|
||||
});
|
||||
}
|
||||
|
||||
const trupp = await db.atemschutztrupp.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: input.atemschutzeinsatzId,
|
||||
funkrufname: input.funkrufname,
|
||||
auftrag: input.auftrag ?? null,
|
||||
mitglieder: {
|
||||
create: input.mitglieder.map((mitglied) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: mitglied.personId,
|
||||
truppfuehrer: mitglied.truppfuehrer,
|
||||
geraetId: mitglied.geraetId ?? null,
|
||||
})),
|
||||
},
|
||||
druckmessungen: {
|
||||
create: input.mitglieder.map((mitglied) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: mitglied.personId,
|
||||
druck: mitglied.eingangsdruck,
|
||||
art: DruckArt.EINGANG,
|
||||
erfasstVonId: ctx.sitzung.personId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: { id: true, funkrufname: true, druckmessungen: true },
|
||||
});
|
||||
|
||||
// Rückzugsdruck steht schon jetzt fest — vorerst nach der
|
||||
// Zwei-Drittel-Regel, bis der Trupp das Einsatzziel meldet.
|
||||
const grenze = truppRueckzugsdruck(
|
||||
trupp.druckmessungen,
|
||||
ueberwachung.warndruck,
|
||||
ueberwachung.sicherheitszuschlag,
|
||||
);
|
||||
|
||||
await db.atemschutztrupp.update({
|
||||
where: { id: trupp.id },
|
||||
data: { rueckzugsdruck: grenze },
|
||||
});
|
||||
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: input.atemschutzeinsatzId,
|
||||
truppId: trupp.id,
|
||||
art: EreignisArt.TRUPP_ANGELEGT,
|
||||
text: `Trupp ${trupp.funkrufname} angelegt, Rückzugsdruck ${grenze ?? "—"} bar.`,
|
||||
personId: ctx.sitzung.personId,
|
||||
});
|
||||
|
||||
return { id: trupp.id, rueckzugsdruck: grenze };
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Eine Druckmessung erfassen.
|
||||
*
|
||||
* Die häufigste Aktion im Einsatz und deshalb die einfachste: Trupp,
|
||||
* Person, Zahl. Alles Weitere ergibt sich.
|
||||
*/
|
||||
druckErfassen: mitRecht("atemschutz.ueberwachung")
|
||||
.input(
|
||||
z.object({
|
||||
truppId: z.string().uuid(),
|
||||
/** Mehrere auf einmal: der Überwacher fragt den Trupp ab, nicht Einzelne. */
|
||||
messungen: z
|
||||
.array(
|
||||
z.object({
|
||||
personId: z.string().uuid(),
|
||||
druck: z.number().int().min(0).max(DRUCK_HOECHSTWERT),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(3),
|
||||
art: z.nativeEnum(DruckArt).default(DruckArt.ZWISCHEN),
|
||||
bemerkung: z.string().trim().max(300).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const trupp = await db.atemschutztrupp.findUnique({
|
||||
where: { id: input.truppId },
|
||||
select: {
|
||||
id: true,
|
||||
funkrufname: true,
|
||||
status: true,
|
||||
atemschutzeinsatzId: true,
|
||||
atemschutzeinsatz: { select: { warndruck: true, sicherheitszuschlag: true } },
|
||||
druckmessungen: {
|
||||
select: { personId: true, druck: true, art: true, zeitpunkt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!trupp) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Trupp gibt es nicht." });
|
||||
}
|
||||
|
||||
const zeitpunkt = new Date();
|
||||
|
||||
await db.druckmessung.createMany({
|
||||
data: input.messungen.map((messung) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
truppId: input.truppId,
|
||||
personId: messung.personId,
|
||||
druck: messung.druck,
|
||||
art: input.art,
|
||||
zeitpunkt,
|
||||
erfasstVonId: ctx.sitzung.personId,
|
||||
bemerkung: input.bemerkung ?? null,
|
||||
})),
|
||||
});
|
||||
|
||||
const alle = [
|
||||
...trupp.druckmessungen,
|
||||
...input.messungen.map((messung) => ({
|
||||
personId: messung.personId,
|
||||
druck: messung.druck,
|
||||
art: input.art,
|
||||
zeitpunkt,
|
||||
})),
|
||||
];
|
||||
|
||||
// Mit der Zielmeldung ändert sich die Grundlage: ab jetzt wird nach
|
||||
// dem tatsächlichen Anmarschverbrauch gerechnet.
|
||||
const grenze = truppRueckzugsdruck(
|
||||
alle,
|
||||
trupp.atemschutzeinsatz.warndruck,
|
||||
trupp.atemschutzeinsatz.sicherheitszuschlag,
|
||||
);
|
||||
|
||||
await db.atemschutztrupp.update({
|
||||
where: { id: trupp.id },
|
||||
data: { rueckzugsdruck: grenze },
|
||||
});
|
||||
|
||||
const werte = input.messungen.map((messung) => `${messung.druck} bar`).join(", ");
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: trupp.atemschutzeinsatzId,
|
||||
truppId: trupp.id,
|
||||
art: EreignisArt.DRUCKABFRAGE,
|
||||
text:
|
||||
input.art === DruckArt.ZIEL
|
||||
? `Einsatzziel erreicht: ${werte}. Rückzugsdruck jetzt ${grenze ?? "—"} bar.`
|
||||
: `Druckabfrage ${trupp.funkrufname}: ${werte}.`,
|
||||
personId: ctx.sitzung.personId,
|
||||
zeitpunkt,
|
||||
});
|
||||
|
||||
return { rueckzugsdruck: grenze, zeitpunkt };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Statuswechsel: Einstieg, Rückzug, draußen. */
|
||||
statusSetzen: mitRecht("atemschutz.ueberwachung")
|
||||
.input(
|
||||
z.object({
|
||||
truppId: z.string().uuid(),
|
||||
status: z.nativeEnum(TruppStatus),
|
||||
/** Beim Herauskommen: der Ausgangsdruck je Person. */
|
||||
ausgangsdruecke: z
|
||||
.array(
|
||||
z.object({
|
||||
personId: z.string().uuid(),
|
||||
druck: z.number().int().min(0).max(DRUCK_HOECHSTWERT),
|
||||
}),
|
||||
)
|
||||
.max(3)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const trupp = await db.atemschutztrupp.findUnique({
|
||||
where: { id: input.truppId },
|
||||
select: {
|
||||
id: true,
|
||||
funkrufname: true,
|
||||
status: true,
|
||||
atemschutzeinsatzId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!trupp) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Trupp gibt es nicht." });
|
||||
}
|
||||
|
||||
if (!statuswechselErlaubt(trupp.status, input.status)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Ein Trupp geht nicht von „${trupp.status}" nach „${input.status}". Der Weg ist: bereit, im Einsatz, Rückweg, draußen.`,
|
||||
});
|
||||
}
|
||||
|
||||
const jetzt = new Date();
|
||||
|
||||
const zeiten =
|
||||
input.status === TruppStatus.IM_EINSATZ
|
||||
? { einstiegUm: jetzt }
|
||||
: input.status === TruppStatus.RUECKWEG
|
||||
? { rueckzugUm: jetzt }
|
||||
: { drausenUm: jetzt };
|
||||
|
||||
await db.atemschutztrupp.update({
|
||||
where: { id: trupp.id },
|
||||
data: { status: input.status, ...zeiten },
|
||||
});
|
||||
|
||||
if (input.status === TruppStatus.DRAUSSEN && input.ausgangsdruecke?.length) {
|
||||
await db.druckmessung.createMany({
|
||||
data: input.ausgangsdruecke.map((messung) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
truppId: trupp.id,
|
||||
personId: messung.personId,
|
||||
druck: messung.druck,
|
||||
art: DruckArt.AUSGANG,
|
||||
zeitpunkt: jetzt,
|
||||
erfasstVonId: ctx.sitzung.personId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const art =
|
||||
input.status === TruppStatus.IM_EINSATZ
|
||||
? EreignisArt.EINSTIEG
|
||||
: input.status === TruppStatus.RUECKWEG
|
||||
? EreignisArt.RUECKZUG_ANGETRETEN
|
||||
: EreignisArt.HERAUSGEKOMMEN;
|
||||
|
||||
const text =
|
||||
input.status === TruppStatus.IM_EINSATZ
|
||||
? `Trupp ${trupp.funkrufname} unter Atemschutz eingestiegen.`
|
||||
: input.status === TruppStatus.RUECKWEG
|
||||
? `Trupp ${trupp.funkrufname} tritt den Rückweg an.`
|
||||
: `Trupp ${trupp.funkrufname} ist draußen${
|
||||
input.ausgangsdruecke?.length
|
||||
? ` (${input.ausgangsdruecke.map((wert) => `${wert.druck} bar`).join(", ")})`
|
||||
: ""
|
||||
}.`;
|
||||
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: trupp.atemschutzeinsatzId,
|
||||
truppId: trupp.id,
|
||||
art,
|
||||
text,
|
||||
personId: ctx.sitzung.personId,
|
||||
zeitpunkt: jetzt,
|
||||
});
|
||||
|
||||
return { status: input.status, zeitpunkt: jetzt };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Rückzug anordnen — der Überwacher, nicht der Trupp. */
|
||||
rueckzugAnordnen: mitRecht("atemschutz.ueberwachung")
|
||||
.input(z.object({ truppId: z.string().uuid(), grund: z.string().trim().max(300).nullish() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const trupp = await db.atemschutztrupp.findUniqueOrThrow({
|
||||
where: { id: input.truppId },
|
||||
select: { id: true, funkrufname: true, atemschutzeinsatzId: true },
|
||||
});
|
||||
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: trupp.atemschutzeinsatzId,
|
||||
truppId: trupp.id,
|
||||
art: EreignisArt.RUECKZUG_ANGEORDNET,
|
||||
text: `Rückzug angeordnet für ${trupp.funkrufname}${input.grund ? `: ${input.grund}` : "."}`,
|
||||
personId: ctx.sitzung.personId,
|
||||
});
|
||||
|
||||
return { angeordnet: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Mayday.
|
||||
*
|
||||
* Der Knopf, der hoffentlich nie gedrückt wird. Er ändert nichts an der
|
||||
* Lage — er hält fest, wann es losging, und macht es auf jedem Bildschirm
|
||||
* sichtbar. Aufgehoben wird er ausdrücklich, nicht durch Wegklicken.
|
||||
*/
|
||||
mayday: mitRecht("atemschutz.ueberwachung")
|
||||
.input(
|
||||
z.object({
|
||||
truppId: z.string().uuid(),
|
||||
aufheben: z.boolean().default(false),
|
||||
text: z.string().trim().max(300).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const trupp = await db.atemschutztrupp.findUniqueOrThrow({
|
||||
where: { id: input.truppId },
|
||||
select: { id: true, funkrufname: true, atemschutzeinsatzId: true, maydayUm: true },
|
||||
});
|
||||
|
||||
const jetzt = new Date();
|
||||
|
||||
if (input.aufheben) {
|
||||
if (!trupp.maydayUm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Für diesen Trupp läuft kein Notfall.",
|
||||
});
|
||||
}
|
||||
|
||||
await db.atemschutztrupp.update({
|
||||
where: { id: trupp.id },
|
||||
data: { maydayAufgehobenUm: jetzt },
|
||||
});
|
||||
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: trupp.atemschutzeinsatzId,
|
||||
truppId: trupp.id,
|
||||
art: EreignisArt.MAYDAY_AUFGEHOBEN,
|
||||
text: `Notfall für ${trupp.funkrufname} aufgehoben${input.text ? `: ${input.text}` : "."}`,
|
||||
personId: ctx.sitzung.personId,
|
||||
zeitpunkt: jetzt,
|
||||
});
|
||||
|
||||
return { mayday: false, zeitpunkt: jetzt };
|
||||
}
|
||||
|
||||
await db.atemschutztrupp.update({
|
||||
where: { id: trupp.id },
|
||||
data: { maydayUm: trupp.maydayUm ?? jetzt, maydayAufgehobenUm: null },
|
||||
});
|
||||
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: trupp.atemschutzeinsatzId,
|
||||
truppId: trupp.id,
|
||||
art: EreignisArt.MAYDAY,
|
||||
text: `MAYDAY — ${trupp.funkrufname}${input.text ? `: ${input.text}` : "."}`,
|
||||
personId: ctx.sitzung.personId,
|
||||
zeitpunkt: jetzt,
|
||||
});
|
||||
|
||||
return { mayday: true, zeitpunkt: jetzt };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Eine Notiz ins Protokoll. */
|
||||
notiz: mitRecht("atemschutz.ueberwachung")
|
||||
.input(
|
||||
z.object({
|
||||
atemschutzeinsatzId: z.string().uuid(),
|
||||
truppId: z.string().uuid().nullish(),
|
||||
text: z.string().trim().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: input.atemschutzeinsatzId,
|
||||
truppId: input.truppId ?? null,
|
||||
art: EreignisArt.HINWEIS,
|
||||
text: input.text,
|
||||
personId: ctx.sitzung.personId,
|
||||
});
|
||||
|
||||
return { erfasst: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Die Überwachung beenden.
|
||||
*
|
||||
* Geht erst, wenn alle Trupps draußen sind. Das ist keine Schikane: eine
|
||||
* beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
|
||||
* gefährlichste Zeile im ganzen Protokoll.
|
||||
*/
|
||||
beenden: mitRecht("atemschutz.ueberwachung")
|
||||
.input(z.object({ id: z.string().uuid(), bemerkung: z.string().trim().max(2000).nullish() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const drinnen = await db.atemschutztrupp.count({
|
||||
where: {
|
||||
atemschutzeinsatzId: input.id,
|
||||
status: { in: [TruppStatus.BEREIT, TruppStatus.IM_EINSATZ, TruppStatus.RUECKWEG] },
|
||||
},
|
||||
});
|
||||
|
||||
if (drinnen > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Es ${drinnen === 1 ? "ist noch ein Trupp" : `sind noch ${drinnen} Trupps`} nicht als draußen gemeldet. Erst abmelden, dann beenden.`,
|
||||
});
|
||||
}
|
||||
|
||||
const jetzt = new Date();
|
||||
|
||||
const ueberwachung = await db.atemschutzeinsatz.update({
|
||||
where: { id: input.id },
|
||||
data: { endeUm: jetzt, bemerkung: input.bemerkung ?? undefined },
|
||||
});
|
||||
|
||||
await protokollieren(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
atemschutzeinsatzId: input.id,
|
||||
art: EreignisArt.HINWEIS,
|
||||
text: `Überwachung beendet durch ${ctx.sitzung.name}.`,
|
||||
personId: ctx.sitzung.personId,
|
||||
zeitpunkt: jetzt,
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: input.id,
|
||||
sitzung: ctx.sitzung,
|
||||
beschreibung: "Überwachung beendet",
|
||||
nachher: { endeUm: jetzt },
|
||||
});
|
||||
|
||||
// Das Protokoll entsteht im Hintergrund: es geht über Gotenberg, und
|
||||
// das darf dauern. Klappt die Beauftragung nicht, ist die Überwachung
|
||||
// trotzdem beendet — das Protokoll lässt sich nachträglich anstoßen.
|
||||
await beauftrage(AUFTRAG.ATEMSCHUTZPROTOKOLL_ERZEUGEN, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
ueberwachungId: input.id,
|
||||
}).catch((fehler) => {
|
||||
console.error("[atemschutz] Protokollauftrag ließ sich nicht einstellen:", fehler);
|
||||
});
|
||||
|
||||
return ueberwachung;
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
|
||||
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
|
||||
import { einsatzberichtErzeugen } from "@/lib/einsatz/bericht-erzeugen";
|
||||
import { atemschutzprotokollErzeugen } from "@/lib/atemschutz/protokoll-erzeugen";
|
||||
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
|
||||
|
||||
/**
|
||||
@@ -105,6 +106,33 @@ async function einsatzberichtErzeugenAuftrag(auftrag: Job<AuftragsDaten>) {
|
||||
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
|
||||
}
|
||||
|
||||
async function atemschutzprotokollErzeugenAuftrag(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId, ueberwachungId } = auftrag.data as AuftragsDaten & {
|
||||
ueberwachungId?: string;
|
||||
};
|
||||
|
||||
if (!ueberwachungId) {
|
||||
throw new Error("Protokollauftrag ohne Angabe, wozu — ueberwachungId fehlt.");
|
||||
}
|
||||
|
||||
const ergebnis = await atemschutzprotokollErzeugen(organisationId, ueberwachungId);
|
||||
|
||||
if (!ergebnis) {
|
||||
protokoll(
|
||||
`Kein Atemschutzprotokoll für ${ueberwachungId} — unbekannt oder noch nicht beendet.`,
|
||||
);
|
||||
return { erzeugt: false };
|
||||
}
|
||||
|
||||
protokoll(
|
||||
ergebnis.neu
|
||||
? `Atemschutzprotokoll für ${ueberwachungId} erzeugt.`
|
||||
: `Atemschutzprotokoll für ${ueberwachungId} lag bereits vor.`,
|
||||
);
|
||||
|
||||
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
|
||||
}
|
||||
|
||||
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
|
||||
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
|
||||
@@ -117,6 +145,7 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
|
||||
[AUFTRAG.ERINNERUNG_SENDEN]: erinnerungSenden,
|
||||
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
|
||||
[AUFTRAG.EINSATZBERICHT_ERZEUGEN]: einsatzberichtErzeugenAuftrag,
|
||||
[AUFTRAG.ATEMSCHUTZPROTOKOLL_ERZEUGEN]: atemschutzprotokollErzeugenAuftrag,
|
||||
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user