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

@@ -1,6 +1,6 @@
{ {
"tabWidth": 2, "tabWidth": 2,
"useTabs": true, "useTabs": true,
"printWidth": 80, "printWidth": 100,
"singleQuote": false "singleQuote": false
} }

View File

@@ -6,3 +6,14 @@ declare module "next-auth/jwt" {
email: string; email: string;
} }
} }
declare module "cookie-parser";
import type { User } from "@repo/db";
declare global {
namespace Express {
interface Request {
user?: User | null;
}
}
}

View File

@@ -11,6 +11,18 @@ import cors from "cors";
import { handleSendMessage } from "socket-events/send-message"; import { handleSendMessage } from "socket-events/send-message";
import { handleConnectPilot } from "socket-events/connect-pilot"; import { handleConnectPilot } from "socket-events/connect-pilot";
import { handleConnectDesktop } from "socket-events/connect-desktop"; import { handleConnectDesktop } from "socket-events/connect-desktop";
import cookieParser from "cookie-parser";
import { authMiddleware } from "modules/expressMiddleware";
import { prisma, User } from "@repo/db";
import { Request, Response, NextFunction } from "express";
declare global {
namespace Express {
interface Request {
user?: User | null;
}
}
}
const app = express(); const app = express();
const server = createServer(app); const server = createServer(app);
@@ -31,6 +43,8 @@ io.on("connection", (socket) => {
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(cookieParser());
app.use(authMiddleware as any);
app.use(router); app.use(router);
server.listen(process.env.PORT, () => { server.listen(process.env.PORT, () => {

View File

@@ -0,0 +1,24 @@
import { prisma, User } from "@repo/db";
import { NextFunction } from "express";
interface AttachUserRequest extends Request {
user?: User | null;
}
interface AttachUserMiddleware {
(req: AttachUserRequest, res: Response, next: NextFunction): Promise<void>;
}
export const authMiddleware: AttachUserMiddleware = async (req, res, next) => {
const authHeader = (req.headers as any).authorization;
if (authHeader && authHeader.startsWith("User ")) {
const userId = authHeader.split(" ")[1];
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
req.user = user;
}
next();
};

View File

@@ -0,0 +1,101 @@
import { ConnectedAircraft, Mission, prisma } from "@repo/db";
import { io } from "index";
import { sendNtfyMission } from "modules/ntfy";
export const sendAlert = async (
id: number,
{
stationId,
}: {
stationId?: number;
},
): Promise<{
connectedAircrafts: ConnectedAircraft[];
mission: Mission;
}> => {
const mission = await prisma.mission.findUnique({
where: { id: id },
});
const Stations = await prisma.station.findMany({
where: {
id: {
in: mission?.missionStationIds,
},
},
});
if (!mission) {
throw new Error("Mission not found");
}
// connectedAircrafts the alert is sent to
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
stationId: stationId
? stationId
: {
in: mission.missionStationIds,
},
logoutTime: null,
},
include: {
Station: true,
},
});
for (const aircraft of connectedAircrafts) {
console.log(`Sending mission to: station:${aircraft.stationId}`);
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission,
Stations,
});
const user = await prisma.user.findUnique({
where: { id: aircraft.userId },
});
if (!user) continue;
if (user.settingsNtfyRoom) {
await sendNtfyMission(
mission,
Stations,
aircraft.Station,
user.settingsNtfyRoom,
);
}
const existingMissionOnStationUser =
await prisma.missionOnStationUsers.findFirst({
where: {
missionId: mission.id,
userId: aircraft.userId,
stationId: aircraft.stationId,
},
});
if (!existingMissionOnStationUser)
await prisma.missionOnStationUsers.create({
data: {
missionId: mission.id,
userId: aircraft.userId,
stationId: aircraft.stationId,
},
});
}
// for statistics only
await prisma.missionsOnStations
.createMany({
data: mission.missionStationIds.map((stationId) => ({
missionId: mission.id,
stationId,
})),
})
.catch(() => {
// Ignore if the entry already exists
});
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
},
});
return { connectedAircrafts, mission };
};

View File

