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

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