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:
co-authored by
Claude Opus 5
parent
bc62a9c1e8
commit
91954c402b
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AuditAktion, protokolliere } from "@/lib/audit";
|
||||
import { jahresexportPdf, type JahresexportDaten } from "@/lib/einsatz/bericht";
|
||||
import { KATEGORIE_TEXT, einsatzAdresse } from "@/lib/einsatz/bericht-erzeugen";
|
||||
import { einsatzdauer, hilfsfrist } from "@/lib/einsatz/zeiten";
|
||||
import { staerkeAlsText, staerkeBerechnen } from "@/lib/einsatz/staerke";
|
||||
|
||||
/**
|
||||
* Sammelexport eines Jahres als PDF.
|
||||
*
|
||||
* Das Blatt, das die Wehr der Gemeinde vorlegt. Eigener Endpunkt statt tRPC,
|
||||
* weil ein PDF eine Datei ist und keine JSON-Antwort.
|
||||
*
|
||||
* Anders als der einzelne Einsatzbericht wird es nicht abgelegt: es ist eine
|
||||
* Auswertung, keine Urkunde, und sie ändert sich, solange noch Einsätze des
|
||||
* Jahres nachgetragen werden.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const eingabe = z.object({
|
||||
jahr: z.coerce.number().int().min(2000).max(2100),
|
||||
});
|
||||
|
||||
export async function GET(anfrage: Request) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "einsatz.lesen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
|
||||
}
|
||||
|
||||
const adresse = new URL(anfrage.url);
|
||||
const geprueft = eingabe.safeParse({
|
||||
jahr: adresse.searchParams.get("jahr") ?? new Date().getUTCFullYear(),
|
||||
});
|
||||
|
||||
if (!geprueft.success) {
|
||||
return NextResponse.json({ fehler: "Ungültige Jahresangabe." }, { status: 400 });
|
||||
}
|
||||
|
||||
const jahr = geprueft.data.jahr;
|
||||
const von = new Date(Date.UTC(jahr, 0, 1));
|
||||
const bis = new Date(Date.UTC(jahr + 1, 0, 1));
|
||||
|
||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const [organisation, einsaetze, uebungen] = await Promise.all([
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { name: true },
|
||||
}),
|
||||
db.einsatz.findMany({
|
||||
where: { geloeschtAm: null, alarmiertUm: { gte: von, lt: bis } },
|
||||
orderBy: { alarmiertUm: "asc" },
|
||||
include: {
|
||||
kraefte: { select: { funktion: true } },
|
||||
},
|
||||
}),
|
||||
db.uebung.findMany({
|
||||
where: { geloeschtAm: null, datum: { gte: von, lt: bis } },
|
||||
select: { _count: { select: { teilnahmen: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { organisation, einsaetze, uebungen };
|
||||
});
|
||||
|
||||
const nachKategorie = new Map<string, number>();
|
||||
const hilfsfristen: number[] = [];
|
||||
let gerettet = 0;
|
||||
let verletzt = 0;
|
||||
let getoetet = 0;
|
||||
|
||||
const zeilen = daten.einsaetze.map((einsatz) => {
|
||||
const text = KATEGORIE_TEXT[einsatz.kategorie] ?? einsatz.kategorie;
|
||||
nachKategorie.set(text, (nachKategorie.get(text) ?? 0) + 1);
|
||||
|
||||
const frist = hilfsfrist(einsatz);
|
||||
if (frist !== null) hilfsfristen.push(frist);
|
||||
|
||||
gerettet += einsatz.geretteteePersonen;
|
||||
verletzt += einsatz.verletztePersonen;
|
||||
getoetet += einsatz.totePersonen;
|
||||
|
||||
return {
|
||||
einsatznummer: einsatz.einsatznummer,
|
||||
datum: einsatz.alarmiertUm,
|
||||
stichwort: einsatz.stichwort,
|
||||
kategorie: text,
|
||||
ort: einsatzAdresse(einsatz),
|
||||
staerke: staerkeAlsText(staerkeBerechnen(einsatz.kraefte.map((kraft) => kraft.funktion))),
|
||||
hilfsfrist: frist,
|
||||
dauer: einsatzdauer(einsatz),
|
||||
};
|
||||
});
|
||||
|
||||
const sortiert = [...hilfsfristen].sort((a, b) => a - b);
|
||||
const perzentil = (anteil: number) =>
|
||||
sortiert.length > 0
|
||||
? (sortiert[Math.min(sortiert.length - 1, Math.floor(anteil * sortiert.length))] ?? null)
|
||||
: null;
|
||||
|
||||
const exportDaten: JahresexportDaten = {
|
||||
organisation: { name: daten.organisation?.name ?? "Feuerwehr" },
|
||||
jahr,
|
||||
einsaetze: zeilen,
|
||||
nachKategorie: [...nachKategorie.entries()]
|
||||
.map(([kategorie, anzahl]) => ({ kategorie, anzahl }))
|
||||
.sort((a, b) => b.anzahl - a.anzahl),
|
||||
zeiten: {
|
||||
hilfsfristP80: perzentil(0.8),
|
||||
hilfsfristMedian: perzentil(0.5),
|
||||
gemessen: hilfsfristen.length,
|
||||
},
|
||||
personen: { gerettet, verletzt, getoetet },
|
||||
uebungen: {
|
||||
gesamt: daten.uebungen.length,
|
||||
teilnahmen: daten.uebungen.reduce((summe, uebung) => summe + uebung._count.teilnahmen, 0),
|
||||
},
|
||||
erzeugtAm: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
const pdf = await jahresexportPdf(exportDaten);
|
||||
|
||||
await protokolliere({
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.EXPORTIERT,
|
||||
entitaet: "Einsatz",
|
||||
beschreibung: `Jahresexport ${jahr} mit ${zeilen.length} Einsätzen erzeugt`,
|
||||
sitzung,
|
||||
kontext: { jahr, anzahl: zeilen.length },
|
||||
});
|
||||
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="einsatzstatistik-${jahr}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[jahresexport] PDF fehlgeschlagen:", fehler);
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler:
|
||||
"Der Jahresexport ließ sich nicht erzeugen. Läuft der Gotenberg-Container? (docker compose ps)",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { PORTAL_FEHLER_TEXT, portalPruefen } from "@/lib/einsatz/portal";
|
||||
import { zugriffZaehlen } from "@/lib/ratelimit";
|
||||
|
||||
/**
|
||||
* Foto-Upload aus dem Einsatzportal.
|
||||
*
|
||||
* Der einzige Endpunkt in FLORIAN, der Dateien ohne Anmeldung annimmt.
|
||||
* Entsprechend eng gefasst:
|
||||
*
|
||||
* * nur mit gültigem Portal-Token
|
||||
* * nur Bilder, höchstens 10 MB
|
||||
* * höchstens zehn Uploads je Zugang und Stunde
|
||||
*
|
||||
* Alles andere — PDF, mehrere Dateien, beliebige Größe — bleibt dem
|
||||
* angemeldeten Weg über /api/dateien vorbehalten.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ERLAUBTE_TYPEN = new Set(["image/jpeg", "image/png", "image/webp", "image/heic"]);
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function ipAus(anfrage: Request): string | null {
|
||||
const weitergeleitet = anfrage.headers.get("x-forwarded-for");
|
||||
if (weitergeleitet) return weitergeleitet.split(",")[0]?.trim() ?? null;
|
||||
return anfrage.headers.get("x-real-ip");
|
||||
}
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const formular = await anfrage.formData().catch(() => null);
|
||||
if (!formular) {
|
||||
return NextResponse.json({ fehler: "Die Anfrage enthält kein Formular." }, { status: 400 });
|
||||
}
|
||||
|
||||
const token = formular.get("token");
|
||||
if (typeof token !== "string") {
|
||||
return NextResponse.json({ fehler: "Es fehlt der Zugangslink." }, { status: 400 });
|
||||
}
|
||||
|
||||
const ipAdresse = ipAus(anfrage);
|
||||
const geprueft = await portalPruefen(token, ipAdresse);
|
||||
|
||||
if ("fehler" in geprueft) {
|
||||
return NextResponse.json(
|
||||
{ fehler: PORTAL_FEHLER_TEXT[geprueft.fehler] },
|
||||
{ status: geprueft.fehler === "zu_viele_zugriffe" ? 429 : 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const sitzung = geprueft.sitzung;
|
||||
|
||||
const grenze = await zugriffZaehlen(`portal-foto:${sitzung.zugangId}`, 10, 3600);
|
||||
if (!grenze.erlaubt) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Zu viele Fotos in kurzer Zeit. Bitte später noch einmal." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const datei = formular.get("datei");
|
||||
if (!(datei instanceof File)) {
|
||||
return NextResponse.json({ fehler: "Es wurde kein Foto übergeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const mimeTyp = datei.type || "application/octet-stream";
|
||||
|
||||
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Über das Portal lassen sich nur Fotos hochladen." },
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
if (datei.size > MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ fehler: `Das Foto ist zu groß (erlaubt sind 10 MB).` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = Buffer.from(await datei.arrayBuffer());
|
||||
|
||||
try {
|
||||
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { slug: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: organisation?.slug ?? sitzung.organisationId,
|
||||
entitaet: "Lagemeldung",
|
||||
entitaetId: sitzung.einsatzId,
|
||||
dateiname: datei.name || "portalfoto.jpg",
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, inhalt, mimeTyp, {
|
||||
portalzugang: sitzung.zugangId,
|
||||
});
|
||||
|
||||
const datensatz = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const angelegt = await db.datei.create({
|
||||
data: {
|
||||
organisationId: sitzung.organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname: datei.name || "portalfoto.jpg",
|
||||
mimeTyp,
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: "Lagemeldung",
|
||||
entitaetId: sitzung.einsatzId,
|
||||
kategorie: "portal-foto",
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: "Lagemeldung",
|
||||
entitaetId: sitzung.einsatzId,
|
||||
beschreibung: `Foto über den Portal-Zugang "${sitzung.bezeichnung}" hochgeladen`,
|
||||
akteurTyp: AkteurTyp.API,
|
||||
akteurName: sitzung.bezeichnung,
|
||||
nachher: { dateiId: angelegt.id, groesseBytes: abgelegt.groesseBytes },
|
||||
ipAdresse,
|
||||
userAgent: anfrage.headers.get("user-agent"),
|
||||
});
|
||||
|
||||
return angelegt;
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: datensatz.id, dateiname: datensatz.dateiname });
|
||||
} catch (fehler) {
|
||||
console.error("[portal] Foto-Upload fehlgeschlagen:", fehler);
|
||||
return NextResponse.json({ fehler: "Das Foto ließ sich nicht ablegen." }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PortalAnsicht } from "@/app/portal/[token]/portal-ansicht";
|
||||
|
||||
export const metadata = {
|
||||
title: "Einsatzportal",
|
||||
// Ein Link, der geteilt wird, soll nicht in Suchmaschinen landen.
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Das Einsatzportal — die einzige Seite in FLORIAN ohne Anmeldung.
|
||||
*
|
||||
* Sie liegt bewusst außerhalb der Gruppe (app): kein Menü, keine Seitenleiste,
|
||||
* kein Zugang zu irgendetwas anderem. Wer den Link hat, sieht genau einen
|
||||
* Einsatz und kann genau eine Sache tun — eine Lagemeldung absetzen.
|
||||
*
|
||||
* Der Token steht in der Adresse. Geprüft wird er bei jedem Aufruf serverseitig
|
||||
* (siehe lib/einsatz/portal.ts) — Gültigkeit, Widerruf und Zugriffsbegrenzung
|
||||
* inbegriffen.
|
||||
*/
|
||||
export default async function PortalSeite({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
|
||||
return <PortalAnsicht token={token} />;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Camera, Flame, RefreshCw, Send } 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 { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { KATEGORIE_TEXT, EINSATZ_STATUS_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte";
|
||||
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Portalansicht für Fremdwehren.
|
||||
*
|
||||
* Aufgebaut für ein Handy im Regen: große Schrift, wenig Text, ein Formular.
|
||||
* Was hier fehlt, fehlt mit Absicht — keine Namen von Einsatzkräften, kein
|
||||
* Bericht, keine Navigation in den Rest der Anwendung.
|
||||
*
|
||||
* Die Ansicht lädt sich alle 30 Sekunden neu. Wer an der Einsatzstelle steht,
|
||||
* soll nicht überlegen müssen, ob der Stand noch aktuell ist.
|
||||
*/
|
||||
export function PortalAnsicht({ token }: { token: string }) {
|
||||
const [text, setText] = React.useState("");
|
||||
const [name, setName] = React.useState("");
|
||||
const [wichtig, setWichtig] = React.useState(false);
|
||||
const [fotoId, setFotoId] = React.useState<string | null>(null);
|
||||
const [fotoName, setFotoName] = React.useState<string | null>(null);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [laedtFoto, setLaedtFoto] = React.useState(false);
|
||||
|
||||
const dateiFeld = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const abfrage = trpc.portal.laden.useQuery({ token }, { refetchInterval: 30_000, retry: false });
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const melden = trpc.portal.melden.useMutation({
|
||||
onSuccess: () => {
|
||||
setText("");
|
||||
setWichtig(false);
|
||||
setFotoId(null);
|
||||
setFotoName(null);
|
||||
setMeldung("Meldung übermittelt.");
|
||||
void werkzeuge.portal.laden.invalidate({ token });
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
/** Foto über den eigenen Portal-Endpunkt hochladen. */
|
||||
async function fotoHochladen(datei: File) {
|
||||
setLaedtFoto(true);
|
||||
setMeldung(null);
|
||||
|
||||
try {
|
||||
const formular = new FormData();
|
||||
formular.append("token", token);
|
||||
formular.append("datei", datei);
|
||||
|
||||
const antwort = await fetch("/api/portal/foto", { method: "POST", body: formular });
|
||||
const inhalt = (await antwort.json()) as { id?: string; dateiname?: string; fehler?: string };
|
||||
|
||||
if (!antwort.ok || !inhalt.id) {
|
||||
setMeldung(inhalt.fehler ?? "Das Foto ließ sich nicht übertragen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setFotoId(inhalt.id);
|
||||
setFotoName(inhalt.dateiname ?? datei.name);
|
||||
} catch {
|
||||
setMeldung("Das Foto ließ sich nicht übertragen. Netzempfang?");
|
||||
} finally {
|
||||
setLaedtFoto(false);
|
||||
if (dateiFeld.current) dateiFeld.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (abfrage.isLoading) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl p-6">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (abfrage.error) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl space-y-4 p-6">
|
||||
<h1 className="flex items-center gap-2 text-xl font-semibold">
|
||||
<Flame className="size-5 text-[var(--primary)]" aria-hidden />
|
||||
Einsatzportal
|
||||
</h1>
|
||||
<Fehlermeldung meldung={abfrage.error.message} kennung="portalfehler" />
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Wenden Sie sich an die Wehr, die Ihnen den Link geschickt hat — sie kann einen neuen
|
||||
erzeugen.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!abfrage.data) return null;
|
||||
|
||||
const { einsatz, lagemeldungen, zugang } = abfrage.data;
|
||||
|
||||
const adresse = [
|
||||
[einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" "),
|
||||
[einsatz.plz, einsatz.ort].filter(Boolean).join(" "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl space-y-6 p-4 sm:p-6">
|
||||
<header className="space-y-1 border-b border-[var(--border)] pb-4">
|
||||
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<Flame className="size-4 text-[var(--primary)]" aria-hidden />
|
||||
Einsatzportal der {einsatz.organisation.name}
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{einsatz.stichwort}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{KATEGORIE_TEXT[einsatz.kategorie]} · {EINSATZ_STATUS_TEXT[einsatz.status]}
|
||||
{adresse && ` · ${adresse}`}
|
||||
{einsatz.objekt && ` · ${einsatz.objekt}`}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Zugang für {zugang.bezeichnung} · gültig bis {alsDatumZeit(zugang.gueltigBis)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ text: "alarmiert", wert: einsatz.alarmiertUm },
|
||||
{ text: "ausgerückt", wert: einsatz.ausgeruecktUm },
|
||||
{ text: "eingetroffen", wert: einsatz.eingetroffenUm },
|
||||
{ text: "Einsatzende", wert: einsatz.einsatzendeUm },
|
||||
].map((eintrag) => (
|
||||
<div
|
||||
key={eintrag.text}
|
||||
className="rounded-md border border-[var(--border)] p-3 text-center"
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{eintrag.text}
|
||||
</div>
|
||||
<div className="tabellenzahlen text-lg font-semibold">
|
||||
{eintrag.wert ? alsUhrzeit(eintrag.wert) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Meldung absetzen */}
|
||||
<section className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||
<h2 className="text-lg font-medium">Lagemeldung absetzen</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Wer meldet?</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(ereignis) => setName(ereignis.target.value)}
|
||||
placeholder="Name oder Funktion"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="meldung">Meldung</Label>
|
||||
<Textarea
|
||||
id="meldung"
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(ereignis) => setText(ereignis.target.value)}
|
||||
placeholder="Was ist der Stand?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={dateiFeld}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
aria-label="Foto aufnehmen"
|
||||
onChange={(ereignis) => {
|
||||
const datei = ereignis.target.files?.[0];
|
||||
if (datei) void fotoHochladen(datei);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => dateiFeld.current?.click()}
|
||||
disabled={laedtFoto}
|
||||
>
|
||||
<Camera aria-hidden />
|
||||
{laedtFoto ? "wird übertragen …" : fotoId ? "Foto ersetzen" : "Foto aufnehmen"}
|
||||
</Button>
|
||||
|
||||
{fotoName && (
|
||||
<span className="text-sm text-[var(--muted-foreground)]">{fotoName}</span>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={wichtig} onCheckedChange={(an) => setWichtig(an === true)} />
|
||||
wichtig
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-auto"
|
||||
disabled={!text.trim() || !name.trim() || melden.isPending}
|
||||
onClick={() =>
|
||||
melden.mutate({
|
||||
token,
|
||||
text,
|
||||
verfasserName: name,
|
||||
wichtig,
|
||||
fotoDateiId: fotoId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send aria-hidden />
|
||||
{melden.isPending ? "wird gesendet …" : "Melden"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="portalmeldung" />}
|
||||
</section>
|
||||
|
||||
{/* Verlauf */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Verlauf</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void werkzeuge.portal.laden.invalidate({ token })}
|
||||
>
|
||||
<RefreshCw aria-hidden />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lagemeldungen.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Noch keine Meldung.</p>
|
||||
) : (
|
||||
<ol className="space-y-3">
|
||||
{lagemeldungen.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className={`border-l-2 pl-3 text-sm ${
|
||||
eintrag.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(eintrag.zeitpunkt)}
|
||||
</span>
|
||||
{eintrag.wichtig && (
|
||||
<span className="text-xs font-medium text-[var(--gesperrt)]">wichtig</span>
|
||||
)}
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{eintrag.verfasserName ?? "Wehr"} · {QUELLE_TEXT[eintrag.quelle]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5">{eintrag.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="border-t border-[var(--border)] pt-4 text-xs text-[var(--muted-foreground)]">
|
||||
Dieser Zugang ist zeitlich begrenzt und protokolliert. Er zeigt ausschließlich diesen
|
||||
Einsatz — keine Personendaten und keine weiteren Daten der Wehr.
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TrpcProvider } from "@/lib/trpc/provider";
|
||||
|
||||
/**
|
||||
* Layout des Einsatzportals.
|
||||
*
|
||||
* Bewusst eigenständig: keine Anmeldung, keine Seitenleiste, kein Weg in den
|
||||
* Rest der Anwendung. Nur der tRPC-Zugang, damit die Seite ihre Daten holen
|
||||
* kann — die Prozeduren darunter sind die einzigen öffentlichen in FLORIAN
|
||||
* und prüfen selbst, ob der Token gilt.
|
||||
*/
|
||||
export default function PortalLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<TrpcProvider>
|
||||
<div className="min-h-screen bg-[var(--background)]">{children}</div>
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import type { EinsatzKategorie, EinsatzStatus, UebungsArt } from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
EINSATZ_STATUS_TEXT,
|
||||
KATEGORIE_GRUPPE,
|
||||
KATEGORIE_TEXT,
|
||||
UEBUNGSART_TEXT,
|
||||
} from "@/lib/einsatz/texte";
|
||||
import { alsDauer } from "@/lib/einsatz/zeiten";
|
||||
|
||||
/**
|
||||
* Anzeigebausteine für Einsätze und Übungen.
|
||||
*
|
||||
* Die Farblehre bleibt dieselbe wie im Rest der Anwendung. Neu ist nur, was
|
||||
* Rot hier bedeutet: nicht „kaputt", sondern „läuft gerade" — ein laufender
|
||||
* Einsatz ist das Dringendste, was die Anwendung zeigen kann.
|
||||
*/
|
||||
|
||||
export function EinsatzStatusAbzeichen({ status }: { status: EinsatzStatus }) {
|
||||
const variante =
|
||||
status === "LAUFEND"
|
||||
? "gesperrt"
|
||||
: status === "ENTWURF"
|
||||
? "pruefungFaellig"
|
||||
: status === "FREIGEGEBEN"
|
||||
? "einsatzbereit"
|
||||
: "outline";
|
||||
|
||||
return <Badge variant={variante}>{EINSATZ_STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function KategorieAbzeichen({ kategorie }: { kategorie: EinsatzKategorie }) {
|
||||
const gruppe = KATEGORIE_GRUPPE[kategorie];
|
||||
const variante =
|
||||
gruppe === "brand" ? "gesperrt" : gruppe === "hilfe" ? "pruefungFaellig" : "secondary";
|
||||
|
||||
return <Badge variant={variante}>{KATEGORIE_TEXT[kategorie]}</Badge>;
|
||||
}
|
||||
|
||||
export function UebungsartAbzeichen({ art }: { art: UebungsArt }) {
|
||||
return <Badge variant="outline">{UEBUNGSART_TEXT[art]}</Badge>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeitleiste eines Einsatzes.
|
||||
*
|
||||
* Sechs Stempel nebeneinander. Was fehlt, bleibt sichtbar leer — das ist die
|
||||
* Aufforderung, es nachzutragen, und gleichzeitig die ehrliche Auskunft
|
||||
* darüber, was dokumentiert ist und was nicht.
|
||||
*/
|
||||
export function Zeitleiste({
|
||||
stempel,
|
||||
}: {
|
||||
stempel: Array<{ bezeichnung: string; uhrzeit: string | null; gesetzt: boolean }>;
|
||||
}) {
|
||||
return (
|
||||
<ol className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{stempel.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.bezeichnung}
|
||||
className={`rounded-md border p-3 text-center ${
|
||||
eintrag.gesetzt
|
||||
? "border-[var(--border)] bg-[var(--card)]"
|
||||
: "border-dashed border-[var(--border)] text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{eintrag.bezeichnung}
|
||||
</div>
|
||||
<div className="tabellenzahlen text-lg font-semibold">{eintrag.uhrzeit ?? "—"}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die drei Kennzahlen, die aus den Stempeln folgen. */
|
||||
export function Zeitkennzahlen({
|
||||
ausrueckzeit,
|
||||
anfahrtszeit,
|
||||
hilfsfrist,
|
||||
dauer,
|
||||
}: {
|
||||
ausrueckzeit: number | null;
|
||||
anfahrtszeit: number | null;
|
||||
hilfsfrist: number | null;
|
||||
dauer: number | null;
|
||||
}) {
|
||||
const werte = [
|
||||
{ text: "Ausrückzeit", wert: ausrueckzeit, hinweis: "Alarm bis Ausrücken" },
|
||||
{ text: "Anfahrt", wert: anfahrtszeit, hinweis: "Ausrücken bis Eintreffen" },
|
||||
{ text: "Hilfsfrist", wert: hilfsfrist, hinweis: "Alarm bis Eintreffen" },
|
||||
{ text: "Einsatzdauer", wert: dauer, hinweis: "Alarm bis Einrücken" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{werte.map((eintrag) => (
|
||||
<div
|
||||
key={eintrag.text}
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-3"
|
||||
>
|
||||
<div className="tabellenzahlen text-xl font-semibold">{alsDauer(eintrag.wert)}</div>
|
||||
<div className="text-sm font-medium">{eintrag.text}</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">{eintrag.hinweis}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Stärke nach FwDV 3, groß dargestellt.
|
||||
*
|
||||
* Vier Zahlen, die jeder in der Wehr lesen kann — und darunter der Klartext
|
||||
* für alle anderen.
|
||||
*/
|
||||
export function StaerkeAnzeige({
|
||||
staerke,
|
||||
einheit,
|
||||
}: {
|
||||
staerke: { fuehrer: number; unterfuehrer: number; mannschaft: number; gesamt: number };
|
||||
einheit?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
||||
<div className="tabellenzahlen text-2xl font-semibold">
|
||||
{staerke.fuehrer}/{staerke.unterfuehrer}/{staerke.mannschaft}/{staerke.gesamt}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{staerke.fuehrer} Führer, {staerke.unterfuehrer} Unterführer, {staerke.mannschaft}{" "}
|
||||
Mannschaft
|
||||
{einheit ? ` · entspricht einer ${einheit}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hinweise zu Zeiten und Stärke — sichtbar, aber nicht blockierend. */
|
||||
export function Hinweisliste({ hinweise }: { hinweise: string[] }) {
|
||||
if (hinweise.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{hinweise.map((hinweis) => (
|
||||
<li key={hinweis}>{hinweis}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein einfaches Balkendiagramm.
|
||||
*
|
||||
* Bewusst ohne Diagrammbibliothek: zwölf Balken mit Beschriftung sind ein
|
||||
* Stück CSS, und jede Bibliothek dafür wäre ein zusätzliches Paket, das
|
||||
* gepflegt und mitgeliefert werden müsste — bei einer Anwendung ohne CDN
|
||||
* fällt das ins Gewicht.
|
||||
*/
|
||||
export function Balkendiagramm({
|
||||
werte,
|
||||
einheit = "",
|
||||
}: {
|
||||
werte: Array<{ beschriftung: string; wert: number }>;
|
||||
einheit?: string;
|
||||
}) {
|
||||
const hoechster = Math.max(1, ...werte.map((eintrag) => eintrag.wert));
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{werte.map((eintrag) => (
|
||||
<div key={eintrag.beschriftung} className="flex items-center gap-2 text-sm">
|
||||
{/* Breit genug für "Technische Hilfeleistung klein" — sonst sehen zwei
|
||||
Kategorien in der Statistik gleich aus. Der volle Text steht
|
||||
zusätzlich im title-Attribut. */}
|
||||
<span
|
||||
className="w-44 shrink-0 truncate text-[var(--muted-foreground)]"
|
||||
title={eintrag.beschriftung}
|
||||
>
|
||||
{eintrag.beschriftung}
|
||||
</span>
|
||||
<div className="h-4 flex-1 overflow-hidden rounded bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full bg-[var(--primary)]"
|
||||
style={{ width: `${(eintrag.wert / hoechster) * 100}%` }}
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
<span className="tabellenzahlen w-14 shrink-0 text-right">
|
||||
{eintrag.wert}
|
||||
{einheit}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Flame,
|
||||
Gauge,
|
||||
GraduationCap,
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
@@ -81,6 +82,13 @@ const EINTRAEGE: Eintrag[] = [
|
||||
abPhase: 2,
|
||||
},
|
||||
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
|
||||
{
|
||||
titel: "Übungen",
|
||||
pfad: "/uebungen",
|
||||
symbol: GraduationCap,
|
||||
rechte: ["ausbildung.lesen"],
|
||||
abPhase: 3,
|
||||
},
|
||||
{
|
||||
titel: "Terminal",
|
||||
pfad: "/kiosk",
|
||||
@@ -115,7 +123,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** 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 = 2;
|
||||
const AKTUELLE_PHASE = 3;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import "server-only";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { berichtPdf, type BerichtDaten } from "@/lib/einsatz/bericht";
|
||||
import { kennzahlen } from "@/lib/einsatz/zeiten";
|
||||
import { staerkeAlsText, staerkeBerechnen } from "@/lib/einsatz/staerke";
|
||||
|
||||
/**
|
||||
* Vom Einsatz zum abgelegten Bericht.
|
||||
*
|
||||
* Wie beim Prüfprotokoll dreigeteilt — lesen, rendern, schreiben. Zwischen
|
||||
* Lesen und Schreiben liegt der Aufruf an Gotenberg, und der darf dauern.
|
||||
*
|
||||
* Wiederholbar: gibt es den Bericht schon, passiert nichts.
|
||||
*/
|
||||
|
||||
const KATEGORIE_TEXT: Record<string, string> = {
|
||||
BRAND_KLEIN: "Brand klein",
|
||||
BRAND_MITTEL: "Brand mittel",
|
||||
BRAND_GROSS: "Brand groß",
|
||||
TECHNISCHE_HILFE_KLEIN: "Technische Hilfeleistung klein",
|
||||
TECHNISCHE_HILFE_MITTEL: "Technische Hilfeleistung mittel",
|
||||
TECHNISCHE_HILFE_GROSS: "Technische Hilfeleistung groß",
|
||||
GEFAHRGUT: "Gefahrguteinsatz",
|
||||
RETTUNGSDIENST: "Unterstützung Rettungsdienst",
|
||||
BRANDMELDEANLAGE: "Brandmeldeanlage",
|
||||
BOESWILLIGER_ALARM: "böswilliger Alarm",
|
||||
SICHERHEITSWACHE: "Sicherheitswache",
|
||||
UNTERSTUETZUNG: "Unterstützung / Amtshilfe",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
const ALARMIERUNG_TEXT: Record<string, string> = {
|
||||
SIRENE: "Sirene",
|
||||
MELDEEMPFAENGER: "Meldeempfänger",
|
||||
TELEFON: "Telefon",
|
||||
APP: "App",
|
||||
PERSOENLICH: "persönlich",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
/** Adresse aus den Einzelfeldern, wie sie im Bericht stehen soll. */
|
||||
function adresse(einsatz: {
|
||||
strasse: string | null;
|
||||
hausnummer: string | null;
|
||||
plz: string | null;
|
||||
ort: string | null;
|
||||
ortsteil: string | null;
|
||||
}): string {
|
||||
const strasse = [einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" ");
|
||||
const ort = [einsatz.plz, einsatz.ort ?? einsatz.ortsteil].filter(Boolean).join(" ");
|
||||
return [strasse, ort].filter(Boolean).join(", ") || "ohne Ortsangabe";
|
||||
}
|
||||
|
||||
export async function einsatzberichtErzeugen(
|
||||
organisationId: string,
|
||||
einsatzId: string,
|
||||
): Promise<{ dateiId: string; neu: boolean } | null> {
|
||||
// ---------------------------------------------------------------- lesen
|
||||
const geladen = await fuerOrganisation(organisationId, async (db) => {
|
||||
const einsatz = await db.einsatz.findUnique({
|
||||
where: { id: einsatzId },
|
||||
include: {
|
||||
organisation: { select: { name: true, slug: true, strasse: true, plz: true, ort: true } },
|
||||
einsatzleiter: { select: { vorname: true, nachname: true } },
|
||||
freigegebenVon: { select: { vorname: true, nachname: true } },
|
||||
fahrzeuge: {
|
||||
include: { fahrzeug: { select: { funkrufname: true, bezeichnung: true } } },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
},
|
||||
kraefte: {
|
||||
include: {
|
||||
person: { select: { vorname: true, nachname: true, dienstgrad: true } },
|
||||
fahrzeug: { select: { funkrufname: true } },
|
||||
},
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
},
|
||||
material: { orderBy: { erstelltAm: "asc" } },
|
||||
lagemeldungen: {
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!einsatz) return null;
|
||||
if (einsatz.status !== "FREIGEGEBEN") return null;
|
||||
|
||||
return einsatz;
|
||||
});
|
||||
|
||||
if (!geladen) return null;
|
||||
if (geladen.berichtDateiId) return { dateiId: geladen.berichtDateiId, neu: false };
|
||||
|
||||
// --------------------------------------------------------------- rendern
|
||||
const staerke = staerkeBerechnen(geladen.kraefte.map((kraft) => kraft.funktion));
|
||||
|
||||
const daten: BerichtDaten = {
|
||||
organisation: {
|
||||
name: geladen.organisation.name,
|
||||
strasse: geladen.organisation.strasse,
|
||||
plz: geladen.organisation.plz,
|
||||
ort: geladen.organisation.ort,
|
||||
},
|
||||
einsatz: {
|
||||
einsatznummer: geladen.einsatznummer,
|
||||
stichwort: geladen.stichwort,
|
||||
kategorie: KATEGORIE_TEXT[geladen.kategorie] ?? geladen.kategorie,
|
||||
ilsNummer: geladen.ilsNummer,
|
||||
alarmierungsart: geladen.alarmierungsart
|
||||
? (ALARMIERUNG_TEXT[geladen.alarmierungsart] ?? geladen.alarmierungsart)
|
||||
: null,
|
||||
adresse: adresse(geladen),
|
||||
objekt: geladen.objekt,
|
||||
koordinaten:
|
||||
geladen.breitengrad && geladen.laengengrad
|
||||
? `${geladen.breitengrad.toString()}, ${geladen.laengengrad.toString()}`
|
||||
: null,
|
||||
beschreibung: geladen.beschreibung,
|
||||
bericht: geladen.bericht,
|
||||
wetter: geladen.wetter,
|
||||
temperatur: geladen.temperatur,
|
||||
weitereKraefte: geladen.weitereKraefte,
|
||||
gerettet: geladen.geretteteePersonen,
|
||||
verletzt: geladen.verletztePersonen,
|
||||
getoetet: geladen.totePersonen,
|
||||
sachschaden: geladen.sachschaden ? geladen.sachschaden.toNumber() : null,
|
||||
kostenpflichtig: geladen.kostenpflichtig,
|
||||
einsatzleiter: geladen.einsatzleiter
|
||||
? `${geladen.einsatzleiter.vorname} ${geladen.einsatzleiter.nachname}`
|
||||
: null,
|
||||
freigegebenVon: geladen.freigegebenVon
|
||||
? `${geladen.freigegebenVon.vorname} ${geladen.freigegebenVon.nachname}`
|
||||
: null,
|
||||
freigegebenAm: geladen.freigegebenAm,
|
||||
},
|
||||
zeiten: {
|
||||
alarmiertUm: geladen.alarmiertUm,
|
||||
ausgeruecktUm: geladen.ausgeruecktUm,
|
||||
eingetroffenUm: geladen.eingetroffenUm,
|
||||
einsatzendeUm: geladen.einsatzendeUm,
|
||||
eingeruecktUm: geladen.eingeruecktUm,
|
||||
einsatzbereitUm: geladen.einsatzbereitUm,
|
||||
},
|
||||
kennzahlen: kennzahlen(geladen),
|
||||
staerkeText: staerkeAlsText(staerke),
|
||||
fahrzeuge: geladen.fahrzeuge.map((eintrag) => ({
|
||||
funkrufname: eintrag.fahrzeug.funkrufname,
|
||||
bezeichnung: eintrag.fahrzeug.bezeichnung,
|
||||
ausgeruecktUm: eintrag.ausgeruecktUm,
|
||||
eingetroffenUm: eintrag.eingetroffenUm,
|
||||
eingeruecktUm: eintrag.eingeruecktUm,
|
||||
kilometer:
|
||||
eintrag.kilometerAbfahrt !== null && eintrag.kilometerRueckkehr !== null
|
||||
? eintrag.kilometerRueckkehr - eintrag.kilometerAbfahrt
|
||||
: null,
|
||||
wasserabgabe: eintrag.wasserabgabe,
|
||||
})),
|
||||
kraefte: geladen.kraefte.map((kraft) => ({
|
||||
name: `${kraft.person.nachname}, ${kraft.person.vorname}`,
|
||||
dienstgrad: kraft.person.dienstgrad,
|
||||
funktion: kraft.funktion,
|
||||
atemschutz: kraft.atemschutz,
|
||||
fahrzeug: kraft.fahrzeug?.funkrufname ?? null,
|
||||
von: kraft.vonUm,
|
||||
bis: kraft.bisUm,
|
||||
})),
|
||||
material: geladen.material.map((posten) => ({
|
||||
bezeichnung: posten.bezeichnung,
|
||||
menge: `${posten.menge.toString()} ${posten.einheit}`,
|
||||
beschaedigt: posten.beschaedigt,
|
||||
})),
|
||||
meldungen: geladen.lagemeldungen.map((meldung) => ({
|
||||
zeitpunkt: meldung.zeitpunkt,
|
||||
text: meldung.text,
|
||||
wichtig: meldung.wichtig,
|
||||
verfasser: meldung.verfasserName,
|
||||
quelle: meldung.quelle,
|
||||
})),
|
||||
erzeugtAm: new Date(),
|
||||
};
|
||||
|
||||
const pdf = await berichtPdf(daten);
|
||||
|
||||
const dateiname = `Einsatzbericht-${geladen.einsatznummer}.pdf`;
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: geladen.organisation.slug,
|
||||
entitaet: "Einsatz",
|
||||
entitaetId: geladen.id,
|
||||
dateiname,
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
|
||||
einsatz: geladen.id,
|
||||
nummer: geladen.einsatznummer,
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- schreiben
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const stand = await db.einsatz.findUnique({
|
||||
where: { id: geladen.id },
|
||||
select: { berichtDateiId: true },
|
||||
});
|
||||
|
||||
if (stand?.berichtDateiId) return { dateiId: stand.berichtDateiId, neu: false };
|
||||
|
||||
const datei = await db.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname,
|
||||
mimeTyp: "application/pdf",
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: "Einsatz",
|
||||
entitaetId: geladen.id,
|
||||
kategorie: "einsatzbericht",
|
||||
hochgeladenVonId: geladen.freigegebenVonId,
|
||||
},
|
||||
});
|
||||
|
||||
// Erlaubt trotz Freigabe: das Berichtsfeld war leer (siehe Trigger
|
||||
// einsatz_freigabe_schuetzen in Migration 20260903000500).
|
||||
await db.einsatz.update({
|
||||
where: { id: geladen.id },
|
||||
data: { berichtDateiId: datei.id },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: "Einsatz",
|
||||
entitaetId: geladen.id,
|
||||
beschreibung: `Einsatzbericht ${geladen.einsatznummer} erzeugt (${Math.round(
|
||||
abgelegt.groesseBytes / 1024,
|
||||
)} kB)`,
|
||||
akteurTyp: AkteurTyp.WORKER,
|
||||
akteurName: "Hintergrundprozess",
|
||||
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
|
||||
});
|
||||
|
||||
return { dateiId: datei.id, neu: true };
|
||||
});
|
||||
}
|
||||
|
||||
export { KATEGORIE_TEXT, ALARMIERUNG_TEXT, adresse as einsatzAdresse };
|
||||
@@ -0,0 +1,642 @@
|
||||
import "server-only";
|
||||
import { htmlZuPdf, html } from "@/lib/pdf";
|
||||
import { alsDatum, alsDatumZeit, alsEuro, alsUhrzeit } from "@/lib/utils";
|
||||
import { alsDauer, ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
|
||||
import { FUNKTION_TEXT, type Funktion } from "@/lib/einsatz/staerke";
|
||||
|
||||
/**
|
||||
* Der Einsatzbericht als PDF.
|
||||
*
|
||||
* Er geht an die Gemeinde, bei Kostenersatz an den Verursacher und im
|
||||
* Schadensfall an die Versicherung. Danach richtet sich der Aufbau:
|
||||
*
|
||||
* Kopf Wehr, Einsatznummer, Stichwort, Ergebnis auf einen Blick
|
||||
* Einsatzdaten Ort, Kategorie, Leitstellennummer
|
||||
* Zeiten die sechs Stempel und die drei Kennzahlen daraus
|
||||
* Kräfte Fahrzeuge und Mannschaft mit Stärke nach FwDV 3
|
||||
* Verlauf Lagemeldungen in zeitlicher Reihenfolge
|
||||
* Bericht der Text des Einsatzleiters
|
||||
* Fuß wer freigegeben hat und wann
|
||||
*
|
||||
* Wie beim Prüfprotokoll: alles eingebettet, keine externen Schriften, keine
|
||||
* nachgeladenen Bilder.
|
||||
*/
|
||||
|
||||
export interface BerichtKraft {
|
||||
name: string;
|
||||
dienstgrad?: string | null;
|
||||
funktion: Funktion;
|
||||
atemschutz: boolean;
|
||||
fahrzeug?: string | null;
|
||||
von?: Date | null;
|
||||
bis?: Date | null;
|
||||
}
|
||||
|
||||
export interface BerichtFahrzeug {
|
||||
funkrufname: string;
|
||||
bezeichnung?: string | null;
|
||||
ausgeruecktUm?: Date | null;
|
||||
eingetroffenUm?: Date | null;
|
||||
eingeruecktUm?: Date | null;
|
||||
kilometer?: number | null;
|
||||
wasserabgabe?: number | null;
|
||||
}
|
||||
|
||||
export interface BerichtMeldung {
|
||||
zeitpunkt: Date;
|
||||
text: string;
|
||||
wichtig: boolean;
|
||||
verfasser?: string | null;
|
||||
quelle: string;
|
||||
}
|
||||
|
||||
export interface BerichtDaten {
|
||||
organisation: {
|
||||
name: string;
|
||||
strasse?: string | null;
|
||||
plz?: string | null;
|
||||
ort?: string | null;
|
||||
};
|
||||
einsatz: {
|
||||
einsatznummer: string;
|
||||
stichwort: string;
|
||||
kategorie: string;
|
||||
ilsNummer?: string | null;
|
||||
alarmierungsart?: string | null;
|
||||
adresse: string;
|
||||
objekt?: string | null;
|
||||
koordinaten?: string | null;
|
||||
beschreibung?: string | null;
|
||||
bericht?: string | null;
|
||||
wetter?: string | null;
|
||||
temperatur?: number | null;
|
||||
weitereKraefte?: string | null;
|
||||
gerettet: number;
|
||||
verletzt: number;
|
||||
getoetet: number;
|
||||
sachschaden?: number | null;
|
||||
kostenpflichtig: boolean;
|
||||
einsatzleiter?: string | null;
|
||||
freigegebenVon?: string | null;
|
||||
freigegebenAm?: Date | null;
|
||||
};
|
||||
zeiten: Partial<Record<Zeitstempel, Date | null>>;
|
||||
kennzahlen: {
|
||||
ausrueckzeit: number | null;
|
||||
anfahrtszeit: number | null;
|
||||
hilfsfrist: number | null;
|
||||
dauer: number | null;
|
||||
};
|
||||
staerkeText: string;
|
||||
fahrzeuge: BerichtFahrzeug[];
|
||||
kraefte: BerichtKraft[];
|
||||
material: Array<{ bezeichnung: string; menge: string; beschaedigt: boolean }>;
|
||||
meldungen: BerichtMeldung[];
|
||||
erzeugtAm: Date;
|
||||
}
|
||||
|
||||
function feld(bezeichnung: string, wert: string | null | undefined): string {
|
||||
return `
|
||||
<div class="feld">
|
||||
<div class="beschriftung">${html(bezeichnung)}</div>
|
||||
<div class="wert">${html(wert && wert !== "" ? wert : "—")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const STIL = `
|
||||
/* Ränder kommen von Gotenberg — hier keine setzen. */
|
||||
@page { size: A4; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: #111;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
h1 { font-size: 15pt; margin: 0 0 1mm; }
|
||||
h2 {
|
||||
font-size: 10.5pt;
|
||||
margin: 6mm 0 2mm;
|
||||
padding-bottom: 1mm;
|
||||
border-bottom: 0.4mm solid #111;
|
||||
}
|
||||
|
||||
.kopf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8mm;
|
||||
padding-bottom: 3mm;
|
||||
border-bottom: 0.6mm solid #111;
|
||||
}
|
||||
|
||||
.kopf .wehr { font-size: 8.5pt; color: #333; }
|
||||
|
||||
.nummer {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nummer .marke {
|
||||
display: inline-block;
|
||||
padding: 1.5mm 3mm;
|
||||
border: 0.5mm solid #111;
|
||||
border-radius: 1.5mm;
|
||||
font-weight: 700;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.felder {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2mm 6mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.feld .beschriftung {
|
||||
font-size: 7.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.feld .wert { font-size: 9.5pt; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 7.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
border-bottom: 0.3mm solid #999;
|
||||
padding: 1.5mm 2mm;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1.6mm 2mm;
|
||||
border-bottom: 0.2mm solid #ddd;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr { break-inside: avoid; }
|
||||
|
||||
td.zahl, th.zahl { text-align: right; white-space: nowrap; }
|
||||
|
||||
.zeitleiste {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2mm;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
.zeitleiste .stempel {
|
||||
border: 0.2mm solid #bbb;
|
||||
border-radius: 1.5mm;
|
||||
padding: 2mm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zeitleiste .bezeichnung {
|
||||
font-size: 7pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.zeitleiste .uhrzeit { font-size: 11pt; font-weight: 700; }
|
||||
|
||||
.kennzahlen {
|
||||
display: flex;
|
||||
gap: 6mm;
|
||||
margin-top: 3mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.kennzahlen strong { font-size: 11pt; }
|
||||
|
||||
.verlauf li { margin-bottom: 1.5mm; break-inside: avoid; }
|
||||
.verlauf .zeit { font-weight: 700; }
|
||||
.verlauf .wichtig { color: #b91c1c; font-weight: 700; }
|
||||
|
||||
.bericht {
|
||||
white-space: pre-line;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
.hinweis {
|
||||
margin-top: 6mm;
|
||||
padding: 2.5mm 3mm;
|
||||
background: #f4f4f5;
|
||||
border-left: 1mm solid #999;
|
||||
font-size: 8pt;
|
||||
color: #333;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Baut das eigenständige HTML des Einsatzberichts. */
|
||||
export function berichtHtml(daten: BerichtDaten): string {
|
||||
const { organisation, einsatz, zeiten, kennzahlen, fahrzeuge, kraefte, material, meldungen } =
|
||||
daten;
|
||||
|
||||
const anschrift = [organisation.strasse, [organisation.plz, organisation.ort].filter(Boolean).join(" ")]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Einsatzbericht ${html(einsatz.einsatznummer)}</title>
|
||||
<style>${STIL}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kopf">
|
||||
<div>
|
||||
<h1>Einsatzbericht</h1>
|
||||
<div class="wehr">${html(organisation.name)}${anschrift ? ` · ${html(anschrift)}` : ""}</div>
|
||||
</div>
|
||||
<div class="nummer">
|
||||
<div class="marke">${html(einsatz.einsatznummer)}</div>
|
||||
<div class="wehr" style="margin-top:1.5mm">${html(einsatz.kategorie)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Einsatz</h2>
|
||||
<div class="felder">
|
||||
${feld("Stichwort", einsatz.stichwort)}
|
||||
${feld("Kategorie", einsatz.kategorie)}
|
||||
${feld("Leitstellennummer", einsatz.ilsNummer)}
|
||||
${feld("Einsatzort", einsatz.adresse)}
|
||||
${feld("Objekt", einsatz.objekt)}
|
||||
${feld("Koordinaten", einsatz.koordinaten)}
|
||||
${feld("Alarmierung", einsatz.alarmierungsart)}
|
||||
${feld("Einsatzleiter", einsatz.einsatzleiter)}
|
||||
${feld("Stärke (FwDV 3)", daten.staerkeText)}
|
||||
${feld("Wetter", [einsatz.wetter, einsatz.temperatur !== null && einsatz.temperatur !== undefined ? `${einsatz.temperatur} °C` : null].filter(Boolean).join(", "))}
|
||||
${feld("Weitere Kräfte", einsatz.weitereKraefte)}
|
||||
${feld("Kostenersatzpflichtig", einsatz.kostenpflichtig ? "ja" : "nein")}
|
||||
</div>
|
||||
|
||||
${einsatz.beschreibung ? `<p style="margin-top:3mm">${html(einsatz.beschreibung)}</p>` : ""}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Zeiten</h2>
|
||||
<div class="zeitleiste">
|
||||
${ZEITSTEMPEL.map(
|
||||
(stempel) => `
|
||||
<div class="stempel">
|
||||
<div class="bezeichnung">${html(ZEITSTEMPEL_TEXT[stempel])}</div>
|
||||
<div class="uhrzeit">${zeiten[stempel] ? html(alsUhrzeit(zeiten[stempel])) : "—"}</div>
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
|
||||
<div class="kennzahlen">
|
||||
<div>Ausrückzeit <strong>${html(alsDauer(kennzahlen.ausrueckzeit))}</strong></div>
|
||||
<div>Anfahrt <strong>${html(alsDauer(kennzahlen.anfahrtszeit))}</strong></div>
|
||||
<div>Hilfsfrist <strong>${html(alsDauer(kennzahlen.hilfsfrist))}</strong></div>
|
||||
<div>Einsatzdauer <strong>${html(alsDauer(kennzahlen.dauer))}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Fahrzeuge</h2>
|
||||
${
|
||||
fahrzeuge.length === 0
|
||||
? "<p>Kein Fahrzeug erfasst.</p>"
|
||||
: `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fahrzeug</th>
|
||||
<th>ausgerückt</th>
|
||||
<th>eingetroffen</th>
|
||||
<th>eingerückt</th>
|
||||
<th class="zahl">km</th>
|
||||
<th class="zahl">Wasser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${fahrzeuge
|
||||
.map(
|
||||
(fahrzeug) => `
|
||||
<tr>
|
||||
<td>
|
||||
${html(fahrzeug.funkrufname)}
|
||||
${fahrzeug.bezeichnung ? `<div style="font-size:8pt;color:#555">${html(fahrzeug.bezeichnung)}</div>` : ""}
|
||||
</td>
|
||||
<td>${fahrzeug.ausgeruecktUm ? html(alsUhrzeit(fahrzeug.ausgeruecktUm)) : "—"}</td>
|
||||
<td>${fahrzeug.eingetroffenUm ? html(alsUhrzeit(fahrzeug.eingetroffenUm)) : "—"}</td>
|
||||
<td>${fahrzeug.eingeruecktUm ? html(alsUhrzeit(fahrzeug.eingeruecktUm)) : "—"}</td>
|
||||
<td class="zahl">${fahrzeug.kilometer ?? "—"}</td>
|
||||
<td class="zahl">${fahrzeug.wasserabgabe ? `${fahrzeug.wasserabgabe} l` : "—"}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>`
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Eingesetzte Kräfte — Stärke ${html(daten.staerkeText)}</h2>
|
||||
${
|
||||
kraefte.length === 0
|
||||
? "<p>Keine Einsatzkraft erfasst.</p>"
|
||||
: `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Funktion</th>
|
||||
<th>Fahrzeug</th>
|
||||
<th>Atemschutz</th>
|
||||
<th>von</th>
|
||||
<th>bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${kraefte
|
||||
.map(
|
||||
(kraft) => `
|
||||
<tr>
|
||||
<td>${html(kraft.name)}</td>
|
||||
<td>${html(FUNKTION_TEXT[kraft.funktion] ?? kraft.funktion)}</td>
|
||||
<td>${html(kraft.fahrzeug ?? "—")}</td>
|
||||
<td>${kraft.atemschutz ? "ja" : "—"}</td>
|
||||
<td>${kraft.von ? html(alsUhrzeit(kraft.von)) : "—"}</td>
|
||||
<td>${kraft.bis ? html(alsUhrzeit(kraft.bis)) : "—"}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>`
|
||||
}
|
||||
</section>
|
||||
|
||||
${
|
||||
material.length > 0
|
||||
? `<section>
|
||||
<h2>Material</h2>
|
||||
<table>
|
||||
<thead><tr><th>Bezeichnung</th><th>Menge</th><th>Anmerkung</th></tr></thead>
|
||||
<tbody>
|
||||
${material
|
||||
.map(
|
||||
(posten) => `
|
||||
<tr>
|
||||
<td>${html(posten.bezeichnung)}</td>
|
||||
<td>${html(posten.menge)}</td>
|
||||
<td>${posten.beschaedigt ? "beschädigt" : "verbraucht"}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
meldungen.length > 0
|
||||
? `<section>
|
||||
<h2>Verlauf</h2>
|
||||
<ul class="verlauf">
|
||||
${meldungen
|
||||
.map(
|
||||
(meldung) => `
|
||||
<li>
|
||||
<span class="zeit">${html(alsUhrzeit(meldung.zeitpunkt))}</span>
|
||||
${meldung.wichtig ? '<span class="wichtig">[wichtig]</span>' : ""}
|
||||
${html(meldung.text)}
|
||||
${meldung.verfasser ? `<span style="color:#555"> — ${html(meldung.verfasser)}</span>` : ""}
|
||||
</li>`,
|
||||
)
|
||||
.join("")}
|
||||
</ul>
|
||||
</section>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
einsatz.bericht
|
||||
? `<section>
|
||||
<h2>Bericht</h2>
|
||||
<div class="bericht">${html(einsatz.bericht)}</div>
|
||||
</section>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<section>
|
||||
<h2>Ergebnis</h2>
|
||||
<div class="felder">
|
||||
${feld("Gerettete Personen", String(einsatz.gerettet))}
|
||||
${feld("Verletzte", String(einsatz.verletzt))}
|
||||
${feld("Getötete", String(einsatz.getoetet))}
|
||||
${feld(
|
||||
"Geschätzter Sachschaden",
|
||||
einsatz.sachschaden !== null && einsatz.sachschaden !== undefined
|
||||
? alsEuro(einsatz.sachschaden)
|
||||
: null,
|
||||
)}
|
||||
${feld("Freigegeben von", einsatz.freigegebenVon)}
|
||||
${feld("Freigegeben am", einsatz.freigegebenAm ? alsDatumZeit(einsatz.freigegebenAm) : null)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hinweis">
|
||||
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Dieser Bericht
|
||||
ist nach der Freigabe unveränderbar gespeichert.
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function kopfzeile(daten: BerichtDaten): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
|
||||
width: 100%; margin: 0; padding: 0 12mm; }
|
||||
.zeile { display: flex; justify-content: space-between; }
|
||||
</style></head><body>
|
||||
<div class="zeile">
|
||||
<span>${html(daten.organisation.name)}</span>
|
||||
<span>Einsatzbericht ${html(daten.einsatz.einsatznummer)} · ${html(daten.einsatz.stichwort)}</span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function fusszeile(daten: BerichtDaten): string {
|
||||
const datum = daten.zeiten.alarmiertUm ? alsDatum(daten.zeiten.alarmiertUm) : "";
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
|
||||
width: 100%; margin: 0; padding: 0 12mm; }
|
||||
.zeile { display: flex; justify-content: space-between; }
|
||||
</style></head><body>
|
||||
<div class="zeile">
|
||||
<span>Einsatz vom ${html(datum)}</span>
|
||||
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
/** Erzeugt den Einsatzbericht als PDF. */
|
||||
export async function berichtPdf(daten: BerichtDaten): Promise<Buffer> {
|
||||
return htmlZuPdf(berichtHtml(daten), {
|
||||
randOben: 0.55,
|
||||
randUnten: 0.55,
|
||||
randLinks: 0.5,
|
||||
randRechts: 0.5,
|
||||
hintergrundDrucken: true,
|
||||
kopfzeileHtml: kopfzeile(daten),
|
||||
fusszeileHtml: fusszeile(daten),
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Jahresexport für die Gemeinde
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface JahresexportDaten {
|
||||
organisation: { name: string };
|
||||
jahr: number;
|
||||
einsaetze: Array<{
|
||||
einsatznummer: string;
|
||||
datum: Date | null;
|
||||
stichwort: string;
|
||||
kategorie: string;
|
||||
ort: string;
|
||||
staerke: string;
|
||||
hilfsfrist: number | null;
|
||||
dauer: number | null;
|
||||
}>;
|
||||
nachKategorie: Array<{ kategorie: string; anzahl: number }>;
|
||||
zeiten: { hilfsfristP80: number | null; hilfsfristMedian: number | null; gemessen: number };
|
||||
personen: { gerettet: number; verletzt: number; getoetet: number };
|
||||
uebungen: { gesamt: number; teilnahmen: number };
|
||||
erzeugtAm: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Sammelexport eines Jahres.
|
||||
*
|
||||
* Das ist das Blatt, das die Wehr der Gemeinde vorlegt: wie viele Einsätze
|
||||
* welcher Art, wie schnell, und was dabei herauskam. Querformat, weil die
|
||||
* Tabelle sonst nicht lesbar ist.
|
||||
*/
|
||||
export function jahresexportHtml(daten: JahresexportDaten): string {
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Einsatzstatistik ${daten.jahr} — ${html(daten.organisation.name)}</title>
|
||||
<style>${STIL}
|
||||
.felder { grid-template-columns: repeat(4, 1fr); }
|
||||
td, th { font-size: 8.5pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kopf">
|
||||
<div>
|
||||
<h1>Einsatzstatistik ${daten.jahr}</h1>
|
||||
<div class="wehr">${html(daten.organisation.name)}</div>
|
||||
</div>
|
||||
<div class="nummer">
|
||||
<div class="marke">${daten.einsaetze.length} Einsätze</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Überblick</h2>
|
||||
<div class="felder">
|
||||
${feld("Einsätze gesamt", String(daten.einsaetze.length))}
|
||||
${feld(
|
||||
"Hilfsfrist (80 %)",
|
||||
daten.zeiten.hilfsfristP80 !== null ? alsDauer(daten.zeiten.hilfsfristP80) : null,
|
||||
)}
|
||||
${feld(
|
||||
"Hilfsfrist (Median)",
|
||||
daten.zeiten.hilfsfristMedian !== null ? alsDauer(daten.zeiten.hilfsfristMedian) : null,
|
||||
)}
|
||||
${feld("gemessen an", `${daten.zeiten.gemessen} Einsätzen`)}
|
||||
${feld("Gerettete Personen", String(daten.personen.gerettet))}
|
||||
${feld("Verletzte", String(daten.personen.verletzt))}
|
||||
${feld("Getötete", String(daten.personen.getoetet))}
|
||||
${feld("Übungen", `${daten.uebungen.gesamt} mit ${daten.uebungen.teilnahmen} Teilnahmen`)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Nach Kategorie</h2>
|
||||
<table>
|
||||
<thead><tr><th>Kategorie</th><th class="zahl">Anzahl</th></tr></thead>
|
||||
<tbody>
|
||||
${daten.nachKategorie
|
||||
.map(
|
||||
(eintrag) =>
|
||||
`<tr><td>${html(eintrag.kategorie)}</td><td class="zahl">${eintrag.anzahl}</td></tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Einsätze im Einzelnen</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nummer</th>
|
||||
<th>Datum</th>
|
||||
<th>Stichwort</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Ort</th>
|
||||
<th>Stärke</th>
|
||||
<th class="zahl">Hilfsfrist</th>
|
||||
<th class="zahl">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${daten.einsaetze
|
||||
.map(
|
||||
(einsatz) => `
|
||||
<tr>
|
||||
<td>${html(einsatz.einsatznummer)}</td>
|
||||
<td>${einsatz.datum ? html(alsDatum(einsatz.datum)) : "—"}</td>
|
||||
<td>${html(einsatz.stichwort)}</td>
|
||||
<td>${html(einsatz.kategorie)}</td>
|
||||
<td>${html(einsatz.ort)}</td>
|
||||
<td>${html(einsatz.staerke)}</td>
|
||||
<td class="zahl">${html(alsDauer(einsatz.hilfsfrist))}</td>
|
||||
<td class="zahl">${html(alsDauer(einsatz.dauer))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="hinweis">
|
||||
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Enthalten sind
|
||||
alle nicht gelöschten Einsätze des Jahres ${daten.jahr}.
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Erzeugt den Jahresexport als PDF — quer, damit die Tabelle passt. */
|
||||
export async function jahresexportPdf(daten: JahresexportDaten): Promise<Buffer> {
|
||||
return htmlZuPdf(jahresexportHtml(daten), {
|
||||
quer: true,
|
||||
randOben: 0.5,
|
||||
randUnten: 0.5,
|
||||
randLinks: 0.4,
|
||||
randRechts: 0.4,
|
||||
hintergrundDrucken: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import "server-only";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashen, pruefen } from "@/lib/passwort";
|
||||
import { ohneMandantenfilter } from "@/lib/db";
|
||||
import { zugriffZaehlen } from "@/lib/ratelimit";
|
||||
|
||||
/**
|
||||
* Einsatzportal für Fremdwehren.
|
||||
*
|
||||
* Die Lage: bei einer größeren Sache sind Nachbarwehren, THW oder
|
||||
* Rettungsdienst mit vor Ort. Die haben kein Konto in FLORIAN und sollen auch
|
||||
* keines bekommen — sie sollen den Stand sehen und eine Meldung mit Foto
|
||||
* absetzen können, mehr nicht.
|
||||
*
|
||||
* Der Weg dahin ist ein Link, den der Einsatzleiter erzeugt und als QR-Code
|
||||
* zeigt oder weiterschickt. Vier Schranken sichern ihn ab:
|
||||
*
|
||||
* 1. **Zufall.** Das Geheimnis im Link sind 32 zufällige Bytes. Raten ist
|
||||
* aussichtslos.
|
||||
* 2. **Nur ein Hash liegt in der Datenbank.** Wer sie liest, bekommt keinen
|
||||
* gültigen Link.
|
||||
* 3. **Zeitlich begrenzt.** Nach `PORTAL_GUELTIGKEIT_STUNDEN` ist Schluss;
|
||||
* widerrufen lässt er sich jederzeit sofort.
|
||||
* 4. **Zugriffsbegrenzung.** Höchstens `PORTAL_MAX_ZUGRIFFE` Aufrufe je Link
|
||||
* und Stunde.
|
||||
*
|
||||
* Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort
|
||||
* und Lagemeldungen — keine Namen von Einsatzkräften, keine Patientendaten,
|
||||
* keine Berichte.
|
||||
*/
|
||||
|
||||
/** Aufbau des Links: `<zugangId>.<geheimnis>`. */
|
||||
const TRENNER = ".";
|
||||
|
||||
export interface NeuerZugang {
|
||||
/** Der vollständige Token für den Link. Nur jetzt einmal im Klartext. */
|
||||
token: string;
|
||||
tokenHash: string;
|
||||
gueltigBis: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt Geheimnis und Hash für einen neuen Zugang.
|
||||
*
|
||||
* Die Kennung des Datensatzes wird erst danach bekannt, deshalb setzt der
|
||||
* Aufrufer sie mit `tokenZusammensetzen` davor.
|
||||
*/
|
||||
export async function zugangErzeugen(gueltigkeitStunden?: number): Promise<NeuerZugang> {
|
||||
const geheimnis = randomBytes(32).toString("base64url");
|
||||
const stunden = gueltigkeitStunden ?? env.PORTAL_GUELTIGKEIT_STUNDEN;
|
||||
|
||||
return {
|
||||
token: geheimnis,
|
||||
tokenHash: await hashen(geheimnis),
|
||||
gueltigBis: new Date(Date.now() + stunden * 3600_000),
|
||||
};
|
||||
}
|
||||
|
||||
/** Setzt Kennung und Geheimnis zum Token des Links zusammen. */
|
||||
export function tokenZusammensetzen(zugangId: string, geheimnis: string): string {
|
||||
return `${zugangId}${TRENNER}${geheimnis}`;
|
||||
}
|
||||
|
||||
/** Zerlegt den Token wieder. Gibt null zurück, wenn er nicht passt. */
|
||||
export function tokenZerlegen(token: string): { zugangId: string; geheimnis: string } | null {
|
||||
const stelle = token.indexOf(TRENNER);
|
||||
if (stelle <= 0 || stelle === token.length - 1) return null;
|
||||
|
||||
const zugangId = token.slice(0, stelle);
|
||||
const geheimnis = token.slice(stelle + 1);
|
||||
|
||||
// Grobe Vorprüfung, bevor irgendetwas in eine Abfrage wandert.
|
||||
if (!/^[0-9a-f-]{36}$/i.test(zugangId)) return null;
|
||||
if (!/^[A-Za-z0-9_-]{20,200}$/.test(geheimnis)) return null;
|
||||
|
||||
return { zugangId, geheimnis };
|
||||
}
|
||||
|
||||
/** Die vollständige Adresse des Portals für einen Token. */
|
||||
export function portalAdresse(token: string): string {
|
||||
return `${env.APP_URL}/portal/${token}`;
|
||||
}
|
||||
|
||||
export type PortalFehler =
|
||||
| "abgeschaltet"
|
||||
| "ungueltig"
|
||||
| "abgelaufen"
|
||||
| "widerrufen"
|
||||
| "zu_viele_zugriffe";
|
||||
|
||||
export interface PortalSitzung {
|
||||
zugangId: string;
|
||||
einsatzId: string;
|
||||
organisationId: string;
|
||||
bezeichnung: string;
|
||||
gueltigBis: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen Token und liefert den Zugang.
|
||||
*
|
||||
* Läuft bewusst ohne Mandantenfilter: der Aufrufer hat keine Sitzung, und
|
||||
* welche Wehr gemeint ist, steht erst im gefundenen Datensatz. Alles Weitere
|
||||
* passiert danach im Mandantenkontext dieser Wehr.
|
||||
*/
|
||||
export async function portalPruefen(
|
||||
token: string,
|
||||
ipAdresse?: string | null,
|
||||
): Promise<{ sitzung: PortalSitzung } | { fehler: PortalFehler; wartenSekunden?: number }> {
|
||||
if (!env.PORTAL_ENABLED) return { fehler: "abgeschaltet" };
|
||||
|
||||
const zerlegt = tokenZerlegen(token);
|
||||
if (!zerlegt) return { fehler: "ungueltig" };
|
||||
|
||||
// Erst die Zugriffsbegrenzung, dann der teure bcrypt-Vergleich. Sonst wäre
|
||||
// die Prüfung selbst der Hebel für eine Überlastung.
|
||||
const begrenzung = await zugriffZaehlen(
|
||||
`portal:${zerlegt.zugangId}`,
|
||||
env.PORTAL_MAX_ZUGRIFFE,
|
||||
);
|
||||
|
||||
if (!begrenzung.erlaubt) {
|
||||
return { fehler: "zu_viele_zugriffe", wartenSekunden: begrenzung.zuruecksetztIn };
|
||||
}
|
||||
|
||||
const zugang = await ohneMandantenfilter(
|
||||
"Portal-Zugang prüfen — der Aufrufer hat keine Sitzung",
|
||||
(db) =>
|
||||
db.portalZugang.findUnique({
|
||||
where: { id: zerlegt.zugangId },
|
||||
select: {
|
||||
id: true,
|
||||
organisationId: true,
|
||||
einsatzId: true,
|
||||
bezeichnung: true,
|
||||
tokenHash: true,
|
||||
gueltigBis: true,
|
||||
widerrufenAm: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!zugang) return { fehler: "ungueltig" };
|
||||
if (!(await pruefen(zerlegt.geheimnis, zugang.tokenHash))) return { fehler: "ungueltig" };
|
||||
if (zugang.widerrufenAm) return { fehler: "widerrufen" };
|
||||
if (zugang.gueltigBis.getTime() < Date.now()) return { fehler: "abgelaufen" };
|
||||
|
||||
// Jeder Aufruf wird mitgezählt. Ein Link, der nach dem Einsatz noch benutzt
|
||||
// wird, fällt so auf.
|
||||
await ohneMandantenfilter("Portal-Aufruf mitzählen", (db) =>
|
||||
db.portalZugang.update({
|
||||
where: { id: zugang.id },
|
||||
data: {
|
||||
aufrufe: { increment: 1 },
|
||||
letzterAufruf: new Date(),
|
||||
letzteIp: ipAdresse ?? null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sitzung: {
|
||||
zugangId: zugang.id,
|
||||
einsatzId: zugang.einsatzId,
|
||||
organisationId: zugang.organisationId,
|
||||
bezeichnung: zugang.bezeichnung,
|
||||
gueltigBis: zugang.gueltigBis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const PORTAL_FEHLER_TEXT: Record<PortalFehler, string> = {
|
||||
abgeschaltet: "Das Einsatzportal ist in dieser Installation abgeschaltet.",
|
||||
ungueltig: "Dieser Link ist nicht gültig.",
|
||||
abgelaufen: "Dieser Link ist abgelaufen. Die Wehr kann einen neuen erzeugen.",
|
||||
widerrufen: "Dieser Link wurde zurückgezogen.",
|
||||
zu_viele_zugriffe: "Zu viele Zugriffe in kurzer Zeit. Bitte später noch einmal versuchen.",
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Einsatzstärke nach FwDV 3.
|
||||
*
|
||||
* Die Stärke ist die Kurzform dafür, wer ausgerückt ist. Geschrieben wird sie
|
||||
* als `Führer / Unterführer / Mannschaft / Gesamt`, gesprochen "eins, drei,
|
||||
* fünf, neun".
|
||||
*
|
||||
* Die taktischen Einheiten der FwDV 3:
|
||||
*
|
||||
* | Einheit | Stärke | wer |
|
||||
* |---|---|---|
|
||||
* | Trupp | 0/1/1/2 | Truppführer und Truppmann |
|
||||
* | Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps |
|
||||
* | Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps |
|
||||
* | Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel |
|
||||
*
|
||||
* **Achtung, regional unterschiedlich.** Manche Wehren zählen den Maschinisten
|
||||
* zu den Unterführern, andere zur Mannschaft; verbreitet ist auch die
|
||||
* dreistellige Schreibweise ohne Unterführer (Gruppe = 1/8/9). Die Zuordnung
|
||||
* steht deshalb an einer Stelle und ist bewusst leicht zu ändern.
|
||||
*/
|
||||
|
||||
export type Funktion =
|
||||
| "EINSATZLEITER"
|
||||
| "ZUGFUEHRER"
|
||||
| "GRUPPENFUEHRER"
|
||||
| "STAFFELFUEHRER"
|
||||
| "TRUPPFUEHRER"
|
||||
| "MASCHINIST"
|
||||
| "MELDER"
|
||||
| "TRUPPMANN"
|
||||
| "ATEMSCHUTZTRAEGER"
|
||||
| "SICHERHEITSTRUPP"
|
||||
| "SANITAETER"
|
||||
| "SONSTIGES";
|
||||
|
||||
export const FUNKTION_TEXT: Record<Funktion, string> = {
|
||||
EINSATZLEITER: "Einsatzleiter",
|
||||
ZUGFUEHRER: "Zugführer",
|
||||
GRUPPENFUEHRER: "Gruppenführer",
|
||||
STAFFELFUEHRER: "Staffelführer",
|
||||
TRUPPFUEHRER: "Truppführer",
|
||||
MASCHINIST: "Maschinist",
|
||||
MELDER: "Melder",
|
||||
TRUPPMANN: "Truppmann",
|
||||
ATEMSCHUTZTRAEGER: "Atemschutzgeräteträger",
|
||||
SICHERHEITSTRUPP: "Sicherheitstrupp",
|
||||
SANITAETER: "Sanitäter",
|
||||
SONSTIGES: "sonstige Funktion",
|
||||
};
|
||||
|
||||
/** Wer als Führer zählt: wer eine taktische Einheit führt. */
|
||||
const FUEHRER: Funktion[] = [
|
||||
"EINSATZLEITER",
|
||||
"ZUGFUEHRER",
|
||||
"GRUPPENFUEHRER",
|
||||
"STAFFELFUEHRER",
|
||||
];
|
||||
|
||||
/** Wer als Unterführer zählt: wer innerhalb der Einheit einen Trupp führt. */
|
||||
const UNTERFUEHRER: Funktion[] = ["TRUPPFUEHRER"];
|
||||
|
||||
export interface Staerke {
|
||||
fuehrer: number;
|
||||
unterfuehrer: number;
|
||||
mannschaft: number;
|
||||
gesamt: number;
|
||||
}
|
||||
|
||||
export const LEERE_STAERKE: Staerke = {
|
||||
fuehrer: 0,
|
||||
unterfuehrer: 0,
|
||||
mannschaft: 0,
|
||||
gesamt: 0,
|
||||
};
|
||||
|
||||
/** Zählt die Funktionen zur Stärke zusammen. */
|
||||
export function staerkeBerechnen(funktionen: Funktion[]): Staerke {
|
||||
const staerke = { ...LEERE_STAERKE };
|
||||
|
||||
for (const funktion of funktionen) {
|
||||
if (FUEHRER.includes(funktion)) staerke.fuehrer += 1;
|
||||
else if (UNTERFUEHRER.includes(funktion)) staerke.unterfuehrer += 1;
|
||||
else staerke.mannschaft += 1;
|
||||
|
||||
staerke.gesamt += 1;
|
||||
}
|
||||
|
||||
return staerke;
|
||||
}
|
||||
|
||||
/** Die übliche Schreibweise: "1/3/5/9". */
|
||||
export function staerkeAlsText(staerke: Staerke): string {
|
||||
return `${staerke.fuehrer}/${staerke.unterfuehrer}/${staerke.mannschaft}/${staerke.gesamt}`;
|
||||
}
|
||||
|
||||
/** Die taktischen Einheiten der FwDV 3, von klein nach groß. */
|
||||
export const EINHEITEN = [
|
||||
{ name: "Trupp", staerke: 2 },
|
||||
{ name: "Staffel", staerke: 6 },
|
||||
{ name: "Gruppe", staerke: 9 },
|
||||
{ name: "Zug", staerke: 18 },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Welcher taktischen Einheit die Mannschaft entspricht.
|
||||
*
|
||||
* Gibt die größte Einheit zurück, die vollständig besetzt ist — und dazu, wie
|
||||
* viele darüber hinaus mitgefahren sind. Eine Wehr rückt selten in exakter
|
||||
* Sollstärke aus; die Angabe soll trotzdem etwas sagen.
|
||||
*/
|
||||
export function taktischeEinheit(staerke: Staerke): {
|
||||
einheit: string | null;
|
||||
ueberzaehlig: number;
|
||||
} {
|
||||
if (staerke.gesamt === 0) return { einheit: null, ueberzaehlig: 0 };
|
||||
|
||||
let gefunden: (typeof EINHEITEN)[number] | null = null;
|
||||
for (const einheit of EINHEITEN) {
|
||||
if (staerke.gesamt >= einheit.staerke) gefunden = einheit;
|
||||
}
|
||||
|
||||
if (!gefunden) {
|
||||
return { einheit: null, ueberzaehlig: staerke.gesamt };
|
||||
}
|
||||
|
||||
return { einheit: gefunden.name, ueberzaehlig: staerke.gesamt - gefunden.staerke };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlt eine Führungskraft?
|
||||
*
|
||||
* Ohne Führer keine taktische Einheit — das ist keine Formsache, sondern der
|
||||
* Unterschied zwischen einer Einheit und einer Ansammlung von Leuten. Die
|
||||
* Oberfläche weist darauf hin; verhindert wird das Speichern nicht, denn beim
|
||||
* Nachtragen eines Einsatzes von vor drei Wochen weiß man es manchmal nicht
|
||||
* mehr genau.
|
||||
*/
|
||||
export function staerkeHinweise(staerke: Staerke): string[] {
|
||||
const hinweise: string[] = [];
|
||||
|
||||
if (staerke.gesamt === 0) {
|
||||
hinweise.push("Zu diesem Einsatz ist noch niemand erfasst.");
|
||||
return hinweise;
|
||||
}
|
||||
|
||||
if (staerke.fuehrer === 0) {
|
||||
hinweise.push("Es ist keine Führungskraft erfasst — wer hat den Einsatz geführt?");
|
||||
}
|
||||
|
||||
if (staerke.gesamt < 2) {
|
||||
hinweise.push("Ein Trupp besteht aus zwei Kräften. Alleine geht niemand vor.");
|
||||
}
|
||||
|
||||
return hinweise;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Alarmierungsart,
|
||||
EinsatzFunktion,
|
||||
EinsatzKategorie,
|
||||
EinsatzStatus,
|
||||
LagemeldungQuelle,
|
||||
UebungsArt,
|
||||
} from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Klartext zu den Aufzählungen aus Phase 3.
|
||||
*
|
||||
* Wie bei der Wartung: eine Datei ohne "use client", damit Server- und
|
||||
* Client-Komponenten dieselben Beschriftungen verwenden können.
|
||||
*/
|
||||
|
||||
export const KATEGORIE_TEXT: Record<EinsatzKategorie, string> = {
|
||||
BRAND_KLEIN: "Brand klein",
|
||||
BRAND_MITTEL: "Brand mittel",
|
||||
BRAND_GROSS: "Brand groß",
|
||||
TECHNISCHE_HILFE_KLEIN: "Technische Hilfeleistung klein",
|
||||
TECHNISCHE_HILFE_MITTEL: "Technische Hilfeleistung mittel",
|
||||
TECHNISCHE_HILFE_GROSS: "Technische Hilfeleistung groß",
|
||||
GEFAHRGUT: "Gefahrguteinsatz",
|
||||
RETTUNGSDIENST: "Unterstützung Rettungsdienst",
|
||||
BRANDMELDEANLAGE: "Brandmeldeanlage",
|
||||
BOESWILLIGER_ALARM: "böswilliger Alarm",
|
||||
SICHERHEITSWACHE: "Sicherheitswache",
|
||||
UNTERSTUETZUNG: "Unterstützung / Amtshilfe",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
/** Grobe Einordnung für Farben und Auswertungen. */
|
||||
export const KATEGORIE_GRUPPE: Record<EinsatzKategorie, "brand" | "hilfe" | "sonstige"> = {
|
||||
BRAND_KLEIN: "brand",
|
||||
BRAND_MITTEL: "brand",
|
||||
BRAND_GROSS: "brand",
|
||||
TECHNISCHE_HILFE_KLEIN: "hilfe",
|
||||
TECHNISCHE_HILFE_MITTEL: "hilfe",
|
||||
TECHNISCHE_HILFE_GROSS: "hilfe",
|
||||
GEFAHRGUT: "hilfe",
|
||||
RETTUNGSDIENST: "hilfe",
|
||||
BRANDMELDEANLAGE: "sonstige",
|
||||
BOESWILLIGER_ALARM: "sonstige",
|
||||
SICHERHEITSWACHE: "sonstige",
|
||||
UNTERSTUETZUNG: "sonstige",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
export const EINSATZ_STATUS_TEXT: Record<EinsatzStatus, string> = {
|
||||
ENTWURF: "Entwurf",
|
||||
LAUFEND: "läuft",
|
||||
ABGESCHLOSSEN: "abgeschlossen",
|
||||
FREIGEGEBEN: "freigegeben",
|
||||
};
|
||||
|
||||
export const ALARMIERUNG_TEXT: Record<Alarmierungsart, string> = {
|
||||
SIRENE: "Sirene",
|
||||
MELDEEMPFAENGER: "Meldeempfänger",
|
||||
TELEFON: "Telefon",
|
||||
APP: "App",
|
||||
PERSOENLICH: "persönlich",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
export const FUNKTION_TEXT: Record<EinsatzFunktion, string> = {
|
||||
EINSATZLEITER: "Einsatzleiter",
|
||||
ZUGFUEHRER: "Zugführer",
|
||||
GRUPPENFUEHRER: "Gruppenführer",
|
||||
STAFFELFUEHRER: "Staffelführer",
|
||||
TRUPPFUEHRER: "Truppführer",
|
||||
MASCHINIST: "Maschinist",
|
||||
MELDER: "Melder",
|
||||
TRUPPMANN: "Truppmann",
|
||||
ATEMSCHUTZTRAEGER: "Atemschutzgeräteträger",
|
||||
SICHERHEITSTRUPP: "Sicherheitstrupp",
|
||||
SANITAETER: "Sanitäter",
|
||||
SONSTIGES: "sonstige Funktion",
|
||||
};
|
||||
|
||||
export const UEBUNGSART_TEXT: Record<UebungsArt, string> = {
|
||||
PRAXIS: "Praxisübung",
|
||||
THEORIE: "Theorie",
|
||||
ATEMSCHUTZ: "Atemschutz",
|
||||
MASCHINIST: "Maschinist",
|
||||
FUNK: "Funk",
|
||||
ERSTE_HILFE: "Erste Hilfe",
|
||||
GEMEINSCHAFTSUEBUNG: "Gemeinschaftsübung",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
export const QUELLE_TEXT: Record<LagemeldungQuelle, string> = {
|
||||
INTERN: "aus der Wehr",
|
||||
PORTAL: "über das Portal",
|
||||
};
|
||||
|
||||
export const MONAT_KURZ = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mär",
|
||||
"Apr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Dez",
|
||||
] as const;
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Einsatzzeiten.
|
||||
*
|
||||
* Sechs Zeitstempel beschreiben einen Einsatz vollständig:
|
||||
*
|
||||
* | Stempel | bedeutet |
|
||||
* |---|---|
|
||||
* | alarmiert | die Leitstelle hat alarmiert |
|
||||
* | ausgerückt | das erste Fahrzeug verlässt das Gerätehaus |
|
||||
* | eingetroffen | das erste Fahrzeug ist an der Einsatzstelle |
|
||||
* | Einsatzende | die Arbeit an der Einsatzstelle ist beendet |
|
||||
* | eingerückt | zurück im Gerätehaus |
|
||||
* | einsatzbereit | Fahrzeuge und Geräte sind wieder hergerichtet |
|
||||
*
|
||||
* Daraus ergeben sich die drei Zahlen, nach denen eine Wehr gefragt wird:
|
||||
*
|
||||
* - **Ausrückzeit** — Alarm bis Ausrücken. Sagt etwas über die Wehr aus.
|
||||
* - **Anfahrtszeit** — Ausrücken bis Eintreffen. Sagt etwas über die Lage aus.
|
||||
* - **Hilfsfrist** — Alarm bis Eintreffen. Die Zahl, die in der Diskussion mit
|
||||
* der Gemeinde zählt (in Bayern: zehn Minuten in 80 Prozent der Fälle).
|
||||
*
|
||||
* Reine Funktionen ohne Datenbankzugriff — sie gehen später in die Statistik
|
||||
* ein und müssen ohne laufenden Stack durchtestbar sein.
|
||||
*/
|
||||
|
||||
export interface Einsatzzeiten {
|
||||
alarmiertUm?: Date | null;
|
||||
ausgeruecktUm?: Date | null;
|
||||
eingetroffenUm?: Date | null;
|
||||
einsatzendeUm?: Date | null;
|
||||
eingeruecktUm?: Date | null;
|
||||
einsatzbereitUm?: Date | null;
|
||||
}
|
||||
|
||||
/** Die Stempel in ihrer natürlichen Reihenfolge. */
|
||||
export const ZEITSTEMPEL = [
|
||||
"alarmiertUm",
|
||||
"ausgeruecktUm",
|
||||
"eingetroffenUm",
|
||||
"einsatzendeUm",
|
||||
"eingeruecktUm",
|
||||
"einsatzbereitUm",
|
||||
] as const;
|
||||
|
||||
export type Zeitstempel = (typeof ZEITSTEMPEL)[number];
|
||||
|
||||
export const ZEITSTEMPEL_TEXT: Record<Zeitstempel, string> = {
|
||||
alarmiertUm: "alarmiert",
|
||||
ausgeruecktUm: "ausgerückt",
|
||||
eingetroffenUm: "eingetroffen",
|
||||
einsatzendeUm: "Einsatzende",
|
||||
eingeruecktUm: "eingerückt",
|
||||
einsatzbereitUm: "einsatzbereit",
|
||||
};
|
||||
|
||||
/** Minuten zwischen zwei Zeitpunkten, auf ganze Minuten gerundet. */
|
||||
function minutenZwischen(von: Date | null | undefined, bis: Date | null | undefined): number | null {
|
||||
if (!von || !bis) return null;
|
||||
const minuten = Math.round((bis.getTime() - von.getTime()) / 60_000);
|
||||
return minuten >= 0 ? minuten : null;
|
||||
}
|
||||
|
||||
/** Alarm bis Ausrücken. */
|
||||
export function ausrueckzeit(zeiten: Einsatzzeiten): number | null {
|
||||
return minutenZwischen(zeiten.alarmiertUm, zeiten.ausgeruecktUm);
|
||||
}
|
||||
|
||||
/** Ausrücken bis Eintreffen. */
|
||||
export function anfahrtszeit(zeiten: Einsatzzeiten): number | null {
|
||||
return minutenZwischen(zeiten.ausgeruecktUm, zeiten.eingetroffenUm);
|
||||
}
|
||||
|
||||
/** Alarm bis Eintreffen — die Hilfsfrist. */
|
||||
export function hilfsfrist(zeiten: Einsatzzeiten): number | null {
|
||||
return minutenZwischen(zeiten.alarmiertUm, zeiten.eingetroffenUm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange der Einsatz gedauert hat.
|
||||
*
|
||||
* Gerechnet wird von der Alarmierung bis zum Einrücken, ersatzweise bis zum
|
||||
* Einsatzende — das ist die Zeit, die den Leuten tatsächlich gefehlt hat.
|
||||
* Die Wiederherstellung der Einsatzbereitschaft zählt bewusst nicht mit: sie
|
||||
* macht der Gerätewart oft am Tag darauf.
|
||||
*/
|
||||
export function einsatzdauer(zeiten: Einsatzzeiten): number | null {
|
||||
return (
|
||||
minutenZwischen(zeiten.alarmiertUm, zeiten.eingeruecktUm) ??
|
||||
minutenZwischen(zeiten.alarmiertUm, zeiten.einsatzendeUm)
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle drei Kennzahlen auf einmal. */
|
||||
export function kennzahlen(zeiten: Einsatzzeiten) {
|
||||
return {
|
||||
ausrueckzeit: ausrueckzeit(zeiten),
|
||||
anfahrtszeit: anfahrtszeit(zeiten),
|
||||
hilfsfrist: hilfsfrist(zeiten),
|
||||
dauer: einsatzdauer(zeiten),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Widersprüche in der Reihenfolge.
|
||||
*
|
||||
* Absichtlich Hinweise und keine Fehler: Zeiten werden oft Tage später aus dem
|
||||
* Gedächtnis nachgetragen, und ein Einsatz, dessen Stempel nicht ganz stimmen,
|
||||
* ist immer noch besser als einer, den niemand erfasst hat. Die Oberfläche
|
||||
* zeigt die Hinweise an, das Speichern verhindern sie nicht.
|
||||
*/
|
||||
export function zeitHinweise(zeiten: Einsatzzeiten): string[] {
|
||||
const hinweise: string[] = [];
|
||||
|
||||
const paare: Array<[Zeitstempel, Zeitstempel]> = [
|
||||
["alarmiertUm", "ausgeruecktUm"],
|
||||
["ausgeruecktUm", "eingetroffenUm"],
|
||||
["eingetroffenUm", "einsatzendeUm"],
|
||||
["einsatzendeUm", "eingeruecktUm"],
|
||||
["eingeruecktUm", "einsatzbereitUm"],
|
||||
];
|
||||
|
||||
for (const [frueher, spaeter] of paare) {
|
||||
const a = zeiten[frueher];
|
||||
const b = zeiten[spaeter];
|
||||
if (a && b && b.getTime() < a.getTime()) {
|
||||
hinweise.push(
|
||||
`„${ZEITSTEMPEL_TEXT[spaeter]}" liegt vor „${ZEITSTEMPEL_TEXT[frueher]}" — bitte prüfen.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const frist = hilfsfrist(zeiten);
|
||||
if (frist !== null && frist > 60) {
|
||||
hinweise.push(
|
||||
`Zwischen Alarm und Eintreffen liegen ${frist} Minuten. Stimmen die Zeiten?`,
|
||||
);
|
||||
}
|
||||
|
||||
return hinweise;
|
||||
}
|
||||
|
||||
/** Der nächste Stempel, der gesetzt werden müsste. */
|
||||
export function naechsterStempel(zeiten: Einsatzzeiten): Zeitstempel | null {
|
||||
for (const stempel of ZEITSTEMPEL) {
|
||||
if (!zeiten[stempel]) return stempel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minuten als "1:05 h" oder "23 min". */
|
||||
export function alsDauer(minuten: number | null | undefined): string {
|
||||
if (minuten === null || minuten === undefined) return "—";
|
||||
if (minuten < 60) return `${minuten} min`;
|
||||
const stunden = Math.floor(minuten / 60);
|
||||
return `${stunden}:${String(minuten % 60).padStart(2, "0")} h`;
|
||||
}
|
||||
@@ -106,6 +106,23 @@ const schema = z.object({
|
||||
LLM_API_KEY: z.string().optional(),
|
||||
LLM_MODELL: z.string().optional(),
|
||||
|
||||
// Einsätze und Übungen (Phase 3)
|
||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||
GEOCODING_ENABLED: boolAusText(false),
|
||||
/// Adresse des SELBST betriebenen Nominatim. Einsatzadressen gehören nicht
|
||||
/// an einen öffentlichen Dienst.
|
||||
NOMINATIM_URL: z.string().url().optional(),
|
||||
GEOCODING_TIMEOUT: zahl(5),
|
||||
/// Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt).
|
||||
GEOCODING_LAENDER: z.string().default("de,at,ch"),
|
||||
|
||||
/// Einsatzportal für Fremdwehren: Link ohne Anmeldung, zeitlich begrenzt.
|
||||
PORTAL_ENABLED: boolAusText(true),
|
||||
/// Wie lange ein neu erzeugter Portal-Link gilt.
|
||||
PORTAL_GUELTIGKEIT_STUNDEN: zahl(24),
|
||||
/// Höchstzahl Zugriffe je Portal-Link und Stunde.
|
||||
PORTAL_MAX_ZUGRIFFE: zahl(120),
|
||||
|
||||
// Wartung und Prüfung (Phase 2)
|
||||
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
|
||||
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Adressen zu Koordinaten — hinter einer Schnittstelle, wie alle externen
|
||||
* Dienste (Projektregel).
|
||||
*
|
||||
* Vorgesehen ist ein **selbst betriebener** Nominatim, kein öffentlicher.
|
||||
* Einsatzadressen sind besonders schutzwürdig: sie sagen, wo es gebrannt hat
|
||||
* und wer betroffen war. Solche Anfragen gehören nicht an einen fremden
|
||||
* Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim erlauben es
|
||||
* ohnehin nicht.
|
||||
*
|
||||
* Ist nichts konfiguriert, liefert die Suche schlicht kein Ergebnis. Die
|
||||
* Anwendung fällt dann auf die Eingabe von Hand zurück — das ist kein Fehler,
|
||||
* sondern der Normalfall in einer frischen Installation.
|
||||
*/
|
||||
|
||||
export interface Ort {
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
/** Die vom Dienst zurückgegebene Schreibweise der Adresse. */
|
||||
anzeige: string;
|
||||
/** Wie sicher der Treffer ist, 0 bis 1. */
|
||||
guete?: number;
|
||||
}
|
||||
|
||||
export interface Adresssuche {
|
||||
suchen(adresse: string): Promise<Ort[]>;
|
||||
/** Umgekehrt: von Koordinaten zur Adresse — für den Kartenklick ab Phase 6. */
|
||||
rueckwaerts(breitengrad: number, laengengrad: number): Promise<Ort | null>;
|
||||
verfuegbar(): boolean;
|
||||
}
|
||||
|
||||
/** Ohne Konfiguration: keine Treffer, kein Fehler. */
|
||||
class KeineSuche implements Adresssuche {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async suchen(adresse: string): Promise<Ort[]> {
|
||||
console.info(
|
||||
`[geocoding] (abgeschaltet) Adresse "${adresse}" wird nicht aufgelöst — Koordinaten von Hand eintragen.`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
async rueckwaerts(): Promise<Ort | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein selbst betriebener Nominatim. */
|
||||
class Nominatim implements Adresssuche {
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.NOMINATIM_URL);
|
||||
}
|
||||
|
||||
private async abfragen(pfad: string, parameter: Record<string, string>): Promise<unknown> {
|
||||
const adresse = new URL(pfad, env.NOMINATIM_URL);
|
||||
for (const [schluessel, wert] of Object.entries(parameter)) {
|
||||
adresse.searchParams.set(schluessel, wert);
|
||||
}
|
||||
adresse.searchParams.set("format", "jsonv2");
|
||||
// Deutsche Schreibweise der Orte, unabhängig davon, wie der Server steht.
|
||||
adresse.searchParams.set("accept-language", "de");
|
||||
|
||||
const antwort = await fetch(adresse, {
|
||||
signal: AbortSignal.timeout(env.GEOCODING_TIMEOUT * 1000),
|
||||
headers: {
|
||||
// Nominatim verlangt eine erkennbare Kennung, auch beim eigenen Server.
|
||||
"User-Agent": "FLORIAN Feuerwehrverwaltung",
|
||||
},
|
||||
});
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Nominatim antwortete mit ${antwort.status}.`);
|
||||
}
|
||||
|
||||
return antwort.json();
|
||||
}
|
||||
|
||||
async suchen(adresse: string): Promise<Ort[]> {
|
||||
if (!this.verfuegbar() || !adresse.trim()) return [];
|
||||
|
||||
try {
|
||||
const ergebnis = (await this.abfragen("/search", {
|
||||
q: adresse,
|
||||
limit: "5",
|
||||
countrycodes: env.GEOCODING_LAENDER,
|
||||
})) as Array<{
|
||||
lat: string;
|
||||
lon: string;
|
||||
display_name: string;
|
||||
importance?: number;
|
||||
}>;
|
||||
|
||||
return ergebnis.map((treffer) => ({
|
||||
breitengrad: Number(treffer.lat),
|
||||
laengengrad: Number(treffer.lon),
|
||||
anzeige: treffer.display_name,
|
||||
guete: treffer.importance,
|
||||
}));
|
||||
} catch (fehler) {
|
||||
// Ein ausgefallener Geocoder darf die Einsatzerfassung nicht aufhalten.
|
||||
// Im Alarmfall zählt, dass der Einsatz erfasst wird, nicht dass die
|
||||
// Karte stimmt.
|
||||
console.error("[geocoding] Adresssuche fehlgeschlagen:", fehler);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async rueckwaerts(breitengrad: number, laengengrad: number): Promise<Ort | null> {
|
||||
if (!this.verfuegbar()) return null;
|
||||
|
||||
try {
|
||||
const treffer = (await this.abfragen("/reverse", {
|
||||
lat: String(breitengrad),
|
||||
lon: String(laengengrad),
|
||||
})) as { lat: string; lon: string; display_name: string } | null;
|
||||
|
||||
if (!treffer) return null;
|
||||
|
||||
return {
|
||||
breitengrad: Number(treffer.lat),
|
||||
laengengrad: Number(treffer.lon),
|
||||
anzeige: treffer.display_name,
|
||||
};
|
||||
} catch (fehler) {
|
||||
console.error("[geocoding] Rückwärtssuche fehlgeschlagen:", fehler);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sucheCache: Adresssuche | null = null;
|
||||
|
||||
export function adresssuche(): Adresssuche {
|
||||
if (!sucheCache) {
|
||||
sucheCache = env.GEOCODING_ENABLED && env.NOMINATIM_URL ? new Nominatim() : new KeineSuche();
|
||||
}
|
||||
return sucheCache;
|
||||
}
|
||||
|
||||
/** Nur für Tests: eigene Umsetzung unterschieben. */
|
||||
export function _adresssucheErsetzen(ersatz: Adresssuche | null): void {
|
||||
sucheCache = ersatz;
|
||||
}
|
||||
|
||||
/** Baut aus den Einzelfeldern eines Einsatzes eine Suchzeile. */
|
||||
export function adresszeile(teile: {
|
||||
strasse?: string | null;
|
||||
hausnummer?: string | null;
|
||||
plz?: string | null;
|
||||
ort?: string | null;
|
||||
ortsteil?: string | null;
|
||||
}): string {
|
||||
const strasse = [teile.strasse, teile.hausnummer].filter(Boolean).join(" ");
|
||||
const ort = [teile.plz, teile.ort ?? teile.ortsteil].filter(Boolean).join(" ");
|
||||
return [strasse, ort].filter(Boolean).join(", ");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Laufende Nummern je Wehr und Jahr.
|
||||
*
|
||||
* Prüfprotokolle, Einsätze und Übungen tragen dieselbe Art von Nummer:
|
||||
* `JJJJ-NNNN`, fortlaufend innerhalb eines Jahres, je Wehr getrennt. Sie steht
|
||||
* auf Papier und wird am Telefon genannt — deshalb kurz, sprechbar und ohne
|
||||
* Bindestrichwüste.
|
||||
*
|
||||
* Die Nummern werden nie wiederverwendet. Vergeben wird die höchste vorhandene
|
||||
* plus eins, nicht die Anzahl der Datensätze; sonst bekäme ein im
|
||||
* Aufbewahrungslauf entfernter Vorgang seine Nummer an einen neuen vererbt.
|
||||
*
|
||||
* Reine Zeichenkettenarbeit, kein Datenbankzugriff: wer die Nummer vergibt,
|
||||
* muss dabei selbst für eine Sperre sorgen (siehe `abschliessen` im
|
||||
* Prüfungs-Router und `anlegen` im Einsatz-Router).
|
||||
*/
|
||||
|
||||
/** Vier Stellen reichen für 9999 Vorgänge im Jahr — mehr hat keine Wehr. */
|
||||
const STELLEN = 4;
|
||||
|
||||
/** Bildet "2026-0042". */
|
||||
export function nummerBilden(jahr: number, laufend: number): string {
|
||||
return `${jahr}-${String(laufend).padStart(STELLEN, "0")}`;
|
||||
}
|
||||
|
||||
/** Zerlegt eine Nummer wieder. Gibt null zurück, wenn sie nicht passt. */
|
||||
export function nummerLesen(
|
||||
nummer: string | null | undefined,
|
||||
): { jahr: number; laufend: number } | null {
|
||||
if (!nummer) return null;
|
||||
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
|
||||
if (!treffer) return null;
|
||||
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Die nächste Nummer nach der bisher höchsten.
|
||||
*
|
||||
* @param hoechste die bisher höchste Nummer des Jahres, oder null
|
||||
*/
|
||||
export function naechsteNummer(jahr: number, hoechste: string | null | undefined): string {
|
||||
return nummerBilden(jahr, (nummerLesen(hoechste)?.laufend ?? 0) + 1);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import "server-only";
|
||||
import { redis } from "@/lib/warteschlange";
|
||||
|
||||
/**
|
||||
* Zugriffsbegrenzung über Redis.
|
||||
*
|
||||
* Gebraucht für alles, was ohne Anmeldung erreichbar ist — bisher das
|
||||
* Einsatzportal für Fremdwehren. Ein Link, der aus Versehen in einer
|
||||
* WhatsApp-Gruppe landet, soll die Anwendung nicht in die Knie zwingen.
|
||||
*
|
||||
* Verfahren: ein Zähler je Schlüssel und Zeitfenster. INCR legt den Schlüssel
|
||||
* an, wenn es ihn nicht gibt; beim ersten Zugriff bekommt er eine Lebensdauer.
|
||||
* Das ist ein festes Fenster und keine gleitende Rate — ungenau an der
|
||||
* Fenstergrenze, dafür in zwei Redis-Befehlen erledigt und ohne Zustand in der
|
||||
* Anwendung.
|
||||
*
|
||||
* Fällt Redis aus, wird durchgelassen. Eine Zugriffsbegrenzung, die bei
|
||||
* Störung den Betrieb anhält, richtet mehr Schaden an als sie verhindert —
|
||||
* die eigentliche Zugangsprüfung sitzt ohnehin woanders.
|
||||
*/
|
||||
|
||||
export interface Begrenzung {
|
||||
/** Wurde der Zugriff durchgelassen? */
|
||||
erlaubt: boolean;
|
||||
/** Wie viele Zugriffe im laufenden Fenster noch übrig sind. */
|
||||
uebrig: number;
|
||||
/** Sekunden bis zum Zurücksetzen des Fensters. */
|
||||
zuruecksetztIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt einen Zugriff und sagt, ob er noch erlaubt ist.
|
||||
*
|
||||
* @param schluessel fachliche Kennung, z. B. `portal:<zugangId>`
|
||||
* @param hoechstzahl erlaubte Zugriffe je Fenster
|
||||
* @param fensterSekunden Länge des Fensters
|
||||
*/
|
||||
export async function zugriffZaehlen(
|
||||
schluessel: string,
|
||||
hoechstzahl: number,
|
||||
fensterSekunden = 3600,
|
||||
): Promise<Begrenzung> {
|
||||
const redisSchluessel = `ratelimit:${schluessel}`;
|
||||
|
||||
try {
|
||||
const verbindung = redis();
|
||||
const anzahl = await verbindung.incr(redisSchluessel);
|
||||
|
||||
if (anzahl === 1) {
|
||||
await verbindung.expire(redisSchluessel, fensterSekunden);
|
||||
}
|
||||
|
||||
const rest = await verbindung.ttl(redisSchluessel);
|
||||
|
||||
return {
|
||||
erlaubt: anzahl <= hoechstzahl,
|
||||
uebrig: Math.max(0, hoechstzahl - anzahl),
|
||||
zuruecksetztIn: rest > 0 ? rest : fensterSekunden,
|
||||
};
|
||||
} catch (fehler) {
|
||||
console.error(`[ratelimit] Redis nicht erreichbar (${schluessel}):`, fehler);
|
||||
return { erlaubt: true, uebrig: hoechstzahl, zuruecksetztIn: fensterSekunden };
|
||||
}
|
||||
}
|
||||
|
||||
/** Setzt den Zähler zurück — für Tests und für den Widerruf eines Zugangs. */
|
||||
export async function zaehlerZuruecksetzen(schluessel: string): Promise<void> {
|
||||
try {
|
||||
await redis().del(`ratelimit:${schluessel}`);
|
||||
} catch {
|
||||
// Ein nicht gelöschter Zähler läuft von selbst ab.
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { env } from "@/lib/env";
|
||||
|
||||
export const WARTESCHLANGE = "florian";
|
||||
|
||||
/** Bekannte Auftragsarten. Ab Phase 2 kommen die fachlichen Jobs dazu. */
|
||||
/** Bekannte Auftragsarten. */
|
||||
export const AUFTRAG = {
|
||||
/** Prüft alle Geräte auf fällige Fristen und setzt den Status. */
|
||||
FRISTEN_PRUEFEN: "fristen.pruefen",
|
||||
@@ -24,6 +24,10 @@ export const AUFTRAG = {
|
||||
ERINNERUNG_SENDEN: "erinnerung.senden",
|
||||
/** Erzeugt ein PDF über Gotenberg. */
|
||||
PDF_ERZEUGEN: "pdf.erzeugen",
|
||||
/** Erzeugt einen Einsatzbericht als PDF (Phase 3). */
|
||||
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
|
||||
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
|
||||
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
||||
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
tagesabstand,
|
||||
utcTag,
|
||||
} from "@/lib/stammdaten/fristen";
|
||||
import { nummerBilden, nummerLesen } from "@/lib/nummernkreis";
|
||||
|
||||
/**
|
||||
* Fristenlogik der Wartung.
|
||||
@@ -148,19 +149,11 @@ export function innerhalbPlanungshorizont(
|
||||
* Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042".
|
||||
*
|
||||
* Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist
|
||||
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz,
|
||||
* sprechbar und ohne Bindestrichwüste.
|
||||
* das, was in einer Rückfrage der Unfallkasse genannt wird.
|
||||
*
|
||||
* Ab Phase 3 teilen sich Prüfprotokolle, Einsätze und Übungen denselben
|
||||
* Nummernkreis-Baustein — die Regel ist dieselbe, und zwei Umsetzungen davon
|
||||
* würden über kurz oder lang verschieden aussehen.
|
||||
*/
|
||||
export function protokollNummer(jahr: number, laufend: number): string {
|
||||
return `${jahr}-${String(laufend).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/** Zerlegt eine Protokollnummer wieder. Gibt null zurück, wenn sie nicht passt. */
|
||||
export function protokollNummerLesen(
|
||||
nummer: string | null | undefined,
|
||||
): { jahr: number; laufend: number } | null {
|
||||
if (!nummer) return null;
|
||||
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
|
||||
if (!treffer) return null;
|
||||
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
|
||||
}
|
||||
export const protokollNummer = nummerBilden;
|
||||
export const protokollNummerLesen = nummerLesen;
|
||||
|
||||
@@ -14,6 +14,10 @@ import { wartungsterminRouter } from "@/server/routers/wartung/termin";
|
||||
import { pruefungRouter } from "@/server/routers/wartung/pruefung";
|
||||
import { mangelRouter } from "@/server/routers/wartung/mangel";
|
||||
import { rueckrufRouter } from "@/server/routers/wartung/rueckruf";
|
||||
import { einsatzRouter } from "@/server/routers/einsatz/einsatz";
|
||||
import { uebungRouter } from "@/server/routers/einsatz/uebung";
|
||||
import { statistikRouter } from "@/server/routers/einsatz/statistik";
|
||||
import { portalRouter } from "@/server/routers/einsatz/portal";
|
||||
|
||||
/**
|
||||
* Wurzel-Router.
|
||||
@@ -151,6 +155,13 @@ export const appRouter = router({
|
||||
pruefung: pruefungRouter,
|
||||
mangel: mangelRouter,
|
||||
rueckruf: rueckrufRouter,
|
||||
|
||||
// Phase 3 — Einsätze und Übungen
|
||||
einsatz: einsatzRouter,
|
||||
uebung: uebungRouter,
|
||||
statistik: statistikRouter,
|
||||
/** Öffentlich erreichbar, über einen signierten Link. Siehe lib/einsatz/portal.ts. */
|
||||
portal: portalRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { LagemeldungQuelle } from "@prisma/client";
|
||||
import { router, oeffentlich } from "@/server/trpc";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { zugriffZaehlen } from "@/lib/ratelimit";
|
||||
import {
|
||||
PORTAL_FEHLER_TEXT,
|
||||
portalPruefen,
|
||||
type PortalSitzung,
|
||||
} from "@/lib/einsatz/portal";
|
||||
|
||||
/**
|
||||
* Das Einsatzportal für Fremdwehren — ohne Anmeldung, über einen signierten
|
||||
* Link.
|
||||
*
|
||||
* Diese Prozeduren sind als einzige in FLORIAN öffentlich erreichbar. Sie
|
||||
* gehen deshalb bewusst sparsam mit dem um, was sie zeigen und annehmen:
|
||||
*
|
||||
* gezeigt wird Stichwort, Kategorie, Ort, Zeitstempel und die
|
||||
* Lagemeldungen — also das, was an der Einsatzstelle
|
||||
* ohnehin jeder sieht
|
||||
* nicht gezeigt Namen von Einsatzkräften, Bericht, Material, Kosten,
|
||||
* irgendetwas über die Wehr außerhalb dieses Einsatzes
|
||||
* angenommen wird eine Lagemeldung mit Text und Foto, mehr nicht
|
||||
*
|
||||
* Jeder Aufruf läuft durch die Zugriffsbegrenzung und wird am Zugang
|
||||
* mitgezählt.
|
||||
*/
|
||||
|
||||
const tokenEingabe = z.object({ token: z.string().min(20).max(400) });
|
||||
|
||||
/** Prüft den Token oder wirft einen sprechenden Fehler. */
|
||||
async function sitzungOderFehler(
|
||||
token: string,
|
||||
ipAdresse: string | null,
|
||||
): Promise<PortalSitzung> {
|
||||
const ergebnis = await portalPruefen(token, ipAdresse);
|
||||
|
||||
if ("fehler" in ergebnis) {
|
||||
throw new TRPCError({
|
||||
code: ergebnis.fehler === "zu_viele_zugriffe" ? "TOO_MANY_REQUESTS" : "UNAUTHORIZED",
|
||||
message: PORTAL_FEHLER_TEXT[ergebnis.fehler],
|
||||
});
|
||||
}
|
||||
|
||||
return ergebnis.sitzung;
|
||||
}
|
||||
|
||||
export const portalRouter = router({
|
||||
/** Was das Portal zeigt. */
|
||||
laden: oeffentlich.input(tokenEingabe).query(async ({ ctx, input }) => {
|
||||
const sitzung = await sitzungOderFehler(input.token, ctx.ipAdresse);
|
||||
|
||||
return fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const einsatz = await db.einsatz.findUnique({
|
||||
where: { id: sitzung.einsatzId },
|
||||
select: {
|
||||
id: true,
|
||||
einsatznummer: true,
|
||||
stichwort: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
strasse: true,
|
||||
hausnummer: true,
|
||||
plz: true,
|
||||
ort: true,
|
||||
ortsteil: true,
|
||||
objekt: true,
|
||||
breitengrad: true,
|
||||
laengengrad: true,
|
||||
alarmiertUm: true,
|
||||
ausgeruecktUm: true,
|
||||
eingetroffenUm: true,
|
||||
einsatzendeUm: true,
|
||||
organisation: { select: { name: true, kurzname: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!einsatz) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Einsatz gibt es nicht mehr." });
|
||||
}
|
||||
|
||||
const lagemeldungen = await db.lagemeldung.findMany({
|
||||
where: { einsatzId: sitzung.einsatzId, geloeschtAm: null },
|
||||
orderBy: { zeitpunkt: "desc" },
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
zeitpunkt: true,
|
||||
text: true,
|
||||
wichtig: true,
|
||||
quelle: true,
|
||||
verfasserName: true,
|
||||
fotoDateiId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
zugang: {
|
||||
bezeichnung: sitzung.bezeichnung,
|
||||
gueltigBis: sitzung.gueltigBis,
|
||||
},
|
||||
einsatz,
|
||||
lagemeldungen,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Eine Lagemeldung aus dem Portal.
|
||||
*
|
||||
* Zusätzlich zur allgemeinen Zugriffsbegrenzung ein eigenes, engeres Limit:
|
||||
* lesen darf man oft, schreiben selten. Sonst wäre ein weitergegebener Link
|
||||
* ein Werkzeug, um die Lagemeldungen zuzuschütten.
|
||||
*/
|
||||
melden: oeffentlich
|
||||
.input(
|
||||
tokenEingabe.extend({
|
||||
text: z.string().trim().min(1, "Ohne Text ist es keine Meldung.").max(2000),
|
||||
verfasserName: z.string().trim().min(1, "Bitte angeben, wer meldet.").max(200),
|
||||
fotoDateiId: z.string().uuid().nullish(),
|
||||
wichtig: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sitzung = await sitzungOderFehler(input.token, ctx.ipAdresse);
|
||||
|
||||
const schreibgrenze = await zugriffZaehlen(`portal-melden:${sitzung.zugangId}`, 30, 3600);
|
||||
if (!schreibgrenze.erlaubt) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Zu viele Meldungen in kurzer Zeit. Bitte kurz warten.",
|
||||
});
|
||||
}
|
||||
|
||||
return fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const meldung = await db.lagemeldung.create({
|
||||
data: {
|
||||
organisationId: sitzung.organisationId,
|
||||
einsatzId: sitzung.einsatzId,
|
||||
text: input.text,
|
||||
wichtig: input.wichtig,
|
||||
quelle: LagemeldungQuelle.PORTAL,
|
||||
verfasserName: `${input.verfasserName} (${sitzung.bezeichnung})`,
|
||||
fotoDateiId: input.fotoDateiId ?? null,
|
||||
portalZugangId: sitzung.zugangId,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Lagemeldung",
|
||||
entitaetId: meldung.id,
|
||||
beschreibung: `Lagemeldung über das Portal von "${sitzung.bezeichnung}"`,
|
||||
akteurTyp: AkteurTyp.API,
|
||||
akteurName: input.verfasserName,
|
||||
nachher: { text: input.text, wichtig: input.wichtig },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { id: meldung.id, zeitpunkt: meldung.zeitpunkt };
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { z } from "zod";
|
||||
import { EinsatzStatus } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { hilfsfrist, ausrueckzeit, einsatzdauer } from "@/lib/einsatz/zeiten";
|
||||
|
||||
/**
|
||||
* Statistik über Einsätze und Übungen.
|
||||
*
|
||||
* Zwei Adressaten, zwei Fragen:
|
||||
*
|
||||
* Die Gemeinde fragt: wie viele Einsätze welcher Art, und wie schnell wart
|
||||
* ihr da? Dafür stehen die Kategorien nach Landesstatistik und die
|
||||
* Hilfsfrist.
|
||||
*
|
||||
* Die Wehr fragt: wer trägt die Last? Dafür steht die Beteiligung je Person
|
||||
* und die Auslastung je Fahrzeug.
|
||||
*
|
||||
* Gerechnet wird in der Anwendung, nicht in SQL. Ein Jahr hat auch bei einer
|
||||
* gut beschäftigten Wehr selten mehr als dreihundert Einsätze; dafür lohnt
|
||||
* keine Aggregatabfrage, die niemand mehr lesen kann.
|
||||
*/
|
||||
|
||||
/** Klassen für die Verteilung der Hilfsfrist, in Minuten. */
|
||||
const FRISTKLASSEN = [
|
||||
{ bis: 5, text: "bis 5 min" },
|
||||
{ bis: 8, text: "5 bis 8 min" },
|
||||
{ bis: 10, text: "8 bis 10 min" },
|
||||
{ bis: 15, text: "10 bis 15 min" },
|
||||
{ bis: Number.POSITIVE_INFINITY, text: "über 15 min" },
|
||||
] as const;
|
||||
|
||||
/** Perzentil einer sortierten Liste. */
|
||||
function perzentil(werte: number[], anteil: number): number | null {
|
||||
if (werte.length === 0) return null;
|
||||
const sortiert = [...werte].sort((a, b) => a - b);
|
||||
const stelle = Math.min(sortiert.length - 1, Math.floor(anteil * sortiert.length));
|
||||
return sortiert[stelle] ?? null;
|
||||
}
|
||||
|
||||
export const statistikRouter = router({
|
||||
/**
|
||||
* Die Jahresübersicht.
|
||||
*
|
||||
* Enthält alles, was in den Jahresbericht der Wehr und in die Meldung an die
|
||||
* Gemeinde geht.
|
||||
*/
|
||||
jahr: mitRecht("einsatz.lesen")
|
||||
.input(
|
||||
z
|
||||
.object({ jahr: z.number().int().min(2000).max(2100).optional() })
|
||||
.default({}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const jahr = input.jahr ?? new Date().getUTCFullYear();
|
||||
const von = new Date(Date.UTC(jahr, 0, 1));
|
||||
const bis = new Date(Date.UTC(jahr + 1, 0, 1));
|
||||
|
||||
const einsaetze = await db.einsatz.findMany({
|
||||
where: { geloeschtAm: null, alarmiertUm: { gte: von, lt: bis } },
|
||||
select: {
|
||||
id: true,
|
||||
einsatznummer: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
alarmiertUm: true,
|
||||
ausgeruecktUm: true,
|
||||
eingetroffenUm: true,
|
||||
einsatzendeUm: true,
|
||||
eingeruecktUm: true,
|
||||
geretteteePersonen: true,
|
||||
verletztePersonen: true,
|
||||
totePersonen: true,
|
||||
kraefte: {
|
||||
select: {
|
||||
personId: true,
|
||||
atemschutz: true,
|
||||
person: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
},
|
||||
fahrzeuge: {
|
||||
select: {
|
||||
fahrzeugId: true,
|
||||
kilometerAbfahrt: true,
|
||||
kilometerRueckkehr: true,
|
||||
wasserabgabe: true,
|
||||
fahrzeug: { select: { funkrufname: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// --- nach Kategorie und Monat -----------------------------------------
|
||||
const nachKategorie = new Map<string, number>();
|
||||
const nachMonat = Array.from({ length: 12 }, () => 0);
|
||||
|
||||
// --- Zeiten ------------------------------------------------------------
|
||||
const hilfsfristen: number[] = [];
|
||||
const ausrueckzeiten: number[] = [];
|
||||
const dauern: number[] = [];
|
||||
const fristklassen = FRISTKLASSEN.map((klasse) => ({ text: klasse.text, anzahl: 0 }));
|
||||
|
||||
// --- Beteiligung und Fahrzeuge ----------------------------------------
|
||||
const jePerson = new Map<
|
||||
string,
|
||||
{ name: string; einsaetze: number; atemschutz: number }
|
||||
>();
|
||||
const jeFahrzeug = new Map<
|
||||
string,
|
||||
{ name: string; einsaetze: number; kilometer: number; wasser: number }
|
||||
>();
|
||||
|
||||
let gerettet = 0;
|
||||
let verletzt = 0;
|
||||
let getoetet = 0;
|
||||
|
||||
for (const einsatz of einsaetze) {
|
||||
nachKategorie.set(einsatz.kategorie, (nachKategorie.get(einsatz.kategorie) ?? 0) + 1);
|
||||
|
||||
if (einsatz.alarmiertUm) {
|
||||
const monat = einsatz.alarmiertUm.getUTCMonth();
|
||||
nachMonat[monat] = (nachMonat[monat] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const frist = hilfsfrist(einsatz);
|
||||
if (frist !== null) {
|
||||
hilfsfristen.push(frist);
|
||||
const klasse = FRISTKLASSEN.findIndex((eintrag) => frist <= eintrag.bis);
|
||||
if (klasse >= 0) fristklassen[klasse]!.anzahl += 1;
|
||||
}
|
||||
|
||||
const ausruecken = ausrueckzeit(einsatz);
|
||||
if (ausruecken !== null) ausrueckzeiten.push(ausruecken);
|
||||
|
||||
const dauer = einsatzdauer(einsatz);
|
||||
if (dauer !== null) dauern.push(dauer);
|
||||
|
||||
gerettet += einsatz.geretteteePersonen;
|
||||
verletzt += einsatz.verletztePersonen;
|
||||
getoetet += einsatz.totePersonen;
|
||||
|
||||
for (const kraft of einsatz.kraefte) {
|
||||
const eintrag = jePerson.get(kraft.personId) ?? {
|
||||
name: `${kraft.person.nachname}, ${kraft.person.vorname}`,
|
||||
einsaetze: 0,
|
||||
atemschutz: 0,
|
||||
};
|
||||
eintrag.einsaetze += 1;
|
||||
if (kraft.atemschutz) eintrag.atemschutz += 1;
|
||||
jePerson.set(kraft.personId, eintrag);
|
||||
}
|
||||
|
||||
for (const fahrzeug of einsatz.fahrzeuge) {
|
||||
const eintrag = jeFahrzeug.get(fahrzeug.fahrzeugId) ?? {
|
||||
name: fahrzeug.fahrzeug.funkrufname,
|
||||
einsaetze: 0,
|
||||
kilometer: 0,
|
||||
wasser: 0,
|
||||
};
|
||||
eintrag.einsaetze += 1;
|
||||
if (fahrzeug.kilometerAbfahrt !== null && fahrzeug.kilometerRueckkehr !== null) {
|
||||
const gefahren = fahrzeug.kilometerRueckkehr - fahrzeug.kilometerAbfahrt;
|
||||
if (gefahren > 0 && gefahren < 5000) eintrag.kilometer += gefahren;
|
||||
}
|
||||
eintrag.wasser += fahrzeug.wasserabgabe ?? 0;
|
||||
jeFahrzeug.set(fahrzeug.fahrzeugId, eintrag);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Übungen -----------------------------------------------------------
|
||||
const uebungen = await db.uebung.findMany({
|
||||
where: { geloeschtAm: null, datum: { gte: von, lt: bis } },
|
||||
select: {
|
||||
id: true,
|
||||
art: true,
|
||||
teilnahmen: {
|
||||
select: {
|
||||
personId: true,
|
||||
person: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const uebungenJePerson = new Map<string, { name: string; anzahl: number }>();
|
||||
const uebungenNachArt = new Map<string, number>();
|
||||
|
||||
for (const uebung of uebungen) {
|
||||
uebungenNachArt.set(uebung.art, (uebungenNachArt.get(uebung.art) ?? 0) + 1);
|
||||
for (const teilnahme of uebung.teilnahmen) {
|
||||
const eintrag = uebungenJePerson.get(teilnahme.personId) ?? {
|
||||
name: `${teilnahme.person.nachname}, ${teilnahme.person.vorname}`,
|
||||
anzahl: 0,
|
||||
};
|
||||
eintrag.anzahl += 1;
|
||||
uebungenJePerson.set(teilnahme.personId, eintrag);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Beteiligung zusammenführen ---------------------------------------
|
||||
const beteiligung = [...jePerson.entries()]
|
||||
.map(([personId, eintrag]) => ({
|
||||
personId,
|
||||
name: eintrag.name,
|
||||
einsaetze: eintrag.einsaetze,
|
||||
atemschutzeinsaetze: eintrag.atemschutz,
|
||||
uebungen: uebungenJePerson.get(personId)?.anzahl ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.einsaetze - a.einsaetze);
|
||||
|
||||
// Wer nur bei Übungen war, fehlt sonst ganz.
|
||||
for (const [personId, eintrag] of uebungenJePerson) {
|
||||
if (!jePerson.has(personId)) {
|
||||
beteiligung.push({
|
||||
personId,
|
||||
name: eintrag.name,
|
||||
einsaetze: 0,
|
||||
atemschutzeinsaetze: 0,
|
||||
uebungen: eintrag.anzahl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
jahr,
|
||||
gesamt: einsaetze.length,
|
||||
freigegeben: einsaetze.filter((e) => e.status === EinsatzStatus.FREIGEGEBEN).length,
|
||||
offen: einsaetze.filter((e) => e.status !== EinsatzStatus.FREIGEGEBEN).length,
|
||||
|
||||
nachKategorie: [...nachKategorie.entries()]
|
||||
.map(([kategorie, anzahl]) => ({ kategorie, anzahl }))
|
||||
.sort((a, b) => b.anzahl - a.anzahl),
|
||||
nachMonat: nachMonat.map((anzahl, monat) => ({ monat: monat + 1, anzahl })),
|
||||
|
||||
zeiten: {
|
||||
// Die Zahl, nach der die Gemeinde fragt: in 80 Prozent der Fälle
|
||||
// war die Wehr innerhalb dieser Zeit da.
|
||||
hilfsfristP80: perzentil(hilfsfristen, 0.8),
|
||||
hilfsfristMedian: perzentil(hilfsfristen, 0.5),
|
||||
ausrueckzeitMedian: perzentil(ausrueckzeiten, 0.5),
|
||||
dauerMedian: perzentil(dauern, 0.5),
|
||||
gemessen: hilfsfristen.length,
|
||||
verteilung: fristklassen,
|
||||
},
|
||||
|
||||
personen: { gerettet, verletzt, getoetet },
|
||||
|
||||
beteiligung,
|
||||
fahrzeuge: [...jeFahrzeug.entries()]
|
||||
.map(([fahrzeugId, eintrag]) => ({ fahrzeugId, ...eintrag }))
|
||||
.sort((a, b) => b.einsaetze - a.einsaetze),
|
||||
|
||||
uebungen: {
|
||||
gesamt: uebungen.length,
|
||||
teilnahmen: uebungen.reduce((summe, uebung) => summe + uebung.teilnahmen.length, 0),
|
||||
nachArt: [...uebungenNachArt.entries()]
|
||||
.map(([art, anzahl]) => ({ art, anzahl }))
|
||||
.sort((a, b) => b.anzahl - a.anzahl),
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
/** In welchen Jahren es überhaupt Daten gibt — für die Auswahl. */
|
||||
jahre: mitRecht("einsatz.lesen").query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const aeltester = await db.einsatz.findFirst({
|
||||
where: { geloeschtAm: null, alarmiertUm: { not: null } },
|
||||
orderBy: { alarmiertUm: "asc" },
|
||||
select: { alarmiertUm: true },
|
||||
});
|
||||
|
||||
const jetzt = new Date().getUTCFullYear();
|
||||
const start = aeltester?.alarmiertUm?.getUTCFullYear() ?? jetzt;
|
||||
|
||||
return Array.from({ length: jetzt - start + 1 }, (_, index) => jetzt - index);
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EinsatzFunktion, Prisma, UebungsArt } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import {
|
||||
blaettern,
|
||||
idEingabe,
|
||||
listenAntwort,
|
||||
listenEingabe,
|
||||
ohneGeloeschte,
|
||||
} from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { naechsteNummer } from "@/lib/nummernkreis";
|
||||
|
||||
/**
|
||||
* Übungen.
|
||||
*
|
||||
* Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und Lernzielen statt
|
||||
* Schadenlage. Der Zweck ist ein anderer: eine Übung ist kein Nachweis
|
||||
* gegenüber der Gemeinde, sondern gegenüber der eigenen Mannschaft — wer war
|
||||
* dabei, wie oft, und woran wurde gearbeitet.
|
||||
*
|
||||
* Daraus ergibt sich die Teilnahmequote, nach der in der Jahreshauptversammlung
|
||||
* gefragt wird, und die Grundlage für Beförderungen.
|
||||
*
|
||||
* Ein Übungsbericht wird nicht festgeschrieben. Er geht an niemanden außerhalb
|
||||
* der Wehr, und ein Tippfehler soll ohne Korrekturvorgang zu beheben sein.
|
||||
*/
|
||||
|
||||
const LISTEN_AUSWAHL = {
|
||||
id: true,
|
||||
nummer: true,
|
||||
thema: true,
|
||||
art: true,
|
||||
datum: true,
|
||||
beginnUm: true,
|
||||
endeUm: true,
|
||||
ort: true,
|
||||
geloeschtAm: true,
|
||||
leiter: { select: { id: true, vorname: true, nachname: true } },
|
||||
_count: { select: { teilnahmen: true } },
|
||||
} satisfies Prisma.UebungSelect;
|
||||
|
||||
const uebungFelder = z.object({
|
||||
thema: z.string().trim().min(1, "Ohne Thema ist es keine Übung, sondern ein Treffen.").max(200),
|
||||
art: z.nativeEnum(UebungsArt).default(UebungsArt.PRAXIS),
|
||||
datum: z.coerce.date(),
|
||||
beginnUm: z.coerce.date().nullish(),
|
||||
endeUm: z.coerce.date().nullish(),
|
||||
ort: z.string().trim().max(200).nullish(),
|
||||
leiterId: z.string().uuid().nullish(),
|
||||
lernziele: z.string().trim().max(2000).nullish(),
|
||||
beschreibung: z.string().trim().max(4000).nullish(),
|
||||
bericht: z.string().trim().max(50_000).nullish(),
|
||||
});
|
||||
|
||||
export const uebungRouter = router({
|
||||
liste: mitRecht("ausbildung.lesen")
|
||||
.input(
|
||||
listenEingabe(["datum", "thema", "nummer"] as const).extend({
|
||||
art: z.array(z.nativeEnum(UebungsArt)).optional(),
|
||||
jahr: z.number().int().min(2000).max(2100).optional(),
|
||||
personId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const wo: Prisma.UebungWhereInput = {
|
||||
...ohneGeloeschte(input.mitGeloeschten),
|
||||
...(input.art?.length ? { art: { in: input.art } } : {}),
|
||||
...(input.jahr
|
||||
? {
|
||||
datum: {
|
||||
gte: new Date(Date.UTC(input.jahr, 0, 1)),
|
||||
lt: new Date(Date.UTC(input.jahr + 1, 0, 1)),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(input.personId ? { teilnahmen: { some: { personId: input.personId } } } : {}),
|
||||
...(input.suche
|
||||
? {
|
||||
OR: [
|
||||
{ thema: { contains: input.suche, mode: "insensitive" as const } },
|
||||
{ nummer: { contains: input.suche, mode: "insensitive" as const } },
|
||||
{ ort: { contains: input.suche, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [eintraege, gesamt] = await Promise.all([
|
||||
db.uebung.findMany({
|
||||
where: wo,
|
||||
select: LISTEN_AUSWAHL,
|
||||
orderBy: input.sortierung ? { [input.sortierung]: input.richtung } : { datum: "desc" },
|
||||
...blaettern(input),
|
||||
}),
|
||||
db.uebung.count({ where: wo }),
|
||||
]);
|
||||
|
||||
return listenAntwort(eintraege, gesamt, input);
|
||||
}),
|
||||
),
|
||||
|
||||
einzeln: mitRecht("ausbildung.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const uebung = await db.uebung.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
leiter: { select: { id: true, vorname: true, nachname: true } },
|
||||
teilnahmen: {
|
||||
include: {
|
||||
person: {
|
||||
select: { id: true, vorname: true, nachname: true, dienstgrad: true },
|
||||
},
|
||||
},
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uebung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
|
||||
}
|
||||
|
||||
const dauer =
|
||||
uebung.beginnUm && uebung.endeUm
|
||||
? Math.round((uebung.endeUm.getTime() - uebung.beginnUm.getTime()) / 60_000)
|
||||
: null;
|
||||
|
||||
return { ...uebung, dauerMinuten: dauer };
|
||||
}),
|
||||
),
|
||||
|
||||
anlegen: mitRecht("ausbildung.bearbeiten")
|
||||
.input(uebungFelder.extend({ teilnehmerIds: z.array(z.string().uuid()).max(200).default([]) }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { teilnehmerIds, ...felder } = input;
|
||||
const jahr = felder.datum.getUTCFullYear();
|
||||
|
||||
await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`uebung:${ctx.sitzung.organisationId}:${jahr}`}))`;
|
||||
|
||||
const letzte = await db.uebung.findFirst({
|
||||
where: { nummer: { startsWith: `${jahr}-` } },
|
||||
orderBy: { nummer: "desc" },
|
||||
select: { nummer: true },
|
||||
});
|
||||
|
||||
const nummer = naechsteNummer(jahr, letzte?.nummer);
|
||||
|
||||
const uebung = await db.uebung.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
nummer,
|
||||
...felder,
|
||||
teilnahmen: {
|
||||
create: teilnehmerIds.map((personId) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Uebung",
|
||||
entitaetId: uebung.id,
|
||||
beschreibung: `Übung ${nummer} — ${uebung.thema} mit ${teilnehmerIds.length} Teilnehmern`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: uebung,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return uebung;
|
||||
}),
|
||||
),
|
||||
|
||||
aendern: mitRecht("ausbildung.bearbeiten")
|
||||
.input(uebungFelder.partial().extend({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...aenderungen } = input;
|
||||
|
||||
const vorher = await db.uebung.findUnique({ where: { id } });
|
||||
if (!vorher) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.uebung.update({ where: { id }, data: aenderungen });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Uebung",
|
||||
entitaetId: id,
|
||||
beschreibung: `Übung ${nachher.nummer} geändert`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Teilnehmerliste setzen — Anwesenheit wird am Stück abgehakt. */
|
||||
teilnahmeSetzen: mitRecht("ausbildung.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
uebungId: z.string().uuid(),
|
||||
teilnehmer: z
|
||||
.array(
|
||||
z.object({
|
||||
personId: z.string().uuid(),
|
||||
funktion: z.nativeEnum(EinsatzFunktion).nullish(),
|
||||
vonUm: z.coerce.date().nullish(),
|
||||
bisUm: z.coerce.date().nullish(),
|
||||
bemerkung: z.string().trim().max(500).nullish(),
|
||||
}),
|
||||
)
|
||||
.max(200),
|
||||
/** Nicht genannte Teilnehmer entfernen — die Liste ist dann vollständig. */
|
||||
ersetzen: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const uebung = await db.uebung.findUnique({
|
||||
where: { id: input.uebungId },
|
||||
select: { id: true, nummer: true },
|
||||
});
|
||||
if (!uebung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
|
||||
}
|
||||
|
||||
if (input.ersetzen) {
|
||||
await db.uebungsteilnahme.deleteMany({
|
||||
where: {
|
||||
uebungId: input.uebungId,
|
||||
personId: { notIn: input.teilnehmer.map((eintrag) => eintrag.personId) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const teilnehmer of input.teilnehmer) {
|
||||
await db.uebungsteilnahme.upsert({
|
||||
where: {
|
||||
uebungId_personId: { uebungId: input.uebungId, personId: teilnehmer.personId },
|
||||
},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
uebungId: input.uebungId,
|
||||
...teilnehmer,
|
||||
},
|
||||
update: {
|
||||
funktion: teilnehmer.funktion ?? null,
|
||||
vonUm: teilnehmer.vonUm ?? null,
|
||||
bisUm: teilnehmer.bisUm ?? null,
|
||||
bemerkung: teilnehmer.bemerkung ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Uebung",
|
||||
entitaetId: input.uebungId,
|
||||
beschreibung: `Übung ${uebung.nummer}: ${input.teilnehmer.length} Teilnehmer erfasst`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { anzahl: input.teilnehmer.length },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { erfasst: input.teilnehmer.length };
|
||||
}),
|
||||
),
|
||||
|
||||
loeschen: mitRecht("ausbildung.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const uebung = await db.uebung.findUnique({ where: { id: input.id } });
|
||||
if (!uebung || uebung.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
|
||||
}
|
||||
|
||||
await db.uebung.update({ where: { id: input.id }, data: { geloeschtAm: new Date() } });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Uebung",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Übung ${uebung.nummer} gelöscht`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher: uebung,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { erfolgreich: true };
|
||||
}),
|
||||
),
|
||||
});
|
||||
+27
-2
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/warteschlange";
|
||||
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
|
||||
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
|
||||
import { einsatzberichtErzeugen } from "@/lib/einsatz/bericht-erzeugen";
|
||||
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
|
||||
|
||||
/**
|
||||
@@ -20,8 +21,8 @@ import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/wor
|
||||
*
|
||||
* Läuft als eigener Container neben der Anwendung und arbeitet die
|
||||
* Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
|
||||
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die
|
||||
* Aufbewahrung folgt in Phase 8.
|
||||
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle, ab Phase 3
|
||||
* dazu die Einsatzberichte. Die Aufbewahrung folgt in Phase 8.
|
||||
*
|
||||
* Beim Start trägt sich der Prozess die wiederkehrenden Läufe je Wehr ein.
|
||||
* Das ist bewusst hier und nicht in der Anwendung: der Worker ist der Einzige,
|
||||
@@ -81,6 +82,29 @@ async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
|
||||
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
|
||||
}
|
||||
|
||||
async function einsatzberichtErzeugenAuftrag(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId, einsatzId } = auftrag.data as AuftragsDaten & { einsatzId?: string };
|
||||
|
||||
if (!einsatzId) {
|
||||
throw new Error("Berichtsauftrag ohne Angabe, wozu — einsatzId fehlt.");
|
||||
}
|
||||
|
||||
const ergebnis = await einsatzberichtErzeugen(organisationId, einsatzId);
|
||||
|
||||
if (!ergebnis) {
|
||||
protokoll(`Kein Bericht für Einsatz ${einsatzId} — unbekannt oder nicht freigegeben.`);
|
||||
return { erzeugt: false };
|
||||
}
|
||||
|
||||
protokoll(
|
||||
ergebnis.neu
|
||||
? `Einsatzbericht für ${einsatzId} erzeugt.`
|
||||
: `Einsatzbericht für ${einsatzId} lag bereits vor.`,
|
||||
);
|
||||
|
||||
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
|
||||
}
|
||||
|
||||
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
|
||||
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
|
||||
@@ -92,6 +116,7 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
|
||||
[AUFTRAG.FRISTEN_PRUEFEN]: fristenPruefen,
|
||||
[AUFTRAG.ERINNERUNG_SENDEN]: erinnerungSenden,
|
||||
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
|
||||
[AUFTRAG.EINSATZBERICHT_ERZEUGEN]: einsatzberichtErzeugenAuftrag,
|
||||
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user