@@ -10,6 +10,7 @@
"devDependencies": { "devDependencies": {
"@repo/db": "*", "@repo/db": "*",
"@repo/typescript-config": "*", "@repo/typescript-config": "*",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
@@ -21,6 +22,7 @@
"@redis/json": "^1.0.7", "@redis/json": "^1.0.7",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron": "^4.1.0", "cron": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",

View File

@@ -1,7 +1,16 @@
import { HpgValidationState, Prisma, prisma } from "@repo/db"; import {
HpgValidationState,
MissionSdsLog,
MissionStationLog,
NotificationPayload,
Prisma,
prisma,
User,
} from "@repo/db";
import { Router } from "express"; import { Router } from "express";
import { io } from "../index"; import { io } from "../index";
import { sendNtfyMission } from "modules/ntfy"; import { sendNtfyMission } from "modules/ntfy";
import { sendAlert } from "modules/mission";
const router = Router(); const router = Router();
@@ -107,90 +116,8 @@ router.post("/:id/send-alert", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { stationId } = req.body as { stationId?: number }; const { stationId } = req.body as { stationId?: number };
try { try {
const mission = await prisma.mission.findUnique({ const { connectedAircrafts, mission } = await sendAlert(Number(id), {
where: { id: Number(id) },
});
const Stations = await prisma.station.findMany({
where: {
id: {
in: mission?.missionStationIds,
},
},
});
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
// connectedAircrafts the alert is sent to
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
stationId: stationId
? stationId
: {
in: mission.missionStationIds,
},
logoutTime: null,
},
include: {
Station: true,
},
});
for (const aircraft of connectedAircrafts) {
console.log(`Sending mission to: station:${aircraft.stationId}`);
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission,
Stations,
});
const user = await prisma.user.findUnique({
where: { id: aircraft.userId },
});
if (!user) continue;
if (user.settingsNtfyRoom) {
await sendNtfyMission(
mission,
Stations,
aircraft.Station,
user.settingsNtfyRoom,
);
}
const existingMissionOnStationUser =
await prisma.missionOnStationUsers.findFirst({
where: {
missionId: mission.id,
userId: aircraft.userId,
stationId: aircraft.stationId,
},
});
if (!existingMissionOnStationUser)
await prisma.missionOnStationUsers.create({
data: {
missionId: mission.id,
userId: aircraft.userId,
stationId: aircraft.stationId,
},
});
}
// for statistics only
await prisma.missionsOnStations
.createMany({
data: mission.missionStationIds.map((stationId) => ({
missionId: mission.id,
stationId, stationId,
})),
})
.catch(() => {
// Ignore if the entry already exists
});
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
},
}); });
res.status(200).json({ res.status(200).json({
@@ -203,9 +130,36 @@ router.post("/:id/send-alert", async (req, res) => {
} }
}); });
router.post("/:id/send-sds", async (req, res) => {
const sdsMessage = req.body as MissionSdsLog;
const newMission = await prisma.mission.update({
where: {
id: Number(req.params.id),
},
data: {
missionLog: {
push: sdsMessage as any,
},
},
});
io.to(`station:${sdsMessage.data.stationId}`).emit("sds-message", sdsMessage);
res.json({
message: "SDS message sent",
mission: newMission,
});
io.to("dispatchers").emit("update-mission", newMission);
});
router.post("/:id/validate-hpg", async (req, res) => { router.post("/:id/validate-hpg", async (req, res) => {
try { try {
console.log(req.user);
const { id } = req.params; const { id } = req.params;
const config = req.body as
| {
alertWhenValid?: boolean;
}
| undefined;
const mission = await prisma.mission.findFirstOrThrow({ const mission = await prisma.mission.findFirstOrThrow({
where: { where: {
id: Number(id), id: Number(id),
@@ -225,16 +179,11 @@ router.post("/:id/validate-hpg", async (req, res) => {
}, },
}); });
/* if (activeAircraftinMission.length === 0) {
res.status(400).json({ error: "No active aircraft in mission" });
return;
} */
res.json({ res.json({
message: "HPG validation started", message: "HPG validation started",
}); });
io.to(`desktop:${activeAircraftinMission}`).emit( /* io.to(`desktop:${activeAircraftinMission}`).emit(
"hpg-validation", "hpg-validation",
{ {
hpgMissionType: mission?.hpgMissionString, hpgMissionType: mission?.hpgMissionString,
@@ -266,8 +215,41 @@ router.post("/:id/validate-hpg", async (req, res) => {
}, },
}); });
io.to("dispatchers").emit("update-mission", newMission); io.to("dispatchers").emit("update-mission", newMission);
const noActionRequired = result.state === "VALID";
if (noActionRequired) {
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "success",
message: `HPG Validierung erfolgreich`,
} as NotificationPayload);
if (config?.alertWhenValid) {
sendAlert(Number(id), {});
}
} else {
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "failed",
message: `HPG Validation fehlgeschlagen`,
} as NotificationPayload);
}
}, },
); ); */
setTimeout(() => {
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "success",
message: "HPG_BUSY",
data: {
mission,
},
} as NotificationPayload);
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "failed",
message: `HPG Validation fehlgeschlagen`,
} as NotificationPayload);
}, 5000);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ error: (error as Error).message || "Failed to validate HPG" }); res.json({ error: (error as Error).message || "Failed to validate HPG" });

View File

@@ -6,6 +6,6 @@
"baseUrl": ".", "baseUrl": ".",
"jsx": "react" "jsx": "react"
}, },
"include": ["**/*.ts", "./index.ts"], "include": ["**/*.ts", "./index.ts", "**/*.d.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -5,9 +5,12 @@ import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { dispatchSocket } from "dispatch/socket"; 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 }) { export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s);
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
new QueryClient({ 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("update-mission", invalidateMission);
dispatchSocket.on("delete-mission", invalidateMission); dispatchSocket.on("delete-mission", invalidateMission);
dispatchSocket.on("new-mission", invalidateMission); dispatchSocket.on("new-mission", invalidateMission);
dispatchSocket.on("dispatchers-update", invalidateConnectedUsers); dispatchSocket.on("dispatchers-update", invalidateConnectedUsers);
dispatchSocket.on("pilots-update", invalidateConnectedUsers); dispatchSocket.on("pilots-update", invalidateConnectedUsers);
dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts); dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts);
}, [queryClient]); dispatchSocket.on("notification", handleNotification);
return ( return () => {
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> 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,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

@@ -37,7 +37,7 @@ import {
TextSearch, TextSearch,
} from "lucide-react"; } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { editMissionAPI } from "querys/missions"; import { editMissionAPI, sendSdsMessageAPI } from "querys/missions";
const FMSStatusHistory = ({ const FMSStatusHistory = ({
aircraft, aircraft,
@@ -298,17 +298,18 @@ const SDSTab = ({
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const editMissionMutation = useMutation({ const sendSdsMutation = useMutation({
mutationFn: ({ mutationFn: async ({
id, id,
mission, message,
}: { }: {
id: number; id: number;
mission: Partial<Prisma.MissionUpdateInput>; message: MissionSdsLog;
}) => editMissionAPI(id, mission), }) => {
mutationKey: ["missions"], await sendSdsMessageAPI(id, message);
onSuccess: () => { queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: ["missions"] }); queryKey: ["missions"],
});
}, },
}); });
@@ -347,9 +348,10 @@ const SDSTab = ({
className="btn btn-sm btn-primary btn-outline" className="btn btn-sm btn-primary btn-outline"
onClick={() => { onClick={() => {
if (!mission) return; if (!mission) return;
const newMissionLog = [ sendSdsMutation
...mission.missionLog, .mutateAsync({
{ id: mission.id,
message: {
type: "sds-log", type: "sds-log",
auto: false, auto: false,
timeStamp: new Date().toISOString(), timeStamp: new Date().toISOString(),
@@ -359,14 +361,6 @@ const SDSTab = ({
message: note, message: note,
user: getPublicUser(session.data!.user), user: getPublicUser(session.data!.user),
}, },
} as MissionSdsLog,
];
editMissionMutation
.mutateAsync({
id: mission.id,
mission: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
missionLog: newMissionLog as any,
}, },
}) })
.then(() => { .then(() => {

View File

@@ -11,11 +11,11 @@ import { useMapStore } from "_store/mapStore";
import { import {
FMS_STATUS_COLORS, FMS_STATUS_COLORS,
FMS_STATUS_TEXT_COLORS, FMS_STATUS_TEXT_COLORS,
} from "dispatch/_components/map/AircraftMarker"; } from "_components/map/AircraftMarker";
import { import {
MISSION_STATUS_COLORS, MISSION_STATUS_COLORS,
MISSION_STATUS_TEXT_COLORS, MISSION_STATUS_TEXT_COLORS,
} from "dispatch/_components/map/MissionMarkers"; } from "_components/map/MissionMarkers";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { checkSimulatorConnected } from "helpers/simulatorConnected"; import { checkSimulatorConnected } from "helpers/simulatorConnected";
import { getConnectedAircraftsAPI } from "querys/aircrafts"; import { getConnectedAircraftsAPI } from "querys/aircrafts";

View File

@@ -367,17 +367,12 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
}, },
}); });
const dispatcherConnected =
useDispatchConnectionStore((s) => s.status) === "connected";
return ( return (
<div className="p-4 text-base-content"> <div className="p-4 text-base-content">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center w-full justify-between">
<h2 className="flex items-center gap-2 text-lg font-bold"> <h2 className="flex items-center gap-2 text-lg font-bold mb-3">
<SmartphoneNfc /> Rettungsmittel <SmartphoneNfc /> Rettungsmittel
</h2> </h2>
{dispatcherConnected && (
<div className="space-x-2">
<div <div
className="tooltip tooltip-primary tooltip-left font-semibold" className="tooltip tooltip-primary tooltip-left font-semibold"
data-tip="Einsatz erneut alarmieren" data-tip="Einsatz erneut alarmieren"
@@ -392,8 +387,6 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
</button> </button>
</div> </div>
</div> </div>
)}
</div>
<ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto"> <ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto">
{missionStations?.map((station, index) => { {missionStations?.map((station, index) => {
const connectedAircraft = conenctedAircrafts?.find( const connectedAircraft = conenctedAircrafts?.find(
@@ -427,25 +420,75 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
); );
})} })}
</ul> </ul>
{dispatcherConnected && (
<div>
<div className="divider mt-0 mb-0" /> <div className="divider mt-0 mb-0" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* TODO: make it a small multiselect */} {/* TODO: make it a small multiselect */}
<select className="select select-sm select-primary select-bordered flex-1"> <select
<option value="1">Feuerwehr</option> className="select select-sm select-primary select-bordered flex-1"
<option value="2">RTW</option> onChange={(e) => {
<option value="3">Polizei</option> 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> </select>
<button className="btn btn-sm btn-primary btn-outline"> <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"> <span className="text-base-content flex items-center gap-2">
<BellRing size={16} /> Nachalarmieren <BellRing size={16} /> Nachalarmieren
</span> </span>
</button> </button>
</div> </div>
</div> </div>
)}
</div>
); );
}; };

View File

@@ -47,6 +47,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
const { room, isTalking } = get(); const { room, isTalking } = get();
if (!room) return; if (!room) return;
room.localParticipant.setMicrophoneEnabled(!isTalking); room.localParticipant.setMicrophoneEnabled(!isTalking);
if (!isTalking) { if (!isTalking) {
// If old status was not talking, we need to emit the PTT event // If old status was not talking, we need to emit the PTT event
if (pilotSocket.connected) { if (pilotSocket.connected) {

View File

@@ -1,5 +1,8 @@
import { create } from "zustand"; import { create } from "zustand";
import { dispatchSocket } from "../../dispatch/socket"; import { dispatchSocket } from "../../dispatch/socket";
import toast from "react-hot-toast";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
import { NotificationPayload } from "@repo/db";
interface ConnectionStore { interface ConnectionStore {
status: "connected" | "disconnected" | "connecting" | "error"; status: "connected" | "disconnected" | "connecting" | "error";

View File

@@ -1,7 +1,7 @@
import { OSMWay } from "@repo/db"; import { OSMWay } from "@repo/db";
import { create } from "zustand"; import { create } from "zustand";
interface MapStore { export interface MapStore {
contextMenu: { contextMenu: {
lat: number; lat: number;
lng: number; lng: number;
@@ -10,6 +10,7 @@ interface MapStore {
center: L.LatLngExpression; center: L.LatLngExpression;
zoom: number; zoom: number;
}; };
setMap: (map: MapStore["map"]) => void;
openMissionMarker: { openMissionMarker: {
id: number; id: number;
tab: "home" | "details" | "patient" | "log"; tab: "home" | "details" | "patient" | "log";
@@ -67,19 +68,24 @@ export const useMapStore = create<MapStore>((set, get) => ({
center: [51.5, 10.5], center: [51.5, 10.5],
zoom: 6, zoom: 6,
}, },
setMap: (map) => {
set(() => ({
map,
}));
},
searchPopup: null, searchPopup: null,
searchElements: [], searchElements: [],
setSearchPopup: (popup) => setSearchPopup: (popup) =>
set((state) => ({ set(() => ({
searchPopup: popup, searchPopup: popup,
})), })),
contextMenu: null, contextMenu: null,
setContextMenu: (contextMenu) => setContextMenu: (contextMenu) =>
set((state) => ({ set(() => ({
contextMenu, contextMenu,
})), })),
setSearchElements: (elements) => setSearchElements: (elements) =>
set((state) => ({ set(() => ({
searchElements: elements, searchElements: elements,
})), })),
aircraftTabs: {}, aircraftTabs: {},

View File

@@ -1,9 +1,15 @@
import { Station } from "@repo/db"; import { MissionSdsLog, Station } from "@repo/db";
import { fmsStatusDescription } from "_data/fmsStatusDescription"; import { fmsStatusDescription } from "_data/fmsStatusDescription";
import { DisplayLineProps } from "pilot/_components/mrt/Mrt"; import { DisplayLineProps } from "pilot/_components/mrt/Mrt";
import { create } from "zustand"; import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs"; import { syncTabs } from "zustand-sync-tabs";
interface SetSdsPageParams {
page: "sds";
station: Station;
sdsMessage: MissionSdsLog;
}
interface SetHomePageParams { interface SetHomePageParams {
page: "home"; page: "home";
station: Station; station: Station;
@@ -23,6 +29,7 @@ interface SetNewStatusPageParams {
type SetPageParams = type SetPageParams =
| SetHomePageParams | SetHomePageParams
| SetSendingStatusPageParams | SetSendingStatusPageParams
| SetSdsPageParams
| SetNewStatusPageParams; | SetNewStatusPageParams;
interface MrtStore { interface MrtStore {
@@ -123,6 +130,25 @@ export const useMrtStore = create<MrtStore>(
}); });
break; break;
} }
case "sds": {
const { sdsMessage } = pageData as SetSdsPageParams;
set({
page: "sds",
lines: [
{
textLeft: `neue SDS-Nachricht`,
style: { fontWeight: "bold" },
textSize: "2",
},
{
textLeft: sdsMessage.data.message,
style: {},
textSize: "1",
},
],
});
break;
}
default: default:
set({ page: "home" }); set({ page: "home" });
break; break;

View File

@@ -1,8 +1,17 @@
import { create } from "zustand"; import { create } from "zustand";
import { dispatchSocket } from "../../dispatch/socket"; import { dispatchSocket } from "../../dispatch/socket";
import { ConnectedAircraft, Mission, Station, User } from "@repo/db"; import {
ConnectedAircraft,
Mission,
MissionSdsLog,
NotificationPayload,
Station,
User,
} from "@repo/db";
import { pilotSocket } from "pilot/socket"; import { pilotSocket } from "pilot/socket";
import { useDmeStore } from "_store/pilot/dmeStore"; import { useDmeStore } from "_store/pilot/dmeStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import toast from "react-hot-toast";
interface ConnectionStore { interface ConnectionStore {
status: "connected" | "disconnected" | "connecting" | "error"; status: "connected" | "disconnected" | "connecting" | "error";
@@ -103,3 +112,13 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => {
page: "new-mission", page: "new-mission",
}); });
}); });
pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => {
const station = usePilotConnectionStore.getState().selectedStation;
if (!station) return;
useMrtStore.getState().setPage({
page: "sds",
station,
sdsMessage,
});
});

View File

@@ -1,32 +0,0 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useMapStore } from "_store/mapStore";
import { MapContainer } from "react-leaflet";
import { BaseMaps } from "dispatch/_components/map/BaseMaps";
import { ContextMenu } from "dispatch/_components/map/ContextMenu";
import { MissionLayer } from "dispatch/_components/map/MissionMarkers";
import { SearchElements } from "dispatch/_components/map/SearchElements";
import { AircraftLayer } from "dispatch/_components/map/AircraftMarker";
import { MarkerCluster } from "dispatch/_components/map/_components/MarkerCluster";
const Map = () => {
const { map } = useMapStore();
return (
<MapContainer
className="flex-1"
center={map.center}
zoom={map.zoom}
fadeAnimation={false}
>
<BaseMaps />
<SearchElements />
<ContextMenu />
<MarkerCluster />
<MissionLayer />
<AircraftLayer />
</MapContainer>
);
};
export default Map;

View File

@@ -17,10 +17,12 @@ import {
createMissionAPI, createMissionAPI,
editMissionAPI, editMissionAPI,
sendMissionAPI, sendMissionAPI,
startHpgValidation,
} from "querys/missions"; } from "querys/missions";
import { getKeywordsAPI } from "querys/keywords"; import { getKeywordsAPI } from "querys/keywords";
import { getStationsAPI } from "querys/stations"; import { getStationsAPI } from "querys/stations";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { getConnectedAircraftsAPI } from "querys/aircrafts";
export const MissionForm = () => { export const MissionForm = () => {
const { isEditingMission, editingMissionId, setEditingMission } = const { isEditingMission, editingMissionId, setEditingMission } =
@@ -33,6 +35,12 @@ export const MissionForm = () => {
queryFn: () => getKeywordsAPI(), queryFn: () => getKeywordsAPI(),
}); });
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: getConnectedAircraftsAPI,
refetchInterval: 10000,
});
const { data: stations } = useQuery({ const { data: stations } = useQuery({
queryKey: ["stations"], queryKey: ["stations"],
queryFn: () => getStationsAPI(), queryFn: () => getStationsAPI(),
@@ -105,8 +113,11 @@ export const MissionForm = () => {
}); });
const { missionFormValues, setOpen } = usePannelStore((state) => state); const { missionFormValues, setOpen } = usePannelStore((state) => state);
const missionInfoText = form.watch("missionAdditionalInfo"); const validationRequired = /* form.watch("missionStationIds")?.some((id) => {
const hpgMissionString = form.watch("hpgMissionString"); const aircraft = aircrafts?.find((a) => a.stationId === id);
return aircraft?.posH145active;
}) && form.watch("hpgMissionString")?.length !== 0; */ true;
useEffect(() => { useEffect(() => {
if (session.data?.user.id) { if (session.data?.user.id) {
@@ -296,12 +307,9 @@ export const MissionForm = () => {
); );
})} })}
</select> </select>
{form.watch("hpgMissionString") && {validationRequired && (
form.watch("hpgMissionString") !== "" && ( <p className="text-sm text-warning">
<p className="text-sm text-error"> Szenario wird vor Alarmierung HPG-Validiert.
Szenario wird vor Alarmierung HPG-Validiert. <br />
Achte nach dem Vorbereiten / Alarmieren auf den Status der
Mission.
</p> </p>
)} )}
</div> </div>
@@ -356,6 +364,9 @@ export const MissionForm = () => {
: mission.missionAdditionalInfo, : mission.missionAdditionalInfo,
}, },
}); });
if (validationRequired) {
await startHpgValidation(newMission.id);
}
toast.success( toast.success(
`Einsatz ${newMission.id} erfolgreich aktualisiert`, `Einsatz ${newMission.id} erfolgreich aktualisiert`,
); );
@@ -391,7 +402,13 @@ export const MissionForm = () => {
? `HPG-Szenario: ${hpgSzenario}` ? `HPG-Szenario: ${hpgSzenario}`
: mission.missionAdditionalInfo, : mission.missionAdditionalInfo,
}); });
if (validationRequired) {
await startHpgValidation(newMission.id, {
alertWhenValid: true,
});
} else {
await sendAlertMutation.mutateAsync(newMission.id); await sendAlertMutation.mutateAsync(newMission.id);
}
setSeachOSMElements([]); // Reset search elements setSeachOSMElements([]); // Reset search elements
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
@@ -421,7 +438,7 @@ export const MissionForm = () => {
: mission.missionAdditionalInfo, : mission.missionAdditionalInfo,
}); });
setSeachOSMElements([]); // Reset search elements setSeachOSMElements([]); // Reset search elements
await startHpgValidation(newMission.id);
toast.success(`Einsatz ${newMission.publicId} erstellt`); toast.success(`Einsatz ${newMission.publicId} erstellt`);
form.reset(); form.reset();
setOpen(false); setOpen(false);

