Phase 3: Einsatz- und Uebungserfassung

Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt
und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit
Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde,
Statistik und Uebungen mit Anwesenheitsliste.

Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung,
zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen
zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich
erreichbaren Endpunkte in FLORIAN.

Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein
freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt
das durch. Koordinaten liegen doppelt — als Dezimalgrad und als
PostGIS-Punkt, zusammengehalten von einem Trigger.

57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 13:50:58 +02:00
co-authored by Claude Opus 5
parent bc62a9c1e8
commit 91954c402b
100 changed files with 9667 additions and 38 deletions
@@ -0,0 +1,691 @@
"use client";
import * as React from "react";
import { Check, Copy, FileDown, Link2, Plus, Send, Trash2, Users } from "lucide-react";
import { EinsatzFunktion } 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 { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import {
EinsatzStatusAbzeichen,
Hinweisliste,
KategorieAbzeichen,
StaerkeAnzeige,
Zeitkennzahlen,
Zeitleiste,
} from "@/components/einsatz/anzeige";
import { DateiFeld } from "@/components/wartung/datei-feld";
import { FUNKTION_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte";
import { ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
import { taktischeEinheit } from "@/lib/einsatz/staerke";
/**
* Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz.
*
* Aufbau in der Reihenfolge, in der gearbeitet wird:
*
* 1. Zeitstempel — ein Knopf je Stempel, solange es läuft
* 2. Fahrzeuge und Kräfte — wer war dabei, in welcher Funktion
* 3. Lagemeldungen — der Verlauf, auch aus dem Portal
* 4. Material — was verbraucht wurde
* 5. Bericht und Freigabe — zum Schluss, in Ruhe
*
* Nach der Freigabe ist alles gesperrt. Die Oberfläche zeigt das, indem sie
* die Bedienelemente weglässt statt sie auszugrauen: was nicht mehr geht,
* braucht auch keinen Knopf.
*/
export function EinsatzDetail({
einsatzId,
darfBearbeiten,
darfFreigeben,
}: {
einsatzId: string;
darfBearbeiten: boolean;
darfFreigeben: boolean;
}) {
const [meldung, setMeldung] = React.useState<string | null>(null);
const [neueMeldung, setNeueMeldung] = React.useState("");
const [meldungWichtig, setMeldungWichtig] = React.useState(false);
const [meldungFoto, setMeldungFoto] = React.useState<string | null>(null);
const [bericht, setBericht] = React.useState<string | null>(null);
const [portalName, setPortalName] = React.useState("");
const [portalLink, setPortalLink] = React.useState<string | null>(null);
const [personalOffen, setPersonalOffen] = React.useState(false);
const werkzeuge = trpc.useUtils();
const abfrage = trpc.einsatz.einzeln.useQuery({ id: einsatzId });
const aktualisieren = () => void werkzeuge.einsatz.einzeln.invalidate({ id: einsatzId });
const fehlerZeigen = (fehler: { message: string }) => setMeldung(fehler.message);
const zeitstempelSetzen = trpc.einsatz.zeitstempelSetzen.useMutation({
onSuccess: aktualisieren,
onError: fehlerZeigen,
});
const kraefteSetzen = trpc.einsatz.kraefteSetzen.useMutation({
onSuccess: (ergebnis) => {
setMeldung(`${ergebnis.erfasst} Kräfte erfasst — Stärke ${ergebnis.staerkeText}.`);
aktualisieren();
},
onError: fehlerZeigen,
});
const kraftEntfernen = trpc.einsatz.kraftEntfernen.useMutation({
onSuccess: aktualisieren,
onError: fehlerZeigen,
});
const lagemeldung = trpc.einsatz.lagemeldungErfassen.useMutation({
onSuccess: () => {
setNeueMeldung("");
setMeldungWichtig(false);
setMeldungFoto(null);
aktualisieren();
},
onError: fehlerZeigen,
});
const aendern = trpc.einsatz.aendern.useMutation({
onSuccess: () => {
setMeldung("Gespeichert.");
aktualisieren();
},
onError: fehlerZeigen,
});
const freigeben = trpc.einsatz.freigeben.useMutation({
onSuccess: () => {
setMeldung("Bericht freigegeben. Das PDF entsteht im Hintergrund.");
aktualisieren();
},
onError: fehlerZeigen,
});
const berichtLink = trpc.einsatz.berichtLink.useMutation({
onSuccess: (ergebnis) => {
if (ergebnis.bereit && ergebnis.adresse) {
window.open(ergebnis.adresse, "_blank", "noopener");
} else {
setMeldung("Der Bericht wird gerade erzeugt. Gleich noch einmal versuchen.");
}
},
onError: fehlerZeigen,
});
const portalErstellen = trpc.einsatz.portalzugangErstellen.useMutation({
onSuccess: (zugang) => {
setPortalLink(zugang.adresse);
setPortalName("");
aktualisieren();
},
onError: fehlerZeigen,
});
const portalWiderrufen = trpc.einsatz.portalzugangWiderrufen.useMutation({
onSuccess: aktualisieren,
onError: fehlerZeigen,
});
const vorschlag = trpc.einsatz.personalvorschlag.useQuery(
{ einsatzId, anzahl: 60 },
{ enabled: personalOffen && darfBearbeiten },
);
if (abfrage.isLoading) {
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
}
if (abfrage.error) return <Fehlermeldung meldung={abfrage.error.message} />;
if (!abfrage.data) return null;
const einsatz = abfrage.data;
const freigegeben = einsatz.status === "FREIGEGEBEN";
const bearbeitbar = darfBearbeiten && !freigegeben;
const einheit = taktischeEinheit(einsatz.staerke);
const adresse = [
[einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" "),
[einsatz.plz, einsatz.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(", ");
return (
<div className="space-y-6">
{/* Kopf */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{einsatz.stichwort}</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
<span className="tabellenzahlen">{einsatz.einsatznummer}</span>
<KategorieAbzeichen kategorie={einsatz.kategorie} />
<EinsatzStatusAbzeichen status={einsatz.status} />
{adresse && <span>· {adresse}</span>}
{einsatz.objekt && <span>· {einsatz.objekt}</span>}
</p>
</div>
<div className="flex flex-wrap gap-2">
{freigegeben && (
<Button
variant="outline"
onClick={() => berichtLink.mutate({ id: einsatzId })}
disabled={berichtLink.isPending}
>
<FileDown aria-hidden />
Bericht als PDF
</Button>
)}
{darfFreigeben && !freigegeben && (
<Button
onClick={() => {
if (
window.confirm(
"Bericht freigeben? Danach lässt sich am Einsatz nichts mehr ändern.",
)
) {
freigeben.mutate({ id: einsatzId, bericht: bericht ?? undefined });
}
}}
disabled={freigeben.isPending}
>
<Check aria-hidden />
Bericht freigeben
</Button>
)}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="einsatzdetailmeldung" />}
<Hinweisliste hinweise={einsatz.hinweise} />
{freigegeben && (
<div className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 p-3 text-sm">
Dieser Bericht ist seit {alsDatumZeit(einsatz.freigegebenAm)} freigegeben
{einsatz.freigegebenVon &&
` — freigegeben von ${einsatz.freigegebenVon.vorname} ${einsatz.freigegebenVon.nachname}`}
. Änderungen sind nicht mehr möglich.
</div>
)}
{/* Zeiten */}
<Card>
<CardHeader>
<CardTitle>Zeiten</CardTitle>
<CardDescription>
Die sechs Stempel des Einsatzes. Ein Klick setzt die aktuelle Uhrzeit.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Zeitleiste
stempel={ZEITSTEMPEL.map((stempel) => ({
bezeichnung: ZEITSTEMPEL_TEXT[stempel],
uhrzeit: einsatz[stempel] ? alsUhrzeit(einsatz[stempel]) : null,
gesetzt: Boolean(einsatz[stempel]),
}))}
/>
{bearbeitbar && (
<div className="flex flex-wrap gap-2">
{ZEITSTEMPEL.map((stempel) => (
<Button
key={stempel}
variant={einsatz[stempel] ? "ghost" : "outline"}
size="sm"
onClick={() =>
zeitstempelSetzen.mutate({
id: einsatzId,
stempel: stempel as Zeitstempel,
zeitpunkt: einsatz[stempel] ? null : undefined,
})
}
>
{einsatz[stempel]
? `${ZEITSTEMPEL_TEXT[stempel]} zurücknehmen`
: `${ZEITSTEMPEL_TEXT[stempel]} jetzt`}
</Button>
))}
</div>
)}
<Zeitkennzahlen
ausrueckzeit={einsatz.ausrueckzeit}
anfahrtszeit={einsatz.anfahrtszeit}
hilfsfrist={einsatz.hilfsfrist}
dauer={einsatz.dauer}
/>
</CardContent>
</Card>
{/* Fahrzeuge und Kräfte */}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>Eingesetzte Kräfte</CardTitle>
<CardDescription>
{einsatz.kraefte.length} Personen · {einsatz.fahrzeuge.length} Fahrzeuge
</CardDescription>
</div>
{bearbeitbar && (
<Button
variant="outline"
size="sm"
onClick={() => setPersonalOffen((offen) => !offen)}
>
<Users aria-hidden />
{personalOffen ? "Vorschlag schließen" : "Mannschaft erfassen"}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{einsatz.fahrzeuge.length > 0 && (
<ul className="flex flex-wrap gap-2">
{einsatz.fahrzeuge.map((eintrag) => (
<li
key={eintrag.id}
className="rounded-md border border-[var(--border)] px-3 py-1.5 text-sm"
>
{eintrag.fahrzeug.funkrufname}
{eintrag.ausgeruecktUm && (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
ab {alsUhrzeit(eintrag.ausgeruecktUm)}
</span>
)}
</li>
))}
</ul>
)}
{einsatz.kraefte.length === 0 ? (
<NichtsGefunden
titel="Noch keine Mannschaft erfasst"
hinweis="Ohne Kräfte lässt sich kein Bericht freigeben — und die Beteiligung je Person bliebe leer."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Funktion</TableHead>
<TableHead>Fahrzeug</TableHead>
<TableHead>Atemschutz</TableHead>
{bearbeitbar && <TableHead className="w-12" aria-label="Aktion" />}
</TableRow>
</TableHeader>
<TableBody>
{einsatz.kraefte.map((kraft) => (
<TableRow key={kraft.id}>
<TableCell>
{kraft.person.nachname}, {kraft.person.vorname}
</TableCell>
<TableCell>
{bearbeitbar ? (
<Select
value={kraft.funktion}
onValueChange={(wert) =>
kraefteSetzen.mutate({
einsatzId,
kraefte: [
{
personId: kraft.personId,
funktion: wert as EinsatzFunktion,
atemschutz: kraft.atemschutz,
fahrzeugId: kraft.fahrzeugId,
},
],
})
}
>
<SelectTrigger
className="h-8 w-52"
aria-label={`Funktion von ${kraft.person.nachname}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(FUNKTION_TEXT) as EinsatzFunktion[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{FUNKTION_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
FUNKTION_TEXT[kraft.funktion]
)}
</TableCell>
<TableCell className="text-sm">
{kraft.fahrzeug?.funkrufname ?? "—"}
</TableCell>
<TableCell>
<Checkbox
checked={kraft.atemschutz}
disabled={!bearbeitbar}
onCheckedChange={(an) =>
kraefteSetzen.mutate({
einsatzId,
kraefte: [
{
personId: kraft.personId,
funktion: kraft.funktion,
atemschutz: an === true,
fahrzeugId: kraft.fahrzeugId,
},
],
})
}
aria-label={`Atemschutz für ${kraft.person.nachname}`}
/>
</TableCell>
{bearbeitbar && (
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() =>
kraftEntfernen.mutate({ einsatzId, personId: kraft.personId })
}
aria-label={`${kraft.person.nachname} entfernen`}
>
<Trash2 aria-hidden />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
{/* Personalvorschlag */}
{personalOffen && bearbeitbar && (
<div className="rounded-md border border-[var(--border)] p-3">
<p className="mb-2 text-sm font-medium">
Vorschlag — sortiert nach Einsätzen im letzten Jahr
</p>
<p className="mb-3 text-xs text-[var(--muted-foreground)]">
Eine echte Verfügbarkeitsmeldung gibt es erst mit den Terminen in Phase 7. Bis
dahin steht hier, wer erfahrungsgemäß kommt — und ob die Qualifikation noch gilt.
</p>
<div className="grid gap-1.5 sm:grid-cols-2">
{vorschlag.data?.map((person) => (
<button
key={person.id}
type="button"
onClick={() =>
kraefteSetzen.mutate({
einsatzId,
kraefte: [
{
personId: person.id,
funktion: EinsatzFunktion.TRUPPMANN,
atemschutz: false,
},
],
})
}
className="flex items-center justify-between gap-2 rounded border border-[var(--border)] px-3 py-2 text-left text-sm hover:border-[var(--primary)]"
>
<span>
{person.nachname}, {person.vorname}
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
{person.einsaetzeImJahr} Einsätze
</span>
{person.hatAbgelaufeneQualifikation && (
<span className="ml-2 text-xs font-medium text-[var(--gesperrt)]">
Qualifikation abgelaufen
</span>
)}
</span>
<Plus className="size-4 shrink-0" aria-hidden />
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
<div className="space-y-6">
<StaerkeAnzeige staerke={einsatz.staerke} einheit={einheit.einheit} />
{/* Portalzugänge */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link2 className="size-4" aria-hidden />
Portal für Fremdwehren
</CardTitle>
<CardDescription>
Ein Link ohne Anmeldung: Stand sehen, Lagemeldung mit Foto absetzen. Zeitlich
begrenzt und jederzeit widerrufbar.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bearbeitbar && (
<div className="flex gap-2">
<Input
value={portalName}
onChange={(ereignis) => setPortalName(ereignis.target.value)}
placeholder="FF Nachbarhausen"
aria-label="Für wen ist der Link?"
/>
<Button
size="sm"
onClick={() =>
portalErstellen.mutate({ einsatzId, bezeichnung: portalName })
}
disabled={!portalName.trim() || portalErstellen.isPending}
>
Erzeugen
</Button>
</div>
)}
{portalLink && (
<div className="space-y-1 rounded-md border border-[var(--primary)] p-2">
<p className="text-xs font-medium">
Dieser Link wird nur jetzt angezeigt — danach steht in der Datenbank nur noch
sein Hash.
</p>
<div className="flex gap-2">
<Input readOnly value={portalLink} className="text-xs" />
<Button
variant="outline"
size="sm"
onClick={() => void navigator.clipboard.writeText(portalLink)}
aria-label="Link kopieren"
>
<Copy aria-hidden />
</Button>
</div>
</div>
)}
<ul className="space-y-2 text-sm">
{einsatz.portalzugaenge.map((zugang) => (
<li key={zugang.id} className="flex items-center justify-between gap-2">
<span>
{zugang.bezeichnung}
<span className="block text-xs text-[var(--muted-foreground)]">
{zugang.widerrufenAm
? "widerrufen"
: `gültig bis ${alsDatumZeit(zugang.gueltigBis)}`}
{" · "}
{zugang.aufrufe} Aufrufe
</span>
</span>
{bearbeitbar && !zugang.widerrufenAm && (
<Button
variant="ghost"
size="sm"
onClick={() => portalWiderrufen.mutate({ id: zugang.id })}
>
Widerrufen
</Button>
)}
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</div>
{/* Lagemeldungen */}
<Card>
<CardHeader>
<CardTitle>Verlauf</CardTitle>
<CardDescription>
Lagemeldungen aus der Wehr und über das Portal. Sie bleiben auch nach der Freigabe
möglich — der Verlauf gehört nicht zum festgeschriebenen Bericht.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{darfBearbeiten && (
<div className="space-y-2 rounded-md border border-[var(--border)] p-3">
<Textarea
rows={2}
value={neueMeldung}
onChange={(ereignis) => setNeueMeldung(ereignis.target.value)}
placeholder="Was ist der Stand?"
aria-label="Neue Lagemeldung"
/>
<DateiFeld
entitaet="Lagemeldung"
entitaetId={einsatzId}
kategorie="lagemeldung"
dateiId={meldungFoto}
onHochgeladen={setMeldungFoto}
/>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={meldungWichtig}
onCheckedChange={(an) => setMeldungWichtig(an === true)}
/>
wichtig
</label>
<Button
size="sm"
className="ml-auto"
disabled={!neueMeldung.trim() || lagemeldung.isPending}
onClick={() =>
lagemeldung.mutate({
einsatzId,
text: neueMeldung,
wichtig: meldungWichtig,
fotoDateiId: meldungFoto,
})
}
>
<Send aria-hidden />
Melden
</Button>
</div>
</div>
)}
{einsatz.lagemeldungen.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Noch keine Meldung.</p>
) : (
<ol className="space-y-3">
{einsatz.lagemeldungen.map((meldung) => (
<li
key={meldung.id}
className={`border-l-2 pl-3 text-sm ${
meldung.wichtig ? "border-[var(--gesperrt)]" : "border-[var(--border)]"
}`}
>
<div className="flex flex-wrap items-baseline gap-2">
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{alsDatumZeit(meldung.zeitpunkt)}
</span>
{meldung.wichtig && (
<span className="text-xs font-medium text-[var(--gesperrt)]">wichtig</span>
)}
<span className="text-xs text-[var(--muted-foreground)]">
{meldung.verfasserName ??
(meldung.verfasser
? `${meldung.verfasser.vorname} ${meldung.verfasser.nachname}`
: "unbekannt")}{" "}
· {QUELLE_TEXT[meldung.quelle]}
</span>
</div>
<p className="mt-0.5">{meldung.text}</p>
</li>
))}
</ol>
)}
</CardContent>
</Card>
{/* Bericht */}
<Card>
<CardHeader>
<CardTitle>Bericht</CardTitle>
<CardDescription>
Der Text, der später an die Gemeinde geht. Mit der Freigabe wird er festgeschrieben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bearbeitbar ? (
<>
<div className="space-y-2">
<Label htmlFor="bericht">Einsatzbericht</Label>
<Textarea
id="bericht"
rows={8}
value={bericht ?? einsatz.bericht ?? ""}
onChange={(ereignis) => setBericht(ereignis.target.value)}
placeholder="Was war die Lage bei Eintreffen, was wurde getan, wie endete der Einsatz?"
/>
</div>
<Button
variant="outline"
onClick={() => aendern.mutate({ id: einsatzId, bericht: bericht ?? "" })}
disabled={aendern.isPending || bericht === null}
>
Bericht speichern
</Button>
</>
) : (
<p className="whitespace-pre-line text-sm">
{einsatz.bericht ?? "Noch kein Bericht geschrieben."}
</p>
)}
</CardContent>
</Card>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
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 { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
export const metadata = { title: "Einsatz" };
/**
* Ein Einsatz mit allem, was dazugehört.
*
* Client-Komponente, weil hier während eines laufenden Einsatzes gearbeitet
* wird: Zeitstempel setzen, Kräfte nachtragen, Lagemeldungen schreiben. Jede
* dieser Aktionen soll die Seite nicht neu laden.
*/
export default async function EinsatzSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/einsaetze">
<ArrowLeft aria-hidden />
Zurück zu den Einsätzen
</Link>
</Button>
</div>
<EinsatzDetail
einsatzId={id}
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
/>
</div>
);
}
+492
View File
@@ -0,0 +1,492 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MapPin, Search, Siren } from "lucide-react";
import { EinsatzKategorie, EinsatzStatus } 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 { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { EinsatzStatusAbzeichen, KategorieAbzeichen } from "@/components/einsatz/anzeige";
import { KATEGORIE_TEXT } from "@/lib/einsatz/texte";
import { alsDauer } from "@/lib/einsatz/zeiten";
import { alsDatumZeit } from "@/lib/utils";
/**
* Einsatzliste mit Schnellerfassung.
*
* Die Schnellerfassung ist der wichtigste Knopf der ganzen Phase. Sie fragt
* drei Dinge: was, wo, welche Fahrzeuge. Die Alarmzeit ist jetzt. Alles
* Weitere — Kräfte, Zeiten, Bericht — wird auf der Detailseite nachgetragen,
* wenn wieder Ruhe ist.
*/
const LEER = {
stichwort: "",
kategorie: EinsatzKategorie.SONSTIGES as EinsatzKategorie,
strasse: "",
hausnummer: "",
plz: "",
ort: "",
objekt: "",
breitengrad: null as number | null,
laengengrad: null as number | null,
koordinatenQuelle: null as string | null,
};
export function EinsatzListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const router = useRouter();
const [suche, setSuche] = React.useState("");
const [status, setStatus] = React.useState<EinsatzStatus | "alle">("alle");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [offen, setOffen] = React.useState(false);
const [formular, setFormular] = React.useState(LEER);
const [fahrzeugIds, setFahrzeugIds] = React.useState<Set<string>>(new Set());
const [ausgerueckt, setAusgerueckt] = React.useState(true);
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const einsaetze = trpc.einsatz.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "desc",
suche: suche || undefined,
status: status === "alle" ? undefined : [status],
jahr: jahr === "alle" ? undefined : jahr,
});
const jahre = trpc.statistik.jahre.useQuery();
const fahrzeuge = trpc.fahrzeug.liste.useQuery(
{ seite: 1, seitengroesse: 100, richtung: "asc" },
{ enabled: offen },
);
// Adresssuche über den eigenen Nominatim — nur auf Knopfdruck, nicht bei
// jedem Tastendruck: der Dienst gehört nicht mit Anfragen überzogen.
const [adressuche, setAdressuche] = React.useState<string | null>(null);
const adressen = trpc.einsatz.adresseSuchen.useQuery(
{ suche: adressuche ?? "" },
{ enabled: Boolean(adressuche) },
);
const anlegen = trpc.einsatz.anlegen.useMutation({
onSuccess: (einsatz) => {
setOffen(false);
setFormular(LEER);
setFahrzeugIds(new Set());
void werkzeuge.einsatz.invalidate();
router.push(`/einsaetze/${einsatz.id}`);
},
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = einsaetze.data?.eintraege ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Stichwort, Einsatznummer, Ort …"
className="pl-8"
aria-label="Einsätze durchsuchen"
/>
</div>
<Select
value={status}
onValueChange={(wert) => setStatus(wert as EinsatzStatus | "alle")}
>
<SelectTrigger className="w-44" aria-label="Nach Stand filtern">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Zustände</SelectItem>
<SelectItem value={EinsatzStatus.LAUFEND}>läuft</SelectItem>
<SelectItem value={EinsatzStatus.ENTWURF}>Entwurf</SelectItem>
<SelectItem value={EinsatzStatus.ABGESCHLOSSEN}>abgeschlossen</SelectItem>
<SelectItem value={EinsatzStatus.FREIGEGEBEN}>freigegeben</SelectItem>
</SelectContent>
</Select>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr wählen">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Jahre</SelectItem>
{(jahre.data ?? [new Date().getFullYear()]).map((eintrag) => (
<SelectItem key={eintrag} value={String(eintrag)}>
{eintrag}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button onClick={() => setOffen(true)}>
<Siren aria-hidden />
Einsatz erfassen
</Button>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="einsatzmeldung" />}
{einsaetze.error && <Fehlermeldung meldung={einsaetze.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nummer</TableHead>
<TableHead>Stichwort</TableHead>
<TableHead>Kategorie</TableHead>
<TableHead>Ort</TableHead>
<TableHead>Fahrzeuge</TableHead>
<TableHead>Stand</TableHead>
<TableHead className="text-right">alarmiert</TableHead>
<TableHead className="text-right">Hilfsfrist</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{einsaetze.isLoading && <LadeZeilen spalten={8} />}
{eintraege.map((einsatz) => (
<TableRow key={einsatz.id}>
<TableCell className="tabellenzahlen font-medium">
<Link
href={`/einsaetze/${einsatz.id}`}
className="underline-offset-4 hover:underline"
>
{einsatz.einsatznummer}
</Link>
</TableCell>
<TableCell>
{einsatz.stichwort}
{einsatz.objekt && (
<div className="text-xs text-[var(--muted-foreground)]">{einsatz.objekt}</div>
)}
</TableCell>
<TableCell>
<KategorieAbzeichen kategorie={einsatz.kategorie} />
</TableCell>
<TableCell className="text-sm">
{[einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" ") || "—"}
{einsatz.ort && (
<div className="text-xs text-[var(--muted-foreground)]">{einsatz.ort}</div>
)}
</TableCell>
<TableCell className="text-sm">
{einsatz.fahrzeuge.length === 0
? "—"
: einsatz.fahrzeuge
.map((eintrag) => eintrag.fahrzeug.funkrufname.split(" ").pop())
.join(", ")}
</TableCell>
<TableCell>
<EinsatzStatusAbzeichen status={einsatz.status} />
</TableCell>
<TableCell className="text-right tabellenzahlen whitespace-nowrap">
{alsDatumZeit(einsatz.alarmiertUm)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDauer(einsatz.hilfsfrist)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!einsaetze.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel={suche ? "Kein Einsatz gefunden" : "Noch kein Einsatz erfasst"}
hinweis={
suche
? "Vielleicht in einem anderen Jahr?"
: "Im Alarmfall genügen Stichwort, Ort und die ausgerückten Fahrzeuge — der Rest wird nachgetragen."
}
aktion={
darfBearbeiten && !suche ? (
<Button onClick={() => setOffen(true)}>
<Siren aria-hidden />
Einsatz erfassen
</Button>
) : undefined
}
/>
</div>
)}
</div>
{/* Schnellerfassung */}
<Dialog open={offen} onOpenChange={setOffen}>
<DialogContent className="max-w-2xl">
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
setMeldung(null);
anlegen.mutate({
stichwort: formular.stichwort,
kategorie: formular.kategorie,
strasse: formular.strasse || null,
hausnummer: formular.hausnummer || null,
plz: formular.plz || null,
ort: formular.ort || null,
objekt: formular.objekt || null,
breitengrad: formular.breitengrad,
laengengrad: formular.laengengrad,
koordinatenQuelle: formular.koordinatenQuelle,
fahrzeugIds: [...fahrzeugIds],
ausgerueckt,
});
}}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>Einsatz erfassen</DialogTitle>
<DialogDescription>
Stichwort, Ort, Fahrzeuge — mehr braucht es jetzt nicht. Alarmzeit ist dieser
Moment.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="stichwort">Einsatzstichwort</Label>
<Input
id="stichwort"
required
autoFocus
value={formular.stichwort}
onChange={(ereignis) =>
setFormular({ ...formular, stichwort: ereignis.target.value })
}
placeholder="B 3 Wohnhausbrand"
/>
</div>
<div className="space-y-2">
<Label htmlFor="kategorie">Kategorie</Label>
<Select
value={formular.kategorie}
onValueChange={(wert) =>
setFormular({ ...formular, kategorie: wert as EinsatzKategorie })
}
>
<SelectTrigger id="kategorie">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(KATEGORIE_TEXT) as EinsatzKategorie[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="strasse">Straße</Label>
<Input
id="strasse"
value={formular.strasse}
onChange={(ereignis) =>
setFormular({ ...formular, strasse: ereignis.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="hausnummer">Nummer</Label>
<Input
id="hausnummer"
value={formular.hausnummer}
onChange={(ereignis) =>
setFormular({ ...formular, hausnummer: ereignis.target.value })
}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="plz">PLZ</Label>
<Input
id="plz"
value={formular.plz}
onChange={(ereignis) => setFormular({ ...formular, plz: ereignis.target.value })}
/>
</div>
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="ort">Ort</Label>
<Input
id="ort"
value={formular.ort}
onChange={(ereignis) => setFormular({ ...formular, ort: ereignis.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="objekt">Objekt</Label>
<Input
id="objekt"
value={formular.objekt}
onChange={(ereignis) => setFormular({ ...formular, objekt: ereignis.target.value })}
placeholder="Grundschule, Autobahn A9 km 312 …"
/>
</div>
{/* Koordinaten über den eigenen Nominatim */}
<div className="space-y-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setAdressuche(
[formular.strasse, formular.hausnummer, formular.plz, formular.ort]
.filter(Boolean)
.join(" "),
)
}
>
<MapPin aria-hidden />
Koordinaten suchen
</Button>
{adressen.data && !adressen.data.verfuegbar && (
<p className="text-xs text-[var(--muted-foreground)]">
Es ist kein eigener Nominatim eingerichtet — Koordinaten bleiben leer. Das ist
in Ordnung; die Karte kommt in Phase 6.
</p>
)}
{adressen.data?.treffer.map((treffer) => (
<button
key={`${treffer.breitengrad},${treffer.laengengrad}`}
type="button"
onClick={() =>
setFormular({
...formular,
breitengrad: treffer.breitengrad,
laengengrad: treffer.laengengrad,
koordinatenQuelle: "nominatim",
})
}
className="block w-full rounded border border-[var(--border)] p-2 text-left text-sm hover:border-[var(--primary)]"
>
{treffer.anzeige}
</button>
))}
{formular.breitengrad !== null && (
<p className="text-xs text-[var(--muted-foreground)]">
Koordinaten übernommen: {formular.breitengrad.toFixed(5)},{" "}
{formular.laengengrad?.toFixed(5)}
</p>
)}
</div>
<div className="space-y-2">
<Label>Ausgerückte Fahrzeuge</Label>
<div className="grid gap-1.5 sm:grid-cols-2">
{fahrzeuge.data?.eintraege.map((fahrzeug) => (
<label
key={fahrzeug.id}
className="flex cursor-pointer items-center gap-2 rounded border border-[var(--border)] px-3 py-2 text-sm"
>
<Checkbox
checked={fahrzeugIds.has(fahrzeug.id)}
onCheckedChange={(an) =>
setFahrzeugIds((alt) => {
const neu = new Set(alt);
if (an) neu.add(fahrzeug.id);
else neu.delete(fahrzeug.id);
return neu;
})
}
/>
<span>
{fahrzeug.funkrufname}
{fahrzeug.dinTyp && (
<span className="text-[var(--muted-foreground)]"> · {fahrzeug.dinTyp}</span>
)}
</span>
</label>
))}
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={ausgerueckt}
onCheckedChange={(an) => setAusgerueckt(an === true)}
/>
Fahrzeuge sind jetzt ausgerückt
</label>
</div>
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={anlegen.isPending}>
{anlegen.isPending ? "wird angelegt …" : "Einsatz anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { BarChart3, GraduationCap } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { EinsatzListe } from "@/app/(app)/einsaetze/einsatz-liste";
export const metadata = { title: "Einsätze" };
/**
* Einsatzübersicht.
*
* Oben die Schnellerfassung: im Alarmfall zählt, dass der Einsatz überhaupt
* angelegt wird. Alles andere wird nachgetragen.
*/
export default async function EinsaetzeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Einsätze</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Vom Alarm bis zum freigegebenen Bericht.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href="/uebungen">
<GraduationCap aria-hidden />
Übungen
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/einsaetze/statistik">
<BarChart3 aria-hidden />
Statistik
</Link>
</Button>
</div>
</div>
<EinsatzListe darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Einsatzstatistik } from "@/app/(app)/einsaetze/statistik/statistik";
export const metadata = { title: "Einsatzstatistik" };
/**
* Die Zahlen des Jahres.
*
* Zwei Adressaten: die Gemeinde fragt nach Anzahl und Hilfsfrist, die Wehr
* nach der Beteiligung. Beides steht auf dieser Seite, und der Export darunter
* bringt es aufs Papier.
*/
export default async function StatistikSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/einsaetze">
<ArrowLeft aria-hidden />
Zurück zu den Einsätzen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Einsatzstatistik</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Einsätze nach Art und Monat, Hilfsfristen, Beteiligung und Fahrzeugauslastung.
</p>
</div>
<Einsatzstatistik />
</div>
);
}
@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import { FileDown } from "lucide-react";
import type { EinsatzKategorie, UebungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { Balkendiagramm } from "@/components/einsatz/anzeige";
import { Kennzahl } from "@/components/wartung/anzeige";
import { KATEGORIE_TEXT, MONAT_KURZ, UEBUNGSART_TEXT } from "@/lib/einsatz/texte";
import { alsDauer } from "@/lib/einsatz/zeiten";
/**
* Einsatzstatistik eines Jahres.
*
* Die wichtigste Zahl steht oben links und heißt Hilfsfrist: in Bayern soll die
* Wehr in 80 Prozent der Fälle innerhalb von zehn Minuten nach Alarmierung an
* der Einsatzstelle sein. Genau dieser Wert wird hier ausgewiesen — nicht der
* Durchschnitt, denn der verdeckt die Ausreißer, um die es geht.
*/
export function Einsatzstatistik() {
const [jahr, setJahr] = React.useState<number>(new Date().getFullYear());
const jahre = trpc.statistik.jahre.useQuery();
const daten = trpc.statistik.jahr.useQuery({ jahr });
if (daten.error) return <Fehlermeldung meldung={daten.error.message} />;
const zahlen = daten.data;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1">
<Label htmlFor="jahr">Jahr</Label>
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger id="jahr" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(jahre.data ?? [new Date().getFullYear()]).map((eintrag) => (
<SelectItem key={eintrag} value={String(eintrag)}>
{eintrag}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" asChild>
<a href={`/api/einsaetze/jahresexport?jahr=${jahr}`} target="_blank" rel="noopener">
<FileDown aria-hidden />
Jahresexport als PDF
</a>
</Button>
</div>
{daten.isLoading && (
<p className="text-sm text-[var(--muted-foreground)]">Wird gerechnet …</p>
)}
{zahlen && zahlen.gesamt === 0 && (
<NichtsGefunden
titel={`Für ${jahr} ist kein Einsatz erfasst`}
hinweis="Ein anderes Jahr zeigt vielleicht mehr."
/>
)}
{zahlen && zahlen.gesamt > 0 && (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl
wert={zahlen.gesamt}
bezeichnung="Einsätze"
hinweis={`${zahlen.freigegeben} Berichte freigegeben`}
/>
<div className="flex flex-col gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
<span className="tabellenzahlen text-3xl font-semibold">
{alsDauer(zahlen.zeiten.hilfsfristP80)}
</span>
<span className="text-sm font-medium">Hilfsfrist (80 %)</span>
<span className="text-xs text-[var(--muted-foreground)]">
in 80 % der Fälle war die Wehr schneller da
</span>
</div>
<div className="flex flex-col gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
<span className="tabellenzahlen text-3xl font-semibold">
{alsDauer(zahlen.zeiten.ausrueckzeitMedian)}
</span>
<span className="text-sm font-medium">Ausrückzeit (Median)</span>
<span className="text-xs text-[var(--muted-foreground)]">Alarm bis Ausrücken</span>
</div>
<Kennzahl
wert={zahlen.personen.gerettet}
bezeichnung="gerettete Personen"
hinweis={`${zahlen.personen.verletzt} verletzt, ${zahlen.personen.getoetet} getötet`}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Nach Kategorie</CardTitle>
<CardDescription>Einteilung nach der Landesstatistik.</CardDescription>
</CardHeader>
<CardContent>
<Balkendiagramm
werte={zahlen.nachKategorie.map((eintrag) => ({
beschriftung: KATEGORIE_TEXT[eintrag.kategorie as EinsatzKategorie] ?? eintrag.kategorie,
wert: eintrag.anzahl,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Nach Monat</CardTitle>
<CardDescription>Wann im Jahr die Arbeit anfällt.</CardDescription>
</CardHeader>
<CardContent>
<Balkendiagramm
werte={zahlen.nachMonat.map((eintrag) => ({
beschriftung: MONAT_KURZ[eintrag.monat - 1] ?? String(eintrag.monat),
wert: eintrag.anzahl,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Hilfsfristen</CardTitle>
<CardDescription>
Verteilung über {zahlen.zeiten.gemessen} Einsätze mit vollständigen Zeiten.
Median {alsDauer(zahlen.zeiten.hilfsfristMedian)}.
</CardDescription>
</CardHeader>
<CardContent>
<Balkendiagramm
werte={zahlen.zeiten.verteilung.map((eintrag) => ({
beschriftung: eintrag.text,
wert: eintrag.anzahl,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Fahrzeuge</CardTitle>
<CardDescription>Einsätze, gefahrene Kilometer, Wasserabgabe.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Fahrzeug</TableHead>
<TableHead className="text-right">Einsätze</TableHead>
<TableHead className="text-right">km</TableHead>
<TableHead className="text-right">Wasser</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zahlen.fahrzeuge.map((fahrzeug) => (
<TableRow key={fahrzeug.fahrzeugId}>
<TableCell>{fahrzeug.name}</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrzeug.einsaetze}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrzeug.kilometer || "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{fahrzeug.wasser ? `${fahrzeug.wasser} l` : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Beteiligung</CardTitle>
<CardDescription>
Wer trägt die Last — Einsätze und Übungen je Person. {zahlen.uebungen.gesamt}{" "}
Übungen mit {zahlen.uebungen.teilnahmen} Teilnahmen
{zahlen.uebungen.nachArt.length > 0 &&
` (${zahlen.uebungen.nachArt
.map(
(eintrag) =>
`${UEBUNGSART_TEXT[eintrag.art as UebungsArt] ?? eintrag.art}: ${eintrag.anzahl}`,
)
.join(", ")})`}
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="text-right">Einsätze</TableHead>
<TableHead className="text-right">davon Atemschutz</TableHead>
<TableHead className="text-right">Übungen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zahlen.beteiligung.map((person) => (
<TableRow key={person.personId}>
<TableCell>{person.name}</TableCell>
<TableCell className="text-right tabellenzahlen">
{person.einsaetze}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{person.atemschutzeinsaetze || "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{person.uebungen || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
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 { UebungDetail } from "@/app/(app)/uebungen/[id]/uebung-detail";
export const metadata = { title: "Übung" };
/** Eine Übung mit Anwesenheitsliste. */
export default async function UebungSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/uebungen">
<ArrowLeft aria-hidden />
Zurück zu den Übungen
</Link>
</Button>
</div>
<UebungDetail uebungId={id} darfBearbeiten={hatRecht(sitzung, "ausbildung.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
import * as React from "react";
import { Save } 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 { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { UebungsartAbzeichen } from "@/components/einsatz/anzeige";
import { alsDatum } from "@/lib/utils";
/**
* Übungsdetail mit Anwesenheitsliste.
*
* Die Liste ist der eigentliche Zweck der Seite: Namen abhaken, fertig. Sie
* zeigt deshalb alle aktiven Mitglieder auf einmal und nicht eine Suche, in
* der man jeden einzeln nachschlagen müsste — bei einer Übung ist die halbe
* Wehr da.
*/
export function UebungDetail({
uebungId,
darfBearbeiten,
}: {
uebungId: string;
darfBearbeiten: boolean;
}) {
const [meldung, setMeldung] = React.useState<string | null>(null);
const [anwesend, setAnwesend] = React.useState<Set<string> | null>(null);
const [bericht, setBericht] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const abfrage = trpc.uebung.einzeln.useQuery({ id: uebungId });
// 200 ist die Höchstzahl je Seite (siehe gemeinsam.ts). Eine Wehr mit mehr
// aktiven Mitgliedern bräuchte hier eine Blätterung — bei einer Freiwilligen
// Feuerwehr ist das der theoretische Fall.
const mitglieder = trpc.person.liste.useQuery({ seite: 1, seitengroesse: 200, richtung: "asc" });
// Die vorhandene Teilnahme einmal in den Bearbeitungsstand übernehmen.
React.useEffect(() => {
if (abfrage.data && anwesend === null) {
setAnwesend(new Set(abfrage.data.teilnahmen.map((teilnahme) => teilnahme.personId)));
}
}, [abfrage.data, anwesend]);
const teilnahmeSetzen = trpc.uebung.teilnahmeSetzen.useMutation({
onSuccess: (ergebnis) => {
setMeldung(`${ergebnis.erfasst} Teilnehmer gespeichert.`);
void werkzeuge.uebung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const aendern = trpc.uebung.aendern.useMutation({
onSuccess: () => {
setMeldung("Gespeichert.");
void werkzeuge.uebung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
if (abfrage.isLoading) {
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
}
if (abfrage.error) return <Fehlermeldung meldung={abfrage.error.message} />;
if (!abfrage.data) return null;
const uebung = abfrage.data;
const personen = mitglieder.data?.eintraege ?? [];
const anwesendeMenge = anwesend ?? new Set<string>();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{uebung.thema}</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
<span className="tabellenzahlen">{uebung.nummer}</span>
<UebungsartAbzeichen art={uebung.art} />
<span>· {alsDatum(uebung.datum)}</span>
{uebung.ort && <span>· {uebung.ort}</span>}
{uebung.leiter && (
<span>
· Leitung {uebung.leiter.nachname}, {uebung.leiter.vorname}
</span>
)}
<span>· {uebung.teilnahmen.length} Teilnehmer</span>
</p>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="uebungmeldung" />}
{uebung.lernziele && (
<Card>
<CardHeader>
<CardTitle>Lernziele</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line text-sm">{uebung.lernziele}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>Anwesenheit</CardTitle>
<CardDescription>
{anwesendeMenge.size} von {personen.length} aktiven Mitgliedern
</CardDescription>
</div>
{darfBearbeiten && (
<Button
size="sm"
onClick={() =>
teilnahmeSetzen.mutate({
uebungId,
teilnehmer: [...anwesendeMenge].map((personId) => ({ personId })),
ersetzen: true,
})
}
disabled={teilnahmeSetzen.isPending}
>
<Save aria-hidden />
Anwesenheit speichern
</Button>
)}
</CardHeader>
<CardContent>
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{personen.map((person) => (
<label
key={person.id}
className="flex cursor-pointer items-center gap-2 rounded border border-[var(--border)] px-3 py-2 text-sm"
>
<Checkbox
checked={anwesendeMenge.has(person.id)}
disabled={!darfBearbeiten}
onCheckedChange={(an) =>
setAnwesend((alt) => {
const neu = new Set(alt ?? []);
if (an) neu.add(person.id);
else neu.delete(person.id);
return neu;
})
}
/>
<span>
{person.nachname}, {person.vorname}
</span>
</label>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bericht</CardTitle>
<CardDescription>
Was gemacht wurde und was aufgefallen ist — die Grundlage für die nächste Übung.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{darfBearbeiten ? (
<>
<div className="space-y-2">
<Label htmlFor="uebungsbericht">Übungsbericht</Label>
<Textarea
id="uebungsbericht"
rows={6}
value={bericht ?? uebung.bericht ?? ""}
onChange={(ereignis) => setBericht(ereignis.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="beginn">Beginn</Label>
<Input
id="beginn"
type="time"
defaultValue={
uebung.beginnUm ? uebung.beginnUm.toISOString().slice(11, 16) : ""
}
onBlur={(ereignis) => {
if (!ereignis.target.value) return;
const [stunde, minute] = ereignis.target.value.split(":").map(Number);
const zeit = new Date(uebung.datum);
zeit.setUTCHours(stunde ?? 0, minute ?? 0, 0, 0);
aendern.mutate({ id: uebungId, beginnUm: zeit });
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ende">Ende</Label>
<Input
id="ende"
type="time"
defaultValue={uebung.endeUm ? uebung.endeUm.toISOString().slice(11, 16) : ""}
onBlur={(ereignis) => {
if (!ereignis.target.value) return;
const [stunde, minute] = ereignis.target.value.split(":").map(Number);
const zeit = new Date(uebung.datum);
zeit.setUTCHours(stunde ?? 0, minute ?? 0, 0, 0);
aendern.mutate({ id: uebungId, endeUm: zeit });
}}
/>
</div>
</div>
<Button
variant="outline"
onClick={() => aendern.mutate({ id: uebungId, bericht: bericht ?? "" })}
disabled={aendern.isPending || bericht === null}
>
Bericht speichern
</Button>
</>
) : (
<p className="whitespace-pre-line text-sm">
{uebung.bericht ?? "Noch kein Bericht geschrieben."}
</p>
)}
{uebung.dauerMinuten !== null && (
<p className="text-xs text-[var(--muted-foreground)]">
Dauer: {uebung.dauerMinuten} Minuten
</p>
)}
</CardContent>
</Card>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { UebungListe } from "@/app/(app)/uebungen/uebung-liste";
export const metadata = { title: "Übungen" };
/**
* Übungen und Ausbildung.
*
* Der Nachweis nach innen: wer war dabei, wie oft, und woran wurde gearbeitet.
* Daraus wird in der Jahreshauptversammlung die Teilnahmequote — und bei
* Beförderungen die Grundlage.
*/
export default async function UebungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Übungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Thema, Lernziele und Teilnahme — je Übung dokumentiert.
</p>
</div>
<UebungListe darfBearbeiten={hatRecht(sitzung, "ausbildung.bearbeiten")} />
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Plus, Search } from "lucide-react";
import { UebungsArt } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { UebungsartAbzeichen } from "@/components/einsatz/anzeige";
import { UEBUNGSART_TEXT } from "@/lib/einsatz/texte";
import { alsDatum } from "@/lib/utils";
/** Datum von heute im Format, das ein date-Feld erwartet. */
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
export function UebungListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const router = useRouter();
const [suche, setSuche] = React.useState("");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [offen, setOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [thema, setThema] = React.useState("");
const [art, setArt] = React.useState<UebungsArt>(UebungsArt.PRAXIS);
const [datum, setDatum] = React.useState(heute());
const [ort, setOrt] = React.useState("");
const [lernziele, setLernziele] = React.useState("");
const werkzeuge = trpc.useUtils();
const uebungen = trpc.uebung.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "desc",
suche: suche || undefined,
jahr: jahr === "alle" ? undefined : jahr,
});
const anlegen = trpc.uebung.anlegen.useMutation({
onSuccess: (uebung) => {
setOffen(false);
setThema("");
setLernziele("");
void werkzeuge.uebung.invalidate();
router.push(`/uebungen/${uebung.id}`);
},
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = uebungen.data?.eintraege ?? [];
const jahreAuswahl = Array.from({ length: 6 }, (_, index) => new Date().getFullYear() - index);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Thema, Nummer, Ort …"
className="pl-8"
aria-label="Übungen durchsuchen"
/>
</div>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr wählen">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Jahre</SelectItem>
{jahreAuswahl.map((eintrag) => (
<SelectItem key={eintrag} value={String(eintrag)}>
{eintrag}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Übung anlegen
</Button>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} />}
{uebungen.error && <Fehlermeldung meldung={uebungen.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nummer</TableHead>
<TableHead>Thema</TableHead>
<TableHead>Art</TableHead>
<TableHead>Ort</TableHead>
<TableHead>Leitung</TableHead>
<TableHead className="text-right">Datum</TableHead>
<TableHead className="text-right">Teilnehmer</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uebungen.isLoading && <LadeZeilen spalten={7} />}
{eintraege.map((uebung) => (
<TableRow key={uebung.id}>
<TableCell className="tabellenzahlen font-medium">
<Link
href={`/uebungen/${uebung.id}`}
className="underline-offset-4 hover:underline"
>
{uebung.nummer}
</Link>
</TableCell>
<TableCell>{uebung.thema}</TableCell>
<TableCell>
<UebungsartAbzeichen art={uebung.art} />
</TableCell>
<TableCell className="text-sm">{uebung.ort ?? "—"}</TableCell>
<TableCell className="text-sm">
{uebung.leiter ? `${uebung.leiter.nachname}, ${uebung.leiter.vorname}` : "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(uebung.datum)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{uebung._count.teilnahmen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!uebungen.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel="Keine Übung erfasst"
hinweis="Thema, Datum und wer dabei war — mehr braucht ein Übungsnachweis nicht."
aktion={
darfBearbeiten ? (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Übung anlegen
</Button>
) : undefined
}
/>
</div>
)}
</div>
<Dialog open={offen} onOpenChange={setOffen}>
<DialogContent>
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
setMeldung(null);
anlegen.mutate({
thema,
art,
datum: new Date(datum),
ort: ort || null,
lernziele: lernziele || null,
});
}}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>Übung anlegen</DialogTitle>
<DialogDescription>
Die Teilnehmer werden im nächsten Schritt abgehakt.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="thema">Thema</Label>
<Input
id="thema"
required
autoFocus
value={thema}
onChange={(ereignis) => setThema(ereignis.target.value)}
placeholder="Innenangriff mit Atemschutz"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as UebungsArt)}>
<SelectTrigger id="art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(UEBUNGSART_TEXT) as UebungsArt[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{UEBUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="datum">Datum</Label>
<Input
id="datum"
type="date"
required
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ort">Ort</Label>
<Input
id="ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
placeholder="Gerätehaus, Übungsgelände …"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lernziele">Lernziele</Label>
<Textarea
id="lernziele"
rows={3}
value={lernziele}
onChange={(ereignis) => setLernziele(ereignis.target.value)}
placeholder="Was soll danach sitzen?"
/>
</div>
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={anlegen.isPending}>
{anlegen.isPending ? "wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}