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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user