View File

@@ -1,117 +0,0 @@
import { useState } from "react";
import { toast } from "react-hot-toast";
interface ToastCard {
id: number;
title: string;
content: string;
}
const MapToastCard2 = () => {
const [cards, setCards] = useState<ToastCard[]>([]);
const [openCardId, setOpenCardId] = useState<number | null>(null);
const addCard = () => {
const newCard: ToastCard = {
id: Date.now(),
title: `Einsatz #${cards.length + 1}`,
content: `Inhalt von Einsatz #${cards.length + 1}.`,
};
setCards([...cards, newCard]);
// DEBUG
/* toast("😖 Christoph 31 sendet Status 4", {
duration: 10000,
}); */
// DEBUG
const toastId = toast.custom(
<div
style={{
display: "flex",
alignItems: "center",
lineHeight: 1.3,
willChange: "transform",
boxShadow:
"0 3px 10px rgba(0, 0, 0, 0.1), 0 3px 3px rgba(0, 0, 0, 0.05)",
maxWidth: "350px",
pointerEvents: "auto",
padding: "8px 10px",
borderRadius: "8px",
background: "var(--color-base-100)",
color: "var(--color-base-content)",
}}
>
<div
className="toastText flex items-center"
style={{
display: "flex",
justifyContent: "center",
margin: "4px 10px",
color: "inherit",
flex: "1 1 auto",
whiteSpace: "pre-line",
}}
>
😖 Christoph 31 sendet Status 5{" "}
<button
className="btn btn-sm btn-soft btn-accent ml-2"
onClick={() => toast.remove(toastId)}
>
U
</button>
</div>
</div>,
{
duration: 999999999,
},
);
// DEBUG
};
const removeCard = (id: number) => {
setCards(cards.filter((card) => card.id !== id));
};
const toggleCard = (id: number) => {
setOpenCardId(openCardId === id ? null : id);
};
return (
<div className="absolute top-4 right-4 z-[1000] flex flex-col space-y-4">
{/* DEBUG */}
<button
onClick={addCard}
className="mb-4 p-2 bg-blue-500 text-white rounded self-end"
>
Debug Einsatz
</button>
{/* DEBUG */}
{cards.map((card) => (
<div
key={card.id}
className="collapse collapse-arrow bg-base-100 border-base-300 border w-120 relative"
>
<input
type="checkbox"
className="absolute top-0 left-0 opacity-0"
checked={openCardId === card.id}
onChange={() => toggleCard(card.id)}
/>
<div className="collapse-title font-semibold flex justify-between items-center">
<span>{card.title}</span>
<button
className="btn btn-sm btn-circle btn-ghost z-10 absolute top-3.5 right-8"
onClick={(e) => {
removeCard(card.id);
}}
>
</button>
</div>
<div className="collapse-content text-sm">{card.content}</div>
</div>
))}
</div>
);
};
export default MapToastCard2;

