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