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:
Florian Wilde
2026-09-04 01:41:24 +02:00
co-authored by Claude Opus 5
parent e1480c0c3e
commit 1c1e141666
53 changed files with 7804 additions and 40 deletions
+387
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+367
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}