View File

@@ -6,7 +6,7 @@ import { cn } from "helpers/cn";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Chat } from "../_components/left/Chat"; import { Chat } from "../_components/left/Chat";
import { Report } from "../_components/left/Report"; import { Report } from "../_components/left/Report";
const Map = dynamic(() => import("./_components/map/Map"), { ssr: false }); const Map = dynamic(() => import("../_components/map/Map"), { ssr: false });
const DispatchPage = () => { const DispatchPage = () => {
const { isOpen } = usePannelStore(); const { isOpen } = usePannelStore();

View File

@@ -1,5 +1,6 @@
import { ConnectedAircraft, ConnectedDispatcher } from "@repo/db"; import { ConnectedAircraft, ConnectedDispatcher } from "@repo/db";
import axios from "axios"; import axios from "axios";
import { getSession } from "next-auth/react";
export const serverApi = axios.create({ export const serverApi = axios.create({
baseURL: process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, baseURL: process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL,
@@ -9,6 +10,22 @@ export const serverApi = axios.create({
}, },
}); });
serverApi.interceptors.request.use(
async (config) => {
const session = await getSession();
const token = session?.user.id; /* session?.accessToken */ // abhängig von deinem NextAuth setup
if (token) {
config.headers.Authorization = `User ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
export const getConenctedUsers = async (): Promise< export const getConenctedUsers = async (): Promise<
(ConnectedDispatcher | ConnectedAircraft)[] (ConnectedDispatcher | ConnectedAircraft)[]
> => { > => {

View File

@@ -1,4 +1,4 @@
import { CSSProperties, useEffect } from "react"; import { CSSProperties } from "react";
import MrtImage from "./MRT.png"; import MrtImage from "./MRT.png";
import { useButtons } from "./useButtons"; import { useButtons } from "./useButtons";
import { useSounds } from "./useSounds"; import { useSounds } from "./useSounds";

View File

@@ -5,7 +5,7 @@ import { Chat } from "../_components/left/Chat";
import { Report } from "../_components/left/Report"; import { Report } from "../_components/left/Report";
import { Dme } from "pilot/_components/dme/Dme"; import { Dme } from "pilot/_components/dme/Dme";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
const Map = dynamic(() => import("../dispatch/_components/map/Map"), { const Map = dynamic(() => import("../_components/map/Map"), {
ssr: false, ssr: false,
}); });

View File

@@ -1,4 +1,4 @@
import { Mission, Prisma } from "@repo/db"; import { Mission, MissionSdsLog, Prisma } from "@repo/db";
import axios from "axios"; import axios from "axios";
import { serverApi } from "helpers/axios"; import { serverApi } from "helpers/axios";
@@ -26,6 +26,29 @@ export const editMissionAPI = async (
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission); const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data; return respone.data;
}; };
export const sendSdsMessageAPI = async (
id: number,
sdsMessage: MissionSdsLog,
) => {
const respone = await serverApi.post<Mission>(
`/mission/${id}/send-sds`,
sdsMessage,
);
return respone.data;
};
export const startHpgValidation = async (
id: number,
config?: {
alertWhenValid?: boolean;
},
) => {
const respone = await serverApi.post<Mission>(
`/mission/${id}/validate-hpg`,
config,
);
return respone.data;
};
export const sendMissionAPI = async ( export const sendMissionAPI = async (
id: number, id: number,

View File

@@ -30,6 +30,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.2",
"react-leaflet": "^5.0.0-rc.2", "react-leaflet": "^5.0.0-rc.2",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwindcss": "^4.0.14", "tailwindcss": "^4.0.14",

Binary file not shown.

35
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.2",
"react-leaflet": "^5.0.0-rc.2", "react-leaflet": "^5.0.0-rc.2",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwindcss": "^4.0.14", "tailwindcss": "^4.0.14",
@@ -63,6 +64,7 @@
"@redis/json": "^1.0.7", "@redis/json": "^1.0.7",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron": "^4.1.0", "cron": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@@ -77,6 +79,7 @@
"devDependencies": { "devDependencies": {
"@repo/db": "*", "@repo/db": "*",
"@repo/typescript-config": "*", "@repo/typescript-config": "*",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
@@ -3122,6 +3125,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz",
"integrity": "sha512-l37JqFrOJ9yQfRQkljb41l0xVphc7kg5JTjjr+pLRZ0IyZ49V4BQ8vbF4Ut2C2e+WH4al3xD3ZwYwIUfnbT4NQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": { "node_modules/@types/cors": {
"version": "2.8.17", "version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
@@ -5330,6 +5343,28 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@@ -0,0 +1,21 @@
import { Mission } from "../../generated/client";
interface ValidationFailed {
type: "hpg-validation";
status: "failed";
message: string;
data: {
mission: Mission;
};
}
interface ValidationSuccess {
type: "hpg-validation";
status: "success";
message: string;
data: {
mission: Mission;
};
}
export type NotificationPayload = ValidationFailed | ValidationSuccess;

View File

@@ -2,3 +2,4 @@ export * from "./ParticipantLog";
export * from "./MissionVehicleLog"; export * from "./MissionVehicleLog";
export * from "./User"; export * from "./User";
export * from "./OSMway"; export * from "./OSMway";
export * from "./SocketEvents";