Atemschutzueberwachung nach FwDV 7 ergaenzen

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 23:19:05 +02:00
co-authored by Claude Opus 5
parent b2b31ed00a
commit 4e2daaafd4
107 changed files with 7214 additions and 40 deletions
+52
View File
@@ -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>
);
}
+114
View File
@@ -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 };
}
+213
View File
@@ -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>
);
}
+344
View File
@@ -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>
);
}
+5 -2
View File
@@ -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[];