added Callback and custon notification Toast, client notification event handler

This commit is contained in:
PxlLoewe
2025-05-22 00:43:03 -07:00
parent 0f04174516
commit 8a4b42f02b
38 changed files with 715 additions and 339 deletions

View File

@@ -5,9 +5,12 @@ import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react";
import { dispatchSocket } from "dispatch/socket";
import { Mission } from "@repo/db";
import { Mission, NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
import { useMapStore } from "_store/mapStore";
export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s);
const [queryClient] = useState(
() =>
new QueryClient({
@@ -48,15 +51,42 @@ export function QueryProvider({ children }: { children: ReactNode }) {
});
};
const handleNotification = (notification: NotificationPayload) => {
console.log("notification", notification);
switch (notification.type) {
case "hpg-validation":
toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{
duration: 9999,
},
);
break;
default:
toast(notification.message);
break;
}
};
dispatchSocket.on("update-mission", invalidateMission);
dispatchSocket.on("delete-mission", invalidateMission);
dispatchSocket.on("new-mission", invalidateMission);
dispatchSocket.on("dispatchers-update", invalidateConnectedUsers);
dispatchSocket.on("pilots-update", invalidateConnectedUsers);
dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts);
}, [queryClient]);
dispatchSocket.on("notification", handleNotification);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return () => {
dispatchSocket.off("update-mission", invalidateMission);
dispatchSocket.off("delete-mission", invalidateMission);
dispatchSocket.off("new-mission", invalidateMission);
dispatchSocket.off("dispatchers-update", invalidateConnectedUsers);
dispatchSocket.off("pilots-update", invalidateConnectedUsers);
dispatchSocket.off("update-connectedAircraft", invalidateConenctedAircrafts);
dispatchSocket.off("notification", handleNotification);
};
}, [queryClient, mapStore]);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -0,0 +1,19 @@
import { cn } from "helpers/cn";
export const BaseNotification = ({
children,
className,
icon,
}: {
children: React.ReactNode;
className?: string;
icon?: React.ReactNode;
}) => {
return (
<div className={cn("alert alert-vertical flex flex-row gap-4")}>
{icon}
<div className={className}>{children}</div>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { NotificationPayload } from "@repo/db";
import { BaseNotification } from "_components/customToasts/BaseNotification";
import { MapStore, useMapStore } from "_store/mapStore";
import { Check, Cross } from "lucide-react";
import toast, { Toast } from "react-hot-toast";
export const HPGnotificationToast = ({
event,
t,
mapStore,
}: {
event: NotificationPayload;
t: Toast;
mapStore: MapStore;
}) => {
const handleClick = () => {
toast.dismiss(t.id);
mapStore.setOpenMissionMarker({
open: [{ id: event.data.mission.id, tab: "home" }],
close: [],
});
mapStore.setMap({
center: [event.data.mission.addressLat, event.data.mission.addressLng],
zoom: 14,
});
};
if (event.status === "failed") {
return (
<BaseNotification icon={<Cross />} className="flex flex-row">
<div className="flex-1">
<h1 className="text-red-500 font-bold">HPG validierung fehlgeschlagen</h1>
<p>{event.message}</p>
</div>
<div className="ml-11">
<button className="btn" onClick={handleClick}>
anzeigen
</button>
</div>
</BaseNotification>
);
} else {
return (
<BaseNotification icon={<Check />} className="flex flex-row">
<div className="flex-1">
<h1 className="text-green-600 font-bold">HPG validierung erfolgreich</h1>
<p className="text-sm">{event.message}</p>
</div>
<div className="ml-11">
<button className="btn" onClick={handleClick}>
anzeigen
</button>
</div>
</BaseNotification>
);
}
};

View 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} />;
})}
</>
);
};

View 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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="invert-100 grayscale"
/>
</>
);
};

View 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>
);
};

View 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;

View 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} />;
})}
</>
);
};

View 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} />
)}
</>
);
};

View File

@@ -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;

View 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>
))}
</>
);
};

View File

@@ -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;