added Callback and custon notification Toast, client notification event handler
This commit is contained in:
456
apps/dispatch/app/_components/map/AircraftMarker.tsx
Normal file
456
apps/dispatch/app/_components/map/AircraftMarker.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import { Marker, useMap } from "react-leaflet";
|
||||
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
|
||||
import { useMapStore } from "_store/mapStore";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { cn } from "helpers/cn";
|
||||
import {
|
||||
ChevronsRightLeft,
|
||||
House,
|
||||
MessageSquareText,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SmartPopup,
|
||||
calculateAnchor,
|
||||
useSmartPopup,
|
||||
} from "_components/SmartPopup";
|
||||
import FMSStatusHistory, {
|
||||
FMSStatusSelector,
|
||||
MissionTab,
|
||||
RettungsmittelTab,
|
||||
SDSTab,
|
||||
} from "./_components/AircraftMarkerTabs";
|
||||
import { ConnectedAircraft, Station } from "@repo/db";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getConnectedAircraftsAPI } from "querys/aircrafts";
|
||||
import { getMissionsAPI } from "querys/missions";
|
||||
import { checkSimulatorConnected } from "helpers/simulatorConnected";
|
||||
|
||||
export const FMS_STATUS_COLORS: { [key: string]: string } = {
|
||||
"0": "rgb(140,10,10)",
|
||||
"1": "rgb(10,134,25)",
|
||||
"2": "rgb(10,134,25)",
|
||||
"3": "rgb(140,10,10)",
|
||||
"4": "rgb(140,10,10)",
|
||||
"5": "rgb(231,77,22)",
|
||||
"6": "rgb(85,85,85)",
|
||||
"7": "rgb(140,10,10)",
|
||||
"8": "rgb(186,105,0)",
|
||||
"9": "rgb(10,134,25)",
|
||||
E: "rgb(186,105,0)",
|
||||
C: "rgb(186,105,0)",
|
||||
F: "rgb(186,105,0)",
|
||||
J: "rgb(186,105,0)",
|
||||
L: "rgb(186,105,0)",
|
||||
c: "rgb(186,105,0)",
|
||||
d: "rgb(186,105,0)",
|
||||
h: "rgb(186,105,0)",
|
||||
o: "rgb(186,105,0)",
|
||||
u: "rgb(186,105,0)",
|
||||
};
|
||||
|
||||
export const FMS_STATUS_TEXT_COLORS: { [key: string]: string } = {
|
||||
"0": "rgb(243,27,25)",
|
||||
"1": "rgb(9,212,33)",
|
||||
"2": "rgb(9,212,33)",
|
||||
"3": "rgb(243,27,25)",
|
||||
"4": "rgb(243,27,25)",
|
||||
"5": "rgb(251,176,158)",
|
||||
"6": "rgb(153,153,153)",
|
||||
"7": "rgb(243,27,25)",
|
||||
"8": "rgb(255,143,0)",
|
||||
"9": "rgb(9,212,33)",
|
||||
N: "rgb(9,212,33)",
|
||||
E: "rgb(255,143,0)",
|
||||
C: "rgb(255,143,0)",
|
||||
F: "rgb(255,143,0)",
|
||||
J: "rgb(255,143,0)",
|
||||
L: "rgb(255,143,0)",
|
||||
c: "rgb(255,143,0)",
|
||||
d: "rgb(255,143,0)",
|
||||
h: "rgb(255,143,0)",
|
||||
o: "rgb(255,143,0)",
|
||||
u: "rgb(255,143,0)",
|
||||
};
|
||||
|
||||
const AircraftPopupContent = ({
|
||||
aircraft,
|
||||
}: {
|
||||
aircraft: ConnectedAircraft & { Station: Station };
|
||||
}) => {
|
||||
const setAircraftTab = useMapStore((state) => state.setAircraftTab);
|
||||
const currentTab = useMapStore(
|
||||
(state) => state.aircraftTabs[aircraft.id] || "home",
|
||||
);
|
||||
|
||||
// Memoize the tab change handler to avoid unnecessary re-renders
|
||||
const handleTabChange = useCallback(
|
||||
(tab: "home" | "fms" | "aircraft" | "mission" | "chat") => {
|
||||
if (currentTab !== tab) {
|
||||
setAircraftTab(aircraft.id, tab);
|
||||
}
|
||||
},
|
||||
[currentTab, aircraft.id, setAircraftTab],
|
||||
);
|
||||
|
||||
const { data: missions } = useQuery({
|
||||
queryKey: ["missions", "missions-map"],
|
||||
queryFn: () =>
|
||||
getMissionsAPI({
|
||||
state: "running",
|
||||
missionStationIds: {
|
||||
has: aircraft.Station.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const mission = missions && missions[0];
|
||||
|
||||
const renderTabContent = useMemo(() => {
|
||||
switch (currentTab) {
|
||||
case "home":
|
||||
return <FMSStatusHistory aircraft={aircraft} mission={mission} />;
|
||||
case "fms":
|
||||
return <FMSStatusSelector aircraft={aircraft} />;
|
||||
case "aircraft":
|
||||
return <RettungsmittelTab aircraft={aircraft} />;
|
||||
case "mission":
|
||||
return mission ? (
|
||||
<MissionTab mission={mission} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center min-h-full">
|
||||
<span className="text-gray-500 my-10 font-semibold">
|
||||
Kein aktiver Einsatz
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "chat":
|
||||
return <SDSTab aircraft={aircraft} mission={mission} />;
|
||||
default:
|
||||
return <span className="text-gray-100">Error</span>;
|
||||
}
|
||||
}, [currentTab, aircraft, mission]);
|
||||
|
||||
const setOpenAircraftMarker = useMapStore(
|
||||
(state) => state.setOpenAircraftMarker,
|
||||
);
|
||||
const { anchor } = useSmartPopup();
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute p-1 z-99 top-0 right-0 transform -translate-y-full bg-base-100 cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpenAircraftMarker({
|
||||
open: [],
|
||||
close: [aircraft.id],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Minimize2 className="text-white" size={16} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[calc(100%+2px)] h-4 z-99", // As offset is 2px, we need to add 2px to the width
|
||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||
)}
|
||||
style={{
|
||||
borderLeft: anchor.includes("left")
|
||||
? `3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "",
|
||||
borderRight: anchor.includes("right")
|
||||
? `3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "",
|
||||
borderTop: anchor.includes("top")
|
||||
? `3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "",
|
||||
borderBottom: anchor.includes("bottom")
|
||||
? `3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "",
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="flex gap-[2px] text-white pb-0.5"
|
||||
style={{
|
||||
backgroundColor: `${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 flex justify-center items-center cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||
borderBottom:
|
||||
currentTab === "home"
|
||||
? `5px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "5px solid transparent",
|
||||
}}
|
||||
onClick={() => handleTabChange("home")}
|
||||
>
|
||||
<House className="text-sm" />
|
||||
</div>
|
||||
<div
|
||||
className="px-4 flex justify-center items-center cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||
}}
|
||||
/* onClick={() => handleTabChange("fms")} */
|
||||
>
|
||||
<ChevronsRightLeft className="text-sm" />
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-center items-center text-5xl font-bold px-6 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||
borderBottom:
|
||||
currentTab === "fms"
|
||||
? `5px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "5px solid transparent",
|
||||
color: "white",
|
||||
}}
|
||||
onClick={() => handleTabChange("fms")}
|
||||
>
|
||||
{aircraft.fmsStatus}
|
||||
</div>
|
||||
<div
|
||||
className="w-100 cursor-pointer px-2"
|
||||
style={{
|
||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||
borderBottom:
|
||||
currentTab === "aircraft"
|
||||
? `5px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "5px solid transparent",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={() => handleTabChange("aircraft")}
|
||||
>
|
||||
<span className="text-white text-base font-medium">
|
||||
{aircraft.Station.bosCallsign}
|
||||
<br />
|
||||
{aircraft.Station.bosUse}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-150 cursor-pointer px-2"
|
||||
style={{
|
||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||
borderBottom:
|
||||
currentTab === "mission"
|
||||
? `5px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "5px solid transparent",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={() => handleTabChange("mission")}
|
||||
>
|
||||
<span className="text-white text-base font-medium">Einsatz</span>
|
||||
<br />
|
||||
<span className="text-white text-sm font-medium">
|
||||
{mission?.publicId || "Kein aktiver Einsatz"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="px-4 flex justify-center items-center cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||
borderBottom:
|
||||
currentTab === "chat"
|
||||
? `5px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`
|
||||
: "5px solid transparent",
|
||||
}}
|
||||
onClick={() => handleTabChange("chat")}
|
||||
>
|
||||
<MessageSquareText className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full min-h-[140px]">
|
||||
<div className="flex-grow">{renderTabContent}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AircraftMarker = ({
|
||||
aircraft,
|
||||
}: {
|
||||
aircraft: ConnectedAircraft & { Station: Station };
|
||||
}) => {
|
||||
const [hideMarker, setHideMarker] = useState(false);
|
||||
const map = useMap();
|
||||
const markerRef = useRef<LMarker>(null);
|
||||
const popupRef = useRef<LPopup>(null);
|
||||
|
||||
const { openAircraftMarker, setOpenAircraftMarker } = useMapStore(
|
||||
(store) => store,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
const open = openAircraftMarker.some((m) => m.id === aircraft.id);
|
||||
if (open) {
|
||||
setOpenAircraftMarker({
|
||||
open: [],
|
||||
close: [aircraft.id],
|
||||
});
|
||||
} else {
|
||||
setOpenAircraftMarker({
|
||||
open: [
|
||||
{
|
||||
id: aircraft.id,
|
||||
tab: "home",
|
||||
},
|
||||
],
|
||||
close: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
const marker = markerRef.current;
|
||||
marker?.on("click", handleClick);
|
||||
return () => {
|
||||
marker?.off("click", handleClick);
|
||||
};
|
||||
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker]);
|
||||
|
||||
const [anchor, setAnchor] = useState<
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright"
|
||||
>("topleft");
|
||||
|
||||
const handleConflict = useCallback(() => {
|
||||
const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker");
|
||||
setAnchor(newAnchor);
|
||||
}, [aircraft.id]);
|
||||
|
||||
/* useEffect(() => {
|
||||
handleConflict();
|
||||
}, [aircrafts, openAircraftMarker, handleConflict]); */
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
handleZoom();
|
||||
}, 100);
|
||||
|
||||
const handleZoom = () => {
|
||||
const zoom = map.getZoom();
|
||||
setHideMarker(zoom < 8);
|
||||
handleConflict();
|
||||
};
|
||||
|
||||
map.on("zoomend", handleZoom);
|
||||
return () => {
|
||||
map.off("zoomend", handleZoom);
|
||||
};
|
||||
}, [map, openAircraftMarker, handleConflict]);
|
||||
|
||||
const getMarkerHTML = (
|
||||
aircraft: ConnectedAircraft & { Station: Station },
|
||||
anchor: "topleft" | "topright" | "bottomleft" | "bottomright",
|
||||
) => {
|
||||
return `<div
|
||||
id="marker-aircraft-${aircraft.id}"
|
||||
class="${cn(
|
||||
"relative w-auto transform inline-flex items-center gap-2 px-2 z-100",
|
||||
anchor.includes("right") && "-translate-x-full",
|
||||
anchor.includes("bottom") && "-translate-y-full",
|
||||
hideMarker ? "opacity-0 pointer-events-none" : "opacity-100",
|
||||
)}"
|
||||
style="
|
||||
background-color: ${FMS_STATUS_COLORS[aircraft.fmsStatus]};
|
||||
${openAircraftMarker.some((m) => m.id === aircraft.id) ? "opacity: 0; pointer-events: none;" : ""}
|
||||
">
|
||||
|
||||
<div
|
||||
class="${cn(
|
||||
"absolute w-4 h-4 z-99",
|
||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||
)}"
|
||||
style="
|
||||
${anchor.includes("left") ? `border-left: 3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]};` : `border-right: 3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]};`}
|
||||
${anchor.includes("top") ? `border-top: 3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]};` : `border-bottom: 3px solid ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]};`}
|
||||
"
|
||||
></div>
|
||||
<span
|
||||
class="font-semibold text-xl"
|
||||
style="color: ${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]};"
|
||||
>
|
||||
${aircraft.fmsStatus}
|
||||
</span>
|
||||
<span class="text-white text-[15px] text-nowrap">
|
||||
${aircraft.Station.bosCallsign}
|
||||
</span>
|
||||
<div
|
||||
data-id="${aircraft.id}"
|
||||
data-anchor-lat="${aircraft.posLat}"
|
||||
data-anchor-lng="${aircraft.posLng}"
|
||||
id="marker-domain-aircraft-${aircraft.id}"
|
||||
class="${cn(
|
||||
"map-collision absolute w-[200%] h-[200%] top-0 left-0 transform pointer-events-none",
|
||||
anchor.includes("left") && "-translate-x-1/2",
|
||||
anchor.includes("top") && "-translate-y-1/2",
|
||||
)}"
|
||||
></div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={aircraft.id}>
|
||||
{
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
position={[aircraft.posLat!, aircraft.posLng!]}
|
||||
icon={
|
||||
new DivIcon({
|
||||
iconAnchor: [0, 0],
|
||||
html: getMarkerHTML(aircraft, anchor),
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
{openAircraftMarker.some((m) => m.id === aircraft.id) && !hideMarker && (
|
||||
<SmartPopup
|
||||
options={{
|
||||
ignoreCluster: true,
|
||||
}}
|
||||
id={`aircraft-${aircraft.id}`}
|
||||
ref={popupRef}
|
||||
position={[aircraft.posLat!, aircraft.posLng!]}
|
||||
autoClose={false}
|
||||
closeOnClick={false}
|
||||
autoPan={false}
|
||||
wrapperClassName="relative"
|
||||
className="w-[502px]"
|
||||
>
|
||||
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
|
||||
<AircraftPopupContent aircraft={aircraft} />
|
||||
</div>
|
||||
</SmartPopup>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export const AircraftLayer = () => {
|
||||
const { data: aircrafts } = useQuery({
|
||||
queryKey: ["aircrafts"],
|
||||
queryFn: getConnectedAircraftsAPI,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{aircrafts
|
||||
?.filter((a) => checkSimulatorConnected(a.lastHeartbeat))
|
||||
?.map((aircraft) => {
|
||||
return <AircraftMarker key={aircraft.id} aircraft={aircraft} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
29
apps/dispatch/app/_components/map/BaseMaps.tsx
Normal file
29
apps/dispatch/app/_components/map/BaseMaps.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import { usePannelStore } from "_store/pannelStore";
|
||||
import { useEffect } from "react";
|
||||
import { TileLayer, useMap } from "react-leaflet";
|
||||
|
||||
export const BaseMaps = () => {
|
||||
const map = useMap();
|
||||
const isPannelOpen = usePannelStore((state) => state.isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 600);
|
||||
}, [isPannelOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="invert-100 grayscale"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
335
apps/dispatch/app/_components/map/ContextMenu.tsx
Normal file
335
apps/dispatch/app/_components/map/ContextMenu.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { OSMWay } from "@repo/db";
|
||||
import { useMapStore } from "_store/mapStore";
|
||||
import { usePannelStore } from "_store/pannelStore";
|
||||
import {
|
||||
MapPin,
|
||||
MapPinned,
|
||||
Radius,
|
||||
Ruler,
|
||||
Search,
|
||||
RulerDimensionLine,
|
||||
Scan,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Popup, useMap } from "react-leaflet";
|
||||
|
||||
export const ContextMenu = () => {
|
||||
const map = useMap();
|
||||
const { contextMenu, setContextMenu, setSearchElements, setSearchPopup } =
|
||||
useMapStore();
|
||||
const { setMissionFormValues, setOpen } = usePannelStore((state) => state);
|
||||
const [showRulerOptions, setShowRulerOptions] = useState(false);
|
||||
const [rulerHover, setRulerHover] = useState(false);
|
||||
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowRulerOptions(rulerHover || rulerOptionsHover);
|
||||
}, [rulerHover, rulerOptionsHover]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleContextMenu = (e: any) => {
|
||||
setContextMenu({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
};
|
||||
const handleClick = () => {
|
||||
setContextMenu(null);
|
||||
setSearchPopup(null);
|
||||
};
|
||||
|
||||
map.on("contextmenu", handleContextMenu);
|
||||
map.on("click", handleClick);
|
||||
|
||||
return () => {
|
||||
map.off("contextmenu", handleContextMenu);
|
||||
map.off("click", handleClick);
|
||||
};
|
||||
}, [map, contextMenu, setContextMenu, setSearchPopup]);
|
||||
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const addOSMobjects = async () => {
|
||||
const res = await fetch(
|
||||
`https://overpass-api.de/api/interpreter?data=${encodeURIComponent(`
|
||||
[out:json];
|
||||
(
|
||||
way["building"](around:100, ${contextMenu.lat}, ${contextMenu.lng});
|
||||
relation["building"](around:100, ${contextMenu.lat}, ${contextMenu.lng});
|
||||
);
|
||||
out body;
|
||||
>;
|
||||
out skel qt;
|
||||
`)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
const parsed: OSMWay[] = data.elements
|
||||
.filter((e: any) => e.type === "way")
|
||||
.map((e: any) => {
|
||||
return {
|
||||
wayID: e.id,
|
||||
nodes: e.nodes.map((nodeId: string) => {
|
||||
const node = data.elements.find(
|
||||
(element: any) => element.id === nodeId,
|
||||
);
|
||||
return {
|
||||
lat: node.lat,
|
||||
lon: node.lon,
|
||||
};
|
||||
}),
|
||||
tags: e.tags,
|
||||
type: e.type,
|
||||
} as OSMWay;
|
||||
});
|
||||
|
||||
setSearchElements(parsed);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
position={[contextMenu.lat, contextMenu.lng]}
|
||||
autoClose={false}
|
||||
closeOnClick={false}
|
||||
autoPan={false}
|
||||
>
|
||||
{/* // TODO: maske: */}
|
||||
<div
|
||||
className="absolute opacity-100 pointer-events-none p-3 flex items-center justify-center"
|
||||
style={{ left: "-13px", top: "-13px" }}
|
||||
>
|
||||
<div className="relative w-38 h-38 flex items-center justify-center -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{/* Top Button */}
|
||||
<button
|
||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 top-0 pointer-events-auto opacity-80 tooltip tooltip-top tooltip-accent"
|
||||
data-tip="Nächstes Element übernehmen"
|
||||
style={{ transform: "translateX(-50%)" }}
|
||||
onClick={async () => {
|
||||
const address = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${contextMenu.lat}&lon=${contextMenu.lng}&format=json`,
|
||||
);
|
||||
const data = (await address.json()) as {
|
||||
address: {
|
||||
ISO3166_2_lvl4: string;
|
||||
country: string;
|
||||
country_code: string;
|
||||
county: string;
|
||||
house_number: string;
|
||||
municipality: string;
|
||||
postcode: string;
|
||||
road: string;
|
||||
state: string;
|
||||
city: string;
|
||||
town: string;
|
||||
};
|
||||
display_name: string;
|
||||
importance: number;
|
||||
lat: string;
|
||||
licence: string;
|
||||
lon: string;
|
||||
name: string;
|
||||
osm_id: number;
|
||||
osm_type: string;
|
||||
place_id: number;
|
||||
place_rank: number;
|
||||
type: string;
|
||||
};
|
||||
const objects = await addOSMobjects();
|
||||
const exactAddress = objects.find((object) => {
|
||||
return (
|
||||
object.tags["addr:street"] == data.address.road &&
|
||||
object.tags["addr:housenumber"]?.includes(
|
||||
data.address.house_number,
|
||||
)
|
||||
);
|
||||
});
|
||||
const closestToContext = objects.reduce((prev, curr) => {
|
||||
const prevLat = prev.nodes?.[0]?.lat ?? 0;
|
||||
const prevLon = prev.nodes?.[0]?.lon ?? 0;
|
||||
const currLat = curr.nodes?.[0]?.lat ?? 0;
|
||||
const currLon = curr.nodes?.[0]?.lon ?? 0;
|
||||
const prevDistance = Math.sqrt(
|
||||
Math.pow(prevLat - contextMenu.lat, 2) +
|
||||
Math.pow(prevLon - contextMenu.lng, 2),
|
||||
);
|
||||
const currDistance = Math.sqrt(
|
||||
Math.pow(currLat - contextMenu.lat, 2) +
|
||||
Math.pow(currLon - contextMenu.lng, 2),
|
||||
);
|
||||
return prevDistance < currDistance ? prev : curr;
|
||||
});
|
||||
|
||||
setOpen(true);
|
||||
console.log(data.address.road);
|
||||
setMissionFormValues({
|
||||
addressLat: contextMenu.lat,
|
||||
addressLng: contextMenu.lng,
|
||||
addressCity: data.address.city || data.address.town || "",
|
||||
addressStreet: `${data.address.road || "keine Straße"}, ${data.address.house_number || "keine HN"}`,
|
||||
addressZip: data.address.postcode || "",
|
||||
state: "draft",
|
||||
addressOSMways: [(exactAddress || closestToContext) as any],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MapPinned size={20} />
|
||||
</button>
|
||||
{/* Left Button */}
|
||||
<button
|
||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 left-0 pointer-events-auto opacity-80"
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onMouseEnter={() => setRulerHover(true)}
|
||||
onMouseLeave={() => setRulerHover(false)}
|
||||
>
|
||||
<Ruler size={20} />
|
||||
</button>
|
||||
{/* Bottom Button */}
|
||||
<button
|
||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 bottom-0 pointer-events-auto opacity-80 tooltip tooltip-bottom tooltip-accent"
|
||||
data-tip="Koordinaten kopieren"
|
||||
style={{ transform: "translateX(-50%)" }}
|
||||
onClick={async () => {
|
||||
const address = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${contextMenu.lat}&lon=${contextMenu.lng}&format=json`,
|
||||
);
|
||||
const data = (await address.json()) as {
|
||||
address: {
|
||||
ISO3166_2_lvl4: string;
|
||||
country: string;
|
||||
country_code: string;
|
||||
county: string;
|
||||
house_number: string;
|
||||
municipality: string;
|
||||
postcode: string;
|
||||
road: string;
|
||||
state: string;
|
||||
city: string;
|
||||
town: string;
|
||||
};
|
||||
display_name: string;
|
||||
importance: number;
|
||||
lat: string;
|
||||
licence: string;
|
||||
lon: string;
|
||||
name: string;
|
||||
osm_id: number;
|
||||
osm_type: string;
|
||||
place_id: number;
|
||||
place_rank: number;
|
||||
type: string;
|
||||
};
|
||||
const objects = await addOSMobjects();
|
||||
const exactAddress = objects.find((object) => {
|
||||
return (
|
||||
object.tags["addr:street"] == data.address.road &&
|
||||
object.tags["addr:housenumber"]?.includes(
|
||||
data.address.house_number,
|
||||
)
|
||||
);
|
||||
});
|
||||
const closestToContext = objects.reduce((prev, curr) => {
|
||||
const prevLat = prev.nodes?.[0]?.lat ?? 0;
|
||||
const prevLon = prev.nodes?.[0]?.lon ?? 0;
|
||||
const currLat = curr.nodes?.[0]?.lat ?? 0;
|
||||
const currLon = curr.nodes?.[0]?.lon ?? 0;
|
||||
const prevDistance = Math.sqrt(
|
||||
Math.pow(prevLat - contextMenu.lat, 2) +
|
||||
Math.pow(prevLon - contextMenu.lng, 2),
|
||||
);
|
||||
const currDistance = Math.sqrt(
|
||||
Math.pow(currLat - contextMenu.lat, 2) +
|
||||
Math.pow(currLon - contextMenu.lng, 2),
|
||||
);
|
||||
return prevDistance < currDistance ? prev : curr;
|
||||
}, [] as any);
|
||||
|
||||
setOpen(true);
|
||||
setMissionFormValues({
|
||||
addressLat: contextMenu.lat,
|
||||
addressLng: contextMenu.lng,
|
||||
addressCity: data.address.city || data.address.town || "",
|
||||
addressStreet: `${data.address.road || "keine Straße"}, ${data.address.house_number || "keine HN"}`,
|
||||
addressZip: data.address.postcode || "",
|
||||
state: "draft",
|
||||
addressOSMways: [(exactAddress || closestToContext) as any],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MapPin size={20} />
|
||||
</button>
|
||||
{/* Right Button (original Search button) */}
|
||||
<button
|
||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 right-0 pointer-events-auto opacity-80 tooltip tooltip-right tooltip-accent"
|
||||
data-tip="Gebäude suchen"
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onClick={async () => {
|
||||
addOSMobjects();
|
||||
}}
|
||||
>
|
||||
<Search size={20} />
|
||||
</button>
|
||||
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
|
||||
{showRulerOptions && (
|
||||
<div
|
||||
className="absolute flex flex-col items-center pointer-events-auto"
|
||||
style={{
|
||||
left: "-100px", // position to the right of the left button
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
zIndex: 10,
|
||||
width: "120px", // Make the hover area wider
|
||||
height: "200px", // Make the hover area taller
|
||||
padding: "20px 0", // Add vertical padding
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseEnter={() => setRulerOptionsHover(true)}
|
||||
onMouseLeave={() => setRulerOptionsHover(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
|
||||
data-tip="Strecke Messen"
|
||||
style={{
|
||||
transform: "translateX(100%)",
|
||||
}}
|
||||
onClick={() => {
|
||||
/* ... */
|
||||
}}
|
||||
>
|
||||
<RulerDimensionLine size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
|
||||
data-tip="Radius Messen"
|
||||
onClick={() => {
|
||||
/* ... */
|
||||
}}
|
||||
>
|
||||
<Radius size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-circle bg-rescuetrack w-10 h-10 opacity-80 tooltip tooltip-left tooltip-accent"
|
||||
data-tip="Fläche Messen"
|
||||
style={{
|
||||
transform: "translateX(100%)",
|
||||
}}
|
||||
onClick={() => {
|
||||
/* ... */
|
||||
}}
|
||||
>
|
||||
<Scan size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
78
apps/dispatch/app/_components/map/Map.tsx
Normal file
78
apps/dispatch/app/_components/map/Map.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { useMapStore } from "_store/mapStore";
|
||||
import { MapContainer } from "react-leaflet";
|
||||
import { BaseMaps } from "_components/map/BaseMaps";
|
||||
import { ContextMenu } from "_components/map/ContextMenu";
|
||||
import { MissionLayer } from "_components/map/MissionMarkers";
|
||||
import { SearchElements } from "_components/map/SearchElements";
|
||||
import { AircraftLayer } from "_components/map/AircraftMarker";
|
||||
import { MarkerCluster } from "_components/map/_components/MarkerCluster";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Map as TMap } from "leaflet";
|
||||
|
||||
const Map = () => {
|
||||
const ref = useRef<TMap | null>(null);
|
||||
const { map, setMap } = useMapStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Sync map zoom and center with the map store
|
||||
if (ref.current) {
|
||||
ref.current.setView(map.center, map.zoom);
|
||||
/* ref.current.on("moveend", () => {
|
||||
const center = ref.current?.getCenter();
|
||||
const zoom = ref.current?.getZoom();
|
||||
if (center && zoom) {
|
||||
setMap({
|
||||
center: [center.lat, center.lng],
|
||||
zoom,
|
||||
});
|
||||
}
|
||||
});
|
||||
ref.current.on("zoomend", () => {
|
||||
const zoom = ref.current?.getZoom();
|
||||
const center = ref.current?.getCenter();
|
||||
|
||||
if (zoom && center) {
|
||||
setMap({
|
||||
center,
|
||||
zoom,
|
||||
});
|
||||
}
|
||||
}); */
|
||||
}
|
||||
}, [map, setMap]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Map center or zoom changed");
|
||||
|
||||
if (ref.current) {
|
||||
const center = ref.current?.getCenter();
|
||||
const zoom = ref.current?.getZoom();
|
||||
console.log("Map center or zoom changed", center.equals(map.center), zoom === map.zoom);
|
||||
if (!center.equals(map.center) || zoom !== map.zoom) {
|
||||
console.log("Updating map center and zoom");
|
||||
ref.current.setView(map.center, map.zoom);
|
||||
}
|
||||
}
|
||||
}, [map.center, map.zoom]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
ref={ref}
|
||||
className="flex-1"
|
||||
center={map.center}
|
||||
zoom={map.zoom}
|
||||
fadeAnimation={false}
|
||||
>
|
||||
<BaseMaps />
|
||||
<SearchElements />
|
||||
<ContextMenu />
|
||||
<MarkerCluster />
|
||||
<MissionLayer />
|
||||
<AircraftLayer />
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Map;
|
||||
406
apps/dispatch/app/_components/map/MissionMarkers.tsx
Normal file
406
apps/dispatch/app/_components/map/MissionMarkers.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import { Marker, useMap } from "react-leaflet";
|
||||
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
|
||||
import { useMapStore } from "_store/mapStore";
|
||||
import { usePannelStore } from "_store/pannelStore";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "helpers/cn";
|
||||
import {
|
||||
ClipboardList,
|
||||
Cross,
|
||||
House,
|
||||
Minimize2,
|
||||
SmartphoneNfc,
|
||||
PencilLine,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
calculateAnchor,
|
||||
SmartPopup,
|
||||
useSmartPopup,
|
||||
} from "_components/SmartPopup";
|
||||
import { HpgValidationState, Mission, MissionState } from "@repo/db";
|
||||
import Einsatzdetails, {
|
||||
FMSStatusHistory,
|
||||
Patientdetails,
|
||||
Rettungsmittel,
|
||||
} from "./_components/MissionMarkerTabs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMissionsAPI } from "querys/missions";
|
||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||
|
||||
export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> =
|
||||
{
|
||||
draft: "#0092b8",
|
||||
running: "#155dfc",
|
||||
finished: "#155dfc",
|
||||
attention: "rgb(186,105,0)",
|
||||
};
|
||||
|
||||
export const MISSION_STATUS_TEXT_COLORS: Record<MissionState, string> = {
|
||||
draft: "#00d3f2",
|
||||
running: "#50a2ff",
|
||||
finished: "#50a2ff",
|
||||
};
|
||||
|
||||
const MissionPopupContent = ({ mission }: { mission: Mission }) => {
|
||||
const { setEditingMission } = usePannelStore();
|
||||
const setMissionMarker = useMapStore((state) => state.setOpenMissionMarker);
|
||||
const currentTab = useMapStore(
|
||||
(state) =>
|
||||
state.openMissionMarker.find((m) => m.id === mission.id)?.tab ?? "home",
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: "home" | "details" | "patient" | "log") => {
|
||||
console.log("handleTabChange", tab);
|
||||
setMissionMarker({
|
||||
open: [
|
||||
{
|
||||
id: mission.id,
|
||||
tab,
|
||||
},
|
||||
],
|
||||
close: [],
|
||||
});
|
||||
},
|
||||
[setMissionMarker, mission.id],
|
||||
);
|
||||
|
||||
const renderTabContent = useMemo(() => {
|
||||
switch (currentTab) {
|
||||
case "home":
|
||||
return <Einsatzdetails mission={mission} />;
|
||||
case "details":
|
||||
return <Rettungsmittel mission={mission} />;
|
||||
case "patient":
|
||||
return <Patientdetails mission={mission} />;
|
||||
case "log":
|
||||
return <FMSStatusHistory mission={mission} />;
|
||||
default:
|
||||
return <span className="text-gray-100">Error</span>;
|
||||
}
|
||||
}, [currentTab, mission]);
|
||||
|
||||
const setOpenMissionMarker = useMapStore(
|
||||
(state) => state.setOpenMissionMarker,
|
||||
);
|
||||
const { anchor } = useSmartPopup();
|
||||
const { setMissionFormValues, setOpen } = usePannelStore((state) => state);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute p-1 z-99 top-0 right-0 transform -translate-y-full bg-base-100 cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpenMissionMarker({
|
||||
open: [],
|
||||
close: [mission.id],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Minimize2 className="text-white" size={16} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[calc(100%+2px)] h-4 z-99",
|
||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||
)}
|
||||
style={{
|
||||
borderLeft: anchor.includes("left")
|
||||
? `3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "",
|
||||
borderRight: anchor.includes("right")
|
||||
? `3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "",
|
||||
borderTop: anchor.includes("top")
|
||||
? `3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "",
|
||||
borderBottom: anchor.includes("bottom")
|
||||
? `3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "",
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="flex gap-[2px] text-white pb-0.5"
|
||||
style={{
|
||||
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="p-2 px-3 flex justify-center items-center cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
||||
borderBottom:
|
||||
currentTab === "home"
|
||||
? `5px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "5px solid transparent",
|
||||
}}
|
||||
onClick={() => handleTabChange("home")}
|
||||
>
|
||||
<House className="text-sm" />
|
||||
</div>
|
||||
<div
|
||||
className="p-2 px-4 flex justify-center items-center cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
||||
borderBottom:
|
||||
currentTab === "patient"
|
||||
? `5px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "5px solid transparent",
|
||||
}}
|
||||
onClick={() => handleTabChange("patient")}
|
||||
>
|
||||
<Cross className="text-sm" />
|
||||
</div>
|
||||
<div
|
||||
className="p-2 px-4 flex justify-center items-center cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
||||
borderBottom:
|
||||
currentTab === "details"
|
||||
? `5px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "5px solid transparent",
|
||||
}}
|
||||
onClick={() => handleTabChange("details")}
|
||||
>
|
||||
<SmartphoneNfc className="text-sm" />
|
||||
</div>
|
||||
{mission.state === "draft" && (
|
||||
<div
|
||||
className="p-2 px-4 flex justify-center items-center cursor-pointer ml-auto"
|
||||
style={{
|
||||
backgroundColor: `${MISSION_STATUS_COLORS["attention"]}`,
|
||||
borderBottom: "5px solid transparent",
|
||||
}}
|
||||
onClick={() => {
|
||||
setMissionFormValues({
|
||||
...mission,
|
||||
state: "draft",
|
||||
hpgLocationLat: mission.hpgLocationLat ?? undefined,
|
||||
hpgLocationLng: mission.hpgLocationLng ?? undefined,
|
||||
});
|
||||
setEditingMission(true, String(mission.id));
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<PencilLine className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"p-2 px-4 flex justify-center items-center cursor-pointer",
|
||||
mission.state !== "draft" && "ml-auto",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
||||
borderBottom:
|
||||
currentTab === "log"
|
||||
? `5px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
|
||||
: "5px solid transparent",
|
||||
}}
|
||||
onClick={() => handleTabChange("log")}
|
||||
>
|
||||
<ClipboardList className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full min-h-[200px]">{renderTabContent}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MissionMarker = ({ mission }: { mission: Mission }) => {
|
||||
const map = useMap();
|
||||
const [hideMarker, setHideMarker] = useState(false);
|
||||
const markerRef = useRef<LMarker>(null);
|
||||
const popupRef = useRef<LPopup>(null);
|
||||
|
||||
const { openMissionMarker, setOpenMissionMarker } = useMapStore(
|
||||
(store) => store,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
const open = openMissionMarker.some((m) => m.id === mission.id);
|
||||
if (open) {
|
||||
setOpenMissionMarker({
|
||||
open: [],
|
||||
close: [mission.id],
|
||||
});
|
||||
} else {
|
||||
setOpenMissionMarker({
|
||||
open: [
|
||||
{
|
||||
id: mission.id,
|
||||
tab: "home",
|
||||
},
|
||||
],
|
||||
close: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
const markerCopy = markerRef.current;
|
||||
markerCopy?.on("click", handleClick);
|
||||
return () => {
|
||||
markerCopy?.off("click", handleClick);
|
||||
};
|
||||
}, [mission.id, openMissionMarker, setOpenMissionMarker]);
|
||||
|
||||
const [anchor, setAnchor] = useState<
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright"
|
||||
>("topleft");
|
||||
|
||||
const handleConflict = useCallback(() => {
|
||||
const newAnchor = calculateAnchor(
|
||||
`mission-${mission.id.toString()}`,
|
||||
"marker",
|
||||
);
|
||||
setAnchor(newAnchor);
|
||||
}, [mission.id]);
|
||||
|
||||
useEffect(() => {
|
||||
handleConflict();
|
||||
}, [handleConflict]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
handleZoom();
|
||||
}, 100);
|
||||
|
||||
const handleZoom = () => {
|
||||
const zoom = map.getZoom();
|
||||
setHideMarker(zoom < 8);
|
||||
handleConflict();
|
||||
};
|
||||
|
||||
map.on("zoomend", handleZoom);
|
||||
return () => {
|
||||
map.off("zoomend", handleZoom);
|
||||
};
|
||||
}, [map, openMissionMarker, handleConflict]);
|
||||
|
||||
const getMarkerHTML = (
|
||||
mission: Mission,
|
||||
anchor: "topleft" | "topright" | "bottomleft" | "bottomright",
|
||||
) => {
|
||||
const markerColor =
|
||||
mission.hpgValidationState ===
|
||||
(HpgValidationState.POSITION_AMANDED ||
|
||||
HpgValidationState.INVALID ||
|
||||
HpgValidationState.HPG_DISCONNECT ||
|
||||
HpgValidationState.HPG_BUSY ||
|
||||
HpgValidationState.HPG_INVALID_MISSION)
|
||||
? MISSION_STATUS_COLORS["attention"]
|
||||
: MISSION_STATUS_COLORS[mission.state];
|
||||
return `<div
|
||||
id="marker-mission-${mission.id}"
|
||||
class="${cn(
|
||||
"relative w-auto transform inline-flex items-center gap-2 px-2 z-100",
|
||||
anchor.includes("right") && "-translate-x-full",
|
||||
anchor.includes("bottom") && "-translate-y-full",
|
||||
hideMarker ? "opacity-0 pointer-events-none" : "opacity-100",
|
||||
)}"
|
||||
style="
|
||||
background-color: ${markerColor};
|
||||
${openMissionMarker.some((m) => m.id === mission.id) ? "opacity: 0; pointer-events: none;" : ""}
|
||||
">
|
||||
|
||||
<div
|
||||
class="${cn(
|
||||
"absolute w-4 h-4 z-99",
|
||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||
)}"
|
||||
style="
|
||||
${anchor.includes("left") ? `border-left: 3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]};` : `border-right: 3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]};`}
|
||||
${anchor.includes("top") ? `border-top: 3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]};` : `border-bottom: 3px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]};`}
|
||||
"
|
||||
></div>
|
||||
<span class="text-white text-[15px] text-nowrap">
|
||||
${mission.missionKeywordAbbreviation} ${mission.missionKeywordName}
|
||||
</span>
|
||||
<div
|
||||
data-anchor-lat="${mission.addressLat}"
|
||||
data-anchor-lng="${mission.addressLng}"
|
||||
data-id="${mission.id}"
|
||||
id="marker-domain-mission-${mission.id}"
|
||||
class="${cn(
|
||||
"map-collision absolute w-[200%] h-[200%] top-0 left-0 transform pointer-events-none",
|
||||
anchor.includes("left") && "-translate-x-1/2",
|
||||
anchor.includes("top") && "-translate-y-1/2",
|
||||
)}"
|
||||
></div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={mission.id}>
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
position={[mission.addressLat, mission.addressLng]}
|
||||
icon={
|
||||
new DivIcon({
|
||||
iconAnchor: [0, 0],
|
||||
html: getMarkerHTML(mission, anchor),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{openMissionMarker.some((m) => m.id === mission.id) && !hideMarker && (
|
||||
<SmartPopup
|
||||
options={{
|
||||
ignoreCluster: true,
|
||||
}}
|
||||
id={`mission-${mission.id.toString()}`}
|
||||
ref={popupRef}
|
||||
position={[mission.addressLat, mission.addressLng]}
|
||||
autoClose={false}
|
||||
closeOnClick={false}
|
||||
autoPan={false}
|
||||
wrapperClassName="relative"
|
||||
className="w-[502px]"
|
||||
>
|
||||
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
|
||||
<MissionPopupContent mission={mission} />
|
||||
</div>
|
||||
</SmartPopup>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export const MissionLayer = () => {
|
||||
const dispatcherConnected =
|
||||
useDispatchConnectionStore((s) => s.status) === "connected";
|
||||
const { data: missions = [] } = useQuery({
|
||||
queryKey: ["missions"],
|
||||
queryFn: () =>
|
||||
getMissionsAPI({
|
||||
OR: [{ state: "draft" }, { state: "running" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const filteredMissions = useMemo(() => {
|
||||
if (!dispatcherConnected) {
|
||||
return missions.filter((m: Mission) => m.state === "running");
|
||||
}
|
||||
return missions;
|
||||
}, [missions, dispatcherConnected]);
|
||||
|
||||
// IDEA: Add Marker to Map Layer / LayerGroup
|
||||
return (
|
||||
<>
|
||||
{filteredMissions.map((mission) => {
|
||||
return <MissionMarker key={mission.id} mission={mission as Mission} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
168
apps/dispatch/app/_components/map/SearchElements.tsx
Normal file
168
apps/dispatch/app/_components/map/SearchElements.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useMapStore } from "_store/mapStore";
|
||||
import { Marker as LMarker } from "leaflet";
|
||||
import { Fragment, useEffect, useRef } from "react";
|
||||
import { Marker, Polygon, Popup } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMissionsAPI } from "querys/missions";
|
||||
import { OSMWay } from "@repo/db";
|
||||
|
||||
export const SearchElements = () => {
|
||||
const {
|
||||
searchElements,
|
||||
searchPopup,
|
||||
setSearchPopup,
|
||||
setContextMenu,
|
||||
openMissionMarker,
|
||||
} = useMapStore();
|
||||
const missions = useQuery({
|
||||
queryKey: ["missions"],
|
||||
queryFn: () =>
|
||||
getMissionsAPI({
|
||||
OR: [
|
||||
{
|
||||
state: "draft",
|
||||
},
|
||||
{
|
||||
state: "running",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const poppupRef = useRef<LMarker>(null);
|
||||
const searchPopupElement = searchElements.find(
|
||||
(element) => element.wayID === searchPopup?.elementId,
|
||||
);
|
||||
|
||||
const SearchElement = ({
|
||||
element,
|
||||
isActive = false,
|
||||
}: {
|
||||
element: (typeof searchElements)[1];
|
||||
isActive?: boolean;
|
||||
}) => {
|
||||
const ref = useRef<L.Polygon>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.on("click", () => {
|
||||
const center = ref.current?.getBounds().getCenter();
|
||||
if (center && searchPopup?.elementId !== element.wayID) {
|
||||
setSearchPopup({
|
||||
lat: center.lat,
|
||||
lng: center.lng,
|
||||
elementId: element.wayID,
|
||||
});
|
||||
} else {
|
||||
setSearchPopup(null);
|
||||
}
|
||||
setContextMenu(null);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!element.nodes) return null;
|
||||
return (
|
||||
<Polygon
|
||||
positions={element.nodes.map((node) => [node.lat, node.lon])}
|
||||
color={
|
||||
searchPopup?.elementId === element.wayID || isActive
|
||||
? "#ff4500"
|
||||
: "#46b7a3"
|
||||
}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchElementPopup = ({
|
||||
element,
|
||||
}: {
|
||||
element: (typeof searchElements)[1];
|
||||
}) => {
|
||||
if (!searchPopup) return null;
|
||||
return (
|
||||
<Popup
|
||||
autoPan={false}
|
||||
position={[searchPopup.lat, searchPopup.lng]}
|
||||
autoClose={false}
|
||||
closeOnClick={false}
|
||||
>
|
||||
<div className="bg-base-100/70 border border-rescuetrack w-[250px] text-white pointer-events-auto p-2">
|
||||
<h3 className="text-lg font-bold">
|
||||
{element.tags?.building === "yes"
|
||||
? "Gebäude"
|
||||
: element.tags?.building}
|
||||
{!element.tags?.building && "unbekannt"}
|
||||
</h3>
|
||||
<p className="">
|
||||
{element.tags?.["addr:street"]} {element.tags?.["addr:housenumber"]}
|
||||
</p>
|
||||
<p className="">
|
||||
{element.tags?.["addr:suburb"]} {element.tags?.["addr:postcode"]}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<button className="btn bg-rescuetrack-highlight">
|
||||
Zum Einsatz Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{openMissionMarker.map(({ id }) => {
|
||||
const mission = missions.data?.find((m) => m.id === id);
|
||||
if (!mission) return null;
|
||||
return (
|
||||
<Fragment key={`mission-osm-${mission.id}`}>
|
||||
{(mission.addressOSMways as (OSMWay | null)[])
|
||||
.filter((element): element is OSMWay => element !== null)
|
||||
.map((element: OSMWay, i) => (
|
||||
<SearchElement
|
||||
key={`mission-elem-${element.wayID}-${i}`}
|
||||
element={element}
|
||||
isActive
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{searchElements.map((element, i) => {
|
||||
if (
|
||||
missions.data?.some(
|
||||
(mission) =>
|
||||
(mission.addressOSMways as (OSMWay | null)[])
|
||||
.filter((e): e is OSMWay => e !== null)
|
||||
.some((e) => e.wayID === element.wayID) &&
|
||||
openMissionMarker.some((m) => m.id === mission.id),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<SearchElement
|
||||
key={`mission-elem-${element.wayID}-${i}`}
|
||||
element={element}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{searchPopup && (
|
||||
<Marker
|
||||
position={[searchPopup.lat, searchPopup.lng]}
|
||||
ref={poppupRef}
|
||||
icon={new L.DivIcon()}
|
||||
opacity={0}
|
||||
>
|
||||
{!searchPopupElement && (
|
||||
<div className="w-20 border border-rescuetrack"></div>
|
||||
)}
|
||||
</Marker>
|
||||
)}
|
||||
{searchPopupElement && (
|
||||
<SearchElementPopup element={searchPopupElement} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "../AircraftMarker";
|
||||
import {
|
||||
ConnectedAircraft,
|
||||
getPublicUser,
|
||||
Mission,
|
||||
MissionLog,
|
||||
MissionSdsLog,
|
||||
MissionStationLog,
|
||||
Prisma,
|
||||
Station,
|
||||
} from "@repo/db";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { editConnectedAircraftAPI } from "querys/aircrafts";
|
||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||
import { cn } from "helpers/cn";
|
||||
import { PersonIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Ban,
|
||||
BellRing,
|
||||
CircleGaugeIcon,
|
||||
Clock,
|
||||
CompassIcon,
|
||||
Component,
|
||||
GaugeIcon,
|
||||
Hash,
|
||||
ListCollapse,
|
||||
LocateFixed,
|
||||
MapPin,
|
||||
Mountain,
|
||||
Navigation,
|
||||
Plus,
|
||||
RadioTower,
|
||||
Sunset,
|
||||
TextSearch,
|
||||
} from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { editMissionAPI, sendSdsMessageAPI } from "querys/missions";
|
||||
|
||||
const FMSStatusHistory = ({
|
||||
aircraft,
|
||||
mission,
|
||||
}: {
|
||||
aircraft: ConnectedAircraft & { Station: Station };
|
||||
mission?: Mission;
|
||||
}) => {
|
||||
console.log("FMSStatusHistory", mission?.missionLog);
|
||||
const log = ((mission?.missionLog as unknown as MissionLog[]) || [])
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.type === "station-log" &&
|
||||
entry.data.stationId === aircraft.Station.id,
|
||||
)
|
||||
.reverse()
|
||||
.splice(0, 6) as MissionStationLog[];
|
||||
|
||||
const aircraftUser =
|
||||
typeof aircraft.publicUser === "string"
|
||||
? JSON.parse(aircraft.publicUser)
|
||||
: aircraft.publicUser;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<ul className="text-base-content font-semibold">
|
||||
<li className="flex items-center gap-2 mb-1">
|
||||
<PersonIcon className="w-5 h-5" /> {aircraftUser.fullName} (
|
||||
{aircraftUser.publicId})
|
||||
</li>
|
||||
</ul>
|
||||
<div className="divider mt-0 mb-0" />
|
||||
<ul className="space-y-2">
|
||||
{log.map((entry, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-bold text-base"
|
||||
style={{
|
||||
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
|
||||
}}
|
||||
>
|
||||
{entry.data.newFMSstatus}
|
||||
</span>
|
||||
<span className="text-base-content">
|
||||
{new Date(entry.timeStamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FMSStatusSelector = ({
|
||||
aircraft,
|
||||
}: {
|
||||
aircraft: ConnectedAircraft & { Station: Station };
|
||||
}) => {
|
||||
const dispatcherConnected =
|
||||
useDispatchConnectionStore((s) => s.status) === "connected";
|
||||
const [hoveredStatus, setHoveredStatus] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const changeAircraftMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
update,
|
||||
}: {
|
||||
id: number;
|
||||
update: Prisma.ConnectedAircraftUpdateInput;
|
||||
}) => {
|
||||
await editConnectedAircraftAPI(id, update);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["aircrafts"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mt-2 p-4 text-base-content">
|
||||
<div className="flex gap-2 justify-center items-center h-full">
|
||||
{Array.from({ length: 9 }, (_, i) => (i + 1).toString())
|
||||
.filter((status) => status !== "5") // Exclude status 5
|
||||
.map((status) => (
|
||||
<button
|
||||
disabled={!dispatcherConnected}
|
||||
key={status}
|
||||
className={cn(
|
||||
"flex justify-center items-center min-w-13 min-h-13 cursor-pointer text-4xl font-bold",
|
||||
!dispatcherConnected && "cursor-not-allowed",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
hoveredStatus === status
|
||||
? FMS_STATUS_COLORS[status]
|
||||
: aircraft.fmsStatus === status
|
||||
? FMS_STATUS_COLORS[status]
|
||||
: "var(--color-base-200)",
|
||||
color:
|
||||
aircraft.fmsStatus === status
|
||||
? "white"
|
||||
: hoveredStatus === status
|
||||
? "white"
|
||||
: "gray",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredStatus(status)}
|
||||
onMouseLeave={() => setHoveredStatus(null)}
|
||||
onClick={async () => {
|
||||
await changeAircraftMutation.mutateAsync({
|
||||
id: aircraft.id,
|
||||
update: {
|
||||
fmsStatus: status,
|
||||
},
|
||||
});
|
||||
toast.success(`Status changed to ${status}`);
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1 p-2 justify-center items-center">
|
||||
{["E", "C", "F", "J", "L", "c", "d", "h", "o", "u"].map((status) => (
|
||||
<button
|
||||
disabled={!dispatcherConnected}
|
||||
key={status}
|
||||
className={cn(
|
||||
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold",
|
||||
!dispatcherConnected && "cursor-not-allowed",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
hoveredStatus === status
|
||||
? FMS_STATUS_COLORS[6]
|
||||
: aircraft.fmsStatus === status
|
||||
? FMS_STATUS_COLORS[status]
|
||||
: "var(--color-base-200)",
|
||||
color:
|
||||
aircraft.fmsStatus === status
|
||||
? "white"
|
||||
: hoveredStatus === status
|
||||
? "white"
|
||||
: "gray",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredStatus(status)}
|
||||
onMouseLeave={() => setHoveredStatus(null)}
|
||||
onClick={async () => {
|
||||
await changeAircraftMutation.mutateAsync({
|
||||
id: aircraft.id,
|
||||
update: {
|
||||
fmsStatus: status,
|
||||
},
|
||||
});
|
||||
toast.success(`Status changed to ${status}`);
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RettungsmittelTab = ({
|
||||
aircraft,
|
||||
}: {
|
||||
aircraft: ConnectedAircraft & { Station: Station };
|
||||
}) => {
|
||||
const station = aircraft.Station;
|
||||
return (
|
||||
<div className="p-4 text-base-content">
|
||||
<ul className="text-base-content font-semibold">
|
||||
<li className="flex items-center gap-2 mb-1">
|
||||
<Component size={16} /> Aktuelle Rufgruppe: LST_01
|
||||
</li>
|
||||
<li className="flex items-center gap-2 mb-1">
|
||||
<RadioTower size={16} /> Leitstellenbereich: Florian Berlin
|
||||
</li>
|
||||
</ul>
|
||||
<div className="divider mt-0 mb-0" />
|
||||
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2 mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock size={16} /> {station.is24h ? "24h Betrieb" : "Tagbetrieb"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sunset size={16} /> NVG: {station.hasNvg ? "Ja" : "Nein"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Mountain size={16} /> Winch: {station.hasWinch ? "Ja" : "Nein"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<TextSearch size={16} /> {station.aircraftRegistration}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divider mt-0 mb-0" />
|
||||
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<CompassIcon size={16} /> HDG: {aircraft.posHeading}°
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<GaugeIcon size={16} /> SPD: {aircraft.posSpeed} kt
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<CircleGaugeIcon size={16} /> ALT: {aircraft.posAlt} ft
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MissionTab = ({ mission }: { mission: Mission }) => {
|
||||
return (
|
||||
<div className="p-4 text-base-content">
|
||||
<ul className="text-base-content font-semibold">
|
||||
<li className="flex items-center gap-2 mb-1">
|
||||
<BellRing size={16} /> {mission.missionKeywordCategory}
|
||||
</li>
|
||||
<li className="flex items-center gap-2 mb-1">
|
||||
<ListCollapse size={16} />
|
||||
{mission.missionKeywordName}
|
||||
</li>
|
||||
<li className="flex items-center gap-2 mt-3">
|
||||
<Hash size={16} />
|
||||
__{new Date().toISOString().slice(0, 10).replace(/-/g, "")}
|
||||
{mission.id}
|
||||
</li>
|
||||
</ul>
|
||||
<div className="divider mt-0 mb-0" />
|
||||
<div className="text-sm font-semibold">
|
||||
<p className="flex items-center gap-2">
|
||||
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<Navigation size={16} /> {mission.addressStreet}
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<LocateFixed size={16} /> {mission.addressZip} {mission.addressCity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SDSTab = ({
|
||||
aircraft,
|
||||
mission,
|
||||
}: {
|
||||
aircraft: ConnectedAircraft & {
|
||||
Station: Station;
|
||||
};
|
||||
mission?: Mission;
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const sendSdsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
message,
|
||||
}: {
|
||||
id: number;
|
||||
message: MissionSdsLog;
|
||||
}) => {
|
||||
await sendSdsMessageAPI(id, message);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const log =
|
||||
(mission?.missionLog as unknown as MissionLog[])
|
||||
?.slice()
|
||||
.reverse()
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.type === "sds-log" &&
|
||||
entry.data.stationId === aircraft.Station.id,
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isChatOpen ? (
|
||||
<button
|
||||
className="text-base-content text-base cursor-pointer"
|
||||
onClick={() => setIsChatOpen(true)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus size={18} /> Notiz hinzufügen
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=""
|
||||
className="input input-sm text-base-content flex-1"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary btn-outline"
|
||||
onClick={() => {
|
||||
if (!mission) return;
|
||||
sendSdsMutation
|
||||
.mutateAsync({
|
||||
id: mission.id,
|
||||
message: {
|
||||
type: "sds-log",
|
||||
auto: false,
|
||||
timeStamp: new Date().toISOString(),
|
||||
data: {
|
||||
stationId: aircraft.Station.id,
|
||||
station: aircraft.Station,
|
||||
message: note,
|
||||
user: getPublicUser(session.data!.user),
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
setIsChatOpen(false);
|
||||
setNote("");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => {
|
||||
setIsChatOpen(false);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
<Ban size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="divider m-0" />
|
||||
<ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto">
|
||||
{log.map((entry, index) => {
|
||||
const sdsEntry = entry as MissionSdsLog;
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-base-content">
|
||||
{new Date(sdsEntry.timeStamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-base"
|
||||
style={{
|
||||
color: FMS_STATUS_TEXT_COLORS[6],
|
||||
}}
|
||||
>
|
||||
{sdsEntry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
|
||||
{sdsEntry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
|
||||
</span>
|
||||
<span className="text-base-content">{sdsEntry.data.message}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!log.length && (
|
||||
<p className="text-gray-500 w-full text-center my-10 font-semibold">
|
||||
Kein SDS-Verlauf verfügbar
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { FMSStatusSelector, RettungsmittelTab, MissionTab, SDSTab };
|
||||
export default FMSStatusHistory;
|
||||
321
apps/dispatch/app/_components/map/_components/MarkerCluster.tsx
Normal file
321
apps/dispatch/app/_components/map/_components/MarkerCluster.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
ConnectedAircraft,
|
||||
HpgValidationState,
|
||||
Mission,
|
||||
Station,
|
||||
} from "@repo/db";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SmartPopup, useSmartPopup } from "_components/SmartPopup";
|
||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||
import { useMapStore } from "_store/mapStore";
|
||||
import {
|
||||
FMS_STATUS_COLORS,
|
||||
FMS_STATUS_TEXT_COLORS,
|
||||
} from "_components/map/AircraftMarker";
|
||||
import {
|
||||
MISSION_STATUS_COLORS,
|
||||
MISSION_STATUS_TEXT_COLORS,
|
||||
} from "_components/map/MissionMarkers";
|
||||
import { cn } from "helpers/cn";
|
||||
import { checkSimulatorConnected } from "helpers/simulatorConnected";
|
||||
import { getConnectedAircraftsAPI } from "querys/aircrafts";
|
||||
import { getMissionsAPI } from "querys/missions";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMap } from "react-leaflet";
|
||||
|
||||
const PopupContent = ({
|
||||
aircrafts,
|
||||
missions,
|
||||
}: {
|
||||
aircrafts: (ConnectedAircraft & { Station: Station })[];
|
||||
missions: Mission[];
|
||||
}) => {
|
||||
const { anchor } = useSmartPopup();
|
||||
const { setOpenAircraftMarker, setOpenMissionMarker } = useMapStore(
|
||||
(state) => state,
|
||||
);
|
||||
const map = useMap();
|
||||
|
||||
let borderColor = "";
|
||||
|
||||
if (anchor.includes("top")) {
|
||||
if (missions.length > 0) {
|
||||
borderColor = MISSION_STATUS_TEXT_COLORS[missions[0]!.state];
|
||||
} else if (aircrafts.length > 0) {
|
||||
borderColor = FMS_STATUS_TEXT_COLORS[aircrafts[0]!.fmsStatus] || "white";
|
||||
}
|
||||
} else if (anchor.includes("bottom")) {
|
||||
if (aircrafts.length > 0) {
|
||||
borderColor =
|
||||
FMS_STATUS_TEXT_COLORS[aircrafts[aircrafts.length - 1]!.fmsStatus] ||
|
||||
"white";
|
||||
} else if (missions.length > 0) {
|
||||
borderColor = MISSION_STATUS_TEXT_COLORS[missions[0]!.state];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex flex-col text-white min-w-[200px]">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[calc(100%+2px)] h-4 z-99 pointer-events-none",
|
||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||
)}
|
||||
style={{
|
||||
borderLeft: anchor.includes("left")
|
||||
? `3px solid ${borderColor}`
|
||||
: "",
|
||||
borderRight: anchor.includes("right")
|
||||
? `3px solid ${borderColor}`
|
||||
: "",
|
||||
borderTop: anchor.includes("top") ? `3px solid ${borderColor}` : "",
|
||||
borderBottom: anchor.includes("bottom")
|
||||
? `3px solid ${borderColor}`
|
||||
: "",
|
||||
}}
|
||||
/>
|
||||
{missions.map((mission) => {
|
||||
const markerColor =
|
||||
mission.hpgValidationState ===
|
||||
(HpgValidationState.POSITION_AMANDED ||
|
||||
HpgValidationState.INVALID ||
|
||||
HpgValidationState.HPG_DISCONNECT ||
|
||||
HpgValidationState.HPG_BUSY ||
|
||||
HpgValidationState.HPG_INVALID_MISSION)
|
||||
? MISSION_STATUS_COLORS["attention"]
|
||||
: MISSION_STATUS_COLORS[mission.state];
|
||||
return (
|
||||
<div
|
||||
key={mission.id}
|
||||
className={cn(
|
||||
"relative inline-flex items-center gap-2 text-nowrap w-full",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: markerColor,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mx-2 my-0.5 flex-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpenMissionMarker({
|
||||
open: [
|
||||
{
|
||||
id: mission.id,
|
||||
tab: "home",
|
||||
},
|
||||
],
|
||||
close: [],
|
||||
});
|
||||
map.setView([mission.addressLat, mission.addressLng], 12, {
|
||||
animate: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{mission.missionKeywordAbbreviation}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{aircrafts
|
||||
.filter((a) => checkSimulatorConnected(a.lastHeartbeat))
|
||||
.map((aircraft) => (
|
||||
<div
|
||||
key={aircraft.id}
|
||||
className="relative w-auto inline-flex items-center gap-2 text-nowrap cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
|
||||
}}
|
||||
onClick={() => {
|
||||
setOpenAircraftMarker({
|
||||
open: [
|
||||
{
|
||||
id: aircraft.id,
|
||||
tab: "aircraft",
|
||||
},
|
||||
],
|
||||
close: [],
|
||||
});
|
||||
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
|
||||
animate: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mx-2 my-0.5 text-gt font-bold"
|
||||
style={{
|
||||
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
|
||||
}}
|
||||
>
|
||||
{aircraft.fmsStatus}
|
||||
</span>
|
||||
<span>{aircraft.Station.bosCallsign}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkerCluster = () => {
|
||||
const map = useMap();
|
||||
const { data: aircrafts } = useQuery({
|
||||
queryKey: ["aircrafts"],
|
||||
queryFn: getConnectedAircraftsAPI,
|
||||
});
|
||||
|
||||
const dispatcherConnected =
|
||||
useDispatchConnectionStore((s) => s.status) === "connected";
|
||||
const { data: missions = [] } = useQuery({
|
||||
queryKey: ["missions"],
|
||||
queryFn: () =>
|
||||
getMissionsAPI({
|
||||
OR: [{ state: "draft" }, { state: "running" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const filteredMissions = useMemo(() => {
|
||||
if (!dispatcherConnected) {
|
||||
return missions.filter((m: Mission) => m.state === "running");
|
||||
}
|
||||
return missions;
|
||||
}, [missions, dispatcherConnected]);
|
||||
|
||||
const [cluster, setCluster] = useState<
|
||||
{
|
||||
aircrafts: (ConnectedAircraft & { Station: Station })[];
|
||||
missions: Mission[];
|
||||
lat: number;
|
||||
lng: number;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
// Compute clusters based on zoom and data using useMemo
|
||||
const clusters = useMemo(() => {
|
||||
const zoom = map.getZoom();
|
||||
if (zoom >= 8) return [];
|
||||
let newCluster: typeof cluster = [];
|
||||
aircrafts
|
||||
?.filter((a) => checkSimulatorConnected(a.lastHeartbeat))
|
||||
.forEach((aircraft) => {
|
||||
const lat = aircraft.posLat!;
|
||||
const lng = aircraft.posLng!;
|
||||
|
||||
const existingClusterIndex = newCluster.findIndex(
|
||||
(c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1,
|
||||
);
|
||||
const existingCluster = newCluster[existingClusterIndex];
|
||||
if (existingCluster) {
|
||||
newCluster = [...newCluster].map((c, i) => {
|
||||
if (i === existingClusterIndex) {
|
||||
return {
|
||||
...c,
|
||||
aircrafts: [...c.aircrafts, aircraft],
|
||||
};
|
||||
}
|
||||
return c;
|
||||
});
|
||||
} else {
|
||||
newCluster = [
|
||||
...newCluster,
|
||||
{
|
||||
aircrafts: [aircraft],
|
||||
missions: [],
|
||||
lat,
|
||||
lng,
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
filteredMissions?.forEach((mission) => {
|
||||
const lat = mission.addressLat;
|
||||
const lng = mission.addressLng;
|
||||
const existingClusterIndex = newCluster.findIndex(
|
||||
(c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1,
|
||||
);
|
||||
const existingCluster = newCluster[existingClusterIndex];
|
||||
|
||||
if (existingCluster) {
|
||||
newCluster = [...newCluster].map((c, i) => {
|
||||
if (i === existingClusterIndex) {
|
||||
return {
|
||||
...c,
|
||||
missions: [...c.missions, mission],
|
||||
};
|
||||
}
|
||||
return c;
|
||||
});
|
||||
} else {
|
||||
newCluster = [
|
||||
...newCluster,
|
||||
{
|
||||
aircrafts: [],
|
||||
missions: [mission],
|
||||
lat,
|
||||
lng,
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const clusterWithAvgPos = newCluster.map((c) => {
|
||||
const aircraftPos = c.aircrafts.map((a) => [a.posLat, a.posLng]);
|
||||
const missionPos = c.missions.map((m) => [m.addressLat, m.addressLng]);
|
||||
const allPos = [...aircraftPos, ...missionPos];
|
||||
|
||||
const avgLat =
|
||||
allPos.reduce((sum, pos) => sum + pos[0]!, 0) / allPos.length;
|
||||
const avgLng =
|
||||
allPos.reduce((sum, pos) => sum + pos[1]!, 0) / allPos.length;
|
||||
|
||||
return {
|
||||
...c,
|
||||
lat: avgLat,
|
||||
lng: avgLng,
|
||||
};
|
||||
});
|
||||
|
||||
return clusterWithAvgPos;
|
||||
}, [aircrafts, filteredMissions, map]);
|
||||
|
||||
// Update clusters on zoom change
|
||||
useEffect(() => {
|
||||
const handleZoom = () => {
|
||||
const zoom = map.getZoom();
|
||||
if (zoom >= 8) {
|
||||
setCluster([]);
|
||||
} else {
|
||||
setCluster(clusters);
|
||||
}
|
||||
};
|
||||
map.on("zoomend", handleZoom);
|
||||
// Set initial clusters
|
||||
handleZoom();
|
||||
return () => {
|
||||
map.off("zoomend", handleZoom);
|
||||
};
|
||||
}, [map, clusters]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{cluster.map((c, i) => (
|
||||
<SmartPopup
|
||||
options={{
|
||||
ignoreMarker: true,
|
||||
}}
|
||||
id={`cluster-${i}`}
|
||||
wrapperClassName="relative"
|
||||
key={i}
|
||||
position={[c.lat, c.lng]}
|
||||
autoPan={false}
|
||||
autoClose={false}
|
||||
className="w-[202px]"
|
||||
>
|
||||
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
|
||||
</SmartPopup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,673 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { FMS_STATUS_TEXT_COLORS } from "../AircraftMarker";
|
||||
import { toast } from "react-hot-toast";
|
||||
import {
|
||||
Ban,
|
||||
BellRing,
|
||||
Flag,
|
||||
Hash,
|
||||
ListCollapse,
|
||||
LocateFixed,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Plus,
|
||||
Repeat2,
|
||||
Trash,
|
||||
User,
|
||||
SmartphoneNfc,
|
||||
CheckCheck,
|
||||
Cross,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getPublicUser,
|
||||
HpgValidationState,
|
||||
Mission,
|
||||
MissionLog,
|
||||
MissionMessageLog,
|
||||
Prisma,
|
||||
Station,
|
||||
} from "@repo/db";
|
||||
import { usePannelStore } from "_store/pannelStore";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
deleteMissionAPI,
|
||||
editMissionAPI,
|
||||
sendMissionAPI,
|
||||
} from "querys/missions";
|
||||
import { getConnectedAircraftsAPI } from "querys/aircrafts";
|
||||
import { getStationsAPI } from "querys/stations";
|
||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||
|
||||
const Einsatzdetails = ({ mission }: { mission: Mission }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMissionMutation = useMutation({
|
||||
mutationKey: ["missions"],
|
||||
mutationFn: deleteMissionAPI,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
const sendAlertMutation = useMutation({
|
||||
mutationKey: ["missions"],
|
||||
mutationFn: (id: number) => sendMissionAPI(id, {}),
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Fehler beim Alarmieren");
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
const editMissionMutation = useMutation({
|
||||
mutationKey: ["missions"],
|
||||
mutationFn: ({
|
||||
id,
|
||||
mission,
|
||||
}: {
|
||||
id: number;
|
||||
mission: Prisma.MissionUpdateInput;
|
||||
}) => editMissionAPI(id, mission),
|
||||
onSuccess: () => {
|
||||
toast.success("Gespeichert");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
const dispatcherConnected =
|
||||
useDispatchConnectionStore((s) => s.status) === "connected";
|
||||
const { setMissionFormValues, setOpen } = usePannelStore((state) => state);
|
||||
return (
|
||||
<div className="p-4 text-base-content">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold">
|
||||
<Flag /> Einsatzdetails
|
||||
</h2>
|
||||
{mission.state !== "draft" && dispatcherConnected && (
|
||||
<div className="space-x-2">
|
||||
<div
|
||||
className="tooltip tooltip-primary tooltip-left font-semibold"
|
||||
data-tip="Einsatzdaten übernehmen"
|
||||
>
|
||||
<button
|
||||
className="btn btn-xs btn-primary btn-dash flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setMissionFormValues({
|
||||
...mission,
|
||||
id: undefined,
|
||||
hpgAmbulanceState: null,
|
||||
hpgFireEngineState: null,
|
||||
hpgPoliceState: null,
|
||||
hpgLocationLat: undefined,
|
||||
hpgLocationLng: undefined,
|
||||
state: "draft",
|
||||
});
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Repeat2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="tooltip tooltip-warning tooltip-left font-semibold z-[9999]"
|
||||
data-tip="Einsatz abschließen"
|
||||
>
|
||||
<button
|
||||
className="btn btn-xs btn-warning flex items-center gap-2"
|
||||
onClick={() => {
|
||||
editMissionMutation.mutate({
|
||||
id: mission.id,
|
||||
mission: {
|
||||
state: "finished",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ul className="text-base-content font-semibold">
|
||||
<li className="flex items-center gap-2 mb-1">
|
||||
<BellRing size={16} /> {mission.missionKeywordCategory}
|
||||
</li>
|
||||
<li className="flex items-center gap-2 mb-1">
|
||||
<ListCollapse size={16} />
|
||||
{mission.missionKeywordName}
|
||||
</li>
|
||||
<li className="flex items-center gap-2 mt-3">
|
||||
<Hash size={16} />
|
||||
{mission.publicId}
|
||||
</li>
|
||||
</ul>
|
||||
<div className="divider mt-0 mb-0" />
|
||||
<div className="text-sm font-semibold">
|
||||
<p className="flex items-center gap-2">
|
||||
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<Navigation size={16} /> {mission.addressStreet}
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<LocateFixed size={16} /> {mission.addressZip} {mission.addressCity}
|
||||
</p>
|
||||
</div>
|
||||
{mission.state === "draft" && (
|
||||
<div>
|
||||
<div className="divider mt-0 mb-0" />
|
||||
|
||||
<div className="form-control mb-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-sm checkbox-primary"
|
||||
/>
|
||||
<span className="label-text font-semibold leading-6">
|
||||
Ohne HPG-Mission alarmieren
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{(mission.hpgValidationState === HpgValidationState.VALID ||
|
||||
mission.hpgValidationState ===
|
||||
HpgValidationState.NOT_VALIDATED) && (
|
||||
<button
|
||||
className="btn btn-sm btn-info btn-outline flex-3"
|
||||
onClick={() => sendAlertMutation.mutate(mission.id)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BellRing size={16} /> Alarmieren
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{(mission.hpgValidationState === HpgValidationState.PENDING ||
|
||||
mission.hpgValidationState === HpgValidationState.HPG_BUSY ||
|
||||
mission.hpgValidationState ===
|
||||
HpgValidationState.HPG_DISCONNECT ||
|
||||
mission.hpgValidationState === HpgValidationState.INVALID ||
|
||||
HpgValidationState.HPG_INVALID_MISSION) &&
|
||||
mission.hpgValidationState !== HpgValidationState.NOT_VALIDATED &&
|
||||
mission.hpgValidationState !==
|
||||
HpgValidationState.POSITION_AMANDED &&
|
||||
mission.hpgValidationState !== HpgValidationState.VALID && (
|
||||
<button
|
||||
className="btn btn-sm btn-info btn-outline flex-3"
|
||||
onClick={() => sendAlertMutation.mutate(mission.id)}
|
||||
disabled
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{mission.hpgValidationState ===
|
||||
HpgValidationState.PENDING && (
|
||||
<div>
|
||||
<span className="loading loading-spinner loading-md"></span>{" "}
|
||||
HPG-Validierung läuft...
|
||||
</div>
|
||||
)}
|
||||
{mission.hpgValidationState ===
|
||||
HpgValidationState.HPG_BUSY && "HPG-Client busy"}
|
||||
{mission.hpgValidationState ===
|
||||
HpgValidationState.HPG_DISCONNECT &&
|
||||
"HPG-Client nicht verbunden"}
|
||||
{mission.hpgValidationState ===
|
||||
HpgValidationState.INVALID && "HPG-Client fehlerhaft"}
|
||||
{mission.hpgValidationState ===
|
||||
HpgValidationState.HPG_INVALID_MISSION &&
|
||||
"Fehlerhafte HPG-Mission"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mission.hpgValidationState ===
|
||||
HpgValidationState.POSITION_AMANDED && (
|
||||
<button
|
||||
className="btn btn-sm btn-warning btn-outline flex-3"
|
||||
onClick={() => sendAlertMutation.mutate(mission.id)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BellRing size={16} /> Mit neuer Position alarmieren
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-sm btn-primary btn-dash flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setMissionFormValues({
|
||||
...mission,
|
||||
id: undefined,
|
||||
hpgAmbulanceState: null,
|
||||
hpgFireEngineState: null,
|
||||
hpgPoliceState: null,
|
||||
hpgLocationLat: undefined,
|
||||
hpgLocationLng: undefined,
|
||||
state: "draft",
|
||||
});
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Repeat2 size={18} /> Daten übernehmen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-error btn-outline"
|
||||
onClick={() => {
|
||||
deleteMissionMutation.mutate(mission.id);
|
||||
}}
|
||||
>
|
||||
<Trash size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Patientdetails = ({ mission }: { mission: Mission }) => {
|
||||
return (
|
||||
<div className="p-4 text-base-content">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
|
||||
<User /> Patientendetails
|
||||
</h2>
|
||||
<p className="text-base-content font-semibold">
|
||||
{mission.missionPatientInfo}
|
||||
</p>
|
||||
<div className="divider my-2" />
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
|
||||
<Cross /> Einsatzinformationen
|
||||
</h2>
|
||||
<p className="text-base-content font-semibold">
|
||||
{mission.missionAdditionalInfo}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedStation, setSelectedStation] = useState<
|
||||
Station | "ambulance" | "police" | "firebrigade" | null
|
||||
>(null);
|
||||
const { data: conenctedAircrafts } = useQuery({
|
||||
queryKey: ["aircrafts"],
|
||||
queryFn: getConnectedAircraftsAPI,
|
||||
});
|
||||
const updateMissionMutation = useMutation({
|
||||
mutationKey: ["missions", "stations-mission", mission.id],
|
||||
mutationFn: ({
|
||||
id,
|
||||
missionEdit,
|
||||
}: {
|
||||
id: number;
|
||||
missionEdit: Prisma.MissionUpdateInput;
|
||||
}) => editMissionAPI(id, missionEdit),
|
||||
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Fehler beim Speichern");
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Cache invalidieren → Query wird neu ausgeführt
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: missionStations, refetch: refetchMissionStationIds } = useQuery(
|
||||
{
|
||||
queryKey: ["stations-mission", mission.id],
|
||||
queryFn: () =>
|
||||
getStationsAPI({
|
||||
id: {
|
||||
in: mission.missionStationIds,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
refetchMissionStationIds();
|
||||
}, [mission.missionStationIds, refetchMissionStationIds]);
|
||||
|
||||
const { data: allStations } = useQuery({
|
||||
queryKey: ["stations"],
|
||||
queryFn: () => getStationsAPI(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (allStations) {
|
||||
const stationsNotItMission = allStations.filter(
|
||||
(s) => !mission.missionStationIds.includes(s.id),
|
||||
);
|
||||
if (stationsNotItMission[0]) {
|
||||
setSelectedStation(stationsNotItMission[0]);
|
||||
}
|
||||
}
|
||||
}, [allStations, mission.missionStationIds]);
|
||||
|
||||
const sendAlertMutation = useMutation({
|
||||
mutationKey: ["missions"],
|
||||
mutationFn: ({ id, stationId }: { id: number; stationId?: number }) =>
|
||||
sendMissionAPI(id, { stationId }),
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Fehler beim Alarmieren");
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4 text-base-content">
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
|
||||
<SmartphoneNfc /> Rettungsmittel
|
||||
</h2>
|
||||
<div
|
||||
className="tooltip tooltip-primary tooltip-left font-semibold"
|
||||
data-tip="Einsatz erneut alarmieren"
|
||||
>
|
||||
<button
|
||||
className="btn btn-xs btn-primary btn-outline"
|
||||
onClick={() => {
|
||||
sendAlertMutation.mutate({ id: mission.id });
|
||||
}}
|
||||
>
|
||||
<BellRing size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto">
|
||||
{missionStations?.map((station, index) => {
|
||||
const connectedAircraft = conenctedAircrafts?.find(
|
||||
(aircraft) => aircraft.stationId === station.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
{connectedAircraft && (
|
||||
<span
|
||||
className="font-bold text-base"
|
||||
style={{
|
||||
color: FMS_STATUS_TEXT_COLORS[connectedAircraft.fmsStatus],
|
||||
}}
|
||||
>
|
||||
{connectedAircraft.fmsStatus}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-base-content">
|
||||
<div>
|
||||
<span className="font-bold">{station.bosCallsign}</span>
|
||||
{/* {item.min > 0 && (
|
||||
<>
|
||||
<br />
|
||||
Ankunft in ca. {item.min} min
|
||||
</>
|
||||
)} */}
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="divider mt-0 mb-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
{/* TODO: make it a small multiselect */}
|
||||
<select
|
||||
className="select select-sm select-primary select-bordered flex-1"
|
||||
onChange={(e) => {
|
||||
const selected = allStations?.find(
|
||||
(s) => s.id.toString() === e.target.value,
|
||||
);
|
||||
if (selected) {
|
||||
setSelectedStation(selected);
|
||||
} else {
|
||||
setSelectedStation(
|
||||
e.target.value as "ambulance" | "police" | "firebrigade",
|
||||
);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
typeof selectedStation === "string"
|
||||
? selectedStation
|
||||
: selectedStation?.id
|
||||
}
|
||||
>
|
||||
{allStations
|
||||
?.filter((s) => !mission.missionStationIds.includes(s.id))
|
||||
?.map((station) => (
|
||||
<option
|
||||
key={station.id}
|
||||
value={station.id}
|
||||
onClick={() => {
|
||||
setSelectedStation(station);
|
||||
}}
|
||||
>
|
||||
{station.bosCallsign}
|
||||
</option>
|
||||
))}
|
||||
<option disabled>Fahrzeuge:</option>
|
||||
<option value="firebrigade">Feuerwehr</option>
|
||||
<option value="ambulance">RTW</option>
|
||||
<option value="police">Polizei</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-sm btn-primary btn-outline"
|
||||
onClick={async () => {
|
||||
if (typeof selectedStation === "string") {
|
||||
toast.error("Fahrzeuge werden aktuell nicht unterstützt");
|
||||
} else {
|
||||
if (!selectedStation?.id) return;
|
||||
await updateMissionMutation.mutateAsync({
|
||||
id: mission.id,
|
||||
missionEdit: {
|
||||
missionStationIds: {
|
||||
push: selectedStation?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
await sendAlertMutation.mutate({
|
||||
id: mission.id,
|
||||
stationId: selectedStation?.id ?? 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-base-content flex items-center gap-2">
|
||||
<BellRing size={16} /> Nachalarmieren
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
||||
const session = useSession();
|
||||
const [isAddingNote, setIsAddingNote] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const dispatcherConnected =
|
||||
useDispatchConnectionStore((s) => s.status) === "connected";
|
||||
|
||||
const editMissionMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
mission,
|
||||
}: {
|
||||
id: number;
|
||||
mission: Partial<Prisma.MissionUpdateInput>;
|
||||
}) => editMissionAPI(id, mission),
|
||||
mutationKey: ["missions"],
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["missions"] });
|
||||
},
|
||||
});
|
||||
console.log(mission.missionLog);
|
||||
|
||||
if (!session.data?.user) return null;
|
||||
return (
|
||||
<div className="p-4">
|
||||
{dispatcherConnected && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isAddingNote ? (
|
||||
<button
|
||||
className="text-base-content text-base cursor-pointer"
|
||||
onClick={() => setIsAddingNote(true)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus size={18} /> Notiz hinzufügen
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=""
|
||||
className="input input-sm text-base-content flex-1"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary btn-outline"
|
||||
onClick={() => {
|
||||
const newMissionLog = [
|
||||
...mission.missionLog,
|
||||
{
|
||||
type: "message-log",
|
||||
auto: false,
|
||||
timeStamp: new Date().toISOString(),
|
||||
data: {
|
||||
message: note,
|
||||
user: getPublicUser(session.data?.user),
|
||||
},
|
||||
} as MissionMessageLog,
|
||||
];
|
||||
editMissionMutation
|
||||
.mutateAsync({
|
||||
id: mission.id,
|
||||
mission: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
missionLog: newMissionLog as any,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
setIsAddingNote(false);
|
||||
setNote("");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => {
|
||||
setIsAddingNote(false);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
<Ban size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="divider m-0" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-1 max-h-[300px] overflow-y-auto overflow-x-auto">
|
||||
{(mission.missionLog as unknown as MissionLog[])
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, index) => {
|
||||
if (entry.type === "station-log")
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-base-content">
|
||||
{new Date(entry.timeStamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-base"
|
||||
style={{
|
||||
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
|
||||
}}
|
||||
>
|
||||
{entry.data.newFMSstatus}
|
||||
</span>
|
||||
<span className="text-base-content">
|
||||
{entry.data.station.bosCallsign}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
if (entry.type === "message-log" || entry.type === "sds-log")
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-base-content">
|
||||
{new Date(entry.timeStamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-base flex items-center gap-0.5"
|
||||
style={{
|
||||
color: FMS_STATUS_TEXT_COLORS[6],
|
||||
}}
|
||||
>
|
||||
{entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
|
||||
{entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
|
||||
{entry.type === "sds-log" && (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
className="size-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{entry.data.station.bosCallsignShort}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-base-content">
|
||||
{entry.data.message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
return null;
|
||||
})}
|
||||
</ul>
|
||||
{!mission.missionLog.length && (
|
||||
<p className="text-gray-500 w-full text-center my-10 font-semibold">
|
||||
Keine Notizen verfügbar
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { FMSStatusHistory, Patientdetails, Rettungsmittel };
|
||||
export default Einsatzdetails;
|
||||
Reference in New Issue
Block a user