added MEasurement-tool, added Dispatch-name auto change

This commit is contained in:
PxlLoewe
2025-06-08 18:15:47 -07:00
parent b553f8107d
commit 448bd94d44
14 changed files with 202 additions and 84 deletions

View File

@@ -20,7 +20,6 @@ import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms"; import { ROOMS } from "_data/livekitRooms";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { dispatchSocket } from "dispatch/socket";
import { useSounds } from "_components/Audio/useSounds"; import { useSounds } from "_components/Audio/useSounds";
export const Audio = () => { export const Audio = () => {

View File

@@ -6,7 +6,8 @@ import { Fragment, useEffect, useState } from "react";
import { cn } from "_helpers/cn"; import { cn } from "_helpers/cn";
import { asPublicUser } from "@repo/db"; import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getConnectedUserAPI } from "_querys/connected-user"; import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const Chat = () => { export const Chat = () => {
const { const {
@@ -26,20 +27,19 @@ export const Chat = () => {
const [addTabValue, setAddTabValue] = useState<string>("default"); const [addTabValue, setAddTabValue] = useState<string>("default");
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const { data: connectedUser } = useQuery({ const { data: dispatcher } = useQuery({
queryKey: ["connected-users"], queryKey: ["dispatcher"],
queryFn: async () => { queryFn: () => getConnectedDispatcherAPI(),
const user = await getConnectedUserAPI(); refetchInterval: 10000,
return user.filter((u) => u.userId !== session.data?.user.id); });
}, const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000, refetchInterval: 10000,
refetchOnWindowFocus: true,
}); });
useEffect(() => { const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
if (!session.data?.user.id) return; const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
setOwnId(session.data.user.id);
}, [session, setOwnId]);
return ( return (
<div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}> <div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}>
@@ -75,31 +75,36 @@ export const Chat = () => {
value={addTabValue} value={addTabValue}
onChange={(e) => setAddTabValue(e.target.value)} onChange={(e) => setAddTabValue(e.target.value)}
> >
{!connectedUser?.length && ( {!filteredDispatcher?.length && !filteredAircrafts?.length && (
<option disabled value="default"> <option disabled value="default">
Keine Chatpartner gefunden Keine Chatpartner gefunden
</option> </option>
)} )}
{connectedUser?.length && ( {filteredDispatcher?.length ||
<option disabled value="default"> (filteredAircrafts?.length && (
Chatpartner auswählen <option disabled value="default">
</option> Chatpartner auswählen
)} </option>
))}
{[ {filteredDispatcher?.map((dispatcher) => (
...(connectedUser?.filter( <option key={dispatcher.userId} value={dispatcher.userId}>
(user, idx, arr) => arr.findIndex((u) => u.userId === user.userId) === idx, {dispatcher.zone} - {asPublicUser(dispatcher.publicUser).fullName}
) || []), </option>
].map((user) => ( ))}
<option key={user.userId} value={user.userId}> {filteredAircrafts?.map((aircraft) => (
{asPublicUser(user.publicUser).fullName} <option key={aircraft.userId} value={aircraft.userId}>
{aircraft.Station.bosCallsignShort} -{" "}
{asPublicUser(aircraft.publicUser).fullName}
</option> </option>
))} ))}
</select> </select>
<button <button
className="btn btn-sm btn-soft btn-primary join-item" className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => { onClick={() => {
const user = connectedUser?.find((user) => user.userId === addTabValue); const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue);
const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue);
const user = aircraftUser || dispatcherUser;
if (!user) return; if (!user) return;
addChat(addTabValue, asPublicUser(user.publicUser).fullName); addChat(addTabValue, asPublicUser(user.publicUser).fullName);
setSelectedChat(addTabValue); setSelectedChat(addTabValue);

View File

@@ -7,8 +7,9 @@ import { toast } from "react-hot-toast";
import { useLeftMenuStore } from "_store/leftMenuStore"; import { useLeftMenuStore } from "_store/leftMenuStore";
import { asPublicUser } from "@repo/db"; import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getConnectedUserAPI } from "_querys/connected-user"; import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { sendReportAPI } from "_querys/report"; import { sendReportAPI } from "_querys/report";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const Report = () => { export const Report = () => {
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore(); const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore();
@@ -22,15 +23,19 @@ export const Report = () => {
setOwnId(session.data.user.id); setOwnId(session.data.user.id);
}, [session, setOwnId]); }, [session, setOwnId]);
const { data: connectedUser } = useQuery({ const { data: dispatcher } = useQuery({
queryKey: ["connected-users"], queryKey: ["dispatcher"],
queryFn: async () => { queryFn: () => getConnectedDispatcherAPI(),
const user = await getConnectedUserAPI();
return user.filter((u) => u.userId !== session.data?.user.id);
},
refetchInterval: 10000, refetchInterval: 10000,
refetchOnWindowFocus: true,
}); });
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
return ( return (
<div className={cn("dropdown dropdown-right", reportTabOpen && "dropdown-open")}> <div className={cn("dropdown dropdown-right", reportTabOpen && "dropdown-open")}>
@@ -60,23 +65,27 @@ export const Report = () => {
value={selectedPlayer} value={selectedPlayer}
onChange={(e) => setSelectedPlayer(e.target.value)} onChange={(e) => setSelectedPlayer(e.target.value)}
> >
{!connectedUser?.length && ( {!filteredDispatcher?.length && !filteredAircrafts?.length && (
<option disabled value="default"> <option disabled value="default">
Kein Nutzer verbunden Keine Nutzer gefunden
</option> </option>
)} )}
{connectedUser?.length && ( {filteredDispatcher?.length ||
<option disabled value="default"> (filteredAircrafts?.length && (
Kein Nutzer auswählen <option disabled value="default">
Nutzer auswählen
</option>
))}
{filteredDispatcher?.map((dispatcher) => (
<option key={dispatcher.userId} value={dispatcher.userId}>
{dispatcher.zone} - {asPublicUser(dispatcher.publicUser).fullName}
</option> </option>
)} ))}
{[ {filteredAircrafts?.map((aircraft) => (
...(connectedUser?.filter( <option key={aircraft.userId} value={aircraft.userId}>
(user, idx, arr) => arr.findIndex((u) => u.userId === user.userId) === idx, {aircraft.Station.bosCallsignShort} -{" "}
) || []), {asPublicUser(aircraft.publicUser).fullName}
].map((user) => (
<option key={user.userId} value={user.userId}>
{asPublicUser(user.publicUser).fullName}
</option> </option>
))} ))}
</select> </select>

View File

@@ -10,6 +10,7 @@ import { AircraftLayer } from "_components/map/AircraftMarker";
import { MarkerCluster } from "_components/map/_components/MarkerCluster"; import { MarkerCluster } from "_components/map/_components/MarkerCluster";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Map as TMap } from "leaflet"; import { Map as TMap } from "leaflet";
import { DistanceLayer } from "_components/map/Measurement";
const Map = () => { const Map = () => {
const ref = useRef<TMap | null>(null); const ref = useRef<TMap | null>(null);
@@ -49,6 +50,7 @@ const Map = () => {
<MarkerCluster /> <MarkerCluster />
<MissionLayer /> <MissionLayer />
<AircraftLayer /> <AircraftLayer />
<DistanceLayer />
</MapContainer> </MapContainer>
); );
}; };

View File

@@ -0,0 +1,102 @@
import "leaflet.polylinemeasure";
import "leaflet.polylinemeasure/Leaflet.PolylineMeasure.css";
import { useEffect, useRef } from "react";
import { useMap } from "react-leaflet";
import L from "leaflet";
export const DistanceLayer = () => {
const map = useMap();
const added = useRef(false);
useEffect(() => {
if (added.current) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(L.control as any)
.polylineMeasure({
position: "topleft",
unit: "metres", // Show imperial or metric distances. Values: 'metres', 'landmiles', 'nauticalmiles'
clearMeasurementsOnStop: true, // Clear all the measurements when the control is unselected
showBearings: true, // Whether bearings are displayed within the tooltips
bearingTextIn: "In", // language dependend label for inbound bearings
bearingTextOut: "Out", // language dependend label for outbound bearings
tooltipTextFinish: "Klicken zum <b>Beenden</b><br>",
tooltipTextDelete: "SHIFT+Click zum <b>Löschen</b>",
tooltipTextMove: "Klicken und ziehen zum <b>Verschieben</b><br>",
tooltipTextResume: "<br>CTRL+Click zum <b>Forsetzen</b>",
tooltipTextAdd: "CTRL+Click zum <b>Hinzufügen</b>",
// language dependend labels for point's tooltips
measureControlTitleOn: "Messung starten", // Title for the control going to be switched on
measureControlTitleOff: "Messung beenden", // Title for the control going to be switched off
measureControlLabel: "&#128207", // Label of the Measure control (maybe a unicode symbol)
measureControlClasses: ["pointer"], // Classes to apply to the Measure control
showClearControl: true, // Show a control to clear all the measurements
clearControlTitle: "Messung löschen", // Title text to show on the clear measurements control button
clearControlLabel: "&times", // Label of the Clear control (maybe a unicode symbol)
clearControlClasses: ["pointer"], // Classes to apply to clear control button
unitControlClasses: ["pointer"],
showUnitControl: true, // Show a control to change the units of measurements
unitControlTitle: {
// Title texts to show on the Unit Control
text: "Einheit ändern",
kilometres: "Kilometer",
nauticalmiles: "Nautische Meilen",
},
unitControlLabel: {
// Unit symbols to show in the Unit Control and measurement labels
metres: "m",
kilometres: "km",
feet: "ft",
landmiles: "mi",
nauticalmiles: "nm",
},
tempLine: {
// Styling settings for the temporary dashed line
color: "#00f", // Dashed line color
weight: 2, // Dashed line weight
},
fixedLine: {
// Styling for the solid line
color: "#006", // Solid line color
weight: 2, // Solid line weight
},
arrow: {
// Styling of the midway arrow
color: "#000", // Color of the arrow
},
startCircle: {
// Style settings for circle marker indicating the starting point of the polyline
color: "#000000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#000000", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
intermedCircle: {
// Style settings for all circle markers between startCircle and endCircle
color: "#000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#000000", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
currentCircle: {
// Style settings for circle marker indicating the latest point of the polyline during drawing a line
color: "#000000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#FFFFFF", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
endCircle: {
// Style settings for circle marker indicating the last point of the polyline
color: "#000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#ffffff", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
})
.addTo(map);
added.current = true;
}, [map]);
return null;
};

View File

@@ -18,6 +18,7 @@ import {
SmartphoneNfc, SmartphoneNfc,
CheckCheck, CheckCheck,
Cross, Cross,
Radio,
} from "lucide-react"; } from "lucide-react";
import { import {
getPublicUser, getPublicUser,
@@ -41,6 +42,7 @@ import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired"; import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getOsmAddress } from "_querys/osm"; import { getOsmAddress } from "_querys/osm";
import { hpgStateToFMSStatus } from "_helpers/hpgStateToFmsStatus"; import { hpgStateToFMSStatus } from "_helpers/hpgStateToFmsStatus";
import { cn } from "_helpers/cn";
const Einsatzdetails = ({ const Einsatzdetails = ({
mission, mission,
@@ -428,7 +430,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
? [{ label: "Rettungsdienst", value: "RTW", type: "vehicle" as const }] ? [{ label: "Rettungsdienst", value: "RTW", type: "vehicle" as const }]
: []), : []),
...(!mission.hpgPoliceState || mission.hpgPoliceState === "NOT_REQUESTED" ...(!mission.hpgPoliceState || mission.hpgPoliceState === "NOT_REQUESTED"
? [{ label: "POLizei", value: "POL", type: "vehicle" as const }] ? [{ label: "Polizei", value: "POL", type: "vehicle" as const }]
: []), : []),
].sort((a, b) => { ].sort((a, b) => {
// 1. Vehicles first // 1. Vehicles first
@@ -443,7 +445,6 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
// 3. Otherwise, sort alphabetically by label // 3. Otherwise, sort alphabetically by label
return a.label.localeCompare(b.label); return a.label.localeCompare(b.label);
return 0;
}); });
useEffect(() => { useEffect(() => {
@@ -558,25 +559,17 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
}} }}
value={typeof selectedStation === "string" ? selectedStation : selectedStation?.id} 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 value={"default"}>
Fahrzeuge:
</option>
{stationsOptions.map((option) => ( {stationsOptions.map((option) => (
<option key={option.value} value={option.value}> <option
key={option.value}
value={option.value}
className={cn(
"flex gap-2",
"isOnline" in option && option?.isOnline && "text-green-500",
)}
>
{option.label} {option.label}
{"isOnline" in option && option?.isOnline && " (Online)"}
</option> </option>
))} ))}
</select> </select>

View File

@@ -2,7 +2,7 @@
import { PublicUser } from "@repo/db"; import { PublicUser } from "@repo/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getConnectedAircraftsAPI, kickAircraftAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI, kickAircraftAPI } from "_querys/aircrafts";
import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/connected-user"; import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/dispatcher";
import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit"; import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit";
import { editUserAPI } from "_querys/user"; import { editUserAPI } from "_querys/user";
import { ParticipantInfo } from "livekit-server-sdk"; import { ParticipantInfo } from "livekit-server-sdk";

View File

@@ -2,17 +2,6 @@ import { ConnectedAircraft, ConnectedDispatcher, Prisma } from "@repo/db";
import { serverApi } from "_helpers/axios"; import { serverApi } from "_helpers/axios";
import axios from "axios"; import axios from "axios";
export const getConnectedUserAPI = async () => {
const res = await axios.get<(ConnectedAircraft | ConnectedDispatcher)[]>(
"/api/connected-user",
{},
);
if (res.status !== 200) {
throw new Error("Failed to fetch Connected User");
}
return res.data;
};
export const changeDispatcherAPI = async ( export const changeDispatcherAPI = async (
id: number, id: number,
data: Prisma.ConnectedDispatcherUpdateInput, data: Prisma.ConnectedDispatcherUpdateInput,

View File

@@ -9,6 +9,8 @@ import { ConnectionQuality, Participant, Room, RoomEvent, RpcInvocationData } fr
import { pilotSocket } from "pilot/socket"; import { pilotSocket } from "pilot/socket";
import { create } from "zustand"; import { create } from "zustand";
import axios from "axios"; import axios from "axios";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { changeDispatcherAPI } from "_querys/dispatcher";
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
@@ -95,7 +97,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
}, },
connect: async (roomName, role) => { connect: async (roomName, role) => {
set({ state: "connecting" }); set({ state: "connecting" });
console.log("Connecting to room: ", roomName);
try { try {
// Clean old room // Clean old room
const connectedRoom = get().room; const connectedRoom = get().room;
@@ -114,7 +116,15 @@ export const useAudioStore = create<TalkState>((set, get) => ({
await room.prepareConnection(url, token); await room.prepareConnection(url, token);
room room
// Connection events // Connection events
.on(RoomEvent.Connected, () => { .on(RoomEvent.Connected, async () => {
const dispatchState = useDispatchConnectionStore.getState();
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: roomName,
});
}
set({ state: "connected", room, message: null }); set({ state: "connected", room, message: null });
}) })
.on(RoomEvent.Disconnected, () => { .on(RoomEvent.Disconnected, () => {

View File

@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Prisma } from "@repo/db"; import { Prisma } from "@repo/db";
import { changeDispatcherAPI } from "_querys/connected-user"; import { changeDispatcherAPI } from "_querys/dispatcher";
export const ConnectionBtn = () => { export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);

View File

@@ -2,7 +2,7 @@ import { BADGES, PublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Badge } from "_components/Badge/Badge"; import { Badge } from "_components/Badge/Badge";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getConnectedDispatcherAPI } from "_querys/connected-user"; import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { Plane, Workflow } from "lucide-react"; import { Plane, Workflow } from "lucide-react";
export const ConnectedDispatcher = () => { export const ConnectedDispatcher = () => {

View File

@@ -2,7 +2,7 @@ import { BADGES, PublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Badge } from "_components/Badge/Badge"; import { Badge } from "_components/Badge/Badge";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getConnectedDispatcherAPI } from "_querys/connected-user"; import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { Plane, Workflow } from "lucide-react"; import { Plane, Workflow } from "lucide-react";
export const ConnectedDispatcher = () => { export const ConnectedDispatcher = () => {

View File

@@ -36,6 +36,7 @@
"i": "^0.3.7", "i": "^0.3.7",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.polylinemeasure": "^3.0.0",
"livekit-client": "^2.13.3", "livekit-client": "^2.13.3",
"livekit-server-sdk": "^2.13.0", "livekit-server-sdk": "^2.13.0",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",

8
pnpm-lock.yaml generated
View File

@@ -150,6 +150,9 @@ importers:
leaflet: leaflet:
specifier: ^1.9.4 specifier: ^1.9.4
version: 1.9.4 version: 1.9.4
leaflet.polylinemeasure:
specifier: ^3.0.0
version: 3.0.0
livekit-client: livekit-client:
specifier: ^2.13.3 specifier: ^2.13.3
version: 2.13.3(@types/dom-mediacapture-record@1.0.22) version: 2.13.3(@types/dom-mediacapture-record@1.0.22)
@@ -3573,6 +3576,9 @@ packages:
leac@0.6.0: leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
leaflet.polylinemeasure@3.0.0:
resolution: {integrity: sha512-PTTHz7NBJiWmNFetH8hb3leQNM15qoM9Xe7VDKh1Hyat/M+USqFYeoa0A0LAecsqsjUqA63Rzux4SivoyrSFkA==}
leaflet@1.9.4: leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
@@ -9279,6 +9285,8 @@ snapshots:
leac@0.6.0: {} leac@0.6.0: {}
leaflet.polylinemeasure@3.0.0: {}
leaflet@1.9.4: {} leaflet@1.9.4: {}
levn@0.4.1: levn@0.4.1: