Kartenmodule ergaenzen
Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan, die Lagekarte eines Einsatzes und die geplante Foerderstrecke. Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst (tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht erreichbaren Server. Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger Tabellendurchlauf. Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen — raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne Statusangabe steht "ungeprueft", nicht "einsatzbereit". 22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung. Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke nicht auf, sagt die Ausgabe das. Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben. Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung pruefen und nicht gegen die eigene Implementierung. Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es nur den Upload. 59 neue Tests (48 Unit, 11 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1480c0c3e
commit
1c1e141666
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Radio, Undo2, WifiOff } from "lucide-react";
|
||||
import { LageelementArt } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { Karte, type KartenLinie, type KartenPunkt } from "@/components/karte/karte";
|
||||
import { useEreignisstrom } from "@/components/atemschutz/stand";
|
||||
import { GRUPPE_TEXT, ZEICHEN, alsSvg, type Zeichengruppe } from "@/lib/karte/taktische-zeichen";
|
||||
|
||||
/**
|
||||
* Die Lagekarte eines Einsatzes.
|
||||
*
|
||||
* Der gemeinsame Zeichenblock: Zeichen setzen, Linien ziehen, beschriften —
|
||||
* und alle sehen dasselbe. Verteilt über denselben Ereignisstrom wie die
|
||||
* Atemschutzüberwachung aus Phase 5, mit demselben Hook.
|
||||
*
|
||||
* Drei Entscheidungen, die sich im Einsatz bewähren müssen:
|
||||
*
|
||||
* 1. **Ein Werkzeug, ein Klick.** Zeichen wählen, in die Karte tippen,
|
||||
* fertig. Kein Dialog dazwischen — wer im Einsatz zeichnet, hat eine Hand
|
||||
* am Funkgerät.
|
||||
* 2. **Zurücknehmen statt löschen.** Was um 14:12 eingezeichnet wurde,
|
||||
* gehört zur Mitschrift, auch wenn es um 14:20 nicht mehr stimmte. Es
|
||||
* verschwindet von der Karte, nicht aus dem Datenbestand.
|
||||
* 3. **Der Verbindungszustand steht sichtbar da.** Wer nicht sieht, dass der
|
||||
* Draht weg ist, hält eine alte Lage für die aktuelle.
|
||||
*/
|
||||
|
||||
/** Wie oft ohne Ereignis nachgefragt wird. */
|
||||
const NACHFRAGE_MS = 5000;
|
||||
|
||||
type Werkzeug = { art: "zeichen"; schluessel: string } | { art: "linie" } | { art: "text" };
|
||||
|
||||
export function Lagekarte({
|
||||
einsatzId,
|
||||
stilUrl,
|
||||
mittelpunkt,
|
||||
zoom,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
einsatzId: string;
|
||||
stilUrl: string | null;
|
||||
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||
zoom: number;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [werkzeug, setWerkzeug] = React.useState<Werkzeug | null>(null);
|
||||
const [linienpunkte, setLinienpunkte] = React.useState<Array<[number, number]>>([]);
|
||||
const [beschriftung, setBeschriftung] = React.useState("");
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const karte = trpc.lagekarte.fuerEinsatz.useQuery(
|
||||
{ einsatzId },
|
||||
{ refetchInterval: NACHFRAGE_MS },
|
||||
);
|
||||
|
||||
const neuLaden = React.useCallback(() => {
|
||||
void werkzeuge.lagekarte.fuerEinsatz.invalidate({ einsatzId });
|
||||
}, [einsatzId, werkzeuge]);
|
||||
|
||||
// Derselbe Hook wie beim Atemschutz — nur eine andere Adresse.
|
||||
const { verbunden } = useEreignisstrom(einsatzId, neuLaden, "/api/lagekarte/strom?einsatz=");
|
||||
|
||||
const eintragen = trpc.lagekarte.eintragen.useMutation({
|
||||
onSuccess: () => {
|
||||
setLinienpunkte([]);
|
||||
setBeschriftung("");
|
||||
neuLaden();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const zuruecknehmen = trpc.lagekarte.zuruecknehmen.useMutation({
|
||||
onSuccess: neuLaden,
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const elemente = karte.data?.elemente ?? [];
|
||||
|
||||
const punkte = React.useMemo<KartenPunkt[]>(() => {
|
||||
const liste: KartenPunkt[] = [];
|
||||
|
||||
for (const element of elemente) {
|
||||
const geometrie = element.geometrie as Array<[number, number]>;
|
||||
if (!Array.isArray(geometrie) || geometrie.length === 0) continue;
|
||||
|
||||
if (element.art === LageelementArt.ZEICHEN && element.zeichen) {
|
||||
liste.push({
|
||||
id: element.id,
|
||||
laengengrad: geometrie[0]![0],
|
||||
breitengrad: geometrie[0]![1],
|
||||
svg: alsSvg(element.zeichen, 36, element.farbe ?? undefined),
|
||||
titel: element.text ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.art === LageelementArt.TEXT) {
|
||||
liste.push({
|
||||
id: element.id,
|
||||
laengengrad: geometrie[0]![0],
|
||||
breitengrad: geometrie[0]![1],
|
||||
farbe: element.farbe ?? "#111827",
|
||||
titel: element.text ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Punkte einer Linie, die gerade gezogen wird.
|
||||
linienpunkte.forEach((punkt, index) => {
|
||||
liste.push({
|
||||
id: `entwurf-${index}`,
|
||||
laengengrad: punkt[0],
|
||||
breitengrad: punkt[1],
|
||||
farbe: "#ca8a04",
|
||||
titel: `Punkt ${index + 1}`,
|
||||
});
|
||||
});
|
||||
|
||||
return liste;
|
||||
}, [elemente, linienpunkte]);
|
||||
|
||||
const linien = React.useMemo<KartenLinie[]>(() => {
|
||||
const liste: KartenLinie[] = [];
|
||||
|
||||
for (const element of elemente) {
|
||||
if (element.art !== LageelementArt.LINIE && element.art !== LageelementArt.FLAECHE) continue;
|
||||
|
||||
const geometrie = element.geometrie as Array<[number, number]>;
|
||||
if (!Array.isArray(geometrie) || geometrie.length < 2) continue;
|
||||
|
||||
liste.push({
|
||||
id: element.id,
|
||||
punkte: geometrie,
|
||||
farbe: element.farbe ?? "#b91c1c",
|
||||
flaeche: element.art === LageelementArt.FLAECHE,
|
||||
});
|
||||
}
|
||||
|
||||
if (linienpunkte.length >= 2) {
|
||||
liste.push({ id: "entwurf", punkte: linienpunkte, farbe: "#ca8a04", gestrichelt: true });
|
||||
}
|
||||
|
||||
return liste;
|
||||
}, [elemente, linienpunkte]);
|
||||
|
||||
function kartenklick(ort: { breitengrad: number; laengengrad: number }) {
|
||||
if (!darfBearbeiten || !werkzeug) return;
|
||||
|
||||
if (werkzeug.art === "zeichen") {
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.ZEICHEN,
|
||||
zeichen: werkzeug.schluessel,
|
||||
geometrie: [[ort.laengengrad, ort.breitengrad]],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (werkzeug.art === "text") {
|
||||
if (!beschriftung.trim()) {
|
||||
setMeldung("Erst den Text eintippen, dann in die Karte tippen.");
|
||||
return;
|
||||
}
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.TEXT,
|
||||
text: beschriftung.trim(),
|
||||
geometrie: [[ort.laengengrad, ort.breitengrad]],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Linie: Punkte sammeln, bis jemand abschließt.
|
||||
setLinienpunkte((bisher) => [...bisher, [ort.laengengrad, ort.breitengrad]]);
|
||||
}
|
||||
|
||||
const nachGruppe = React.useMemo(() => {
|
||||
const karten = new Map<Zeichengruppe, typeof ZEICHEN>();
|
||||
for (const zeichen of ZEICHEN) {
|
||||
const liste = karten.get(zeichen.gruppe) ?? [];
|
||||
liste.push(zeichen);
|
||||
karten.set(zeichen.gruppe, liste);
|
||||
}
|
||||
return karten;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="lagekarte">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="lagekartenfehler" />}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{elemente.length === 0
|
||||
? "Noch nichts eingezeichnet."
|
||||
: `${elemente.length} ${elemente.length === 1 ? "Eintrag" : "Einträge"} — alle Geräte sehen dasselbe.`}
|
||||
</p>
|
||||
|
||||
<span
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm ${
|
||||
verbunden
|
||||
? "border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 text-[var(--einsatzbereit)]"
|
||||
: "border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 text-[var(--gesperrt)]"
|
||||
}`}
|
||||
role="status"
|
||||
data-testid="lagekarte-verbindung"
|
||||
data-verbunden={verbunden}
|
||||
>
|
||||
{verbunden ? <Radio className="size-4" aria-hidden /> : <WifiOff className="size-4" aria-hidden />}
|
||||
{verbunden ? "Abgleich läuft" : "Abgleich unterbrochen"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-3">
|
||||
{/* Zeichenauswahl nach Gruppen. */}
|
||||
{[...nachGruppe.entries()].map(([gruppe, zeichenliste]) => (
|
||||
<div key={gruppe} className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{GRUPPE_TEXT[gruppe]}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{zeichenliste.map((zeichen) => {
|
||||
const gewaehlt =
|
||||
werkzeug?.art === "zeichen" && werkzeug.schluessel === zeichen.schluessel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={zeichen.schluessel}
|
||||
type="button"
|
||||
title={zeichen.name}
|
||||
aria-label={zeichen.name}
|
||||
aria-pressed={gewaehlt}
|
||||
data-testid={`zeichen-${zeichen.schluessel}`}
|
||||
onClick={() =>
|
||||
setWerkzeug(
|
||||
gewaehlt ? null : { art: "zeichen", schluessel: zeichen.schluessel },
|
||||
)
|
||||
}
|
||||
className={`rounded-md border p-1 ${
|
||||
gewaehlt
|
||||
? "border-[var(--primary)] bg-[var(--primary)]/10"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: alsSvg(zeichen.schluessel, 32) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2 border-t border-[var(--border)] pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={werkzeug?.art === "linie" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setWerkzeug(werkzeug?.art === "linie" ? null : { art: "linie" });
|
||||
setLinienpunkte([]);
|
||||
}}
|
||||
>
|
||||
Linie ziehen
|
||||
</Button>
|
||||
|
||||
{werkzeug?.art === "linie" && linienpunkte.length >= 2 && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.LINIE,
|
||||
geometrie: linienpunkte,
|
||||
})
|
||||
}
|
||||
>
|
||||
Linie übernehmen ({linienpunkte.length} Punkte)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
eintragen.mutate({
|
||||
einsatzId,
|
||||
art: LageelementArt.FLAECHE,
|
||||
geometrie: linienpunkte,
|
||||
})
|
||||
}
|
||||
>
|
||||
Als Fläche
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Input
|
||||
className="w-52"
|
||||
value={beschriftung}
|
||||
onChange={(ereignis) => setBeschriftung(ereignis.target.value)}
|
||||
placeholder="Beschriftung"
|
||||
aria-label="Beschriftung"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={werkzeug?.art === "text" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setWerkzeug(werkzeug?.art === "text" ? null : { art: "text" })}
|
||||
>
|
||||
Text setzen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{werkzeug && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{werkzeug.art === "linie"
|
||||
? "In die Karte tippen setzt Punkte."
|
||||
: "In die Karte tippen setzt das Gewählte."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Karte
|
||||
stilUrl={stilUrl}
|
||||
mittelpunkt={
|
||||
karte.data?.breitengrad && karte.data?.laengengrad
|
||||
? {
|
||||
breitengrad: Number(karte.data.breitengrad),
|
||||
laengengrad: Number(karte.data.laengengrad),
|
||||
}
|
||||
: mittelpunkt
|
||||
}
|
||||
zoom={karte.data?.zoomstufe ?? zoom}
|
||||
punkte={punkte}
|
||||
linien={linien}
|
||||
beiKartenklick={darfBearbeiten ? kartenklick : undefined}
|
||||
hoehe="60vh"
|
||||
fadenkreuz={Boolean(werkzeug)}
|
||||
/>
|
||||
|
||||
{/* Was eingetragen wurde — mit der Möglichkeit, es zurückzunehmen. */}
|
||||
{elemente.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{elemente.map((element) => (
|
||||
<li key={element.id} className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{new Date(element.erstelltAm).toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{element.zeichen ?? element.art.toLowerCase()}
|
||||
{element.text ? ` — ${element.text}` : ""}
|
||||
</span>
|
||||
{element.erstelltVon && (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{element.erstelltVon.nachname}
|
||||
</span>
|
||||
)}
|
||||
{darfBearbeiten && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => zuruecknehmen.mutate({ id: element.id })}
|
||||
>
|
||||
<Undo2 aria-hidden />
|
||||
zurücknehmen
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { env } from "@/lib/env";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EinsatzDetail } from "@/app/(app)/einsaetze/[id]/einsatz-detail";
|
||||
import { Lagekarte } from "@/app/(app)/einsaetze/[id]/lagekarte";
|
||||
|
||||
export const metadata = { title: "Einsatz" };
|
||||
|
||||
@@ -21,6 +25,32 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Mittelpunkt der Lagekarte: die Einsatzstelle, sonst das Gerätehaus, sonst
|
||||
// die Vorgabe aus der Konfiguration.
|
||||
const orte = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const [einsatz, standort] = await Promise.all([
|
||||
db.einsatz.findUnique({
|
||||
where: { id },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
}),
|
||||
db.standort.findFirst({
|
||||
where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
}),
|
||||
]);
|
||||
return { einsatz, standort };
|
||||
});
|
||||
|
||||
const mittelpunkt = {
|
||||
breitengrad: Number(
|
||||
orte.einsatz?.breitengrad ?? orte.standort?.breitengrad ?? env.KARTE_BREITENGRAD,
|
||||
),
|
||||
laengengrad: Number(
|
||||
orte.einsatz?.laengengrad ?? orte.standort?.laengengrad ?? env.KARTE_LAENGENGRAD,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -37,6 +67,30 @@ export default async function EinsatzSeite({ params }: { params: Promise<{ id: s
|
||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||
darfFreigeben={hatRecht(sitzung, "einsatz.bericht")}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Die Lagekarte (Phase 6). Sie steht unter dem Einsatz und nicht in einem
|
||||
eigenen Reiter: wer sie braucht, braucht sie zusammen mit den Zeiten und
|
||||
den Lagemeldungen, nicht statt ihnen.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lagekarte</CardTitle>
|
||||
<CardDescription>
|
||||
Taktische Zeichen nach FwDV 102. Was einer einträgt, sehen alle — auch auf einem
|
||||
zweiten Gerät.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Lagekarte
|
||||
einsatzId={id}
|
||||
stilUrl={env.KARTE_STIL_URL ?? null}
|
||||
mittelpunkt={mittelpunkt}
|
||||
zoom={env.KARTE_ZOOM + 3}
|
||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Droplets, Plus, Trash2, Upload } from "lucide-react";
|
||||
import { HydrantStatus, HydrantTyp } from "@prisma/client";
|
||||
import Papa from "papaparse";
|
||||
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 {
|
||||
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 { Karte } from "@/components/karte/karte";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Hydranten pflegen.
|
||||
*
|
||||
* Zwei Wege hinein: einzeln über die Karte (antippen, Angaben eintragen) und
|
||||
* als Liste über den Import. Der Import ist der übliche Fall — die Wehr
|
||||
* bekommt eine Datei von den Stadtwerken oder vom Landratsamt und will sie
|
||||
* nicht abtippen.
|
||||
*
|
||||
* Wie beim Geräteimport aus Phase 1 in zwei Schritten: erst zeigen, was
|
||||
* herauskäme, dann übernehmen. Niemand schreibt tausend Datensätze in eine
|
||||
* Datenbank, ohne vorher gesehen zu haben, was drinsteht.
|
||||
*/
|
||||
|
||||
const TYP_TEXT: Record<HydrantTyp, string> = {
|
||||
UNTERFLUR: "Unterflur",
|
||||
UEBERFLUR: "Überflur",
|
||||
SAUGSTELLE: "Saugstelle",
|
||||
ZISTERNE: "Zisterne",
|
||||
OFFENES_GEWAESSER: "offenes Gewässer",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
const STATUS_TEXT: Record<HydrantStatus, string> = {
|
||||
EINSATZBEREIT: "einsatzbereit",
|
||||
EINGESCHRAENKT: "eingeschränkt",
|
||||
DEFEKT: "defekt",
|
||||
UNBEKANNT: "ungeprüft",
|
||||
};
|
||||
|
||||
export function HydrantenPflege({
|
||||
stilUrl,
|
||||
mittelpunkt,
|
||||
zoom,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
stilUrl: string | null;
|
||||
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||
zoom: number;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [erfolg, setErfolg] = React.useState<string | null>(null);
|
||||
const [neuerOrt, setNeuerOrt] = React.useState<{
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
} | null>(null);
|
||||
|
||||
const [vorschau, setVorschau] = React.useState<{
|
||||
format: "geojson" | "zeilen";
|
||||
inhalt?: string;
|
||||
zeilen?: Array<Record<string, unknown>>;
|
||||
gefunden: number;
|
||||
uebersprungen: Array<{ zeile: number; grund: string }>;
|
||||
hinweise: string[];
|
||||
beispiele: Array<{ bezeichnung: string | null; typ: string; strasse: string | null }>;
|
||||
} | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const hydranten = trpc.hydrant.liste.useQuery({});
|
||||
|
||||
const anlegen = trpc.hydrant.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setNeuerOrt(null);
|
||||
setErfolg("Hydrant angelegt.");
|
||||
void werkzeuge.hydrant.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const entfernen = trpc.hydrant.entfernen.useMutation({
|
||||
onSuccess: () => void werkzeuge.hydrant.invalidate(),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const importieren = trpc.hydrant.importieren.useMutation({
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
/** Datei einlesen und beim Server prüfen lassen — noch nichts schreiben. */
|
||||
async function dateiWaehlen(datei: File) {
|
||||
setMeldung(null);
|
||||
setErfolg(null);
|
||||
|
||||
const text = await datei.text();
|
||||
const istGeoJson = datei.name.toLowerCase().endsWith(".geojson") || text.trimStart().startsWith("{");
|
||||
|
||||
if (istGeoJson) {
|
||||
const ergebnis = await importieren.mutateAsync({
|
||||
format: "geojson",
|
||||
inhalt: text,
|
||||
nurPruefen: true,
|
||||
});
|
||||
|
||||
setVorschau({
|
||||
format: "geojson",
|
||||
inhalt: text,
|
||||
gefunden: ergebnis.gefunden,
|
||||
uebersprungen: ergebnis.uebersprungen,
|
||||
hinweise: ergebnis.hinweise,
|
||||
beispiele: ergebnis.vorschau.map((eintrag) => ({
|
||||
bezeichnung: eintrag.bezeichnung,
|
||||
typ: eintrag.typ,
|
||||
strasse: eintrag.strasse,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// CSV: mit demselben Werkzeug zerlegen wie der Geräteimport aus Phase 1.
|
||||
const zerlegt = Papa.parse<Record<string, unknown>>(text, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
delimiter: "",
|
||||
});
|
||||
|
||||
const ergebnis = await importieren.mutateAsync({
|
||||
format: "zeilen",
|
||||
zeilen: zerlegt.data,
|
||||
nurPruefen: true,
|
||||
});
|
||||
|
||||
setVorschau({
|
||||
format: "zeilen",
|
||||
zeilen: zerlegt.data,
|
||||
gefunden: ergebnis.gefunden,
|
||||
uebersprungen: ergebnis.uebersprungen,
|
||||
hinweise: ergebnis.hinweise,
|
||||
beispiele: ergebnis.vorschau.map((eintrag) => ({
|
||||
bezeichnung: eintrag.bezeichnung,
|
||||
typ: eintrag.typ,
|
||||
strasse: eintrag.strasse,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function uebernehmen() {
|
||||
if (!vorschau) return;
|
||||
|
||||
const ergebnis = await importieren.mutateAsync({
|
||||
format: vorschau.format,
|
||||
inhalt: vorschau.inhalt,
|
||||
zeilen: vorschau.zeilen,
|
||||
nurPruefen: false,
|
||||
});
|
||||
|
||||
setVorschau(null);
|
||||
setErfolg(
|
||||
`${ergebnis.uebernommen} neu angelegt, ${ergebnis.aktualisiert} aktualisiert, ${ergebnis.uebersprungen.length} übersprungen.`,
|
||||
);
|
||||
void werkzeuge.hydrant.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="hydrantfehler" />}
|
||||
{erfolg && (
|
||||
<p
|
||||
className="rounded-md border border-[var(--einsatzbereit)]/40 bg-[var(--einsatzbereit)]/10 px-4 py-3 text-sm"
|
||||
role="status"
|
||||
data-testid="hydrant-erfolg"
|
||||
>
|
||||
{erfolg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Import</CardTitle>
|
||||
<CardDescription>
|
||||
GeoJSON oder eine Tabelle der Stadtwerke. Spaltennamen werden erkannt, deutsche
|
||||
Kommazahlen und Datumsangaben ebenso. Hydranten mit einer Nummer des Versorgers
|
||||
werden beim zweiten Import aktualisiert statt doppelt angelegt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="importdatei">Datei</Label>
|
||||
<Input
|
||||
id="importdatei"
|
||||
type="file"
|
||||
accept=".geojson,.json,.csv,.txt"
|
||||
onChange={(ereignis) => {
|
||||
const datei = ereignis.target.files?.[0];
|
||||
if (datei) void dateiWaehlen(datei);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{vorschau && (
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||
<p className="font-medium">
|
||||
{vorschau.gefunden} {vorschau.gefunden === 1 ? "Hydrant" : "Hydranten"} gefunden
|
||||
{vorschau.uebersprungen.length > 0
|
||||
? `, ${vorschau.uebersprungen.length} übersprungen`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{vorschau.hinweise.map((hinweis) => (
|
||||
<p key={hinweis} className="text-sm text-[var(--pruefung-faellig)]">
|
||||
{hinweis}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{vorschau.beispiele.length > 0 && (
|
||||
<ul className="text-sm text-[var(--muted-foreground)]">
|
||||
{vorschau.beispiele.slice(0, 5).map((beispiel, index) => (
|
||||
<li key={index}>
|
||||
{beispiel.bezeichnung ?? "ohne Bezeichnung"} ·{" "}
|
||||
{TYP_TEXT[beispiel.typ as HydrantTyp]} · {beispiel.strasse ?? "ohne Straße"}
|
||||
</li>
|
||||
))}
|
||||
{vorschau.gefunden > 5 && <li>… und {vorschau.gefunden - 5} weitere</li>}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{vorschau.uebersprungen.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Übersprungene Zeilen ansehen
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1 text-[var(--muted-foreground)]">
|
||||
{vorschau.uebersprungen.slice(0, 20).map((eintrag) => (
|
||||
<li key={eintrag.zeile}>
|
||||
Zeile {eintrag.zeile}: {eintrag.grund}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => void uebernehmen()}
|
||||
disabled={vorschau.gefunden === 0 || importieren.isPending}
|
||||
>
|
||||
<Upload aria-hidden />
|
||||
{vorschau.gefunden} übernehmen
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setVorschau(null)}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Einzeln erfassen</CardTitle>
|
||||
<CardDescription>
|
||||
In die Karte tippen setzt den Ort — danach die Angaben eintragen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Karte
|
||||
stilUrl={stilUrl}
|
||||
mittelpunkt={neuerOrt ?? mittelpunkt}
|
||||
zoom={zoom}
|
||||
punkte={
|
||||
neuerOrt
|
||||
? [{ id: "neu", ...neuerOrt, farbe: "#1d4ed8", titel: "Neuer Hydrant" }]
|
||||
: (hydranten.data ?? []).map((hydrant) => ({
|
||||
id: hydrant.id,
|
||||
breitengrad: Number(hydrant.breitengrad),
|
||||
laengengrad: Number(hydrant.laengengrad),
|
||||
titel: hydrant.bezeichnung ?? TYP_TEXT[hydrant.typ],
|
||||
}))
|
||||
}
|
||||
beiKartenklick={setNeuerOrt}
|
||||
hoehe="40vh"
|
||||
/>
|
||||
|
||||
{neuerOrt && (
|
||||
<form
|
||||
className="grid gap-3 sm:grid-cols-4"
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
const formular = new FormData(ereignis.currentTarget);
|
||||
|
||||
anlegen.mutate({
|
||||
...neuerOrt,
|
||||
bezeichnung: String(formular.get("bezeichnung") ?? "").trim() || null,
|
||||
typ: (formular.get("typ") as HydrantTyp) ?? HydrantTyp.UNTERFLUR,
|
||||
status: (formular.get("status") as HydrantStatus) ?? HydrantStatus.UNBEKANNT,
|
||||
nennweite: Number(formular.get("nennweite")) || null,
|
||||
strasse: String(formular.get("strasse") ?? "").trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="h-bezeichnung">Bezeichnung</Label>
|
||||
<Input id="h-bezeichnung" name="bezeichnung" placeholder="H-0042" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="h-typ">Art</Label>
|
||||
<select
|
||||
id="h-typ"
|
||||
name="typ"
|
||||
className="h-10 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 text-sm"
|
||||
defaultValue={HydrantTyp.UNTERFLUR}
|
||||
>
|
||||
{(Object.keys(TYP_TEXT) as HydrantTyp[]).map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{TYP_TEXT[wert]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="h-status">Zustand</Label>
|
||||
<select
|
||||
id="h-status"
|
||||
name="status"
|
||||
className="h-10 w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 text-sm"
|
||||
defaultValue={HydrantStatus.UNBEKANNT}
|
||||
>
|
||||
{(Object.keys(STATUS_TEXT) as HydrantStatus[]).map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{STATUS_TEXT[wert]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="h-nennweite">Nennweite</Label>
|
||||
<Input id="h-nennweite" name="nennweite" type="number" placeholder="100" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:col-span-3">
|
||||
<Label htmlFor="h-strasse">Straße</Label>
|
||||
<Input id="h-strasse" name="strasse" placeholder="Hauptstraße" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Button type="submit" disabled={anlegen.isPending}>
|
||||
<Plus aria-hidden />
|
||||
Anlegen
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setNeuerOrt(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bestand</CardTitle>
|
||||
<CardDescription>
|
||||
{hydranten.data?.length ?? 0} Löschwasserstellen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(hydranten.data?.length ?? 0) === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Hydrant erfasst"
|
||||
hinweis="Am schnellsten geht es über den Import — die Stadtwerke haben die Liste."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bezeichnung</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Straße</TableHead>
|
||||
<TableHead className="text-right">DN</TableHead>
|
||||
<TableHead className="text-right">l/min</TableHead>
|
||||
<TableHead>Zustand</TableHead>
|
||||
<TableHead>geprüft</TableHead>
|
||||
{darfBearbeiten && <TableHead className="w-16" aria-label="Aktion" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hydranten.data?.slice(0, 200).map((hydrant) => (
|
||||
<TableRow key={hydrant.id} data-testid={`hydrant-${hydrant.id}`}>
|
||||
<TableCell>
|
||||
<Droplets className="mr-1 inline size-4 text-[var(--muted-foreground)]" aria-hidden />
|
||||
{hydrant.bezeichnung ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{TYP_TEXT[hydrant.typ]}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{hydrant.strasse} {hydrant.hausnummer}
|
||||
</TableCell>
|
||||
<TableCell className="tabellenzahlen text-right">
|
||||
{hydrant.nennweite ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="tabellenzahlen text-right">
|
||||
{hydrant.durchfluss ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{STATUS_TEXT[hydrant.status]}</TableCell>
|
||||
<TableCell className="tabellenzahlen text-sm">
|
||||
{hydrant.letztePruefung ? alsDatum(hydrant.letztePruefung) : "nie"}
|
||||
</TableCell>
|
||||
{darfBearbeiten && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${hydrant.bezeichnung ?? "Hydrant"} entfernen`}
|
||||
onClick={() => {
|
||||
if (window.confirm("Diesen Hydranten entfernen?")) {
|
||||
entfernen.mutate({ id: hydrant.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{(hydranten.data?.length ?? 0) > 200 && (
|
||||
<p className="mt-3 text-sm text-[var(--muted-foreground)]">
|
||||
Die ersten 200 von {hydranten.data?.length}. Die Karte zeigt alle.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 { env } from "@/lib/env";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HydrantenPflege } from "@/app/(app)/karte/hydranten/hydranten-pflege";
|
||||
|
||||
export const metadata = { title: "Hydranten" };
|
||||
|
||||
/** Löschwasserstellen pflegen und importieren. */
|
||||
export default async function HydrantenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!hatRecht(sitzung, "geraet.lesen")) redirect("/");
|
||||
|
||||
const standort = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.standort.findFirst({
|
||||
where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/karte">
|
||||
<ArrowLeft aria-hidden />
|
||||
Karte
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Hydranten</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Löschwasserstellen erfassen, importieren und den Zustand pflegen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HydrantenPflege
|
||||
stilUrl={env.KARTE_STIL_URL ?? null}
|
||||
mittelpunkt={{
|
||||
breitengrad: standort?.breitengrad ? Number(standort.breitengrad) : env.KARTE_BREITENGRAD,
|
||||
laengengrad: standort?.laengengrad ? Number(standort.laengengrad) : env.KARTE_LAENGENGRAD,
|
||||
}}
|
||||
zoom={env.KARTE_ZOOM}
|
||||
darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Droplets, MapPin, Plus, Search } from "lucide-react";
|
||||
import { HydrantStatus, HydrantTyp } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { Karte, type KartenPunkt } from "@/components/karte/karte";
|
||||
import { alsSvg } from "@/lib/karte/taktische-zeichen";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Die Karte des Ausrückegebiets.
|
||||
*
|
||||
* Zwei Ebenen: Hydranten und Objekte. Beide sind ein- und ausschaltbar, denn
|
||||
* wer nach Löschwasser sucht, will keine Feuerwehrpläne sehen und umgekehrt.
|
||||
*
|
||||
* Die Umkreissuche ist der eigentliche Zweck: **was liegt in der Nähe?** Sie
|
||||
* geht von einem angetippten Punkt aus — im Einsatz von der Einsatzstelle, am
|
||||
* Schreibtisch von dort, wo man gerade hinschaut.
|
||||
*/
|
||||
|
||||
const TYP_TEXT: Record<HydrantTyp, string> = {
|
||||
UNTERFLUR: "Unterflur",
|
||||
UEBERFLUR: "Überflur",
|
||||
SAUGSTELLE: "Saugstelle",
|
||||
ZISTERNE: "Zisterne",
|
||||
OFFENES_GEWAESSER: "offenes Gewässer",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
const STATUS_TEXT: Record<HydrantStatus, string> = {
|
||||
EINSATZBEREIT: "einsatzbereit",
|
||||
EINGESCHRAENKT: "eingeschränkt",
|
||||
DEFEKT: "defekt",
|
||||
UNBEKANNT: "ungeprüft",
|
||||
};
|
||||
|
||||
const STATUS_FARBE: Record<HydrantStatus, string> = {
|
||||
EINSATZBEREIT: "#15803d",
|
||||
EINGESCHRAENKT: "#ca8a04",
|
||||
DEFEKT: "#b91c1c",
|
||||
UNBEKANNT: "#6b7280",
|
||||
};
|
||||
|
||||
export function KartenAnsicht({
|
||||
stilUrl,
|
||||
mittelpunkt,
|
||||
zoom,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
stilUrl: string | null;
|
||||
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||
zoom: number;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [suche, setSuche] = React.useState("");
|
||||
const [typ, setTyp] = React.useState<HydrantTyp | "alle">("alle");
|
||||
const [status, setStatus] = React.useState<HydrantStatus | "alle">("alle");
|
||||
const [zeigeObjekte, setZeigeObjekte] = React.useState(true);
|
||||
const [zeigeHydranten, setZeigeHydranten] = React.useState(true);
|
||||
const [gewaehlt, setGewaehlt] = React.useState<{ art: "hydrant" | "objekt"; id: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [umkreisPunkt, setUmkreisPunkt] = React.useState<{
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
} | null>(null);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const hydranten = trpc.hydrant.liste.useQuery({
|
||||
suche: suche || undefined,
|
||||
typ: typ === "alle" ? undefined : typ,
|
||||
status: status === "alle" ? undefined : status,
|
||||
});
|
||||
const objekte = trpc.objekt.liste.useQuery({ suche: suche || undefined });
|
||||
const kennzahlen = trpc.hydrant.kennzahlen.useQuery();
|
||||
|
||||
const umkreis = trpc.hydrant.umkreis.useQuery(
|
||||
umkreisPunkt ? { ...umkreisPunkt, umkreisMeter: 500 } : { breitengrad: 0, laengengrad: 0 },
|
||||
{ enabled: umkreisPunkt !== null },
|
||||
);
|
||||
|
||||
const hydrantSvg = React.useMemo(() => alsSvg("hydrant", 26), []);
|
||||
|
||||
const punkte = React.useMemo<KartenPunkt[]>(() => {
|
||||
const liste: KartenPunkt[] = [];
|
||||
|
||||
if (zeigeHydranten) {
|
||||
for (const hydrant of hydranten.data ?? []) {
|
||||
liste.push({
|
||||
id: `h-${hydrant.id}`,
|
||||
breitengrad: Number(hydrant.breitengrad),
|
||||
laengengrad: Number(hydrant.laengengrad),
|
||||
svg: hydrantSvg.replace("color:#1d4ed8", `color:${STATUS_FARBE[hydrant.status]}`),
|
||||
titel: `${hydrant.bezeichnung ?? TYP_TEXT[hydrant.typ]} — ${STATUS_TEXT[hydrant.status]}`,
|
||||
beiKlick: () => setGewaehlt({ art: "hydrant", id: hydrant.id }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (zeigeObjekte) {
|
||||
for (const objekt of objekte.data ?? []) {
|
||||
if (objekt.breitengrad === null || objekt.laengengrad === null) continue;
|
||||
liste.push({
|
||||
id: `o-${objekt.id}`,
|
||||
breitengrad: Number(objekt.breitengrad),
|
||||
laengengrad: Number(objekt.laengengrad),
|
||||
farbe: "#1d4ed8",
|
||||
titel: objekt.name,
|
||||
beiKlick: () => setGewaehlt({ art: "objekt", id: objekt.id }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return liste;
|
||||
}, [hydranten.data, objekte.data, zeigeHydranten, zeigeObjekte, hydrantSvg]);
|
||||
|
||||
const gewaehlterHydrant =
|
||||
gewaehlt?.art === "hydrant"
|
||||
? hydranten.data?.find((eintrag) => eintrag.id === gewaehlt.id)
|
||||
: undefined;
|
||||
const gewaehltesObjekt =
|
||||
gewaehlt?.art === "objekt"
|
||||
? objekte.data?.find((eintrag) => eintrag.id === gewaehlt.id)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="kartenansicht">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="kartenfehler" />}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-52 flex-1 space-y-1">
|
||||
<Label htmlFor="kartensuche">Suchen</Label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
id="kartensuche"
|
||||
className="pl-9"
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Straße, Nummer, Objektname, Gefahr …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="kartentyp">Art</Label>
|
||||
<Select value={typ} onValueChange={(wert) => setTyp(wert as HydrantTyp | "alle")}>
|
||||
<SelectTrigger id="kartentyp" className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Arten</SelectItem>
|
||||
{(Object.keys(TYP_TEXT) as HydrantTyp[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{TYP_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="kartenstatus">Zustand</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(wert) => setStatus(wert as HydrantStatus | "alle")}
|
||||
>
|
||||
<SelectTrigger id="kartenstatus" className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Zustände</SelectItem>
|
||||
{(Object.keys(STATUS_TEXT) as HydrantStatus[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{STATUS_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={zeigeHydranten ? "default" : "outline"}
|
||||
onClick={() => setZeigeHydranten((wert) => !wert)}
|
||||
>
|
||||
<Droplets aria-hidden />
|
||||
Hydranten ({hydranten.data?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={zeigeObjekte ? "default" : "outline"}
|
||||
onClick={() => setZeigeObjekte((wert) => !wert)}
|
||||
>
|
||||
<MapPin aria-hidden />
|
||||
Objekte ({objekte.data?.length ?? 0})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/karte/hydranten">
|
||||
<Plus aria-hidden />
|
||||
Hydranten pflegen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<Karte
|
||||
stilUrl={stilUrl}
|
||||
mittelpunkt={mittelpunkt}
|
||||
zoom={zoom}
|
||||
punkte={punkte}
|
||||
beiKartenklick={(ort) => {
|
||||
setUmkreisPunkt(ort);
|
||||
setGewaehlt(null);
|
||||
}}
|
||||
hoehe="68vh"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Was gerade angetippt wurde. */}
|
||||
{gewaehlterHydrant && (
|
||||
<Card data-testid="hydrant-details">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{gewaehlterHydrant.bezeichnung ?? TYP_TEXT[gewaehlterHydrant.typ]}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{TYP_TEXT[gewaehlterHydrant.typ]} · {STATUS_TEXT[gewaehlterHydrant.status]}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
{gewaehlterHydrant.strasse && (
|
||||
<p>
|
||||
{gewaehlterHydrant.strasse} {gewaehlterHydrant.hausnummer ?? ""}
|
||||
</p>
|
||||
)}
|
||||
<p className="tabellenzahlen">
|
||||
{gewaehlterHydrant.nennweite ? `DN ${gewaehlterHydrant.nennweite}` : "Nennweite unbekannt"}
|
||||
{gewaehlterHydrant.durchfluss
|
||||
? ` · ${gewaehlterHydrant.durchfluss} l/min`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
{gewaehlterHydrant.letztePruefung
|
||||
? `zuletzt geprüft ${alsDatum(gewaehlterHydrant.letztePruefung)}`
|
||||
: "noch nie geprüft"}
|
||||
</p>
|
||||
{gewaehlterHydrant.hinweis && <p>{gewaehlterHydrant.hinweis}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{gewaehltesObjekt && (
|
||||
<Card data-testid="objekt-details">
|
||||
<CardHeader>
|
||||
<CardTitle>{gewaehltesObjekt.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{gewaehltesObjekt.strasse} {gewaehltesObjekt.hausnummer}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{gewaehltesObjekt.gefahren && (
|
||||
<p>
|
||||
<strong>Gefahren:</strong> {gewaehltesObjekt.gefahren}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/karte/objekte/${gewaehltesObjekt.id}`}>Feuerwehrplan öffnen</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Umkreissuche */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>In der Nähe</CardTitle>
|
||||
<CardDescription>
|
||||
{umkreisPunkt
|
||||
? "Löschwasser im Umkreis von 500 Metern, nach Entfernung."
|
||||
: "In die Karte tippen — dann steht hier, was in 500 Metern erreichbar ist."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{umkreisPunkt && (umkreis.data?.length ?? 0) === 0 && !umkreis.isPending && (
|
||||
<p className="text-sm text-[var(--gesperrt)]">
|
||||
Kein Löschwasser im Umkreis von 500 Metern. Das ist eine Angabe, die man vor
|
||||
dem Einsatz wissen will, nicht währenddessen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2 text-sm">
|
||||
{umkreis.data?.map((treffer) => (
|
||||
<li key={treffer.id} className="flex items-start justify-between gap-3">
|
||||
<span>
|
||||
<strong>{treffer.bezeichnung ?? TYP_TEXT[treffer.typ]}</strong>
|
||||
<span className="block text-[var(--muted-foreground)]">
|
||||
{treffer.strasse ?? "ohne Straße"}
|
||||
{treffer.nennweite ? ` · DN ${treffer.nennweite}` : ""}
|
||||
{treffer.durchfluss ? ` · ${treffer.durchfluss} l/min` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="tabellenzahlen whitespace-nowrap"
|
||||
style={{ color: STATUS_FARBE[treffer.status] }}
|
||||
>
|
||||
{treffer.entfernung} m
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bestand</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">gesamt</p>
|
||||
<p className="tabellenzahlen text-2xl font-semibold">
|
||||
{kennzahlen.data?.gesamt ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">defekt</p>
|
||||
<p className="tabellenzahlen text-2xl font-semibold text-[var(--gesperrt)]">
|
||||
{kennzahlen.data?.defekt ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">ungeprüft</p>
|
||||
<p className="tabellenzahlen text-2xl font-semibold text-[var(--pruefung-faellig)]">
|
||||
{kennzahlen.data?.ungeprueft ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
DoorOpen,
|
||||
Droplets,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Phone,
|
||||
Plus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { PlanArt } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { KATEGORIE_TEXT } from "@/app/(app)/karte/objekte/objekt-liste";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Ein Objekt mit seinem Feuerwehrplan.
|
||||
*
|
||||
* Die Reihenfolge der Blöcke ist nicht willkürlich: **Zufahrt, Gefahren,
|
||||
* Schlüsseldepot** stehen oben, weil sie auf der Anfahrt gelesen werden.
|
||||
* Ansprechpartner und Pläne folgen. Stammdaten wie die Personenzahl stehen
|
||||
* unten — die interessieren beim Erfassen, nicht im Einsatz.
|
||||
*/
|
||||
|
||||
const PLANART_TEXT: Record<PlanArt, string> = {
|
||||
UEBERSICHTSPLAN: "Übersichtsplan",
|
||||
GESCHOSSPLAN: "Geschossplan",
|
||||
UMGEBUNGSPLAN: "Umgebungsplan",
|
||||
SONDERPLAN: "Sonderplan",
|
||||
ANLAGE: "Anlage",
|
||||
};
|
||||
|
||||
export function ObjektDetail({ id, darfBearbeiten }: { id: string; darfBearbeiten: boolean }) {
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [bearbeiten, setBearbeiten] = React.useState(false);
|
||||
const [kontaktOffen, setKontaktOffen] = React.useState(false);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const objekt = trpc.objekt.einzeln.useQuery({ id });
|
||||
|
||||
const speichern = trpc.objekt.bearbeiten.useMutation({
|
||||
onSuccess: () => {
|
||||
setBearbeiten(false);
|
||||
void werkzeuge.objekt.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const kontaktAnlegen = trpc.objekt.kontaktAnlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setKontaktOffen(false);
|
||||
void werkzeuge.objekt.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const kontaktEntfernen = trpc.objekt.kontaktEntfernen.useMutation({
|
||||
onSuccess: () => void werkzeuge.objekt.invalidate(),
|
||||
});
|
||||
|
||||
// Formularzustand — erst wenn geladen ist.
|
||||
const [felder, setFelder] = React.useState<Record<string, string>>({});
|
||||
React.useEffect(() => {
|
||||
if (!objekt.data) return;
|
||||
setFelder({
|
||||
zufahrt: objekt.data.zufahrt ?? "",
|
||||
gefahren: objekt.data.gefahren ?? "",
|
||||
schluesseldepot: objekt.data.schluesseldepot ?? "",
|
||||
loeschwasser: objekt.data.loeschwasser ?? "",
|
||||
bemerkung: objekt.data.bemerkung ?? "",
|
||||
});
|
||||
}, [objekt.data]);
|
||||
|
||||
if (objekt.isPending) {
|
||||
return <p className="text-[var(--muted-foreground)]">Objekt wird geladen …</p>;
|
||||
}
|
||||
|
||||
if (objekt.isError || !objekt.data) {
|
||||
return (
|
||||
<Fehlermeldung
|
||||
meldung={objekt.error?.message ?? "Dieses Objekt gibt es nicht."}
|
||||
kennung="objektfehler"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const daten = objekt.data;
|
||||
|
||||
const block = (
|
||||
titel: string,
|
||||
schluessel: string,
|
||||
symbol: React.ReactNode,
|
||||
platzhalter: string,
|
||||
) => (
|
||||
<div className="rounded-lg border border-[var(--border)] p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{symbol}
|
||||
{titel}
|
||||
</p>
|
||||
|
||||
{bearbeiten ? (
|
||||
<Textarea
|
||||
className="mt-2"
|
||||
rows={3}
|
||||
aria-label={titel}
|
||||
value={felder[schluessel] ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
setFelder((bisher) => ({ ...bisher, [schluessel]: ereignis.target.value }))
|
||||
}
|
||||
placeholder={platzhalter}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-1 whitespace-pre-line text-sm">
|
||||
{felder[schluessel] || (
|
||||
<span className="text-[var(--muted-foreground)]">nichts hinterlegt</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="objekt-detail">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="objektfehler" />}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/karte/objekte">
|
||||
<ArrowLeft aria-hidden />
|
||||
Objekte
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{daten.name}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{KATEGORIE_TEXT[daten.kategorie]} · {daten.strasse} {daten.hausnummer}
|
||||
{daten.ort ? `, ${daten.plz ?? ""} ${daten.ort}` : ""}
|
||||
{daten.personenzahl !== null ? ` · ${daten.personenzahl} Personen` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<div className="flex gap-2">
|
||||
{bearbeiten ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() =>
|
||||
speichern.mutate({
|
||||
id,
|
||||
zufahrt: felder.zufahrt || null,
|
||||
gefahren: felder.gefahren || null,
|
||||
schluesseldepot: felder.schluesseldepot || null,
|
||||
loeschwasser: felder.loeschwasser || null,
|
||||
bemerkung: felder.bemerkung || null,
|
||||
})
|
||||
}
|
||||
disabled={speichern.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setBearbeiten(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setBearbeiten(true)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Was auf der Anfahrt zählt. */}
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{block(
|
||||
"Zufahrt",
|
||||
"zufahrt",
|
||||
<DoorOpen className="size-4" aria-hidden />,
|
||||
"Tordurchfahrt 3,20 m, Aufstellfläche im Hof, Schranke mit Feuerwehrschlüssel",
|
||||
)}
|
||||
{block(
|
||||
"Gefahren",
|
||||
"gefahren",
|
||||
<AlertTriangle className="size-4 text-[var(--pruefung-faellig)]" aria-hidden />,
|
||||
"Photovoltaik auf dem Dach, Gasflaschen im Nebengebäude",
|
||||
)}
|
||||
{block(
|
||||
"Schlüsseldepot",
|
||||
"schluesseldepot",
|
||||
<KeyRound className="size-4" aria-hidden />,
|
||||
"FSD links neben dem Haupteingang, Schlüssel 3",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{block(
|
||||
"Löschwasser am Objekt",
|
||||
"loeschwasser",
|
||||
<Droplets className="size-4" aria-hidden />,
|
||||
"Wandhydranten in jedem Geschoss, Sprinklerzentrale im Keller",
|
||||
)}
|
||||
{block(
|
||||
"Bemerkung",
|
||||
"bemerkung",
|
||||
<FileText className="size-4" aria-hidden />,
|
||||
"Alles, was sonst noch wichtig ist",
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ansprechpartner. */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>Ansprechpartner</CardTitle>
|
||||
<CardDescription>
|
||||
Wer nachts ans Telefon geht, steht mit „jederzeit" markiert.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{darfBearbeiten && (
|
||||
<Button variant="outline" size="sm" onClick={() => setKontaktOffen((wert) => !wert)}>
|
||||
<Plus aria-hidden />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{kontaktOffen && (
|
||||
<form
|
||||
className="grid gap-3 rounded-lg border border-[var(--border)] p-3 sm:grid-cols-4"
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
const formular = new FormData(ereignis.currentTarget);
|
||||
const name = String(formular.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
|
||||
kontaktAnlegen.mutate({
|
||||
objektId: id,
|
||||
name,
|
||||
funktion: String(formular.get("funktion") ?? "").trim() || null,
|
||||
telefon: String(formular.get("telefon") ?? "").trim() || null,
|
||||
mobil: String(formular.get("mobil") ?? "").trim() || null,
|
||||
jederzeit: formular.get("jederzeit") === "on",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="k-name">Name</Label>
|
||||
<Input id="k-name" name="name" required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="k-funktion">Funktion</Label>
|
||||
<Input id="k-funktion" name="funktion" placeholder="Hausmeister" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="k-telefon">Telefon</Label>
|
||||
<Input id="k-telefon" name="telefon" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="k-mobil">Mobil</Label>
|
||||
<Input id="k-mobil" name="mobil" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input type="checkbox" name="jederzeit" />
|
||||
rund um die Uhr erreichbar
|
||||
</label>
|
||||
<div className="sm:col-span-2">
|
||||
<Button type="submit" size="sm" disabled={kontaktAnlegen.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{daten.kontakte.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Kein Ansprechpartner hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{daten.kontakte.map((kontakt) => (
|
||||
<li key={kontakt.id} className="flex flex-wrap items-center gap-3">
|
||||
<Phone className="size-4 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<strong>{kontakt.name}</strong>
|
||||
{kontakt.funktion && (
|
||||
<span className="text-[var(--muted-foreground)]">{kontakt.funktion}</span>
|
||||
)}
|
||||
{kontakt.telefon && (
|
||||
<a href={`tel:${kontakt.telefon}`} className="tabellenzahlen underline">
|
||||
{kontakt.telefon}
|
||||
</a>
|
||||
)}
|
||||
{kontakt.mobil && (
|
||||
<a href={`tel:${kontakt.mobil}`} className="tabellenzahlen underline">
|
||||
{kontakt.mobil}
|
||||
</a>
|
||||
)}
|
||||
{kontakt.jederzeit && (
|
||||
<span className="rounded-full bg-[var(--einsatzbereit)]/15 px-2 py-0.5 text-xs text-[var(--einsatzbereit)]">
|
||||
jederzeit
|
||||
</span>
|
||||
)}
|
||||
{darfBearbeiten && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => kontaktEntfernen.mutate({ id: kontakt.id })}
|
||||
>
|
||||
entfernen
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feuerwehrpläne. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Feuerwehrplan nach DIN 14095</CardTitle>
|
||||
<CardDescription>
|
||||
Übersichts-, Geschoss- und Umgebungsplan als PDF. Der Stand gehört dazu — ein Plan
|
||||
von 2009 ist eine Vermutung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{daten.plaene.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Noch kein Plan hinterlegt. Pläne werden über den Dateiupload hochgeladen und hier
|
||||
verknüpft.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{daten.plaene.map((plan) => (
|
||||
<li key={plan.id} className="flex flex-wrap items-center gap-3">
|
||||
<FileText className="size-4 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<a
|
||||
href={`/api/dateien/${plan.dateiId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
{plan.bezeichnung}
|
||||
</a>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{PLANART_TEXT[plan.art]}
|
||||
{plan.standAm ? ` · Stand ${alsDatum(plan.standAm)}` : " · ohne Stand"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
<Users className="mr-1 inline size-3" aria-hidden />
|
||||
Was hier steht, wird auf der Anfahrt gelesen. Kurze Sätze sind besser als vollständige.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { ObjektDetail } from "@/app/(app)/karte/objekte/[id]/objekt-detail";
|
||||
|
||||
export const metadata = { title: "Objekt" };
|
||||
|
||||
/** Ein Objekt mit Feuerwehrplan. */
|
||||
export default async function ObjektSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!hatRecht(sitzung, "geraet.lesen")) redirect("/");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
return <ObjektDetail id={id} darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")} />;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, KeyRound, Plus, Search, Users } from "lucide-react";
|
||||
import { ObjektKategorie } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Objekte mit Feuerwehrplan.
|
||||
*
|
||||
* Die Liste ist eine Suchliste, keine Tabelle: gesucht wird nach dem Namen,
|
||||
* nach der Straße, nach einem Gefahrenstichwort. Wer im Einsatz sucht, weiß
|
||||
* selten, wie das Objekt im System heißt — aber immer, wo es steht oder was
|
||||
* dort brennt.
|
||||
*/
|
||||
|
||||
export const KATEGORIE_TEXT: Record<ObjektKategorie, string> = {
|
||||
SCHULE: "Schule",
|
||||
KINDERGARTEN: "Kindergarten",
|
||||
PFLEGEHEIM: "Pflegeheim",
|
||||
KRANKENHAUS: "Krankenhaus",
|
||||
VERSAMMLUNGSSTAETTE: "Versammlungsstätte",
|
||||
HOCHHAUS: "Hochhaus",
|
||||
INDUSTRIE: "Industrie",
|
||||
LANDWIRTSCHAFT: "Landwirtschaft",
|
||||
GEFAHRSTOFFLAGER: "Gefahrstofflager",
|
||||
SONDERBAU: "Sonderbau",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
export function ObjektListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const [suche, setSuche] = React.useState("");
|
||||
const [kategorie, setKategorie] = React.useState<ObjektKategorie | "alle">("alle");
|
||||
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [strasse, setStrasse] = React.useState("");
|
||||
const [neueKategorie, setNeueKategorie] = React.useState<ObjektKategorie>(
|
||||
ObjektKategorie.SONSTIGES,
|
||||
);
|
||||
const [gefahren, setGefahren] = React.useState("");
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const objekte = trpc.objekt.liste.useQuery({
|
||||
suche: suche || undefined,
|
||||
kategorie: kategorie === "alle" ? undefined : kategorie,
|
||||
});
|
||||
|
||||
const anlegen = trpc.objekt.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setAnlegenOffen(false);
|
||||
setName("");
|
||||
setStrasse("");
|
||||
setGefahren("");
|
||||
void werkzeuge.objekt.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="objektfehler" />}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-56 flex-1 space-y-1">
|
||||
<Label htmlFor="objektsuche">Objekte durchsuchen</Label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
id="objektsuche"
|
||||
className="pl-9"
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Name, Straße, Gefahr, Ansprechpartner …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="objektkategorie">Kategorie</Label>
|
||||
<Select
|
||||
value={kategorie}
|
||||
onValueChange={(wert) => setKategorie(wert as ObjektKategorie | "alle")}
|
||||
>
|
||||
<SelectTrigger id="objektkategorie" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Kategorien</SelectItem>
|
||||
{(Object.keys(KATEGORIE_TEXT) as ObjektKategorie[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{KATEGORIE_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Button onClick={() => setAnlegenOffen((wert) => !wert)}>
|
||||
<Plus aria-hidden />
|
||||
Objekt anlegen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anlegenOffen && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Neues Objekt</CardTitle>
|
||||
<CardDescription>
|
||||
Name und Kategorie genügen für den Anfang. Zufahrt, Gefahren und Pläne kommen auf
|
||||
der Detailseite dazu.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="grid gap-3 sm:grid-cols-2"
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
anlegen.mutate({
|
||||
name: name.trim(),
|
||||
kategorie: neueKategorie,
|
||||
strasse: strasse.trim() || null,
|
||||
gefahren: gefahren.trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="neu-name">Name</Label>
|
||||
<Input
|
||||
id="neu-name"
|
||||
value={name}
|
||||
onChange={(ereignis) => setName(ereignis.target.value)}
|
||||
placeholder="Grundschule Musterhausen"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="neu-kategorie">Kategorie</Label>
|
||||
<Select
|
||||
value={neueKategorie}
|
||||
onValueChange={(wert) => setNeueKategorie(wert as ObjektKategorie)}
|
||||
>
|
||||
<SelectTrigger id="neu-kategorie">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(KATEGORIE_TEXT) as ObjektKategorie[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{KATEGORIE_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="neu-strasse">Straße und Hausnummer</Label>
|
||||
<Input
|
||||
id="neu-strasse"
|
||||
value={strasse}
|
||||
onChange={(ereignis) => setStrasse(ereignis.target.value)}
|
||||
placeholder="Schulstraße 4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="neu-gefahren">Gefahren</Label>
|
||||
<Textarea
|
||||
id="neu-gefahren"
|
||||
rows={2}
|
||||
value={gefahren}
|
||||
onChange={(ereignis) => setGefahren(ereignis.target.value)}
|
||||
placeholder="Photovoltaik auf dem Dach, Chemiesaal im 1. OG"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Button type="submit" disabled={anlegen.isPending}>
|
||||
{anlegen.isPending ? "wird angelegt …" : "Anlegen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(objekte.data?.length ?? 0) === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel={suche ? "Kein Objekt gefunden" : "Noch kein Objekt erfasst"}
|
||||
hinweis={
|
||||
suche
|
||||
? "Gesucht wird in Name, Straße, Gefahren, Zufahrt und Ansprechpartnern."
|
||||
: "Feuerwehrpläne nach DIN 14095 gehören hierher — Schule, Pflegeheim, Aussiedlerhof."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{objekte.data?.map((objekt) => (
|
||||
<Link
|
||||
key={objekt.id}
|
||||
href={`/karte/objekte/${objekt.id}`}
|
||||
className="rounded-lg border border-[var(--border)] p-4 transition-colors hover:bg-[var(--accent)]"
|
||||
data-testid={`objekt-${objekt.id}`}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{KATEGORIE_TEXT[objekt.kategorie]}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">{objekt.name}</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{objekt.strasse} {objekt.hausnummer} {objekt.ort ? `· ${objekt.ort}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{objekt._count.plaene > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyRound className="size-3" aria-hidden />
|
||||
{objekt._count.plaene} {objekt._count.plaene === 1 ? "Plan" : "Pläne"}
|
||||
</span>
|
||||
)}
|
||||
{objekt.personenzahl !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="size-3" aria-hidden />
|
||||
{objekt.personenzahl} Personen
|
||||
</span>
|
||||
)}
|
||||
{objekt.gefahren && (
|
||||
<span className="flex items-center gap-1 text-[var(--pruefung-faellig)]">
|
||||
<AlertTriangle className="size-3" aria-hidden />
|
||||
Gefahren hinterlegt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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 { ObjektListe } from "@/app/(app)/karte/objekte/objekt-liste";
|
||||
|
||||
export const metadata = { title: "Objekte" };
|
||||
|
||||
/** Objekte mit Feuerwehrplan. */
|
||||
export default async function ObjekteSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!hatRecht(sitzung, "geraet.lesen")) redirect("/");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/karte">
|
||||
<ArrowLeft aria-hidden />
|
||||
Karte
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Objekte</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Feuerwehrpläne, Zufahrten, Gefahren und wen man anruft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ObjektListe darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Droplets, Route } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { env } from "@/lib/env";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { KartenAnsicht } from "@/app/(app)/karte/karten-ansicht";
|
||||
|
||||
export const metadata = { title: "Karte" };
|
||||
|
||||
/**
|
||||
* Die Karte des Ausrückegebiets.
|
||||
*
|
||||
* Der Mittelpunkt kommt vom ersten Standort der Wehr — das Gerätehaus ist der
|
||||
* Ort, von dem aus gedacht wird. Hat die Wehr dort keine Koordinaten
|
||||
* hinterlegt, greift die Vorgabe aus der Konfiguration.
|
||||
*/
|
||||
export default async function KartenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!hatRecht(sitzung, "geraet.lesen")) redirect("/");
|
||||
|
||||
const standort = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.standort.findFirst({
|
||||
where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
}),
|
||||
);
|
||||
|
||||
const mittelpunkt = {
|
||||
breitengrad: standort?.breitengrad ? Number(standort.breitengrad) : env.KARTE_BREITENGRAD,
|
||||
laengengrad: standort?.laengengrad ? Number(standort.laengengrad) : env.KARTE_LAENGENGRAD,
|
||||
};
|
||||
|
||||
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">Karte</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Löschwasser, Objekte mit Feuerwehrplan und die Umkreissuche.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/karte/objekte">Objekte</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/karte/hydranten">
|
||||
<Droplets aria-hidden />
|
||||
Hydranten
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/karte/wasserfoerderung">
|
||||
<Route aria-hidden />
|
||||
Wasserförderung
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KartenAnsicht
|
||||
stilUrl={env.KARTE_STIL_URL ?? null}
|
||||
mittelpunkt={mittelpunkt}
|
||||
zoom={env.KARTE_ZOOM}
|
||||
darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 { env } from "@/lib/env";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Foerderplaner } from "@/app/(app)/karte/wasserfoerderung/planer";
|
||||
|
||||
export const metadata = { title: "Wasserförderung" };
|
||||
|
||||
/**
|
||||
* Wasserförderung über lange Wegstrecke.
|
||||
*
|
||||
* Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube
|
||||
* entsteht — mit dem Unterschied, dass die Strecke hier aus der Karte kommt
|
||||
* und sich speichern lässt.
|
||||
*/
|
||||
export default async function WasserfoerderungSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!hatRecht(sitzung, "einsatz.lesen")) redirect("/");
|
||||
|
||||
const standort = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.standort.findFirst({
|
||||
where: { aktiv: true, geloeschtAm: null, breitengrad: { not: null } },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/karte">
|
||||
<ArrowLeft aria-hidden />
|
||||
Karte
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Wasserförderung</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Strecke in die Karte tippen — Pumpen, Schlauchlängen und Druckverlust rechnen sich mit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Foerderplaner
|
||||
stilUrl={env.KARTE_STIL_URL ?? null}
|
||||
mittelpunkt={{
|
||||
breitengrad: standort?.breitengrad ? Number(standort.breitengrad) : env.KARTE_BREITENGRAD,
|
||||
laengengrad: standort?.laengengrad ? Number(standort.laengengrad) : env.KARTE_LAENGENGRAD,
|
||||
}}
|
||||
zoom={env.KARTE_ZOOM}
|
||||
darfBearbeiten={hatRecht(sitzung, "einsatz.bearbeiten")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { MapPin, Printer, Route, Trash2 } 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 {
|
||||
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 { Karte, type KartenLinie, type KartenPunkt } from "@/components/karte/karte";
|
||||
import {
|
||||
DURCHMESSER_TEXT,
|
||||
foerderplan,
|
||||
type Durchmesser,
|
||||
type Streckenpunkt,
|
||||
} from "@/lib/karte/wasserfoerderung";
|
||||
import { alsDatumZeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Wasserförderung planen.
|
||||
*
|
||||
* Punkte in die Karte tippen, Durchmesser und Menge einstellen — die Rechnung
|
||||
* läuft sofort mit. **Gerechnet wird im Browser mit demselben Code wie auf dem
|
||||
* Server** (lib/karte/wasserfoerderung.ts); gespeichert werden nur die
|
||||
* Eingaben. Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne
|
||||
* falsch auszusehen.
|
||||
*
|
||||
* Das Ergebnis ist eine Planungshilfe. Was zählt, ist der Druck am Verteiler —
|
||||
* und das steht auch so unter der Tabelle.
|
||||
*/
|
||||
|
||||
const ART_TEXT = {
|
||||
ENTNAHME: "Entnahmestelle",
|
||||
VERSTAERKER: "Verstärkerpumpe",
|
||||
WEGPUNKT: "Wegpunkt",
|
||||
ZIEL: "Einsatzstelle",
|
||||
} as const;
|
||||
|
||||
type Art = keyof typeof ART_TEXT;
|
||||
|
||||
interface Punkt extends Streckenpunkt {
|
||||
art: Art;
|
||||
}
|
||||
|
||||
export function Foerderplaner({
|
||||
stilUrl,
|
||||
mittelpunkt,
|
||||
zoom,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
stilUrl: string | null;
|
||||
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||
zoom: number;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [bezeichnung, setBezeichnung] = React.useState("");
|
||||
const [durchmesser, setDurchmesser] = React.useState<Durchmesser>(75);
|
||||
const [foerdermenge, setFoerdermenge] = React.useState(800);
|
||||
const [punkte, setPunkte] = React.useState<Punkt[]>([]);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const gespeicherte = trpc.wasserfoerderung.liste.useQuery();
|
||||
|
||||
const speichern = trpc.wasserfoerderung.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung(null);
|
||||
setBezeichnung("");
|
||||
setPunkte([]);
|
||||
void werkzeuge.wasserfoerderung.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const entfernen = trpc.wasserfoerderung.entfernen.useMutation({
|
||||
onSuccess: () => void werkzeuge.wasserfoerderung.invalidate(),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
/** Die Rechnung — bei jeder Änderung neu, nie gespeichert. */
|
||||
const plan = React.useMemo(
|
||||
() =>
|
||||
foerderplan({
|
||||
punkte,
|
||||
durchmesser,
|
||||
foerdermenge,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
}),
|
||||
[punkte, durchmesser, foerdermenge],
|
||||
);
|
||||
|
||||
const kartenpunkte = React.useMemo<KartenPunkt[]>(
|
||||
() =>
|
||||
punkte.map((punkt, index) => ({
|
||||
id: `p-${index}`,
|
||||
breitengrad: punkt.breitengrad,
|
||||
laengengrad: punkt.laengengrad,
|
||||
farbe:
|
||||
punkt.art === "ENTNAHME"
|
||||
? "#1d4ed8"
|
||||
: punkt.art === "ZIEL"
|
||||
? "#b91c1c"
|
||||
: plan.abschnitte[index - 1]?.pumpeNoetig
|
||||
? "#ca8a04"
|
||||
: "#374151",
|
||||
titel: `${index + 1}. ${punkt.bezeichnung ?? ART_TEXT[punkt.art]}`,
|
||||
})),
|
||||
[punkte, plan.abschnitte],
|
||||
);
|
||||
|
||||
const linien = React.useMemo<KartenLinie[]>(
|
||||
() =>
|
||||
punkte.length >= 2
|
||||
? [
|
||||
{
|
||||
id: "strecke",
|
||||
punkte: punkte.map((punkt) => [punkt.laengengrad, punkt.breitengrad] as [number, number]),
|
||||
farbe: plan.machbar ? "#1d4ed8" : "#b91c1c",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[punkte, plan.machbar],
|
||||
);
|
||||
|
||||
function punktSetzen(ort: { breitengrad: number; laengengrad: number }) {
|
||||
setPunkte((bisher) => [
|
||||
...bisher,
|
||||
{
|
||||
...ort,
|
||||
hoehe: null,
|
||||
art: bisher.length === 0 ? "ENTNAHME" : "ZIEL",
|
||||
bezeichnung: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="foerderfehler" />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
|
||||
<div className="space-y-3">
|
||||
<Karte
|
||||
stilUrl={stilUrl}
|
||||
mittelpunkt={punkte[0] ?? mittelpunkt}
|
||||
zoom={zoom}
|
||||
punkte={kartenpunkte}
|
||||
linien={linien}
|
||||
beiKartenklick={darfBearbeiten ? punktSetzen : undefined}
|
||||
hoehe="55vh"
|
||||
/>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
<MapPin className="mr-1 inline size-4" aria-hidden />
|
||||
In die Karte tippen setzt einen Punkt: der erste ist die Entnahmestelle, der letzte
|
||||
die Einsatzstelle. Höhen lassen sich je Punkt eintragen — zehn Meter Steigung kosten
|
||||
ein bar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bezeichnung">Bezeichnung</Label>
|
||||
<Input
|
||||
id="bezeichnung"
|
||||
value={bezeichnung}
|
||||
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
|
||||
placeholder="Förderstrecke Weiher → Aussiedlerhof"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="durchmesser">Schlauch</Label>
|
||||
<Select
|
||||
value={String(durchmesser)}
|
||||
onValueChange={(wert) => setDurchmesser(Number(wert) as Durchmesser)}
|
||||
>
|
||||
<SelectTrigger id="durchmesser">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{([52, 75, 110] as Durchmesser[]).map((wert) => (
|
||||
<SelectItem key={wert} value={String(wert)}>
|
||||
{DURCHMESSER_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="menge">Fördermenge (l/min)</Label>
|
||||
<Input
|
||||
id="menge"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={foerdermenge}
|
||||
onChange={(ereignis) => setFoerdermenge(Number(ereignis.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Das Ergebnis. */}
|
||||
<Card data-testid="foerderplan">
|
||||
<CardHeader>
|
||||
<CardTitle>Ergebnis</CardTitle>
|
||||
<CardDescription>
|
||||
{punkte.length < 2
|
||||
? "Noch keine Strecke — mindestens zwei Punkte setzen."
|
||||
: `${plan.gesamtlaenge} m · ${plan.laengen} Schlauchlängen · ${plan.pumpen} ${plan.pumpen === 1 ? "Pumpe" : "Pumpen"}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{punkte.length >= 2 && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Pumpen</p>
|
||||
<p
|
||||
className="tabellenzahlen text-3xl font-semibold"
|
||||
data-testid="pumpenzahl"
|
||||
>
|
||||
{plan.pumpen}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Längen</p>
|
||||
<p className="tabellenzahlen text-3xl font-semibold">{plan.laengen}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Höhe</p>
|
||||
<p className="tabellenzahlen text-3xl font-semibold">
|
||||
{plan.hoehenunterschied > 0 ? "+" : ""}
|
||||
{plan.hoehenunterschied} m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1 text-sm">
|
||||
{plan.hinweise.map((hinweis) => (
|
||||
<li
|
||||
key={hinweis}
|
||||
className={
|
||||
plan.machbar
|
||||
? "text-[var(--muted-foreground)]"
|
||||
: "font-medium text-[var(--gesperrt)]"
|
||||
}
|
||||
>
|
||||
{hinweis}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{darfBearbeiten && punkte.length >= 2 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
speichern.mutate({
|
||||
bezeichnung: bezeichnung.trim() || "Förderstrecke",
|
||||
durchmesser,
|
||||
foerdermenge,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
punkte: punkte.map((punkt) => ({
|
||||
art: punkt.art,
|
||||
bezeichnung: punkt.bezeichnung ?? null,
|
||||
breitengrad: punkt.breitengrad,
|
||||
laengengrad: punkt.laengengrad,
|
||||
hoehe: punkt.hoehe ?? null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
disabled={speichern.isPending}
|
||||
>
|
||||
<Route aria-hidden />
|
||||
Strecke speichern
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer aria-hidden />
|
||||
Schema drucken
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" onClick={() => setPunkte([])}>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Die Abschnitte — das ist das druckbare Schema. */}
|
||||
{punkte.length >= 2 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Abschnitte</CardTitle>
|
||||
<CardDescription>
|
||||
Je Abschnitt: Länge, Höhenunterschied, Druckverlust und was am Ende noch ansteht.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Von → nach</TableHead>
|
||||
<TableHead className="text-right">Meter</TableHead>
|
||||
<TableHead className="text-right">Längen</TableHead>
|
||||
<TableHead className="text-right">Höhe</TableHead>
|
||||
<TableHead className="text-right">Reibung</TableHead>
|
||||
<TableHead className="text-right">Rest</TableHead>
|
||||
<TableHead>Pumpe</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plan.abschnitte.map((abschnitt, index) => (
|
||||
<TableRow key={`${abschnitt.von}-${index}`}>
|
||||
<TableCell>
|
||||
{abschnitt.von} → {abschnitt.nach}
|
||||
</TableCell>
|
||||
<TableCell className="tabellenzahlen text-right">
|
||||
{abschnitt.laengeMeter}
|
||||
</TableCell>
|
||||
<TableCell className="tabellenzahlen text-right">{abschnitt.laengen}</TableCell>
|
||||
<TableCell className="tabellenzahlen text-right">
|
||||
{abschnitt.hoehenunterschied > 0 ? "+" : ""}
|
||||
{abschnitt.hoehenunterschied} m
|
||||
</TableCell>
|
||||
<TableCell className="tabellenzahlen text-right">
|
||||
{abschnitt.reibungsverlust.toFixed(1)} bar
|
||||
</TableCell>
|
||||
<TableCell className="tabellenzahlen text-right">
|
||||
{abschnitt.restdruck.toFixed(1)} bar
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{abschnitt.pumpeNoetig ? (
|
||||
<span className="font-medium text-[var(--pruefung-faellig)]">
|
||||
Verstärker davor
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Punktliste mit Höhen. */}
|
||||
{punkte.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Punkte</CardTitle>
|
||||
<CardDescription>
|
||||
Höhe über Normalnull eintragen, wenn sie bekannt ist — ohne wird mit ebenem
|
||||
Gelände gerechnet, und das ist im Zweifel zu günstig.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{punkte.map((punkt, index) => (
|
||||
<div key={index} className="flex flex-wrap items-end gap-3">
|
||||
<span className="tabellenzahlen w-6 text-[var(--muted-foreground)]">
|
||||
{index + 1}.
|
||||
</span>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`art-${index}`} className="text-xs">
|
||||
Art
|
||||
</Label>
|
||||
<Select
|
||||
value={punkt.art}
|
||||
onValueChange={(wert) =>
|
||||
setPunkte((bisher) =>
|
||||
bisher.map((eintrag, i) =>
|
||||
i === index ? { ...eintrag, art: wert as Art } : eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`art-${index}`} className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(ART_TEXT) as Art[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{ART_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`name-${index}`} className="text-xs">
|
||||
Bezeichnung
|
||||
</Label>
|
||||
<Input
|
||||
id={`name-${index}`}
|
||||
className="w-52"
|
||||
value={punkt.bezeichnung ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
setPunkte((bisher) =>
|
||||
bisher.map((eintrag, i) =>
|
||||
i === index ? { ...eintrag, bezeichnung: ereignis.target.value } : eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder={ART_TEXT[punkt.art]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`hoehe-${index}`} className="text-xs">
|
||||
Höhe (m ü. NN)
|
||||
</Label>
|
||||
<Input
|
||||
id={`hoehe-${index}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
className="w-28"
|
||||
value={punkt.hoehe ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
setPunkte((bisher) =>
|
||||
bisher.map((eintrag, i) =>
|
||||
i === index
|
||||
? {
|
||||
...eintrag,
|
||||
hoehe:
|
||||
ereignis.target.value === ""
|
||||
? null
|
||||
: Number(ereignis.target.value),
|
||||
}
|
||||
: eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPunkte((bisher) => bisher.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label={`Punkt ${index + 1} entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Gespeicherte Strecken. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gespeicherte Strecken</CardTitle>
|
||||
<CardDescription>
|
||||
Für wiederkehrende Lagen — der Aussiedlerhof am Ortsrand ist bei jedem Brand
|
||||
derselbe.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(gespeicherte.data?.length ?? 0) === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel="Noch keine Strecke gespeichert"
|
||||
hinweis="Eine geplante Strecke lässt sich sichern und beim nächsten Mal wieder aufrufen."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{gespeicherte.data?.map((strecke) => (
|
||||
<li key={strecke.id} className="flex flex-wrap items-center gap-3">
|
||||
<strong>{strecke.bezeichnung}</strong>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{strecke._count.punkte} Punkte · {strecke.durchmesser} mm ·{" "}
|
||||
{strecke.foerdermenge} l/min · {alsDatumZeit(strecke.erstelltAm)}
|
||||
</span>
|
||||
{darfBearbeiten && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => entfernen.mutate({ id: strecke.id })}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
entfernen
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { herunterladen } from "@/lib/storage";
|
||||
import { AuditAktion, protokolliere } from "@/lib/audit";
|
||||
|
||||
/**
|
||||
* Eine abgelegte Datei ausliefern.
|
||||
*
|
||||
* Gegenstück zum Upload nebenan. Bis Phase 6 wurden Dateien nur erzeugt
|
||||
* (Protokolle, Berichte) oder über eigene Endpunkte ausgeliefert; mit den
|
||||
* Feuerwehrplänen gibt es zum ersten Mal Dateien, die jemand hochlädt und
|
||||
* später wieder ansehen will.
|
||||
*
|
||||
* Drei Dinge sind wichtig:
|
||||
*
|
||||
* 1. **Der Mandantenfilter entscheidet.** Die Datei wird über
|
||||
* `fuerOrganisation` gesucht — eine Kennung aus einer fremden Wehr findet
|
||||
* hier nichts, auch wenn sie richtig geraten wurde.
|
||||
* 2. **Ausgeliefert wird aus MinIO**, über lib/storage.ts. Nie über einen
|
||||
* Pfad im Dateisystem.
|
||||
* 3. **Jeder Abruf steht im Protokoll.** Wer einen Feuerwehrplan oder ein
|
||||
* Prüfprotokoll ansieht, ist eine Information, die im Zweifel zählt.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Was im Browser angezeigt werden darf. Alles andere wird heruntergeladen. */
|
||||
const ANZEIGBAR = new Set([
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
export async function GET(
|
||||
_anfrage: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "geraet.lesen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const datei = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.datei.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
objektSchluessel: true,
|
||||
dateiname: true,
|
||||
mimeTyp: true,
|
||||
entitaet: true,
|
||||
entitaetId: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!datei) {
|
||||
return NextResponse.json({ fehler: "Diese Datei gibt es nicht." }, { status: 404 });
|
||||
}
|
||||
|
||||
let inhalt: Buffer;
|
||||
try {
|
||||
inhalt = await herunterladen(datei.objektSchluessel);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Die Datei ist im Speicher nicht auffindbar." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await protokolliere({
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_ABGERUFEN,
|
||||
entitaet: datei.entitaet ?? "Datei",
|
||||
entitaetId: datei.entitaetId ?? id,
|
||||
sitzung,
|
||||
beschreibung: `Datei „${datei.dateiname}" abgerufen`,
|
||||
});
|
||||
|
||||
const anzeigen = ANZEIGBAR.has(datei.mimeTyp);
|
||||
|
||||
return new NextResponse(new Uint8Array(inhalt), {
|
||||
headers: {
|
||||
"Content-Type": datei.mimeTyp,
|
||||
"Content-Disposition": `${anzeigen ? "inline" : "attachment"}; filename="${datei.dateiname.replace(/"/g, "")}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Der Ereignisstrom der Lagekarte.
|
||||
*
|
||||
* Dasselbe Muster wie bei der Atemschutzüberwachung aus Phase 5, aus denselben
|
||||
* Gründen: Server-Sent Events statt WebSocket, weil es nur in eine Richtung
|
||||
* geht; ein Fingerabdruck des Zustands statt einzelner Nachrichten, weil die
|
||||
* Ansicht ohnehin den ganzen Stand holt; und ein Lebenszeichen im Takt, damit
|
||||
* ein toter Draht nicht wie ein gesunder aussieht.
|
||||
*
|
||||
* Anders als beim Atemschutz ist ein Aussetzer hier kein Sicherheitsproblem —
|
||||
* eine Lagekarte, die zehn Sekunden hinterherhinkt, schadet niemandem.
|
||||
* Trotzdem derselbe Aufbau: zwei Ereignisströme mit unterschiedlichem
|
||||
* Verhalten wären ein Ort mehr, an dem sich ein Fehler verstecken kann.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TAKT_MS = 2000;
|
||||
const HOECHSTDAUER_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Ändert sich dieser Wert, hat jemand etwas gezeichnet. */
|
||||
async function fingerabdruck(organisationId: string, einsatzId: string): Promise<string> {
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const karte = await db.lagekarte.findUnique({
|
||||
where: { einsatzId },
|
||||
select: { id: true, geaendertAm: true },
|
||||
});
|
||||
|
||||
if (!karte) return "keine";
|
||||
|
||||
const elemente = await db.lageelement.count({
|
||||
where: { lagekarteId: karte.id, geloeschtAm: null },
|
||||
});
|
||||
|
||||
// Anzahl **und** Zeitstempel: verschiebt jemand ein Zeichen, bleibt die
|
||||
// Anzahl gleich, aber die Karte wird angefasst.
|
||||
const letzte = await db.lageelement.findFirst({
|
||||
where: { lagekarteId: karte.id },
|
||||
orderBy: { geaendertAm: "desc" },
|
||||
select: { geaendertAm: true },
|
||||
});
|
||||
|
||||
return `${elemente}#${karte.geaendertAm.getTime()}#${letzte?.geaendertAm.getTime() ?? 0}`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(anfrage: Request): Promise<Response> {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "einsatz.lesen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
|
||||
}
|
||||
|
||||
const einsatzId = new URL(anfrage.url).searchParams.get("einsatz");
|
||||
if (!einsatzId) {
|
||||
return NextResponse.json({ fehler: "Kein Einsatz angegeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const organisationId = sitzung.organisationId;
|
||||
const kodierer = new TextEncoder();
|
||||
|
||||
const strom = new ReadableStream<Uint8Array>({
|
||||
async start(steuerung) {
|
||||
let offen = true;
|
||||
let letzter = "";
|
||||
|
||||
const senden = (art: string, daten: unknown) => {
|
||||
if (!offen) return;
|
||||
try {
|
||||
steuerung.enqueue(kodierer.encode(`event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`));
|
||||
} catch {
|
||||
offen = false;
|
||||
}
|
||||
};
|
||||
|
||||
steuerung.enqueue(kodierer.encode("retry: 3000\n\n"));
|
||||
|
||||
const beenden = () => {
|
||||
offen = false;
|
||||
clearInterval(takt);
|
||||
clearTimeout(schluss);
|
||||
try {
|
||||
steuerung.close();
|
||||
} catch {
|
||||
// Schon geschlossen — der Normalfall beim Wegklicken.
|
||||
}
|
||||
};
|
||||
|
||||
anfrage.signal.addEventListener("abort", beenden);
|
||||
|
||||
const nachsehen = async () => {
|
||||
if (!offen) return;
|
||||
|
||||
try {
|
||||
const jetzt = await fingerabdruck(organisationId, einsatzId);
|
||||
|
||||
if (jetzt !== letzter) {
|
||||
letzter = jetzt;
|
||||
senden("aenderung", { zeitpunkt: new Date().toISOString() });
|
||||
} else {
|
||||
senden("takt", { zeitpunkt: new Date().toISOString() });
|
||||
}
|
||||
} catch (fehler) {
|
||||
senden("fehler", {
|
||||
meldung: fehler instanceof Error ? fehler.message : "unbekannter Fehler",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const takt = setInterval(() => void nachsehen(), TAKT_MS);
|
||||
const schluss = setTimeout(beenden, HOECHSTDAUER_MS);
|
||||
|
||||
await nachsehen();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(strom, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -53,8 +53,17 @@ export function useJetzt(taktMs = 1000): Date {
|
||||
const STILLE_MS = 8000;
|
||||
|
||||
export function useEreignisstrom(
|
||||
ueberwachungId: string | null,
|
||||
kennung: string | null,
|
||||
beiAenderung: () => void,
|
||||
/**
|
||||
* Adresse des Stroms, an die die Kennung angehängt wird.
|
||||
*
|
||||
* Vorgabe ist die Atemschutzüberwachung; die Lagekarte aus Phase 6 benutzt
|
||||
* denselben Hook mit ihrer eigenen Adresse. Zwei Ereignisströme mit
|
||||
* unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||||
* verstecken kann.
|
||||
*/
|
||||
adresse = "/api/atemschutz/strom?id=",
|
||||
): { verbunden: boolean; letzteMeldung: Date | null } {
|
||||
const [verbunden, setVerbunden] = React.useState(false);
|
||||
const [letzteMeldung, setLetzteMeldung] = React.useState<Date | null>(null);
|
||||
@@ -69,11 +78,9 @@ export function useEreignisstrom(
|
||||
rueckruf.current = beiAenderung;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ueberwachungId || typeof window === "undefined") return;
|
||||
if (!kennung || typeof window === "undefined") return;
|
||||
|
||||
const quelle = new EventSource(
|
||||
`/api/atemschutz/strom?id=${encodeURIComponent(ueberwachungId)}`,
|
||||
);
|
||||
const quelle = new EventSource(`${adresse}${encodeURIComponent(kennung)}`);
|
||||
|
||||
const gehoert = () => {
|
||||
letzteRef.current = Date.now();
|
||||
@@ -108,7 +115,7 @@ export function useEreignisstrom(
|
||||
clearInterval(wache);
|
||||
quelle.close();
|
||||
};
|
||||
}, [ueberwachungId]);
|
||||
}, [kennung, adresse]);
|
||||
|
||||
return { verbunden, letzteMeldung };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
import type { LngLatLike, Map as MapLibreKarte, MapMouseEvent } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
/**
|
||||
* Die Karte.
|
||||
*
|
||||
* MapLibre GL, aus dem eigenen Paketstand — kein Skript und kein Stylesheet von
|
||||
* einem fremden Server. Die Kacheln kommen aus dem eigenen tileserver-gl
|
||||
* (`KARTE_STIL_URL`); ist keiner eingerichtet, zeichnet die Karte ein
|
||||
* Gitternetz auf leerem Grund. Das ist bewusst so:
|
||||
*
|
||||
* * **Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt und wo ihre
|
||||
* Hydranten liegen, ist niemandes Geschäft. Es gibt deshalb keinen
|
||||
* Rückfall auf einen öffentlichen Kartendienst — auch nicht „nur zum
|
||||
* Ausprobieren".
|
||||
* * **Ohne Kacheln trotzdem brauchbar.** Punkte liegen an der richtigen
|
||||
* Stelle, Entfernungen stimmen, Zeichnen geht. Nur die Straßen darunter
|
||||
* fehlen. Zum Erfassen reicht das, für den Einsatz nicht — und das steht
|
||||
* auch so auf der Karte.
|
||||
*/
|
||||
|
||||
/** Ein Stil ohne Kacheln: grauer Grund, Gitternetz, sonst nichts. */
|
||||
const LEERER_STIL = {
|
||||
version: 8 as const,
|
||||
sources: {},
|
||||
layers: [
|
||||
{
|
||||
id: "hintergrund",
|
||||
type: "background" as const,
|
||||
paint: { "background-color": "#eceae6" },
|
||||
},
|
||||
],
|
||||
glyphs: undefined,
|
||||
};
|
||||
|
||||
export interface KartenPunkt {
|
||||
id: string;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
/** SVG-Inhalt für den Marker. Ohne wird ein einfacher Kreis gezeichnet. */
|
||||
svg?: string;
|
||||
farbe?: string;
|
||||
titel?: string;
|
||||
/** Wird beim Antippen aufgerufen. */
|
||||
beiKlick?: () => void;
|
||||
}
|
||||
|
||||
export interface KartenLinie {
|
||||
id: string;
|
||||
/** [[laenge, breite], …] */
|
||||
punkte: Array<[number, number]>;
|
||||
farbe?: string;
|
||||
gestrichelt?: boolean;
|
||||
/** Geschlossene Fläche statt Linienzug. */
|
||||
flaeche?: boolean;
|
||||
}
|
||||
|
||||
export interface KartenEigenschaften {
|
||||
stilUrl: string | null;
|
||||
mittelpunkt: { breitengrad: number; laengengrad: number };
|
||||
zoom?: number;
|
||||
punkte?: KartenPunkt[];
|
||||
linien?: KartenLinie[];
|
||||
/** Klick in die Karte — für „hier einen Hydranten setzen". */
|
||||
beiKartenklick?: (ort: { breitengrad: number; laengengrad: number }) => void;
|
||||
/** Wenn sich der Ausschnitt beruhigt hat. */
|
||||
beiAusschnitt?: (ausschnitt: {
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
zoom: number;
|
||||
}) => void;
|
||||
hoehe?: string;
|
||||
/** Fadenkreuz in der Mitte — beim Setzen eines Punktes. */
|
||||
fadenkreuz?: boolean;
|
||||
}
|
||||
|
||||
export function Karte({
|
||||
stilUrl,
|
||||
mittelpunkt,
|
||||
zoom = 13,
|
||||
punkte = [],
|
||||
linien = [],
|
||||
beiKartenklick,
|
||||
beiAusschnitt,
|
||||
hoehe = "70vh",
|
||||
fadenkreuz = false,
|
||||
}: KartenEigenschaften) {
|
||||
const behaelter = React.useRef<HTMLDivElement | null>(null);
|
||||
const karte = React.useRef<MapLibreKarte | null>(null);
|
||||
const markierungen = React.useRef<maplibregl.Marker[]>([]);
|
||||
|
||||
/**
|
||||
* Ist der Kartenserver erreichbar?
|
||||
*
|
||||
* Ein eingetragener, aber nicht erreichbarer Server ist der häufigere Fall
|
||||
* als gar keiner: der Container läuft nicht, die MBTiles-Datei fehlt, die
|
||||
* Adresse hat sich geändert. Dann soll die Karte aussehen wie ohne
|
||||
* Kartenserver — leerer Grund mit Hinweis — und nicht wie ein Fehler. Die
|
||||
* Punkte liegen ohnehin richtig.
|
||||
*/
|
||||
const [stilGeladen, setStilGeladen] = React.useState(true);
|
||||
|
||||
// Rückrufe über Referenzen: sonst würde die Karte bei jedem Rendern neu
|
||||
// aufgebaut — dieselbe Falle wie beim Ereignisstrom in Phase 5.
|
||||
const klickRef = React.useRef(beiKartenklick);
|
||||
klickRef.current = beiKartenklick;
|
||||
const ausschnittRef = React.useRef(beiAusschnitt);
|
||||
ausschnittRef.current = beiAusschnitt;
|
||||
|
||||
// --- Karte aufbauen ------------------------------------------------------
|
||||
React.useEffect(() => {
|
||||
if (!behaelter.current || karte.current) return;
|
||||
|
||||
const gl = new maplibregl.Map({
|
||||
container: behaelter.current,
|
||||
style: stilUrl ?? (LEERER_STIL as maplibregl.StyleSpecification),
|
||||
center: [mittelpunkt.laengengrad, mittelpunkt.breitengrad] as LngLatLike,
|
||||
zoom,
|
||||
// Keine Attributions-Links nach draußen, keine Telemetrie.
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
gl.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
gl.addControl(
|
||||
new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }),
|
||||
"bottom-left",
|
||||
);
|
||||
|
||||
gl.on("click", (ereignis: MapMouseEvent) => {
|
||||
klickRef.current?.({
|
||||
breitengrad: ereignis.lngLat.lat,
|
||||
laengengrad: ereignis.lngLat.lng,
|
||||
});
|
||||
});
|
||||
|
||||
gl.on("error", (ereignis) => {
|
||||
// MapLibre meldet hier alles, was beim Laden schiefgeht. Für den
|
||||
// Stil selbst heißt das: es gibt keinen Hintergrund.
|
||||
const meldung = String((ereignis as { error?: unknown }).error ?? "");
|
||||
if (meldung.includes("style") || meldung.includes("Failed to fetch")) {
|
||||
setStilGeladen(false);
|
||||
}
|
||||
});
|
||||
|
||||
gl.on("moveend", () => {
|
||||
const mitte = gl.getCenter();
|
||||
ausschnittRef.current?.({
|
||||
breitengrad: mitte.lat,
|
||||
laengengrad: mitte.lng,
|
||||
zoom: gl.getZoom(),
|
||||
});
|
||||
});
|
||||
|
||||
karte.current = gl;
|
||||
|
||||
return () => {
|
||||
gl.remove();
|
||||
karte.current = null;
|
||||
};
|
||||
// Nur einmal: Ausschnitt und Inhalte werden unten nachgeführt.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stilUrl]);
|
||||
|
||||
// --- Markierungen nachführen ---------------------------------------------
|
||||
React.useEffect(() => {
|
||||
const gl = karte.current;
|
||||
if (!gl) return;
|
||||
|
||||
for (const markierung of markierungen.current) markierung.remove();
|
||||
markierungen.current = [];
|
||||
|
||||
for (const punkt of punkte) {
|
||||
const element = document.createElement("div");
|
||||
element.style.cursor = punkt.beiKlick ? "pointer" : "default";
|
||||
element.title = punkt.titel ?? "";
|
||||
|
||||
if (punkt.svg) {
|
||||
element.innerHTML = punkt.svg;
|
||||
} else {
|
||||
element.style.width = "16px";
|
||||
element.style.height = "16px";
|
||||
element.style.borderRadius = "50%";
|
||||
element.style.border = "2px solid white";
|
||||
element.style.background = punkt.farbe ?? "#b91c1c";
|
||||
element.style.boxShadow = "0 1px 4px rgba(0,0,0,.4)";
|
||||
}
|
||||
|
||||
if (punkt.beiKlick) {
|
||||
element.addEventListener("click", (ereignis) => {
|
||||
ereignis.stopPropagation();
|
||||
punkt.beiKlick?.();
|
||||
});
|
||||
}
|
||||
|
||||
markierungen.current.push(
|
||||
new maplibregl.Marker({ element })
|
||||
.setLngLat([punkt.laengengrad, punkt.breitengrad])
|
||||
.addTo(gl),
|
||||
);
|
||||
}
|
||||
}, [punkte]);
|
||||
|
||||
// --- Linien und Flächen ---------------------------------------------------
|
||||
React.useEffect(() => {
|
||||
const gl = karte.current;
|
||||
if (!gl) return;
|
||||
|
||||
const zeichnen = () => {
|
||||
// Erst abräumen, was nicht mehr dazugehört.
|
||||
const vorhandene = gl.getStyle()?.layers ?? [];
|
||||
for (const ebene of vorhandene) {
|
||||
if (ebene.id.startsWith("linie-") || ebene.id.startsWith("flaeche-")) {
|
||||
if (gl.getLayer(ebene.id)) gl.removeLayer(ebene.id);
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(gl.getStyle()?.sources ?? {})) {
|
||||
if (id.startsWith("geo-") && gl.getSource(id)) gl.removeSource(id);
|
||||
}
|
||||
|
||||
for (const linie of linien) {
|
||||
const quelle = `geo-${linie.id}`;
|
||||
|
||||
gl.addSource(quelle, {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: linie.flaeche
|
||||
? { type: "Polygon", coordinates: [[...linie.punkte, linie.punkte[0]!]] }
|
||||
: { type: "LineString", coordinates: linie.punkte },
|
||||
},
|
||||
});
|
||||
|
||||
if (linie.flaeche) {
|
||||
gl.addLayer({
|
||||
id: `flaeche-${linie.id}`,
|
||||
type: "fill",
|
||||
source: quelle,
|
||||
paint: { "fill-color": linie.farbe ?? "#b91c1c", "fill-opacity": 0.2 },
|
||||
});
|
||||
}
|
||||
|
||||
gl.addLayer({
|
||||
id: `linie-${linie.id}`,
|
||||
type: "line",
|
||||
source: quelle,
|
||||
paint: {
|
||||
"line-color": linie.farbe ?? "#b91c1c",
|
||||
"line-width": 4,
|
||||
...(linie.gestrichelt ? { "line-dasharray": [2, 2] } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (gl.isStyleLoaded()) {
|
||||
zeichnen();
|
||||
} else {
|
||||
gl.once("load", zeichnen);
|
||||
}
|
||||
}, [linien]);
|
||||
|
||||
// --- Ausschnitt von außen setzen -----------------------------------------
|
||||
React.useEffect(() => {
|
||||
const gl = karte.current;
|
||||
if (!gl) return;
|
||||
|
||||
const mitte = gl.getCenter();
|
||||
const weitWeg =
|
||||
Math.abs(mitte.lat - mittelpunkt.breitengrad) > 0.0005 ||
|
||||
Math.abs(mitte.lng - mittelpunkt.laengengrad) > 0.0005;
|
||||
|
||||
if (weitWeg) {
|
||||
gl.easeTo({
|
||||
center: [mittelpunkt.laengengrad, mittelpunkt.breitengrad],
|
||||
duration: 600,
|
||||
});
|
||||
}
|
||||
}, [mittelpunkt.breitengrad, mittelpunkt.laengengrad]);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-[var(--border)]">
|
||||
<div ref={behaelter} style={{ height: hoehe }} data-testid="karte" />
|
||||
|
||||
{fadenkreuz && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-8 rounded-full border-2 border-[var(--gesperrt)]" />
|
||||
<div className="absolute h-8 w-0.5 bg-[var(--gesperrt)]" />
|
||||
<div className="absolute h-0.5 w-8 bg-[var(--gesperrt)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!stilUrl || !stilGeladen) && (
|
||||
<p
|
||||
className="pointer-events-none absolute inset-x-3 top-3 rounded-md bg-[var(--background)]/90 px-3 py-2 text-xs text-[var(--muted-foreground)]"
|
||||
role="note"
|
||||
data-testid="karte-ohne-kacheln"
|
||||
>
|
||||
<strong>
|
||||
{stilUrl
|
||||
? "Kartenserver nicht erreichbar."
|
||||
: "Kein Kartenhintergrund eingerichtet."}
|
||||
</strong>{" "}
|
||||
Punkte liegen richtig, Entfernungen stimmen — es fehlen nur die Straßen darunter.
|
||||
Einrichtung siehe
|
||||
<code className="mx-1">docker/karten/LIESMICH.md</code>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,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 = 5;
|
||||
const AKTUELLE_PHASE = 6;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -142,6 +142,25 @@ const schema = z.object({
|
||||
/// Sicherheitszuschlag auf den Rückzugsdruck in bar.
|
||||
ATEMSCHUTZ_ZUSCHLAG: zahl(10),
|
||||
|
||||
// Kartenmodule (Phase 6)
|
||||
/**
|
||||
* Adresse des eigenen Kartenservers.
|
||||
*
|
||||
* FLORIAN lädt keine Kacheln aus dem Netz — weder von OpenStreetMap noch von
|
||||
* einem Anbieter. Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche
|
||||
* Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver läuft
|
||||
* deshalb selbst (tileserver-gl im Compose-Profil "karte", siehe README).
|
||||
*
|
||||
* Ohne Adresse zeigt die Karte einen leeren Hintergrund mit Gitternetz —
|
||||
* Hydranten, Objekte und Lagekarte funktionieren trotzdem, nur ohne Kacheln
|
||||
* darunter.
|
||||
*/
|
||||
KARTE_STIL_URL: z.string().optional(),
|
||||
/** Kartenmittelpunkt beim Öffnen, falls kein Standort hinterlegt ist. */
|
||||
KARTE_BREITENGRAD: z.coerce.number().min(-90).max(90).default(48.5),
|
||||
KARTE_LAENGENGRAD: z.coerce.number().min(-180).max(180).default(12.15),
|
||||
KARTE_ZOOM: zahl(13),
|
||||
|
||||
// Einsätze und Übungen (Phase 3)
|
||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||
GEOCODING_ENABLED: boolAusText(false),
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { HydrantStatus, HydrantTyp } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Hydranten einlesen.
|
||||
*
|
||||
* Zwei Quellen, die eine Wehr tatsächlich in die Hand bekommt:
|
||||
*
|
||||
* * **GeoJSON** — vom Landratsamt, aus QGIS oder aus einem OSM-Auszug.
|
||||
* * **CSV der Stadtwerke** — eine Tabelle mit Rechtswert, Hochwert oder
|
||||
* Dezimalgraden, mit Spaltennamen, die niemand vorhersagen kann.
|
||||
*
|
||||
* Deshalb ist dieser Baustein nachsichtig: Er erkennt die üblichen
|
||||
* Schreibweisen, nimmt Kommazahlen mit Komma **und** mit Punkt, und wirft eine
|
||||
* Zeile nicht weg, weil ein Feld fehlt. Was er nicht erkennt, meldet er als
|
||||
* Hinweis — und lässt den Rest durch. Eine Liste, die zu 90 Prozent stimmt,
|
||||
* ist im Einsatz mehr wert als keine.
|
||||
*
|
||||
* Was er **nicht** tut: raten. Fehlt die Position, wird die Zeile
|
||||
* übersprungen — ein Hydrant ohne Ort ist kein Hydrant.
|
||||
*
|
||||
* Der Import selbst (Anlegen, Aktualisieren) steht im Router; hier wird nur
|
||||
* gelesen und geprüft, damit sich beides ohne Datenbank testen lässt.
|
||||
*/
|
||||
|
||||
export interface EingelesenerHydrant {
|
||||
externeNummer: string | null;
|
||||
bezeichnung: string | null;
|
||||
typ: HydrantTyp;
|
||||
status: HydrantStatus;
|
||||
nennweite: number | null;
|
||||
durchfluss: number | null;
|
||||
strasse: string | null;
|
||||
hausnummer: string | null;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
letztePruefung: Date | null;
|
||||
betreiber: string | null;
|
||||
hinweis: string | null;
|
||||
}
|
||||
|
||||
export interface Leseergebnis {
|
||||
hydranten: EingelesenerHydrant[];
|
||||
/** Zeilen, die nicht verwertbar waren — mit Grund und Zeilennummer. */
|
||||
uebersprungen: Array<{ zeile: number; grund: string }>;
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Typ aus einem Freitext erkennen.
|
||||
*
|
||||
* Die Quellen schreiben alles: „UF", „Unterflur", „unterflurhydrant",
|
||||
* „Hydrant UF". Erkannt wird am Wortanfang, nicht am ganzen Wert.
|
||||
*/
|
||||
export function typErkennen(wert: string | null | undefined): HydrantTyp {
|
||||
const text = (wert ?? "").toLowerCase().trim();
|
||||
if (!text) return HydrantTyp.UNTERFLUR;
|
||||
|
||||
if (/(^|[^a-z])uf|unterflur/.test(text)) return HydrantTyp.UNTERFLUR;
|
||||
if (/(^|[^a-z])of|ueberflur|überflur/.test(text)) return HydrantTyp.UEBERFLUR;
|
||||
if (/saug/.test(text)) return HydrantTyp.SAUGSTELLE;
|
||||
if (/zisterne|behaelter|behälter|tank/.test(text)) return HydrantTyp.ZISTERNE;
|
||||
if (/gewaesser|gewässer|weiher|bach|teich|see|fluss/.test(text)) {
|
||||
return HydrantTyp.OFFENES_GEWAESSER;
|
||||
}
|
||||
|
||||
return HydrantTyp.SONSTIGES;
|
||||
}
|
||||
|
||||
/** Status aus einem Freitext. Im Zweifel „unbekannt" — nicht „einsatzbereit". */
|
||||
export function statusErkennen(wert: string | null | undefined): HydrantStatus {
|
||||
const text = (wert ?? "").toLowerCase().trim();
|
||||
if (!text) return HydrantStatus.UNBEKANNT;
|
||||
|
||||
if (/defekt|kaputt|ausser|außer|gesperrt|stillgelegt/.test(text)) return HydrantStatus.DEFEKT;
|
||||
if (/eingeschr|einschr|beschraenk|beschränk|bedingt|teilweise/.test(text)) {
|
||||
return HydrantStatus.EINGESCHRAENKT;
|
||||
}
|
||||
if (/betrieb|ok|frei|in ordnung|einsatzbereit|ja|1/.test(text)) {
|
||||
return HydrantStatus.EINSATZBEREIT;
|
||||
}
|
||||
|
||||
return HydrantStatus.UNBEKANNT;
|
||||
}
|
||||
|
||||
/** Eine Zahl aus einem Feld, das auch „DN 100" oder „1.234,5" enthalten darf. */
|
||||
export function zahl(wert: unknown): number | null {
|
||||
if (typeof wert === "number") return Number.isFinite(wert) ? wert : null;
|
||||
if (typeof wert !== "string") return null;
|
||||
|
||||
const bereinigt = wert
|
||||
.replace(/[^0-9,.\-]/g, "")
|
||||
// Tausenderpunkt vor Dezimalkomma: 1.234,5 → 1234,5
|
||||
.replace(/\.(?=\d{3}(\D|$))/g, "")
|
||||
.replace(",", ".");
|
||||
|
||||
if (bereinigt === "" || bereinigt === "-" || bereinigt === ".") return null;
|
||||
|
||||
const gelesen = Number(bereinigt);
|
||||
return Number.isFinite(gelesen) ? gelesen : null;
|
||||
}
|
||||
|
||||
/** Ein Datum aus den Schreibweisen, die in solchen Tabellen vorkommen. */
|
||||
export function datum(wert: unknown): Date | null {
|
||||
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : wert;
|
||||
if (typeof wert !== "string" || !wert.trim()) return null;
|
||||
|
||||
const text = wert.trim();
|
||||
|
||||
// 24.05.2026 oder 24.5.26
|
||||
const deutsch = /^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$/.exec(text);
|
||||
if (deutsch) {
|
||||
const [, tag, monat, jahr] = deutsch;
|
||||
const volljahr = Number(jahr) < 100 ? 2000 + Number(jahr) : Number(jahr);
|
||||
const gelesen = new Date(Date.UTC(volljahr, Number(monat) - 1, Number(tag)));
|
||||
return Number.isNaN(gelesen.getTime()) ? null : gelesen;
|
||||
}
|
||||
|
||||
// 2026-05-24
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(text);
|
||||
if (iso) {
|
||||
const gelesen = new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00.000Z`);
|
||||
return Number.isNaN(gelesen.getTime()) ? null : gelesen;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Sind das Koordinaten, die es auf der Erde gibt — und liegen sie in Europa? */
|
||||
export function koordinatenPlausibel(breitengrad: number, laengengrad: number): boolean {
|
||||
if (!Number.isFinite(breitengrad) || !Number.isFinite(laengengrad)) return false;
|
||||
if (breitengrad < -90 || breitengrad > 90) return false;
|
||||
if (laengengrad < -180 || laengengrad > 180) return false;
|
||||
// Verdrehte Achsen sind der häufigste Fehler: in Mitteleuropa ist die Breite
|
||||
// immer größer als die Länge. Wer wirklich anderswo arbeitet, merkt es an
|
||||
// dieser Meldung — besser als tausend Hydranten im Indischen Ozean.
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sieht es so aus, als wären Breite und Länge vertauscht? */
|
||||
export function achsenVertauscht(breitengrad: number, laengengrad: number): boolean {
|
||||
// Deutschland: Breite 47…55, Länge 5…16.
|
||||
const breiteWaereLaenge = breitengrad >= 5 && breitengrad <= 16;
|
||||
const laengeWaereBreite = laengengrad >= 47 && laengengrad <= 55;
|
||||
return breiteWaereLaenge && laengeWaereBreite;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GeoJSON
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface GeoJsonFeature {
|
||||
type?: string;
|
||||
geometry?: { type?: string; coordinates?: unknown };
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON einlesen.
|
||||
*
|
||||
* Erwartet eine FeatureCollection mit Punkten. Die Eigenschaften werden mit
|
||||
* denselben Regeln gelesen wie bei der CSV — die Spaltennamen sind dieselbe
|
||||
* Lotterie.
|
||||
*/
|
||||
export function ausGeoJson(inhalt: string): Leseergebnis {
|
||||
const uebersprungen: Leseergebnis["uebersprungen"] = [];
|
||||
const hinweise: string[] = [];
|
||||
const hydranten: EingelesenerHydrant[] = [];
|
||||
|
||||
let daten: { type?: string; features?: GeoJsonFeature[] };
|
||||
try {
|
||||
daten = JSON.parse(inhalt) as typeof daten;
|
||||
} catch {
|
||||
return {
|
||||
hydranten: [],
|
||||
uebersprungen: [],
|
||||
hinweise: ["Die Datei ist kein gültiges JSON."],
|
||||
};
|
||||
}
|
||||
|
||||
const features = Array.isArray(daten.features) ? daten.features : [];
|
||||
if (features.length === 0) {
|
||||
hinweise.push("Keine Features gefunden — erwartet wird eine FeatureCollection.");
|
||||
}
|
||||
|
||||
features.forEach((feature, index) => {
|
||||
const zeile = index + 1;
|
||||
const koordinaten = feature.geometry?.coordinates;
|
||||
|
||||
if (feature.geometry?.type !== "Point" || !Array.isArray(koordinaten)) {
|
||||
uebersprungen.push({ zeile, grund: "Keine Punktgeometrie." });
|
||||
return;
|
||||
}
|
||||
|
||||
// GeoJSON: erst die Länge, dann die Breite.
|
||||
const laengengrad = zahl(koordinaten[0]);
|
||||
const breitengrad = zahl(koordinaten[1]);
|
||||
|
||||
if (breitengrad === null || laengengrad === null) {
|
||||
uebersprungen.push({ zeile, grund: "Koordinaten fehlen oder sind unlesbar." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!koordinatenPlausibel(breitengrad, laengengrad)) {
|
||||
uebersprungen.push({ zeile, grund: "Koordinaten liegen nicht auf der Erde." });
|
||||
return;
|
||||
}
|
||||
|
||||
hydranten.push(ausEigenschaften(feature.properties ?? {}, breitengrad, laengengrad));
|
||||
});
|
||||
|
||||
return { hydranten, uebersprungen, hinweise };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CSV
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Spaltennamen, unter denen ein Wert stecken kann. Kleingeschrieben geprüft. */
|
||||
const SPALTEN = {
|
||||
nummer: ["nummer", "nr", "id", "hydrantnr", "hydrant_nr", "objektnr", "externenummer"],
|
||||
bezeichnung: ["bezeichnung", "name", "beschriftung", "hydrant"],
|
||||
typ: ["typ", "art", "bauart", "hydranttyp", "kategorie"],
|
||||
status: ["status", "zustand", "betriebszustand", "funktion"],
|
||||
nennweite: ["nennweite", "dn", "durchmesser", "leitung", "leitungsdurchmesser"],
|
||||
durchfluss: ["durchfluss", "leistung", "menge", "l/min", "lmin", "ergiebigkeit"],
|
||||
strasse: ["strasse", "straße", "str", "adresse", "lage", "standort"],
|
||||
hausnummer: ["hausnummer", "hnr", "nr_haus"],
|
||||
breite: ["breitengrad", "breite", "lat", "latitude", "y", "geo_lat"],
|
||||
laenge: ["laengengrad", "längengrad", "laenge", "länge", "lon", "lng", "longitude", "x", "geo_lon"],
|
||||
pruefung: ["letztepruefung", "letzte_pruefung", "prüfung", "pruefdatum", "geprueft", "geprüft"],
|
||||
betreiber: ["betreiber", "eigentuemer", "eigentümer", "versorger", "stadtwerke"],
|
||||
hinweis: ["hinweis", "bemerkung", "notiz", "kommentar", "anmerkung"],
|
||||
} as const;
|
||||
|
||||
/** Findet den Wert einer Zeile unter irgendeinem der bekannten Spaltennamen. */
|
||||
function feld(zeile: Record<string, unknown>, namen: readonly string[]): string | null {
|
||||
for (const [spalte, wert] of Object.entries(zeile)) {
|
||||
const schluessel = spalte.toLowerCase().replace(/[\s._-]/g, "");
|
||||
if (namen.some((name) => schluessel === name.replace(/[\s._-]/g, ""))) {
|
||||
const text = typeof wert === "string" ? wert.trim() : wert;
|
||||
if (text !== null && text !== undefined && text !== "") return String(text);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ausEigenschaften(
|
||||
zeile: Record<string, unknown>,
|
||||
breitengrad: number,
|
||||
laengengrad: number,
|
||||
): EingelesenerHydrant {
|
||||
return {
|
||||
externeNummer: feld(zeile, SPALTEN.nummer),
|
||||
bezeichnung: feld(zeile, SPALTEN.bezeichnung),
|
||||
typ: typErkennen(feld(zeile, SPALTEN.typ)),
|
||||
status: statusErkennen(feld(zeile, SPALTEN.status)),
|
||||
nennweite: zahl(feld(zeile, SPALTEN.nennweite)),
|
||||
durchfluss: zahl(feld(zeile, SPALTEN.durchfluss)),
|
||||
strasse: feld(zeile, SPALTEN.strasse),
|
||||
hausnummer: feld(zeile, SPALTEN.hausnummer),
|
||||
breitengrad,
|
||||
laengengrad,
|
||||
letztePruefung: datum(feld(zeile, SPALTEN.pruefung)),
|
||||
betreiber: feld(zeile, SPALTEN.betreiber),
|
||||
hinweis: feld(zeile, SPALTEN.hinweis),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereits eingelesene CSV-Zeilen verarbeiten.
|
||||
*
|
||||
* Das Zerlegen der Datei übernimmt Papaparse im Import-Assistenten aus Phase 1
|
||||
* — hier kommen die Zeilen schon als Objekte an. So bleibt dieser Baustein
|
||||
* ohne Abhängigkeit und ohne Datei-Handling testbar.
|
||||
*/
|
||||
export function ausZeilen(zeilen: Array<Record<string, unknown>>): Leseergebnis {
|
||||
const uebersprungen: Leseergebnis["uebersprungen"] = [];
|
||||
const hinweise: string[] = [];
|
||||
const hydranten: EingelesenerHydrant[] = [];
|
||||
|
||||
let vertauschte = 0;
|
||||
|
||||
zeilen.forEach((zeile, index) => {
|
||||
const nummer = index + 2; // Zeile 1 ist die Kopfzeile.
|
||||
|
||||
let breitengrad = zahl(feld(zeile, SPALTEN.breite));
|
||||
let laengengrad = zahl(feld(zeile, SPALTEN.laenge));
|
||||
|
||||
if (breitengrad === null || laengengrad === null) {
|
||||
uebersprungen.push({ zeile: nummer, grund: "Keine Koordinaten in der Zeile." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (achsenVertauscht(breitengrad, laengengrad)) {
|
||||
[breitengrad, laengengrad] = [laengengrad, breitengrad];
|
||||
vertauschte += 1;
|
||||
}
|
||||
|
||||
if (!koordinatenPlausibel(breitengrad, laengengrad)) {
|
||||
uebersprungen.push({ zeile: nummer, grund: "Koordinaten liegen nicht auf der Erde." });
|
||||
return;
|
||||
}
|
||||
|
||||
hydranten.push(ausEigenschaften(zeile, breitengrad, laengengrad));
|
||||
});
|
||||
|
||||
if (vertauschte > 0) {
|
||||
hinweise.push(
|
||||
`${vertauschte} ${vertauschte === 1 ? "Zeile hatte" : "Zeilen hatten"} Breite und Länge vertauscht — beim Einlesen getauscht.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hydranten.length > 0) {
|
||||
const ohneNummer = hydranten.filter((hydrant) => !hydrant.externeNummer).length;
|
||||
if (ohneNummer > 0) {
|
||||
hinweise.push(
|
||||
`${ohneNummer} ${ohneNummer === 1 ? "Hydrant hat" : "Hydranten haben"} keine Nummer des Versorgers. Ein zweiter Import legt sie erneut an, statt sie zu aktualisieren.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { hydranten, uebersprungen, hinweise };
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Taktische Zeichen nach FwDV 102.
|
||||
*
|
||||
* Selbst gezeichnet, nicht eingekauft: fertige Zeichensätze sind selten frei
|
||||
* verwendbar, und ein Kartenmodul, das ohne Netz laufen soll, darf keine
|
||||
* Bilder von fremden Servern nachladen. Jedes Zeichen ist deshalb ein kurzer
|
||||
* SVG-Pfad in dieser Datei — zusammen wenige Kilobyte, im Zwischenspeicher des
|
||||
* Browsers wie alles andere.
|
||||
*
|
||||
* Die Formensprache der FwDV 102 in Kürze:
|
||||
*
|
||||
* * **Rechteck** — eine Einheit oder ein Fahrzeug.
|
||||
* * **Rechteck mit Schrägstrich** — eine Person oder Führungskraft.
|
||||
* * **Kreis** — eine Stelle oder Einrichtung.
|
||||
* * **Dreieck** — eine Gefahr oder ein Schadenereignis.
|
||||
* * **Rot** steht für die Feuerwehr, **blau** für Hilfsorganisationen,
|
||||
* **grün** für Polizei, **gelb** für Gefahr.
|
||||
*
|
||||
* Die Zeichen hier sind bewusst vereinfacht: sie müssen auf einem Tablet bei
|
||||
* Tageslicht in einer Karte erkennbar sein, nicht in einem Lehrbuch abgedruckt
|
||||
* werden. Wer die volle Bandbreite braucht, hat sie ohnehin auf Papier.
|
||||
*/
|
||||
|
||||
export type Zeichengruppe = "einheit" | "fahrzeug" | "person" | "stelle" | "gefahr" | "massnahme";
|
||||
|
||||
export interface TaktischesZeichen {
|
||||
schluessel: string;
|
||||
name: string;
|
||||
gruppe: Zeichengruppe;
|
||||
/** Grundfarbe nach FwDV 102. */
|
||||
farbe: string;
|
||||
/**
|
||||
* SVG-Inhalt in einem Feld von 0 0 100 100. Ohne eigenes `svg`-Element,
|
||||
* damit die Karte Größe und Drehung selbst bestimmt.
|
||||
*/
|
||||
pfad: string;
|
||||
}
|
||||
|
||||
const ROT = "#b91c1c";
|
||||
const BLAU = "#1d4ed8";
|
||||
const GELB = "#ca8a04";
|
||||
const SCHWARZ = "#111827";
|
||||
|
||||
/** Das Grundrechteck einer Einheit. */
|
||||
const RECHTECK = `<rect x="8" y="24" width="84" height="52" fill="none" stroke="currentColor" stroke-width="6"/>`;
|
||||
/** Das Rechteck mit Schrägstrich — eine Person. */
|
||||
const PERSON = `${RECHTECK}<line x1="8" y1="76" x2="92" y2="24" stroke="currentColor" stroke-width="6"/>`;
|
||||
/** Der Kreis einer Stelle. */
|
||||
const KREIS = `<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" stroke-width="6"/>`;
|
||||
/** Das Dreieck einer Gefahr. */
|
||||
const DREIECK = `<path d="M50 10 L92 84 L8 84 Z" fill="none" stroke="currentColor" stroke-width="6" stroke-linejoin="round"/>`;
|
||||
|
||||
export const ZEICHEN: TaktischesZeichen[] = [
|
||||
// --- Fahrzeuge -----------------------------------------------------------
|
||||
{
|
||||
schluessel: "loeschfahrzeug",
|
||||
name: "Löschfahrzeug",
|
||||
gruppe: "fahrzeug",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">LF</text>`,
|
||||
},
|
||||
{
|
||||
schluessel: "drehleiter",
|
||||
name: "Drehleiter",
|
||||
gruppe: "fahrzeug",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">DL</text>`,
|
||||
},
|
||||
{
|
||||
schluessel: "einsatzleitwagen",
|
||||
name: "Einsatzleitwagen",
|
||||
gruppe: "fahrzeug",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">ELW</text>`,
|
||||
},
|
||||
{
|
||||
schluessel: "ruestwagen",
|
||||
name: "Rüstwagen",
|
||||
gruppe: "fahrzeug",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><text x="50" y="58" text-anchor="middle" font-size="26" font-family="sans-serif" fill="currentColor">RW</text>`,
|
||||
},
|
||||
{
|
||||
schluessel: "rettungswagen",
|
||||
name: "Rettungswagen",
|
||||
gruppe: "fahrzeug",
|
||||
farbe: BLAU,
|
||||
pfad: `${RECHTECK}<circle cx="30" cy="76" r="8" fill="currentColor"/><circle cx="70" cy="76" r="8" fill="currentColor"/><path d="M42 42 h16 v-10 h-16 z M34 50 h32 v10 h-32 z" fill="currentColor"/>`,
|
||||
},
|
||||
|
||||
// --- Einheiten -----------------------------------------------------------
|
||||
{
|
||||
schluessel: "gruppe",
|
||||
name: "Gruppe",
|
||||
gruppe: "einheit",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<circle cx="34" cy="16" r="5" fill="currentColor"/><circle cx="50" cy="16" r="5" fill="currentColor"/><circle cx="66" cy="16" r="5" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "staffel",
|
||||
name: "Staffel",
|
||||
gruppe: "einheit",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<circle cx="42" cy="16" r="5" fill="currentColor"/><circle cx="58" cy="16" r="5" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "trupp",
|
||||
name: "Trupp",
|
||||
gruppe: "einheit",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<circle cx="50" cy="16" r="5" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "zug",
|
||||
name: "Zug",
|
||||
gruppe: "einheit",
|
||||
farbe: ROT,
|
||||
pfad: `${RECHTECK}<line x1="34" y1="10" x2="34" y2="20" stroke="currentColor" stroke-width="5"/><line x1="50" y1="10" x2="50" y2="20" stroke="currentColor" stroke-width="5"/><line x1="66" y1="10" x2="66" y2="20" stroke="currentColor" stroke-width="5"/>`,
|
||||
},
|
||||
|
||||
// --- Personen ------------------------------------------------------------
|
||||
{
|
||||
schluessel: "einsatzleiter",
|
||||
name: "Einsatzleiter",
|
||||
gruppe: "person",
|
||||
farbe: ROT,
|
||||
pfad: `${PERSON}<text x="50" y="60" text-anchor="middle" font-size="22" font-family="sans-serif" fill="currentColor">EL</text>`,
|
||||
},
|
||||
{
|
||||
schluessel: "atemschutzueberwachung",
|
||||
name: "Atemschutzüberwachung",
|
||||
gruppe: "person",
|
||||
farbe: ROT,
|
||||
pfad: `${PERSON}<text x="50" y="60" text-anchor="middle" font-size="20" font-family="sans-serif" fill="currentColor">AS</text>`,
|
||||
},
|
||||
|
||||
// --- Stellen und Einrichtungen -------------------------------------------
|
||||
{
|
||||
schluessel: "bereitstellungsraum",
|
||||
name: "Bereitstellungsraum",
|
||||
gruppe: "stelle",
|
||||
farbe: ROT,
|
||||
pfad: `${KREIS}<text x="50" y="60" text-anchor="middle" font-size="30" font-family="sans-serif" fill="currentColor">B</text>`,
|
||||
},
|
||||
{
|
||||
schluessel: "verteiler",
|
||||
name: "Verteiler",
|
||||
gruppe: "stelle",
|
||||
farbe: ROT,
|
||||
pfad: `<path d="M50 20 L80 70 L20 70 Z" fill="none" stroke="currentColor" stroke-width="6" stroke-linejoin="round"/><line x1="50" y1="70" x2="50" y2="90" stroke="currentColor" stroke-width="6"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "hydrant",
|
||||
name: "Löschwasserentnahme",
|
||||
gruppe: "stelle",
|
||||
farbe: BLAU,
|
||||
pfad: `${KREIS}<path d="M50 26 C 34 46 34 58 50 74 C 66 58 66 46 50 26 Z" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "sammelplatz",
|
||||
name: "Sammelplatz",
|
||||
gruppe: "stelle",
|
||||
farbe: BLAU,
|
||||
pfad: `${KREIS}<circle cx="40" cy="42" r="8" fill="currentColor"/><circle cx="60" cy="42" r="8" fill="currentColor"/><path d="M26 70 q24 -16 48 0" fill="none" stroke="currentColor" stroke-width="6"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "abschnitt",
|
||||
name: "Einsatzabschnitt",
|
||||
gruppe: "stelle",
|
||||
farbe: SCHWARZ,
|
||||
pfad: `<rect x="8" y="24" width="84" height="52" fill="none" stroke="currentColor" stroke-width="6" stroke-dasharray="10 8"/>`,
|
||||
},
|
||||
|
||||
// --- Gefahren und Ereignisse ---------------------------------------------
|
||||
{
|
||||
schluessel: "brandstelle",
|
||||
name: "Brandstelle",
|
||||
gruppe: "gefahr",
|
||||
farbe: ROT,
|
||||
pfad: `<path d="M50 12 C 62 34 78 40 72 60 C 68 76 58 86 50 88 C 42 86 32 76 28 60 C 22 40 38 34 50 12 Z" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "gefahr",
|
||||
name: "Gefahrenstelle",
|
||||
gruppe: "gefahr",
|
||||
farbe: GELB,
|
||||
pfad: `${DREIECK}<line x1="50" y1="38" x2="50" y2="62" stroke="currentColor" stroke-width="7" stroke-linecap="round"/><circle cx="50" cy="73" r="4" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "gefahrstoff",
|
||||
name: "Gefahrstoff",
|
||||
gruppe: "gefahr",
|
||||
farbe: GELB,
|
||||
pfad: `${DREIECK}<circle cx="50" cy="56" r="9" fill="currentColor"/><path d="M50 36 l7 12 h-14 z M32 68 l12 -7 v14 z M68 68 l-12 -7 v14 z" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "verletzte",
|
||||
name: "Verletzte",
|
||||
gruppe: "gefahr",
|
||||
farbe: BLAU,
|
||||
pfad: `${KREIS}<path d="M42 30 h16 v16 h16 v16 h-16 v16 h-16 v-16 h-16 v-16 h16 z" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "absperrung",
|
||||
name: "Absperrung",
|
||||
gruppe: "massnahme",
|
||||
farbe: SCHWARZ,
|
||||
pfad: `<line x1="6" y1="50" x2="94" y2="50" stroke="currentColor" stroke-width="8" stroke-dasharray="16 10"/><line x1="20" y1="34" x2="20" y2="66" stroke="currentColor" stroke-width="6"/><line x1="80" y1="34" x2="80" y2="66" stroke="currentColor" stroke-width="6"/>`,
|
||||
},
|
||||
{
|
||||
schluessel: "menschenrettung",
|
||||
name: "Menschenrettung",
|
||||
gruppe: "massnahme",
|
||||
farbe: ROT,
|
||||
pfad: `${KREIS}<circle cx="50" cy="34" r="9" fill="currentColor"/><path d="M50 44 v22 M50 50 l-14 8 M50 50 l14 8 M50 66 l-10 16 M50 66 l10 16" stroke="currentColor" stroke-width="6" fill="none" stroke-linecap="round"/>`,
|
||||
},
|
||||
];
|
||||
|
||||
/** Nachschlagen über den Schlüssel — das steht im Datensatz. */
|
||||
export const ZEICHEN_KARTE = new Map(ZEICHEN.map((zeichen) => [zeichen.schluessel, zeichen]));
|
||||
|
||||
export const GRUPPE_TEXT: Record<Zeichengruppe, string> = {
|
||||
fahrzeug: "Fahrzeuge",
|
||||
einheit: "Einheiten",
|
||||
person: "Führung",
|
||||
stelle: "Stellen",
|
||||
gefahr: "Gefahren",
|
||||
massnahme: "Maßnahmen",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein Zeichen als vollständiges SVG.
|
||||
*
|
||||
* Für die Karte und für die Auswahlleiste — beide brauchen dasselbe Bild in
|
||||
* unterschiedlicher Größe.
|
||||
*/
|
||||
export function alsSvg(schluessel: string, groesse = 40, farbe?: string): string {
|
||||
const zeichen = ZEICHEN_KARTE.get(schluessel);
|
||||
if (!zeichen) return "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${groesse}" height="${groesse}" style="color:${farbe ?? zeichen.farbe}" role="img" aria-label="${zeichen.name}">${zeichen.pfad}</svg>`;
|
||||
}
|
||||
|
||||
/** Als Data-URI — so nimmt MapLibre ein Bild ohne einen zweiten Abruf entgegen. */
|
||||
export function alsDataUri(schluessel: string, groesse = 40, farbe?: string): string {
|
||||
const svg = alsSvg(schluessel, groesse, farbe);
|
||||
if (!svg) return "";
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Wasserförderung über lange Wegstrecke.
|
||||
*
|
||||
* Die Frage, die das Modul beantwortet: **Wie viele Pumpen brauche ich, und wo
|
||||
* stehen sie?** Sie stellt sich, wenn die Einsatzstelle weiter von der
|
||||
* Entnahme entfernt liegt, als eine Pumpe drücken kann — und sie wird sonst
|
||||
* mit Tabelle und Bleistift auf der Motorhaube gerechnet.
|
||||
*
|
||||
* Drei Größen kosten Druck:
|
||||
*
|
||||
* 1. **Reibung im Schlauch.** Steigt mit dem Quadrat der Fördermenge und
|
||||
* sinkt stark mit dem Durchmesser — deshalb ist die Frage „B oder A"
|
||||
* wichtiger als jede andere.
|
||||
* 2. **Höhenunterschied.** Zehn Meter Steigung sind ein bar, unabhängig von
|
||||
* allem anderen. Bergab gewinnt man ihn zurück.
|
||||
* 3. **Der Mindestdruck am Eingang der nächsten Pumpe.** Darunter zieht sie
|
||||
* Luft.
|
||||
*
|
||||
* Die Zahlen hier sind die üblichen Werte der Feuerwehrausbildung. Sie sind
|
||||
* bewusst konservativ: eine Rechnung, die knapp aufgeht, geht im Einsatz nicht
|
||||
* auf, weil Kupplungen, Knicke und ein müder Schlauch nicht in der Formel
|
||||
* stehen.
|
||||
*
|
||||
* **Das Ergebnis ist eine Planungshilfe, keine Zusage.** Was zählt, ist der
|
||||
* Druck am Verteiler — und den liest jemand ab.
|
||||
*/
|
||||
|
||||
/** Schlauchdurchmesser, die es gibt. */
|
||||
export const DURCHMESSER = [52, 75, 110] as const;
|
||||
export type Durchmesser = (typeof DURCHMESSER)[number];
|
||||
|
||||
export const DURCHMESSER_TEXT: Record<Durchmesser, string> = {
|
||||
52: "C-Schlauch (52 mm)",
|
||||
75: "B-Schlauch (75 mm)",
|
||||
110: "A-Schlauch (110 mm)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Reibungsbeiwert je Durchmesser.
|
||||
*
|
||||
* Der Druckverlust je 100 Meter wird gerechnet als
|
||||
*
|
||||
* verlust = beiwert × (menge / 100)²
|
||||
*
|
||||
* mit der Menge in Litern je Minute. Die Beiwerte sind so gewählt, dass sie
|
||||
* die Tabellenwerte der Ausbildung treffen — etwa 1,5 bar je 100 m im
|
||||
* B-Schlauch bei 800 l/min.
|
||||
*/
|
||||
const BEIWERT: Record<Durchmesser, number> = {
|
||||
// C bei 100 l/min: 0,5 bar je 100 m — bei 200 l/min schon 2 bar.
|
||||
52: 0.5,
|
||||
// B bei 800 l/min: 1,5 bar je 100 m. Der Wert, den jeder Maschinist kennt.
|
||||
75: 0.0234,
|
||||
// A bei 800 l/min: 0,2 bar je 100 m. Deshalb legt man A, wenn es weit wird.
|
||||
110: 0.003,
|
||||
};
|
||||
|
||||
/** Eine Länge Schlauch. Gerechnet wird in Metern, gelegt wird in Längen. */
|
||||
export const LAENGE_METER = 20;
|
||||
|
||||
/** Zehn Meter Höhenunterschied kosten ein bar. */
|
||||
export const BAR_JE_METER = 0.1;
|
||||
|
||||
/**
|
||||
* Druckverlust durch Reibung, in bar.
|
||||
*
|
||||
* @param laengeMeter Länge der Strecke in Metern
|
||||
* @param foerdermenge Liter je Minute
|
||||
*/
|
||||
export function reibungsverlust(
|
||||
laengeMeter: number,
|
||||
foerdermenge: number,
|
||||
durchmesser: Durchmesser,
|
||||
): number {
|
||||
if (laengeMeter <= 0 || foerdermenge <= 0) return 0;
|
||||
|
||||
const jeHundert = BEIWERT[durchmesser] * Math.pow(foerdermenge / 100, 2);
|
||||
return runden((laengeMeter / 100) * jeHundert);
|
||||
}
|
||||
|
||||
/** Druckverlust durch Höhenunterschied, in bar. Negativ, wenn es bergab geht. */
|
||||
export function hoehenverlust(hoehenunterschiedMeter: number): number {
|
||||
return runden(hoehenunterschiedMeter * BAR_JE_METER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernung zwischen zwei Punkten in Metern.
|
||||
*
|
||||
* Haversine auf der Kugel. Für ein Ausrückegebiet ist der Unterschied zum
|
||||
* Ellipsoid kleiner als die Ungenauigkeit beim Schlauchlegen.
|
||||
*/
|
||||
export function entfernung(
|
||||
von: { breitengrad: number; laengengrad: number },
|
||||
nach: { breitengrad: number; laengengrad: number },
|
||||
): number {
|
||||
const ERDRADIUS = 6_371_000;
|
||||
const bogen = (grad: number) => (grad * Math.PI) / 180;
|
||||
|
||||
const dBreite = bogen(nach.breitengrad - von.breitengrad);
|
||||
const dLaenge = bogen(nach.laengengrad - von.laengengrad);
|
||||
|
||||
const a =
|
||||
Math.sin(dBreite / 2) ** 2 +
|
||||
Math.cos(bogen(von.breitengrad)) * Math.cos(bogen(nach.breitengrad)) * Math.sin(dLaenge / 2) ** 2;
|
||||
|
||||
return Math.round(ERDRADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
|
||||
}
|
||||
|
||||
export interface Streckenpunkt {
|
||||
bezeichnung?: string | null;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
/** Höhe über Normalnull in Metern. Fehlt sie, wird mit eben gerechnet. */
|
||||
hoehe?: number | null;
|
||||
}
|
||||
|
||||
export interface Abschnitt {
|
||||
von: string;
|
||||
nach: string;
|
||||
laengeMeter: number;
|
||||
/** Zahl der Schlauchlängen, aufgerundet — halbe Längen gibt es nicht. */
|
||||
laengen: number;
|
||||
hoehenunterschied: number;
|
||||
reibungsverlust: number;
|
||||
hoehenverlust: number;
|
||||
/** Druck am Ende des Abschnitts, in bar. */
|
||||
restdruck: number;
|
||||
/** Muss hier eine Verstärkerpumpe stehen? */
|
||||
pumpeNoetig: boolean;
|
||||
}
|
||||
|
||||
export interface Foerderplan {
|
||||
abschnitte: Abschnitt[];
|
||||
gesamtlaenge: number;
|
||||
/** Schlauchlängen insgesamt — die Zahl, die der Maschinist braucht. */
|
||||
laengen: number;
|
||||
hoehenunterschied: number;
|
||||
/** Pumpen insgesamt, die erste eingerechnet. */
|
||||
pumpen: number;
|
||||
/** Druck, der an der Einsatzstelle ankommt. */
|
||||
enddruck: number;
|
||||
/** Klartext, was zu tun ist — die Zahlen allein plant niemand. */
|
||||
hinweise: string[];
|
||||
/** Geht die Strecke überhaupt auf? */
|
||||
machbar: boolean;
|
||||
}
|
||||
|
||||
export interface Foerderangaben {
|
||||
punkte: Streckenpunkt[];
|
||||
durchmesser: Durchmesser;
|
||||
foerdermenge: number;
|
||||
/** Womit eine Pumpe herausdrückt. */
|
||||
ausgangsdruck: number;
|
||||
/** Womit die nächste mindestens versorgt sein muss. */
|
||||
eingangsdruck: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet die Strecke durch.
|
||||
*
|
||||
* Vorgehen: von der Entnahme aus Abschnitt für Abschnitt den Druck abziehen.
|
||||
* Fällt er unter den Mindesteingangsdruck, muss **vor** diesem Abschnitt eine
|
||||
* Pumpe stehen — sie setzt den Druck wieder auf den Ausgangsdruck.
|
||||
*
|
||||
* Reicht ein einzelner Abschnitt selbst mit voller Pumpe nicht, ist die
|
||||
* Strecke so nicht zu fördern. Dann sagt die Ausgabe das, statt eine Zahl
|
||||
* auszuwerfen, die niemand halten kann.
|
||||
*/
|
||||
export function foerderplan(angaben: Foerderangaben): Foerderplan {
|
||||
const { punkte, durchmesser, foerdermenge, ausgangsdruck, eingangsdruck } = angaben;
|
||||
|
||||
const leer: Foerderplan = {
|
||||
abschnitte: [],
|
||||
gesamtlaenge: 0,
|
||||
laengen: 0,
|
||||
hoehenunterschied: 0,
|
||||
pumpen: punkte.length > 0 ? 1 : 0,
|
||||
enddruck: 0,
|
||||
hinweise: ["Mindestens zwei Punkte nötig: Entnahmestelle und Einsatzstelle."],
|
||||
machbar: false,
|
||||
};
|
||||
|
||||
if (punkte.length < 2) return leer;
|
||||
|
||||
const abschnitte: Abschnitt[] = [];
|
||||
const hinweise: string[] = [];
|
||||
|
||||
let druck = ausgangsdruck;
|
||||
let pumpen = 1;
|
||||
let gesamtlaenge = 0;
|
||||
let machbar = true;
|
||||
|
||||
const name = (punkt: Streckenpunkt, nummer: number) =>
|
||||
punkt.bezeichnung?.trim() || `Punkt ${nummer}`;
|
||||
|
||||
for (let i = 1; i < punkte.length; i += 1) {
|
||||
const von = punkte[i - 1]!;
|
||||
const nach = punkte[i]!;
|
||||
|
||||
const laengeMeter = entfernung(von, nach);
|
||||
const hoehenunterschied = (nach.hoehe ?? 0) - (von.hoehe ?? 0);
|
||||
|
||||
const reibung = reibungsverlust(laengeMeter, foerdermenge, durchmesser);
|
||||
const hoehe = hoehenverlust(hoehenunterschied);
|
||||
const verlust = reibung + hoehe;
|
||||
|
||||
// Reicht der Druck bis ans Ende des Abschnitts?
|
||||
let pumpeNoetig = false;
|
||||
if (druck - verlust < eingangsdruck) {
|
||||
// Vor diesem Abschnitt muss eine Pumpe stehen.
|
||||
pumpeNoetig = true;
|
||||
pumpen += 1;
|
||||
druck = ausgangsdruck;
|
||||
|
||||
// Und wenn selbst die volle Pumpe nicht reicht, geht es so nicht.
|
||||
if (druck - verlust < eingangsdruck) {
|
||||
machbar = false;
|
||||
hinweise.push(
|
||||
`Abschnitt ${name(von, i)} → ${name(nach, i + 1)} ist mit ${verlust.toFixed(1)} bar Verlust zu lang für eine Pumpe. Strecke unterteilen, größeren Durchmesser wählen oder weniger fördern.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
druck = runden(druck - verlust);
|
||||
gesamtlaenge += laengeMeter;
|
||||
|
||||
abschnitte.push({
|
||||
von: name(von, i),
|
||||
nach: name(nach, i + 1),
|
||||
laengeMeter,
|
||||
laengen: Math.ceil(laengeMeter / LAENGE_METER),
|
||||
hoehenunterschied,
|
||||
reibungsverlust: reibung,
|
||||
hoehenverlust: hoehe,
|
||||
restdruck: druck,
|
||||
pumpeNoetig,
|
||||
});
|
||||
}
|
||||
|
||||
const hoehenunterschiedGesamt =
|
||||
(punkte.at(-1)?.hoehe ?? 0) - (punkte[0]?.hoehe ?? 0);
|
||||
|
||||
if (machbar) {
|
||||
hinweise.push(
|
||||
pumpen === 1
|
||||
? "Eine Pumpe genügt für diese Strecke."
|
||||
: `${pumpen} Pumpen: die Entnahmestelle und ${pumpen - 1} Verstärker${pumpen - 1 === 1 ? "" : "pumpen"}.`,
|
||||
);
|
||||
|
||||
if (hoehenunterschiedGesamt >= 20) {
|
||||
hinweise.push(
|
||||
`${hoehenunterschiedGesamt} Höhenmeter kosten allein ${hoehenverlust(hoehenunterschiedGesamt).toFixed(1)} bar — das ist mehr als die halbe Leistung einer Pumpe.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hoehenunterschiedGesamt <= -20) {
|
||||
hinweise.push(
|
||||
"Die Strecke fällt deutlich ab. Am Ende steht mehr Druck an als gerechnet — Druckbegrenzung am Verteiler beachten.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hinweise.push(
|
||||
"Planungshilfe. Was zählt, ist der Druck am Verteiler — und den liest jemand ab.",
|
||||
);
|
||||
|
||||
return {
|
||||
abschnitte,
|
||||
gesamtlaenge,
|
||||
laengen: abschnitte.reduce((summe, abschnitt) => summe + abschnitt.laengen, 0),
|
||||
hoehenunterschied: hoehenunterschiedGesamt,
|
||||
pumpen,
|
||||
enddruck: druck,
|
||||
hinweise,
|
||||
machbar,
|
||||
};
|
||||
}
|
||||
|
||||
/** Auf eine Nachkommastelle — mehr Genauigkeit gibt kein Manometer her. */
|
||||
function runden(wert: number): number {
|
||||
return Math.round(wert * 10) / 10;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import { portalRouter } from "@/server/routers/einsatz/portal";
|
||||
import { kioskRouter } from "@/server/routers/kiosk/kiosk";
|
||||
import { atemschutzRouter } from "@/server/routers/atemschutz/ueberwachung";
|
||||
import { atemschutzTraegerRouter } from "@/server/routers/atemschutz/traeger";
|
||||
import { hydrantRouter } from "@/server/routers/karte/hydrant";
|
||||
import { objektRouter } from "@/server/routers/karte/objekt";
|
||||
import { lagekarteRouter, wasserfoerderungRouter } from "@/server/routers/karte/lagekarte";
|
||||
|
||||
/**
|
||||
* Wurzel-Router.
|
||||
@@ -172,6 +175,12 @@ export const appRouter = router({
|
||||
// Phase 5 — Atemschutz nach FwDV 7
|
||||
atemschutz: atemschutzRouter,
|
||||
atemschutzTraeger: atemschutzTraegerRouter,
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
hydrant: hydrantRouter,
|
||||
objekt: objektRouter,
|
||||
lagekarte: lagekarteRouter,
|
||||
wasserfoerderung: wasserfoerderungRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { HydrantStatus, HydrantTyp, Prisma } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { ausGeoJson, ausZeilen } from "@/lib/karte/hydrant-import";
|
||||
import { entfernung } from "@/lib/karte/wasserfoerderung";
|
||||
|
||||
/**
|
||||
* Hydranten und andere Löschwasserstellen.
|
||||
*
|
||||
* Die Abfrage, auf die es ankommt, ist die Umkreissuche: **was liegt in der
|
||||
* Nähe der Einsatzstelle?** Sie läuft über den PostGIS-Punkt und den
|
||||
* GiST-Index, nicht über die Dezimalgrade — bei tausend Hydranten wäre alles
|
||||
* andere ein vollständiger Durchlauf der Tabelle, und gefragt wird auf der
|
||||
* Anfahrt.
|
||||
*
|
||||
* Gelöscht wird weich. Ein Hydrant, der laut Liste nicht mehr existiert, ist
|
||||
* im Zweifel nur nicht mehr in der Liste — und wer davorsteht, will wissen,
|
||||
* was zuletzt darüber bekannt war.
|
||||
*/
|
||||
|
||||
const koordinaten = z.object({
|
||||
breitengrad: z.number().min(-90).max(90),
|
||||
laengengrad: z.number().min(-180).max(180),
|
||||
});
|
||||
|
||||
const hydrantFelder = z.object({
|
||||
bezeichnung: z.string().trim().max(120).nullish(),
|
||||
typ: z.nativeEnum(HydrantTyp).default(HydrantTyp.UNTERFLUR),
|
||||
status: z.nativeEnum(HydrantStatus).default(HydrantStatus.UNBEKANNT),
|
||||
nennweite: z.number().int().min(10).max(1000).nullish(),
|
||||
durchfluss: z.number().int().min(0).max(50_000).nullish(),
|
||||
strasse: z.string().trim().max(200).nullish(),
|
||||
hausnummer: z.string().trim().max(20).nullish(),
|
||||
ortsteil: z.string().trim().max(120).nullish(),
|
||||
letztePruefung: z.coerce.date().nullish(),
|
||||
naechstePruefung: z.coerce.date().nullish(),
|
||||
betreiber: z.string().trim().max(200).nullish(),
|
||||
hinweis: z.string().trim().max(1000).nullish(),
|
||||
fotoDateiId: z.string().uuid().nullish(),
|
||||
});
|
||||
|
||||
/** Was jede Ansicht braucht. */
|
||||
const auswahl = {
|
||||
id: true,
|
||||
bezeichnung: true,
|
||||
typ: true,
|
||||
status: true,
|
||||
nennweite: true,
|
||||
durchfluss: true,
|
||||
strasse: true,
|
||||
hausnummer: true,
|
||||
ortsteil: true,
|
||||
breitengrad: true,
|
||||
laengengrad: true,
|
||||
letztePruefung: true,
|
||||
naechstePruefung: true,
|
||||
betreiber: true,
|
||||
hinweis: true,
|
||||
fotoDateiId: true,
|
||||
quelle: true,
|
||||
externeNummer: true,
|
||||
} as const;
|
||||
|
||||
export const hydrantRouter = router({
|
||||
/** Alle Hydranten für die Karte. */
|
||||
liste: mitRecht("geraet.lesen")
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
suche: z.string().trim().max(120).optional(),
|
||||
typ: z.nativeEnum(HydrantTyp).optional(),
|
||||
status: z.nativeEnum(HydrantStatus).optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.hydrant.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
...(input?.typ ? { typ: input.typ } : {}),
|
||||
...(input?.status ? { status: input.status } : {}),
|
||||
...(input?.suche
|
||||
? {
|
||||
OR: [
|
||||
{ bezeichnung: { contains: input.suche, mode: "insensitive" } },
|
||||
{ strasse: { contains: input.suche, mode: "insensitive" } },
|
||||
{ externeNummer: { contains: input.suche, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ strasse: "asc" }, { bezeichnung: "asc" }],
|
||||
select: auswahl,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/**
|
||||
* Was liegt in der Nähe?
|
||||
*
|
||||
* Die Abfrage geht über PostGIS: `ST_DWithin` auf der Geografie nutzt den
|
||||
* GiST-Index und rechnet in Metern, nicht in Grad — ein Grad Länge ist in
|
||||
* Bayern etwas anderes als am Äquator.
|
||||
*/
|
||||
umkreis: mitRecht("geraet.lesen")
|
||||
.input(
|
||||
koordinaten.extend({
|
||||
umkreisMeter: z.number().int().min(50).max(20_000).default(500),
|
||||
anzahl: z.number().int().min(1).max(100).default(20),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const treffer = await ctx.db((db) =>
|
||||
db.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
bezeichnung: string | null;
|
||||
typ: HydrantTyp;
|
||||
status: HydrantStatus;
|
||||
nennweite: number | null;
|
||||
durchfluss: number | null;
|
||||
strasse: string | null;
|
||||
breitengrad: Prisma.Decimal;
|
||||
laengengrad: Prisma.Decimal;
|
||||
entfernung: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
"id", "bezeichnung", "typ", "status", "nennweite", "durchfluss",
|
||||
"strasse", "breitengrad", "laengengrad",
|
||||
ROUND(ST_Distance(
|
||||
"koordinaten",
|
||||
ST_SetSRID(ST_MakePoint(${input.laengengrad}, ${input.breitengrad}), 4326)::geography
|
||||
))::int AS "entfernung"
|
||||
FROM "hydrant"
|
||||
WHERE "geloeschtAm" IS NULL
|
||||
AND ST_DWithin(
|
||||
"koordinaten",
|
||||
ST_SetSRID(ST_MakePoint(${input.laengengrad}, ${input.breitengrad}), 4326)::geography,
|
||||
${input.umkreisMeter}
|
||||
)
|
||||
ORDER BY "entfernung" ASC
|
||||
LIMIT ${input.anzahl}
|
||||
`,
|
||||
);
|
||||
|
||||
return treffer.map((eintrag) => ({
|
||||
...eintrag,
|
||||
breitengrad: Number(eintrag.breitengrad),
|
||||
laengengrad: Number(eintrag.laengengrad),
|
||||
}));
|
||||
}),
|
||||
|
||||
einzeln: mitRecht("geraet.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const hydrant = await ctx.db((db) =>
|
||||
db.hydrant.findUnique({ where: { id: input.id }, select: auswahl }),
|
||||
);
|
||||
|
||||
if (!hydrant) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Hydranten gibt es nicht." });
|
||||
}
|
||||
|
||||
return hydrant;
|
||||
}),
|
||||
|
||||
anlegen: mitRecht("geraet.bearbeiten")
|
||||
.input(hydrantFelder.merge(koordinaten))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const hydrant = await db.hydrant.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
...input,
|
||||
quelle: "hand",
|
||||
},
|
||||
select: auswahl,
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Hydrant",
|
||||
entitaetId: hydrant.id,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { bezeichnung: hydrant.bezeichnung, typ: hydrant.typ },
|
||||
});
|
||||
|
||||
return hydrant;
|
||||
}),
|
||||
),
|
||||
|
||||
bearbeiten: mitRecht("geraet.bearbeiten")
|
||||
.input(hydrantFelder.partial().merge(koordinaten.partial()).extend({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...felder } = input;
|
||||
|
||||
const vorher = await db.hydrant.findUniqueOrThrow({ where: { id }, select: auswahl });
|
||||
const nachher = await db.hydrant.update({ where: { id }, data: felder, select: auswahl });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Hydrant",
|
||||
entitaetId: id,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
entfernen: mitRecht("geraet.bearbeiten")
|
||||
.input(z.object({ id: z.string().uuid(), grund: z.string().trim().max(300).optional() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
await db.hydrant.update({
|
||||
where: { id: input.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Hydrant",
|
||||
entitaetId: input.id,
|
||||
sitzung: ctx.sitzung,
|
||||
beschreibung: input.grund,
|
||||
});
|
||||
|
||||
return { entfernt: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Import aus GeoJSON oder aus einer Tabelle.
|
||||
*
|
||||
* Zwei Durchgänge: erst lesen und zeigen, was dabei herauskäme
|
||||
* (`nurPruefen`), dann übernehmen. Dasselbe Vorgehen wie beim
|
||||
* Geräteimport aus Phase 1 — niemand schreibt tausend Datensätze in eine
|
||||
* Datenbank, ohne vorher gesehen zu haben, was drinsteht.
|
||||
*
|
||||
* Wiederholbar: Hydranten mit einer Nummer des Versorgers werden
|
||||
* aktualisiert statt doppelt angelegt.
|
||||
*/
|
||||
importieren: mitRecht("geraet.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
format: z.enum(["geojson", "zeilen"]),
|
||||
/** GeoJSON als Text. */
|
||||
inhalt: z.string().max(20_000_000).optional(),
|
||||
/** Bereits zerlegte Tabellenzeilen. */
|
||||
zeilen: z.array(z.record(z.unknown())).max(20_000).optional(),
|
||||
nurPruefen: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gelesen =
|
||||
input.format === "geojson"
|
||||
? ausGeoJson(input.inhalt ?? "")
|
||||
: ausZeilen(input.zeilen ?? []);
|
||||
|
||||
if (input.nurPruefen) {
|
||||
return {
|
||||
uebernommen: 0,
|
||||
aktualisiert: 0,
|
||||
gefunden: gelesen.hydranten.length,
|
||||
uebersprungen: gelesen.uebersprungen,
|
||||
hinweise: gelesen.hinweise,
|
||||
vorschau: gelesen.hydranten.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
const quelle = input.format === "geojson" ? "geojson" : "tabelle";
|
||||
|
||||
const ergebnis = await ctx.db(async (db) => {
|
||||
let neu = 0;
|
||||
let aktualisiert = 0;
|
||||
|
||||
for (const hydrant of gelesen.hydranten) {
|
||||
const vorhanden = hydrant.externeNummer
|
||||
? await db.hydrant.findFirst({
|
||||
where: { externeNummer: hydrant.externeNummer },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (vorhanden) {
|
||||
await db.hydrant.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: { ...hydrant, quelle, geloeschtAm: null },
|
||||
});
|
||||
aktualisiert += 1;
|
||||
} else {
|
||||
await db.hydrant.create({
|
||||
data: { organisationId: ctx.sitzung.organisationId, ...hydrant, quelle },
|
||||
});
|
||||
neu += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Hydrant",
|
||||
sitzung: ctx.sitzung,
|
||||
beschreibung: `Import aus ${quelle}: ${neu} neu, ${aktualisiert} aktualisiert, ${gelesen.uebersprungen.length} übersprungen`,
|
||||
});
|
||||
|
||||
return { neu, aktualisiert };
|
||||
});
|
||||
|
||||
return {
|
||||
uebernommen: ergebnis.neu,
|
||||
aktualisiert: ergebnis.aktualisiert,
|
||||
gefunden: gelesen.hydranten.length,
|
||||
uebersprungen: gelesen.uebersprungen,
|
||||
hinweise: gelesen.hinweise,
|
||||
vorschau: [],
|
||||
};
|
||||
}),
|
||||
|
||||
/** Zahlen für die Übersicht. */
|
||||
kennzahlen: mitRecht("geraet.lesen").query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const [gesamt, defekt, ungeprueft] = await Promise.all([
|
||||
db.hydrant.count({ where: { geloeschtAm: null } }),
|
||||
db.hydrant.count({ where: { geloeschtAm: null, status: HydrantStatus.DEFEKT } }),
|
||||
db.hydrant.count({ where: { geloeschtAm: null, letztePruefung: null } }),
|
||||
]);
|
||||
|
||||
return { gesamt, defekt, ungeprueft };
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export { entfernung };
|
||||
@@ -0,0 +1,320 @@
|
||||
import { z } from "zod";
|
||||
import { LageelementArt } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { ZEICHEN_KARTE } from "@/lib/karte/taktische-zeichen";
|
||||
import { foerderplan, type Durchmesser } from "@/lib/karte/wasserfoerderung";
|
||||
|
||||
/**
|
||||
* Lagekarte und Wasserförderung.
|
||||
*
|
||||
* Die Lagekarte ist der gemeinsame Zeichenblock eines Einsatzes: was einer
|
||||
* einträgt, sehen die anderen. Verteilt wird das über denselben Ereignisstrom
|
||||
* wie in Phase 5 — der Server meldet „hat sich geändert", die Ansicht holt den
|
||||
* Stand. Geschrieben wird über tRPC.
|
||||
*
|
||||
* Gelöscht wird weich. Eine Lagekarte ist die Mitschrift einer Entscheidung:
|
||||
* was jemand um 14:12 eingezeichnet hat, gehört auch dann noch dazu, wenn es
|
||||
* um 14:20 nicht mehr stimmte.
|
||||
*
|
||||
* Die Wasserförderung speichert nur die Eingaben, nie das Ergebnis. Eine
|
||||
* gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne falsch
|
||||
* auszusehen — gerechnet wird bei jeder Anzeige neu.
|
||||
*/
|
||||
|
||||
const punkt = z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]);
|
||||
|
||||
export const lagekarteRouter = router({
|
||||
/** Die Lagekarte eines Einsatzes — wird beim ersten Aufruf angelegt. */
|
||||
fuerEinsatz: mitRecht("einsatz.lesen")
|
||||
.input(z.object({ einsatzId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const karte = await db.lagekarte.findUnique({
|
||||
where: { einsatzId: input.einsatzId },
|
||||
include: {
|
||||
elemente: {
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
include: {
|
||||
erstelltVon: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (karte) return { ...karte, stand: new Date() };
|
||||
|
||||
// Noch keine: die Einsatzdaten geben den Ausschnitt vor.
|
||||
const einsatz = await db.einsatz.findUnique({
|
||||
where: { id: input.einsatzId },
|
||||
select: { breitengrad: true, laengengrad: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: null,
|
||||
einsatzId: input.einsatzId,
|
||||
breitengrad: einsatz?.breitengrad ?? null,
|
||||
laengengrad: einsatz?.laengengrad ?? null,
|
||||
zoomstufe: null,
|
||||
elemente: [],
|
||||
stand: new Date(),
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Ein Element eintragen.
|
||||
*
|
||||
* Legt die Karte an, falls es noch keine gibt — wer zeichnet, soll nicht
|
||||
* vorher einen Knopf „Lagekarte anlegen" suchen müssen.
|
||||
*/
|
||||
eintragen: mitRecht("einsatz.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
einsatzId: z.string().uuid(),
|
||||
art: z.nativeEnum(LageelementArt).default(LageelementArt.ZEICHEN),
|
||||
zeichen: z.string().trim().max(60).nullish(),
|
||||
text: z.string().trim().max(300).nullish(),
|
||||
farbe: z.string().trim().max(20).nullish(),
|
||||
geometrie: z.array(punkt).min(1).max(500),
|
||||
drehung: z.number().int().min(0).max(359).default(0),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
// Ein Zeichen, das es nicht gibt, wäre auf der Karte ein leerer Fleck.
|
||||
if (input.art === LageelementArt.ZEICHEN && input.zeichen) {
|
||||
if (!ZEICHEN_KARTE.has(input.zeichen)) {
|
||||
throw new Error(`Das taktische Zeichen „${input.zeichen}" gibt es nicht.`);
|
||||
}
|
||||
}
|
||||
|
||||
const karte = await db.lagekarte.upsert({
|
||||
where: { einsatzId: input.einsatzId },
|
||||
update: {},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
einsatzId: input.einsatzId,
|
||||
breitengrad: input.geometrie[0]![1],
|
||||
laengengrad: input.geometrie[0]![0],
|
||||
zoomstufe: 16,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const element = await db.lageelement.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
lagekarteId: karte.id,
|
||||
art: input.art,
|
||||
zeichen: input.zeichen ?? null,
|
||||
text: input.text ?? null,
|
||||
farbe: input.farbe ?? null,
|
||||
geometrie: input.geometrie,
|
||||
drehung: input.drehung,
|
||||
erstelltVonId: ctx.sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
// Damit die Ansicht am Ereignisstrom merkt, dass sich etwas getan hat.
|
||||
await db.lagekarte.update({
|
||||
where: { id: karte.id },
|
||||
data: { geaendertAm: new Date() },
|
||||
});
|
||||
|
||||
return element;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Ein Element verschieben oder umbenennen. */
|
||||
aendern: mitRecht("einsatz.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
geometrie: z.array(punkt).min(1).max(500).optional(),
|
||||
text: z.string().trim().max(300).nullish(),
|
||||
farbe: z.string().trim().max(20).nullish(),
|
||||
drehung: z.number().int().min(0).max(359).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...felder } = input;
|
||||
return db.lageelement.update({ where: { id }, data: felder });
|
||||
}),
|
||||
),
|
||||
|
||||
/** Ein Element zurücknehmen. Weich — es bleibt im Datenbestand. */
|
||||
zuruecknehmen: mitRecht("einsatz.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
await db.lageelement.update({
|
||||
where: { id: input.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
});
|
||||
return { zurueckgenommen: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Kartenausschnitt merken, damit alle denselben Blick haben. */
|
||||
ausschnittMerken: mitRecht("einsatz.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
einsatzId: z.string().uuid(),
|
||||
breitengrad: z.number().min(-90).max(90),
|
||||
laengengrad: z.number().min(-180).max(180),
|
||||
zoomstufe: z.number().int().min(1).max(22),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.lagekarte.upsert({
|
||||
where: { einsatzId: input.einsatzId },
|
||||
update: {
|
||||
breitengrad: input.breitengrad,
|
||||
laengengrad: input.laengengrad,
|
||||
zoomstufe: input.zoomstufe,
|
||||
},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
einsatzId: input.einsatzId,
|
||||
breitengrad: input.breitengrad,
|
||||
laengengrad: input.laengengrad,
|
||||
zoomstufe: input.zoomstufe,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export const wasserfoerderungRouter = router({
|
||||
liste: mitRecht("einsatz.lesen")
|
||||
.input(z.object({ einsatzId: z.string().uuid().nullish() }).optional())
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.wasserfoerderung.findMany({
|
||||
where: input?.einsatzId ? { einsatzId: input.einsatzId } : {},
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
_count: { select: { punkte: true } },
|
||||
einsatz: { select: { id: true, einsatznummer: true, stichwort: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/**
|
||||
* Eine Strecke samt Rechnung.
|
||||
*
|
||||
* Gerechnet wird hier, nicht in der Datenbank: die Formeln stehen in
|
||||
* lib/karte/wasserfoerderung.ts und sind dort ohne alles getestet.
|
||||
*/
|
||||
einzeln: mitRecht("einsatz.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const strecke = await ctx.db((db) =>
|
||||
db.wasserfoerderung.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
punkte: { orderBy: { reihenfolge: "asc" } },
|
||||
einsatz: { select: { id: true, einsatznummer: true, stichwort: true } },
|
||||
geplantVon: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!strecke) return null;
|
||||
|
||||
const plan = foerderplan({
|
||||
punkte: strecke.punkte.map((punktDaten) => ({
|
||||
bezeichnung: punktDaten.bezeichnung,
|
||||
breitengrad: Number(punktDaten.breitengrad),
|
||||
laengengrad: Number(punktDaten.laengengrad),
|
||||
hoehe: punktDaten.hoehe === null ? null : Number(punktDaten.hoehe),
|
||||
})),
|
||||
durchmesser: strecke.durchmesser as Durchmesser,
|
||||
foerdermenge: strecke.foerdermenge,
|
||||
ausgangsdruck: Number(strecke.ausgangsdruck),
|
||||
eingangsdruck: Number(strecke.eingangsdruck),
|
||||
});
|
||||
|
||||
return { ...strecke, plan };
|
||||
}),
|
||||
|
||||
anlegen: mitRecht("einsatz.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
bezeichnung: z.string().trim().min(1).max(200),
|
||||
einsatzId: z.string().uuid().nullish(),
|
||||
durchmesser: z.union([z.literal(52), z.literal(75), z.literal(110)]).default(75),
|
||||
foerdermenge: z.number().int().min(50).max(10_000).default(800),
|
||||
ausgangsdruck: z.number().min(1).max(16).default(8),
|
||||
eingangsdruck: z.number().min(0).max(10).default(1.5),
|
||||
punkte: z
|
||||
.array(
|
||||
z.object({
|
||||
art: z.enum(["ENTNAHME", "VERSTAERKER", "WEGPUNKT", "ZIEL"]).default("WEGPUNKT"),
|
||||
bezeichnung: z.string().trim().max(200).nullish(),
|
||||
breitengrad: z.number().min(-90).max(90),
|
||||
laengengrad: z.number().min(-180).max(180),
|
||||
hoehe: z.number().min(-500).max(5000).nullish(),
|
||||
}),
|
||||
)
|
||||
.min(2, "Eine Strecke braucht mindestens Entnahmestelle und Ziel.")
|
||||
.max(50),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { punkte, ...felder } = input;
|
||||
|
||||
const strecke = await db.wasserfoerderung.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
...felder,
|
||||
geplantVonId: ctx.sitzung.personId,
|
||||
punkte: {
|
||||
create: punkte.map((punktDaten, index) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
reihenfolge: index,
|
||||
...punktDaten,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Wasserfoerderung",
|
||||
entitaetId: strecke.id,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { bezeichnung: strecke.bezeichnung, punkte: punkte.length },
|
||||
});
|
||||
|
||||
return strecke;
|
||||
}),
|
||||
),
|
||||
|
||||
entfernen: mitRecht("einsatz.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
await db.wasserfoerderung.delete({ where: { id: input.id } });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Wasserfoerderung",
|
||||
entitaetId: input.id,
|
||||
sitzung: ctx.sitzung,
|
||||
});
|
||||
|
||||
return { entfernt: true };
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ObjektKategorie, PlanArt } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
|
||||
/**
|
||||
* Objekte und Feuerwehrpläne.
|
||||
*
|
||||
* Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom
|
||||
* Beifahrersitz, oft in zwanzig Sekunden. Daraus folgt die Reihenfolge, in der
|
||||
* die Ansicht die Felder zeigt: **Zufahrt, Gefahren, Schlüsseldepot.** Alles
|
||||
* andere kann warten.
|
||||
*
|
||||
* Die Suche ist bewusst breit: gesucht wird nach dem Namen, aber genauso nach
|
||||
* der Straße, nach einem Gefahrenstichwort („Photovoltaik") oder nach dem
|
||||
* Ansprechpartner. Wer im Einsatz sucht, weiß selten, wie das Objekt im
|
||||
* System heißt.
|
||||
*/
|
||||
|
||||
const objektFelder = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
kategorie: z.nativeEnum(ObjektKategorie).default(ObjektKategorie.SONSTIGES),
|
||||
strasse: z.string().trim().max(200).nullish(),
|
||||
hausnummer: z.string().trim().max(20).nullish(),
|
||||
plz: z.string().trim().max(10).nullish(),
|
||||
ort: z.string().trim().max(120).nullish(),
|
||||
breitengrad: z.number().min(-90).max(90).nullish(),
|
||||
laengengrad: z.number().min(-180).max(180).nullish(),
|
||||
zufahrt: z.string().trim().max(2000).nullish(),
|
||||
gefahren: z.string().trim().max(2000).nullish(),
|
||||
schluesseldepot: z.string().trim().max(500).nullish(),
|
||||
loeschwasser: z.string().trim().max(1000).nullish(),
|
||||
bemerkung: z.string().trim().max(2000).nullish(),
|
||||
personenzahl: z.number().int().min(0).max(100_000).nullish(),
|
||||
});
|
||||
|
||||
const listenauswahl = {
|
||||
id: true,
|
||||
name: true,
|
||||
kategorie: true,
|
||||
strasse: true,
|
||||
hausnummer: true,
|
||||
ort: true,
|
||||
breitengrad: true,
|
||||
laengengrad: true,
|
||||
gefahren: true,
|
||||
personenzahl: true,
|
||||
_count: { select: { plaene: true } },
|
||||
} as const;
|
||||
|
||||
export const objektRouter = router({
|
||||
liste: mitRecht("geraet.lesen")
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
suche: z.string().trim().max(120).optional(),
|
||||
kategorie: z.nativeEnum(ObjektKategorie).optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.objekt.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
...(input?.kategorie ? { kategorie: input.kategorie } : {}),
|
||||
...(input?.suche
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: input.suche, mode: "insensitive" } },
|
||||
{ strasse: { contains: input.suche, mode: "insensitive" } },
|
||||
{ ort: { contains: input.suche, mode: "insensitive" } },
|
||||
{ gefahren: { contains: input.suche, mode: "insensitive" } },
|
||||
{ zufahrt: { contains: input.suche, mode: "insensitive" } },
|
||||
{
|
||||
kontakte: {
|
||||
some: { name: { contains: input.suche, mode: "insensitive" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
select: listenauswahl,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
einzeln: mitRecht("geraet.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const objekt = await ctx.db((db) =>
|
||||
db.objekt.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
kontakte: { orderBy: [{ reihenfolge: "asc" }, { name: "asc" }] },
|
||||
plaene: {
|
||||
orderBy: [{ reihenfolge: "asc" }, { erstelltAm: "asc" }],
|
||||
include: { datei: { select: { id: true, dateiname: true, mimeTyp: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!objekt) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Objekt gibt es nicht." });
|
||||
}
|
||||
|
||||
return objekt;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Objekte in der Nähe einer Einsatzstelle.
|
||||
*
|
||||
* Der Weg vom Einsatz zum Feuerwehrplan: Wer einen Einsatz erfasst, soll
|
||||
* nicht suchen müssen, ob es für die Adresse einen Plan gibt.
|
||||
*/
|
||||
umkreis: mitRecht("geraet.lesen")
|
||||
.input(
|
||||
z.object({
|
||||
breitengrad: z.number().min(-90).max(90),
|
||||
laengengrad: z.number().min(-180).max(180),
|
||||
umkreisMeter: z.number().int().min(50).max(20_000).default(300),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
kategorie: ObjektKategorie;
|
||||
strasse: string | null;
|
||||
gefahren: string | null;
|
||||
entfernung: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
"id", "name", "kategorie", "strasse", "gefahren",
|
||||
ROUND(ST_Distance(
|
||||
"koordinaten",
|
||||
ST_SetSRID(ST_MakePoint(${input.laengengrad}, ${input.breitengrad}), 4326)::geography
|
||||
))::int AS "entfernung"
|
||||
FROM "objekt"
|
||||
WHERE "geloeschtAm" IS NULL
|
||||
AND "koordinaten" IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
"koordinaten",
|
||||
ST_SetSRID(ST_MakePoint(${input.laengengrad}, ${input.breitengrad}), 4326)::geography,
|
||||
${input.umkreisMeter}
|
||||
)
|
||||
ORDER BY "entfernung" ASC
|
||||
LIMIT 10
|
||||
`,
|
||||
),
|
||||
),
|
||||
|
||||
anlegen: mitRecht("geraet.bearbeiten")
|
||||
.input(objektFelder)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const objekt = await db.objekt.create({
|
||||
data: { organisationId: ctx.sitzung.organisationId, ...input },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Objekt",
|
||||
entitaetId: objekt.id,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { name: objekt.name, kategorie: objekt.kategorie },
|
||||
});
|
||||
|
||||
return objekt;
|
||||
}),
|
||||
),
|
||||
|
||||
bearbeiten: mitRecht("geraet.bearbeiten")
|
||||
.input(objektFelder.partial().extend({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...felder } = input;
|
||||
|
||||
const vorher = await db.objekt.findUniqueOrThrow({ where: { id } });
|
||||
const nachher = await db.objekt.update({ where: { id }, data: felder });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Objekt",
|
||||
entitaetId: id,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
entfernen: mitRecht("geraet.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
await db.objekt.update({ where: { id: input.id }, data: { geloeschtAm: new Date() } });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Objekt",
|
||||
entitaetId: input.id,
|
||||
sitzung: ctx.sitzung,
|
||||
});
|
||||
|
||||
return { entfernt: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Einen Ansprechpartner hinterlegen. */
|
||||
kontaktAnlegen: mitRecht("geraet.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
objektId: z.string().uuid(),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
funktion: z.string().trim().max(120).nullish(),
|
||||
telefon: z.string().trim().max(60).nullish(),
|
||||
mobil: z.string().trim().max(60).nullish(),
|
||||
jederzeit: z.boolean().default(false),
|
||||
bemerkung: z.string().trim().max(500).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.objektkontakt.create({
|
||||
data: { organisationId: ctx.sitzung.organisationId, ...input },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
kontaktEntfernen: mitRecht("geraet.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
await db.objektkontakt.delete({ where: { id: input.id } });
|
||||
return { entfernt: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Ein Planblatt anhängen.
|
||||
*
|
||||
* Die Datei liegt schon in MinIO — hochgeladen wie überall über
|
||||
* /api/dateien. Hier wird nur verknüpft.
|
||||
*/
|
||||
planAnhaengen: mitRecht("geraet.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
objektId: z.string().uuid(),
|
||||
dateiId: z.string().uuid(),
|
||||
art: z.nativeEnum(PlanArt).default(PlanArt.UEBERSICHTSPLAN),
|
||||
bezeichnung: z.string().trim().min(1).max(200),
|
||||
standAm: z.coerce.date().nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const plan = await db.objektplan.create({
|
||||
data: { organisationId: ctx.sitzung.organisationId, ...input },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: "Objekt",
|
||||
entitaetId: input.objektId,
|
||||
sitzung: ctx.sitzung,
|
||||
beschreibung: `Plan „${input.bezeichnung}" angehängt`,
|
||||
});
|
||||
|
||||
return plan;
|
||||
}),
|
||||
),
|
||||
|
||||
planEntfernen: mitRecht("geraet.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
// Nur die Verknüpfung — die Datei selbst bleibt, wie überall im
|
||||
// Projekt: gelöscht wird sie erst im Aufbewahrungslauf.
|
||||
await db.objektplan.delete({ where: { id: input.id } });
|
||||
return { entfernt: true };
|
||||
}),
|
||||
),
|
||||
});
|
||||
Reference in New Issue
Block a user