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

@@ -6,3 +6,14 @@ declare module "next-auth/jwt" {
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 { handleConnectPilot } from "socket-events/connect-pilot";
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 server = createServer(app);
@@ -31,6 +43,8 @@ io.on("connection", (socket) => {
app.use(cors());
app.use(express.json());
app.use(cookieParser());
app.use(authMiddleware as any);
app.use(router);
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": {
"@repo/db": "*",
"@repo/typescript-config": "*",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/node": "^22.13.5",
"@types/nodemailer": "^6.4.17",
@@ -21,6 +22,7 @@
"@redis/json": "^1.0.7",
"@socket.io/redis-adapter": "^8.3.0",
"axios": "^1.7.9",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron": "^4.1.0",
"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 { io } from "../index";
import { sendNtfyMission } from "modules/ntfy";
import { sendAlert } from "modules/mission";
const router = Router();
@@ -107,90 +116,8 @@ router.post("/:id/send-alert", async (req, res) => {
const { id } = req.params;
const { stationId } = req.body as { stationId?: number };
try {
const mission = await prisma.mission.findUnique({
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,
})),
})
.catch(() => {
// Ignore if the entry already exists
});
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
},
const { connectedAircrafts, mission } = await sendAlert(Number(id), {
stationId,
});
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) => {
try {
console.log(req.user);
const { id } = req.params;
const config = req.body as
| {
alertWhenValid?: boolean;
}
| undefined;
const mission = await prisma.mission.findFirstOrThrow({
where: {
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({
message: "HPG validation started",
});
io.to(`desktop:${activeAircraftinMission}`).emit(
/* io.to(`desktop:${activeAircraftinMission}`).emit(
"hpg-validation",
{
hpgMissionType: mission?.hpgMissionString,
@@ -266,8 +215,41 @@ router.post("/:id/validate-hpg", async (req, res) => {
},
});
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) {
console.error(error);
res.json({ error: (error as Error).message || "Failed to validate HPG" });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -367,32 +367,25 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
},
});
const dispatcherConnected =
useDispatchConnectionStore((s) => s.status) === "connected";
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">
<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>
{dispatcherConnected && (
<div className="space-x-2">
<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>
)}
<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) => {
@@ -427,24 +420,74 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
);
})}
</ul>
{dispatcherConnected && (
<div>
<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">
<option value="1">Feuerwehr</option>
<option value="2">RTW</option>
<option value="3">Polizei</option>
</select>
<button className="btn btn-sm btn-primary btn-outline">
<span className="text-base-content flex items-center gap-2">
<BellRing size={16} /> Nachalarmieren
</span>
</button>
</div>
</div>
)}
<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>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,15 @@
import { Station } from "@repo/db";
import { MissionSdsLog, Station } from "@repo/db";
import { fmsStatusDescription } from "_data/fmsStatusDescription";
import { DisplayLineProps } from "pilot/_components/mrt/Mrt";
import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetSdsPageParams {
page: "sds";
station: Station;
sdsMessage: MissionSdsLog;
}
interface SetHomePageParams {
page: "home";
station: Station;
@@ -23,6 +29,7 @@ interface SetNewStatusPageParams {
type SetPageParams =
| SetHomePageParams
| SetSendingStatusPageParams
| SetSdsPageParams
| SetNewStatusPageParams;
interface MrtStore {
@@ -123,6 +130,25 @@ export const useMrtStore = create<MrtStore>(
});
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:
set({ page: "home" });
break;

View File

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

View File

@@ -1,5 +1,6 @@
import { ConnectedAircraft, ConnectedDispatcher } from "@repo/db";
import axios from "axios";
import { getSession } from "next-auth/react";
export const serverApi = axios.create({
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<
(ConnectedDispatcher | ConnectedAircraft)[]
> => {

View File

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

View File

@@ -5,7 +5,7 @@ import { Chat } from "../_components/left/Chat";
import { Report } from "../_components/left/Report";
import { Dme } from "pilot/_components/dme/Dme";
import dynamic from "next/dynamic";
const Map = dynamic(() => import("../dispatch/_components/map/Map"), {
const Map = dynamic(() => import("../_components/map/Map"), {
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 { serverApi } from "helpers/axios";
@@ -26,6 +26,29 @@ export const editMissionAPI = async (
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
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 (
id: number,

View File

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