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