diff --git a/apps/dispatch-server/index.ts b/apps/dispatch-server/index.ts index c75d907f..4cd7c1e0 100644 --- a/apps/dispatch-server/index.ts +++ b/apps/dispatch-server/index.ts @@ -9,11 +9,12 @@ import { handleConnectDispatch } from "socket-events/connect-dispatch"; import router from "routes/router"; import cors from "cors"; import { handleSendMessage } from "socket-events/send-message"; +import { handleConnectPilot } from "socket-events/connect-pilot"; const app = express(); const server = createServer(app); -const io = new Server(server, { +export const io = new Server(server, { adapter: createAdapter(pubClient, subClient), cors: {}, }); @@ -21,6 +22,7 @@ io.use(jwtMiddleware); io.on("connection", (socket) => { socket.on("connect-dispatch", handleConnectDispatch(socket, io)); + socket.on("connect-pilot", handleConnectPilot(socket, io)); socket.on("send-message", handleSendMessage(socket, io)); }); diff --git a/apps/dispatch-server/routes/mission.ts b/apps/dispatch-server/routes/mission.ts index a3356188..3c8596f6 100644 --- a/apps/dispatch-server/routes/mission.ts +++ b/apps/dispatch-server/routes/mission.ts @@ -1,5 +1,6 @@ import { prisma } from "@repo/db"; import { Router } from "express"; +import { io } from "../index"; const router = Router(); @@ -41,6 +42,7 @@ router.put("/", async (req, res) => { const newMission = await prisma.mission.create({ data: req.body, }); + io.to("missions").emit("new-mission", newMission); res.status(201).json(newMission); } catch (error) { res.status(500).json({ error: "Failed to create mission" }); @@ -55,6 +57,7 @@ router.patch("/:id", async (req, res) => { where: { id: Number(id) }, data: req.body, }); + io.to("missions").emit("update-mission", updatedMission); res.json(updatedMission); } catch (error) { console.error(error); @@ -69,6 +72,7 @@ router.delete("/:id", async (req, res) => { await prisma.mission.delete({ where: { id: Number(id) }, }); + io.to("missions").emit("delete-mission", id); res.status(204).send(); } catch (error) { console.error(error); diff --git a/apps/dispatch-server/socket-events/connect-dispatch.ts b/apps/dispatch-server/socket-events/connect-dispatch.ts index f5f97878..9fec06db 100644 --- a/apps/dispatch-server/socket-events/connect-dispatch.ts +++ b/apps/dispatch-server/socket-events/connect-dispatch.ts @@ -1,5 +1,4 @@ import { getPublicUser, prisma, User } from "@repo/db"; -import { pubClient } from "modules/redis"; import { Server, Socket } from "socket.io"; export const handleConnectDispatch = @@ -80,9 +79,10 @@ export const handleConnectDispatch = socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten socket.join(`user:${user.id}`); // Dem User-Raum beitreten + socket.join("missions"); - io.to("dispatchers").emit("dispatcher-update"); - io.to("pilots").emit("dispatcher-update"); + io.to("dispatchers").emit("dispatchers-update"); + io.to("pilots").emit("dispatchers-update"); socket.on("disconnect", async () => { console.log("Disconnected from dispatch server"); @@ -94,6 +94,8 @@ export const handleConnectDispatch = logoutTime: new Date().toISOString(), }, }); + io.to("dispatchers").emit("dispatchers-update"); + io.to("pilots").emit("dispatchers-update"); }); socket.on("reconnect", async () => { console.log("Reconnected to dispatch server"); diff --git a/apps/dispatch-server/socket-events/connect-pilot.ts b/apps/dispatch-server/socket-events/connect-pilot.ts index 83b40869..5135195a 100644 --- a/apps/dispatch-server/socket-events/connect-pilot.ts +++ b/apps/dispatch-server/socket-events/connect-pilot.ts @@ -1,25 +1,42 @@ import { getPublicUser, prisma } from "@repo/db"; import { Server, Socket } from "socket.io"; -export const handleConnectDispatch = +export const handleConnectPilot = (socket: Socket, io: Server) => async ({ logoffTime, stationId, }: { logoffTime: string; - stationId: number; + stationId: string; }) => { try { + const user = socket.data.user; // User ID aus dem JWT-Token const userId = socket.data.user.id; // User ID aus dem JWT-Token - console.log("User connected to dispatch server"); - const user = await prisma.user.findUnique({ + + if (!user) return Error("User not found"); + + const existingConnection = await prisma.connectedAircraft.findFirst({ where: { - id: userId, + userId: user.id, + logoutTime: null, }, }); - if (!user) return Error("User not found"); + if (existingConnection) { + await io + .to(`user:${user.id}`) + .emit("force-disconnect", "double-connection"); + await prisma.connectedAircraft.updateMany({ + where: { + userId: user.id, + logoutTime: null, + }, + data: { + logoutTime: new Date().toISOString(), + }, + }); + } let parsedLogoffDate = null; if (logoffTime.length > 0) { @@ -44,7 +61,7 @@ export const handleConnectDispatch = lastHeartbeat: new Date().toISOString(), userId: userId, loginTime: new Date().toISOString(), - stationId: stationId, + stationId: parseInt(stationId), /* user: { connect: { id: userId } }, // Ensure the user relationship is set station: { connect: { id: stationId } }, // Ensure the station relationship is set */ }, @@ -54,8 +71,8 @@ export const handleConnectDispatch = socket.join(`user:${userId}`); // Join the user-specific room socket.join(`station:${stationId}`); // Join the station-specific room - io.to("dispatchers").emit("dispatcher-update"); - io.to("pilots").emit("dispatcher-update"); + io.to("dispatchers").emit("pilots-update"); + io.to("pilots").emit("pilots-update"); // Add a listener for station-specific events socket.on(`station:${stationId}:event`, async (data) => { @@ -66,7 +83,7 @@ export const handleConnectDispatch = socket.on("disconnect", async () => { console.log("Disconnected from dispatch server"); - await prisma.connectedDispatcher.update({ + await prisma.connectedAircraft.update({ where: { id: connectedAircraftEntry.id, }, @@ -74,19 +91,8 @@ export const handleConnectDispatch = logoutTime: new Date().toISOString(), }, }); - }); - - socket.on("reconnect", async () => { - console.log("Reconnected to dispatch server"); - await prisma.connectedDispatcher.update({ - where: { - id: connectedAircraftEntry.id, - }, - data: { - lastHeartbeat: new Date().toISOString(), - logoutTime: null, - }, - }); + io.to("dispatchers").emit("pilots-update"); + io.to("pilots").emit("pilots-update"); }); } catch (error) { console.error("Error connecting to dispatch server:", error); diff --git a/apps/dispatch/app/_components/QueryProvider.tsx b/apps/dispatch/app/_components/QueryProvider.tsx new file mode 100644 index 00000000..5d46b394 --- /dev/null +++ b/apps/dispatch/app/_components/QueryProvider.tsx @@ -0,0 +1,47 @@ +// components/TanstackProvider.tsx +"use client"; + +import { toast } from "react-hot-toast"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode, useEffect, useState } from "react"; +import { dispatchSocket } from "dispatch/socket"; + +export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + mutations: { + onError: (error) => { + toast.error("An error occurred: " + (error as Error).message, { + position: "top-right", + }); + }, + }, + }, + }), + ); + useEffect(() => { + const invalidateMission = () => { + queryClient.invalidateQueries({ + queryKey: ["missions"], + }); + }; + + const invalidateConnectedUsers = () => { + queryClient.invalidateQueries({ + queryKey: ["connected-users"], + }); + }; + + 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); + }, [queryClient]); + + return ( + {children} + ); +} diff --git a/apps/dispatch/app/_components/StationStatusToast.tsx b/apps/dispatch/app/_components/StationStatusToast.tsx new file mode 100644 index 00000000..f60d725c --- /dev/null +++ b/apps/dispatch/app/_components/StationStatusToast.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { toast } from "react-hot-toast"; + +interface ToastCard { + id: number; + title: string; + content: string; +} + +const MapToastCard2 = () => { + const [cards, setCards] = useState([]); + const [openCardId, setOpenCardId] = useState(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( + + + 😖 Christoph 31 sendet Status 5{" "} + toast.remove(toastId)} + > + U + + + , + { + duration: 999999999, + }, + ); + // DEBUG + }; + + const removeCard = (id: number) => { + setCards(cards.filter((card) => card.id !== id)); + }; + + const toggleCard = (id: number) => { + setOpenCardId(openCardId === id ? null : id); + }; + + return ( + + {/* DEBUG */} + + Debug Einsatz + + {/* DEBUG */} + {cards.map((card) => ( + + toggleCard(card.id)} + /> + + {card.title} + { + removeCard(card.id); + }} + > + ✕ + + + {card.content} + + ))} + + ); +}; + +export default MapToastCard2; diff --git a/apps/dispatch/app/dispatch/_components/left/Chat.tsx b/apps/dispatch/app/_components/left/Chat.tsx similarity index 80% rename from apps/dispatch/app/dispatch/_components/left/Chat.tsx rename to apps/dispatch/app/_components/left/Chat.tsx index e1fa097d..3ebef93d 100644 --- a/apps/dispatch/app/dispatch/_components/left/Chat.tsx +++ b/apps/dispatch/app/_components/left/Chat.tsx @@ -2,10 +2,11 @@ import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons"; import { useLeftMenuStore } from "_store/leftMenuStore"; import { useSession } from "next-auth/react"; -import { Fragment, useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { cn } from "helpers/cn"; -import { getConenctedUsers } from "helpers/axios"; -import { asPublicUser, ConnectedAircraft, ConnectedDispatcher } from "@repo/db"; +import { asPublicUser } from "@repo/db"; +import { useQuery } from "@tanstack/react-query"; +import { getConnectedUserAPI } from "querys/connected-user"; export const Chat = () => { const { @@ -22,49 +23,24 @@ export const Chat = () => { } = useLeftMenuStore(); const [sending, setSending] = useState(false); const session = useSession(); - const [addTabValue, setAddTabValue] = useState(""); + const [addTabValue, setAddTabValue] = useState("default"); const [message, setMessage] = useState(""); - const [connectedUser, setConnectedUser] = useState< - (ConnectedAircraft | ConnectedDispatcher)[] | null - >(null); - const timeout = useRef(null); + + const { data: connectedUser } = useQuery({ + queryKey: ["connected-users"], + queryFn: async () => { + const user = await getConnectedUserAPI(); + return user.filter((u) => u.userId !== session.data?.user.id); + }, + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); useEffect(() => { if (!session.data?.user.id) return; setOwnId(session.data.user.id); }, [session, setOwnId]); - useEffect(() => { - const fetchConnectedUser = async () => { - const data = await getConenctedUsers(); - if (data) { - const filteredConnectedUser = data.filter((user) => { - return ( - user.userId !== session.data?.user.id && - !Object.keys(chats).includes(user.userId) - ); - - return true; - }); - setConnectedUser(filteredConnectedUser); - } - if (!addTabValue && data[0]) setAddTabValue(data[0].userId); - }; - - timeout.current = setInterval(() => { - fetchConnectedUser(); - }, 1000); - fetchConnectedUser(); - - return () => { - if (timeout.current) { - clearInterval(timeout.current); - timeout.current = null; - console.log("cleared"); - } - }; - }, [addTabValue, chats, session.data?.user.id]); - return ( @@ -100,8 +76,16 @@ export const Chat = () => { onChange={(e) => setAddTabValue(e.target.value)} > {!connectedUser?.length && ( - Keine Chatpartner gefunden + + Keine Chatpartner gefunden + )} + {connectedUser?.length && ( + + Chatpartner auswählen + + )} + {connectedUser?.map((user) => ( {asPublicUser(user.publicUser).fullName} diff --git a/apps/dispatch/app/dispatch/_components/left/Report.tsx b/apps/dispatch/app/_components/left/Report.tsx similarity index 72% rename from apps/dispatch/app/dispatch/_components/left/Report.tsx rename to apps/dispatch/app/_components/left/Report.tsx index 8b9e4b6c..40667fd2 100644 --- a/apps/dispatch/app/dispatch/_components/left/Report.tsx +++ b/apps/dispatch/app/_components/left/Report.tsx @@ -1,53 +1,36 @@ "use client"; import { ExclamationTriangleIcon, PaperPlaneIcon } from "@radix-ui/react-icons"; import { useSession } from "next-auth/react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { cn } from "helpers/cn"; -import { getConenctedUsers, serverApi } from "helpers/axios"; +import { serverApi } from "helpers/axios"; import { useLeftMenuStore } from "_store/leftMenuStore"; -import { asPublicUser, ConnectedAircraft, ConnectedDispatcher } from "@repo/db"; +import { asPublicUser } from "@repo/db"; +import { useQuery } from "@tanstack/react-query"; +import { getConnectedUserAPI } from "querys/connected-user"; export const Report = () => { const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore(); const [sending, setSending] = useState(false); const session = useSession(); - const [selectedPlayer, setSelectedPlayer] = useState(""); + const [selectedPlayer, setSelectedPlayer] = useState("default"); const [message, setMessage] = useState(""); - const [connectedUser, setConnectedUser] = useState< - (ConnectedAircraft | ConnectedDispatcher)[] | null - >(null); - const timeout = useRef(null); useEffect(() => { if (!session.data?.user.id) return; setOwnId(session.data.user.id); }, [session, setOwnId]); - useEffect(() => { - const fetchConnectedUser = async () => { - const data = await getConenctedUsers(); - if (data) { - const filteredConnectedUser = data.filter( - (user) => user.userId !== session.data?.user.id, - ); - setConnectedUser(filteredConnectedUser); - } - if (!selectedPlayer && data[0]) setSelectedPlayer(data[0].userId); - }; - - timeout.current = setInterval(() => { - fetchConnectedUser(); - }, 1000); - fetchConnectedUser(); - - return () => { - if (timeout.current) { - clearInterval(timeout.current); - timeout.current = null; - } - }; - }, [selectedPlayer, session.data?.user.id]); + const { data: connectedUser } = useQuery({ + queryKey: ["connected-users"], + queryFn: async () => { + const user = await getConnectedUserAPI(); + return user.filter((u) => u.userId !== session.data?.user.id); + }, + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); return ( { onChange={(e) => setSelectedPlayer(e.target.value)} > {!connectedUser?.length && ( - Keine Spieler gefunden + + Keine Chatpartner gefunden + + )} + {connectedUser?.length && ( + + Chatpartner auswählen + )} {connectedUser?.map((user) => ( diff --git a/apps/dispatch/app/_store/connectionStore.ts b/apps/dispatch/app/_store/dispatch/connectionStore.ts similarity index 71% rename from apps/dispatch/app/_store/connectionStore.ts rename to apps/dispatch/app/_store/dispatch/connectionStore.ts index 879ad0df..411dbd05 100644 --- a/apps/dispatch/app/_store/connectionStore.ts +++ b/apps/dispatch/app/_store/dispatch/connectionStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { socket } from "../dispatch/socket"; +import { dispatchSocket } from "../../dispatch/socket"; interface ConnectionStore { status: "connected" | "disconnected" | "connecting" | "error"; @@ -20,11 +20,11 @@ export const useDispatchConnectionStore = create((set) => ({ connect: async (uid, selectedZone, logoffTime) => new Promise((resolve) => { set({ status: "connecting", message: "" }); - socket.auth = { uid }; + dispatchSocket.auth = { uid }; set({ selectedZone }); - socket.connect(); - socket.once("connect", () => { - socket.emit("connect-dispatch", { + dispatchSocket.connect(); + dispatchSocket.once("connect", () => { + dispatchSocket.emit("connect-dispatch", { logoffTime, selectedZone, }); @@ -32,26 +32,26 @@ export const useDispatchConnectionStore = create((set) => ({ }); }), disconnect: () => { - socket.disconnect(); + dispatchSocket.disconnect(); }, })); -socket.on("connect", () => { +dispatchSocket.on("connect", () => { useDispatchConnectionStore.setState({ status: "connected", message: "" }); }); -socket.on("connect_error", (err) => { +dispatchSocket.on("connect_error", (err) => { useDispatchConnectionStore.setState({ status: "error", message: err.message, }); }); -socket.on("disconnect", () => { +dispatchSocket.on("disconnect", () => { useDispatchConnectionStore.setState({ status: "disconnected", message: "" }); }); -socket.on("force-disconnect", (reason: string) => { +dispatchSocket.on("force-disconnect", (reason: string) => { console.log("force-disconnect", reason); useDispatchConnectionStore.setState({ status: "disconnected", diff --git a/apps/dispatch/app/_store/leftMenuStore.ts b/apps/dispatch/app/_store/leftMenuStore.ts index e3174638..91e0c364 100644 --- a/apps/dispatch/app/_store/leftMenuStore.ts +++ b/apps/dispatch/app/_store/leftMenuStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { ChatMessage } from "@repo/db"; -import { socket } from "dispatch/socket"; +import { dispatchSocket } from "dispatch/socket"; +import { pilotSocket } from "pilot/socket"; interface ChatStore { reportTabOpen: boolean; @@ -33,7 +34,7 @@ export const useLeftMenuStore = create((set, get) => ({ chats: {}, sendMessage: (userId: string, message: string) => { return new Promise((resolve, reject) => { - socket.emit( + dispatchSocket.emit( "send-message", { userId, message }, ({ error }: { error?: string }) => { @@ -96,7 +97,16 @@ export const useLeftMenuStore = create((set, get) => ({ }, })); -socket.on( +dispatchSocket.on( + "chat-message", + ({ userId, message }: { userId: string; message: ChatMessage }) => { + const store = useLeftMenuStore.getState(); + console.log("chat-message", userId, message); + // Update the chat store with the new message + store.addMessage(userId, message); + }, +); +pilotSocket.on( "chat-message", ({ userId, message }: { userId: string; message: ChatMessage }) => { const store = useLeftMenuStore.getState(); diff --git a/apps/dispatch/app/_store/missionsStore.ts b/apps/dispatch/app/_store/missionsStore.ts deleted file mode 100644 index 89cf1378..00000000 --- a/apps/dispatch/app/_store/missionsStore.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Mission, Prisma } from "@repo/db"; -import { MissionOptionalDefaults } from "@repo/db/zod"; -import { serverApi } from "helpers/axios"; -import { create } from "zustand"; -import { toast } from "react-hot-toast"; -import axios from "axios"; - -interface MissionStore { - missions: Mission[]; - setMissions: (missions: Mission[]) => void; - getMissions: () => Promise; - createMission: (mission: MissionOptionalDefaults) => Promise; - deleteMission: (id: number) => Promise; - editMission: (id: number, mission: Partial) => Promise; -} - -export const useMissionsStore = create((set) => ({ - missions: [], - setMissions: (missions) => set({ missions }), - createMission: async (mission) => { - const { data } = await serverApi.put("/mission", mission); - - set((state) => ({ missions: [...state.missions, data] })); - return data; - }, - editMission: async (id, mission) => { - const { data, status } = await serverApi.patch( - `/mission/${id}`, - mission, - ); - if (status.toString().startsWith("2") && data) { - set((state) => ({ - missions: state.missions.map((m) => (m.id === id ? data : m)), - })); - toast.success("Mission updated successfully"); - } else { - toast.error("Failed to update mission"); - } - }, - deleteMission: async (id) => { - serverApi - .delete(`/mission/${id}`) - .then((res) => { - if (res.status.toString().startsWith("2")) { - set((state) => ({ - missions: state.missions.filter((mission) => mission.id !== id), - })); - toast.success("Mission deleted successfully"); - } else { - toast.error("Failed to delete mission"); - } - }) - .catch((err) => { - toast.error("Failed to delete mission"); - }); - }, - getMissions: async () => { - const { data } = await serverApi.post("/mission", { - filter: { - OR: [{ state: "draft" }, { state: "running" }], - } as Prisma.MissionWhereInput, - }); - set({ missions: data }); - return undefined; - }, -})); - -useMissionsStore - .getState() - .getMissions() - .then(() => { - console.log("Missions loaded"); - }); diff --git a/apps/dispatch/app/_store/pilot/connectionStore.ts b/apps/dispatch/app/_store/pilot/connectionStore.ts new file mode 100644 index 00000000..5c28284e --- /dev/null +++ b/apps/dispatch/app/_store/pilot/connectionStore.ts @@ -0,0 +1,63 @@ +import { create } from "zustand"; +import { dispatchSocket } from "../../dispatch/socket"; +import { Station } from "@repo/db"; +import { pilotSocket } from "pilot/socket"; + +interface ConnectionStore { + status: "connected" | "disconnected" | "connecting" | "error"; + message: string; + selectedStation: Station | null; + connect: ( + uid: string, + stationId: string, + logoffTime: string, + station: Station, + ) => Promise; + disconnect: () => void; +} + +export const useDispatchConnectionStore = create((set) => ({ + status: "disconnected", + message: "", + selectedStation: null, + connect: async (uid, stationId, logoffTime, station) => + new Promise((resolve) => { + set({ status: "connecting", message: "", selectedStation: station }); + dispatchSocket.auth = { uid }; + dispatchSocket.connect(); + dispatchSocket.once("connect", () => { + dispatchSocket.emit("connect-pilot", { + logoffTime, + stationId, + }); + resolve(); + }); + }), + disconnect: () => { + dispatchSocket.disconnect(); + }, +})); + +dispatchSocket.on("connect", () => { + pilotSocket.disconnect(); + useDispatchConnectionStore.setState({ status: "connected", message: "" }); +}); + +dispatchSocket.on("connect_error", (err) => { + useDispatchConnectionStore.setState({ + status: "error", + message: err.message, + }); +}); + +dispatchSocket.on("disconnect", () => { + useDispatchConnectionStore.setState({ status: "disconnected", message: "" }); +}); + +dispatchSocket.on("force-disconnect", (reason: string) => { + console.log("force-disconnect", reason); + useDispatchConnectionStore.setState({ + status: "disconnected", + message: reason, + }); +}); diff --git a/apps/dispatch/app/_store/stationsStore.ts b/apps/dispatch/app/_store/stationsStore.ts index 88cf2d6d..b713dc50 100644 --- a/apps/dispatch/app/_store/stationsStore.ts +++ b/apps/dispatch/app/_store/stationsStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import { socket } from "../dispatch/socket"; -export const stationStore = create((set) => { +export const useStationStore = create((set) => { return { stations: [], setStations: (stations: any) => set({ stations }), diff --git a/apps/dispatch/app/api/connected-user/route.ts b/apps/dispatch/app/api/connected-user/route.ts new file mode 100644 index 00000000..4fc04a6a --- /dev/null +++ b/apps/dispatch/app/api/connected-user/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@repo/db"; + +export async function GET(): Promise { + try { + const connectedDispatcher = await prisma.connectedDispatcher.findMany({ + where: { + logoutTime: null, + }, + }); + + const connectedAircraft = await prisma.connectedAircraft.findMany({ + where: { + logoutTime: null, + }, + }); + + return NextResponse.json([...connectedDispatcher, ...connectedAircraft], { + status: 200, + }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Failed to fetch connected User" }, + { status: 500 }, + ); + } +} diff --git a/apps/dispatch/app/api/dispatcher/route.ts b/apps/dispatch/app/api/dispatcher/route.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/dispatch/app/api/keywords/route.ts b/apps/dispatch/app/api/keywords/route.ts new file mode 100644 index 00000000..b822b857 --- /dev/null +++ b/apps/dispatch/app/api/keywords/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@repo/db"; + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + const filter = searchParams.get("filter"); + + try { + const data = await prisma.keyword.findMany({ + where: { + id: id ? Number(id) : undefined, + ...(filter ? JSON.parse(filter) : {}), + }, + }); + + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Failed to fetch keyword" }, + { status: 500 }, + ); + } +} diff --git a/apps/dispatch/app/api/missions/route.ts b/apps/dispatch/app/api/missions/route.ts new file mode 100644 index 00000000..81c603fa --- /dev/null +++ b/apps/dispatch/app/api/missions/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@repo/db"; + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + const filter = searchParams.get("filter"); + + try { + const data = await prisma.mission.findMany({ + where: { + id: id ? Number(id) : undefined, + ...(filter ? JSON.parse(filter) : {}), + }, + }); + + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Failed to fetch mission" }, + { status: 500 }, + ); + } +} diff --git a/apps/dispatch/app/api/stations/route.ts b/apps/dispatch/app/api/stations/route.ts new file mode 100644 index 00000000..81014e7d --- /dev/null +++ b/apps/dispatch/app/api/stations/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@repo/db"; + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + const filter = searchParams.get("filter"); + + try { + const data = await prisma.station.findMany({ + where: { + id: id ? Number(id) : undefined, + ...(filter ? JSON.parse(filter) : {}), + }, + }); + + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Failed to fetch station" }, + { status: 500 }, + ); + } +} diff --git a/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx b/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx index 4c3f35bc..bb4b9dcc 100644 --- a/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx +++ b/apps/dispatch/app/dispatch/_components/map/MissionMarkers.tsx @@ -1,4 +1,3 @@ -import { useMissionsStore } from "_store/missionsStore"; import { Marker, useMap } from "react-leaflet"; import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet"; import { useMapStore } from "_store/mapStore"; @@ -31,6 +30,8 @@ import Einsatzdetails, { Patientdetails, Rettungsmittel, } from "./_components/MissionMarkerTabs"; +import { useQuery } from "@tanstack/react-query"; +import { getMissionsAPI } from "querys/missions"; export const MISSION_STATUS_COLORS: Record = { @@ -367,7 +368,13 @@ const MissionMarker = ({ mission }: { mission: Mission }) => { }; export const MissionLayer = () => { - const missions = useMissionsStore((state) => state.missions); + const { data: missions = [] } = useQuery({ + queryKey: ["missions"], + queryFn: () => + getMissionsAPI({ + OR: [{ state: "draft" }, { state: "running" }], + }), + }); // IDEA: Add Marker to Map Layer / LayerGroup return ( diff --git a/apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx b/apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx index aedba804..60c05fe1 100644 --- a/apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx +++ b/apps/dispatch/app/dispatch/_components/map/_components/MarkerCluster.tsx @@ -1,8 +1,8 @@ import { Mission } from "@repo/db"; +import { useQuery } from "@tanstack/react-query"; import { SmartPopup, useSmartPopup } from "_components/SmartPopup"; import { Aircraft, useAircraftsStore } from "_store/aircraftsStore"; import { useMapStore } from "_store/mapStore"; -import { useMissionsStore } from "_store/missionsStore"; import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS, @@ -12,6 +12,7 @@ import { MISSION_STATUS_TEXT_COLORS, } from "dispatch/_components/map/MissionMarkers"; import { cn } from "helpers/cn"; +import { getMissionsAPI } from "querys/missions"; import { useEffect, useState } from "react"; import { useMap } from "react-leaflet"; @@ -141,7 +142,13 @@ const PopupContent = ({ export const MarkerCluster = () => { const map = useMap(); const aircrafts = useAircraftsStore((state) => state.aircrafts); - const missions = useMissionsStore((state) => state.missions); + const { data: missions } = useQuery({ + queryKey: ["missions"], + queryFn: () => + getMissionsAPI({ + OR: [{ state: "draft" }, { state: "running" }], + }), + }); const [cluster, setCluster] = useState< { aircrafts: Aircraft[]; @@ -185,7 +192,7 @@ export const MarkerCluster = () => { ]; } }); - missions.forEach((mission) => { + missions?.forEach((mission) => { const lat = mission.addressLat; const lng = mission.addressLng; const existingClusterIndex = newCluster.findIndex( diff --git a/apps/dispatch/app/dispatch/_components/map/_components/MissionMarkerTabs.tsx b/apps/dispatch/app/dispatch/_components/map/_components/MissionMarkerTabs.tsx index 2eb4da52..9740ecfe 100644 --- a/apps/dispatch/app/dispatch/_components/map/_components/MissionMarkerTabs.tsx +++ b/apps/dispatch/app/dispatch/_components/map/_components/MissionMarkerTabs.tsx @@ -24,13 +24,24 @@ import { Mission, MissionLog, MissionMessageLog, + Prisma, } from "@repo/db"; -import { useMissionsStore } from "_store/missionsStore"; import { usePannelStore } from "_store/pannelStore"; import { useSession } from "next-auth/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteMissionAPI, editMissionAPI } from "querys/missions"; const Einsatzdetails = ({ mission }: { mission: Mission }) => { - const { deleteMission } = useMissionsStore((state) => state); + const queryClient = useQueryClient(); + const deleteMissionMutation = useMutation({ + mutationKey: ["missions"], + mutationFn: deleteMissionAPI, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["missions"], + }); + }, + }); const { setMissionFormValues, setOpen } = usePannelStore((state) => state); return ( @@ -93,7 +104,7 @@ const Einsatzdetails = ({ mission }: { mission: Mission }) => { { - deleteMission(mission.id); + deleteMissionMutation.mutate(mission.id); }} > @@ -180,8 +191,22 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => { const FMSStatusHistory = ({ mission }: { mission: Mission }) => { const session = useSession(); const [isAddingNote, setIsAddingNote] = useState(false); - const { editMission } = useMissionsStore((state) => state); const [note, setNote] = useState(""); + const queryClient = useQueryClient(); + + const editMissionMutation = useMutation({ + mutationFn: ({ + id, + mission, + }: { + id: number; + mission: Partial; + }) => editMissionAPI(id, mission), + mutationKey: ["missions"], + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["missions"] }); + }, + }); if (!session.data?.user) return null; return ( @@ -220,13 +245,18 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => { }, } as MissionMessageLog, ]; - editMission(mission.id, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - missionLog: newMissionLog as any, - }).then(() => { - setIsAddingNote(false); - setNote(""); - }); + editMissionMutation + .mutateAsync({ + id: mission.id, + mission: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + missionLog: newMissionLog as any, + }, + }) + .then(() => { + setIsAddingNote(false); + setNote(""); + }); }} > diff --git a/apps/dispatch/app/dispatch/_components/navbar/_components/Audio.tsx b/apps/dispatch/app/dispatch/_components/navbar/_components/Audio.tsx index 6aedc994..381dd979 100644 --- a/apps/dispatch/app/dispatch/_components/navbar/_components/Audio.tsx +++ b/apps/dispatch/app/dispatch/_components/navbar/_components/Audio.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useDispatchConnectionStore } from "_store/connectionStore"; +import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { Disc, Mic, diff --git a/apps/dispatch/app/dispatch/_components/navbar/_components/Connection.tsx b/apps/dispatch/app/dispatch/_components/navbar/_components/Connection.tsx index 762b30ae..50f75f61 100644 --- a/apps/dispatch/app/dispatch/_components/navbar/_components/Connection.tsx +++ b/apps/dispatch/app/dispatch/_components/navbar/_components/Connection.tsx @@ -1,6 +1,6 @@ "use client"; import { useSession } from "next-auth/react"; -import { useDispatchConnectionStore } from "../../../../_store/connectionStore"; +import { useDispatchConnectionStore } from "../../../../_store/dispatch/connectionStore"; import { useRef, useState } from "react"; export const ConnectionBtn = () => { diff --git a/apps/dispatch/app/dispatch/_components/navbar/_components/action.ts b/apps/dispatch/app/dispatch/_components/navbar/_components/action.ts deleted file mode 100644 index f0a09620..00000000 --- a/apps/dispatch/app/dispatch/_components/navbar/_components/action.ts +++ /dev/null @@ -1,11 +0,0 @@ -"use server"; - -export interface Dispatcher { - userId: string; - lastSeen: string; - loginTime: string; - logoffTime: string; - selectedZone: string; - name: string; - socketId: string; -} diff --git a/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx b/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx index 4f5eb046..d458a2a1 100644 --- a/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx +++ b/apps/dispatch/app/dispatch/_components/pannel/MissionForm.tsx @@ -1,11 +1,10 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { BellRing, BookmarkPlus } from "lucide-react"; import { Select } from "_components/Select"; -import { Keyword, KEYWORD_CATEGORY, missionType, Station } from "@repo/db"; -import { getKeywords, getStations } from "dispatch/_components/pannel/action"; +import { KEYWORD_CATEGORY, missionType, Prisma } from "@repo/db"; import { MissionOptionalDefaults, MissionOptionalDefaultsSchema, @@ -13,13 +12,52 @@ import { import { usePannelStore } from "_store/pannelStore"; import { useSession } from "next-auth/react"; import { toast } from "react-hot-toast"; -import { useMissionsStore } from "_store/missionsStore"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createMissionAPI, editMissionAPI } from "querys/missions"; +import { getKeywordsAPI } from "querys/keywords"; +import { getStationsAPI } from "querys/stations"; export const MissionForm = () => { const { isEditingMission, editingMissionId, setEditingMission } = usePannelStore(); - const createMission = useMissionsStore((state) => state.createMission); - const { deleteMission } = useMissionsStore((state) => state); + const queryClient = useQueryClient(); + + const { data: keywords } = useQuery({ + queryKey: ["keywords"], + queryFn: () => getKeywordsAPI(), + }); + + const { data: stations } = useQuery({ + queryKey: ["stations"], + queryFn: () => getStationsAPI(), + }); + + const createMissionMutation = useMutation({ + mutationFn: createMissionAPI, + mutationKey: ["missions"], + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["missions"], + }); + }, + }); + + const editMissionMutation = useMutation({ + mutationFn: ({ + id, + mission, + }: { + id: number; + mission: Partial; + }) => editMissionAPI(id, mission), + mutationKey: ["missions"], + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["missions"], + }); + }, + }); + const session = useSession(); const defaultFormValues = React.useMemo( () => @@ -85,18 +123,6 @@ export const MissionForm = () => { } }, [missionFormValues, form, defaultFormValues]); - const [stations, setStations] = useState([]); - const [keywords, setKeywords] = useState([]); - - useEffect(() => { - getKeywords().then((data) => { - setKeywords(data); - }); - getStations().then((data) => { - setStations(data); - }); - }, []); - console.log(form.formState.errors); return ( @@ -165,7 +191,7 @@ export const MissionForm = () => { placeholder="Wähle ein oder mehrere Rettungsmittel aus" isMulti form={form} - options={stations.map((s) => ({ + options={stations?.map((s) => ({ label: s.bosCallsign, value: s.id.toString(), }))} @@ -217,7 +243,7 @@ export const MissionForm = () => { {...form.register("missionKeywordAbbreviation")} className="select select-primary select-bordered w-full mb-4" onChange={(e) => { - const keyword = keywords.find( + const keyword = keywords?.find( (k) => k.abreviation === e.target.value, ); form.setValue("missionKeywordName", keyword?.name || null); @@ -232,15 +258,16 @@ export const MissionForm = () => { Einsatzstichwort auswählen... - {keywords - .filter( - (k) => k.category === form.watch("missionKeywordCategory"), - ) - .map((keyword) => ( - - {keyword.name} - - ))} + {keywords && + keywords + .filter( + (k) => k.category === form.watch("missionKeywordCategory"), + ) + .map((keyword) => ( + + {keyword.name} + + ))} {/* TODO: Nur anzeigen wenn eine Station mit HPG ausgewählt ist */} { Einsatz Szenerie auswählen... - {keywords - .find((k) => k.name === form.watch("missionKeywordName")) - ?.hpgMissionTypes?.map((missionString) => { - const [name] = missionString.split(":"); - return ( - - {name} - - ); - })} + {keywords && + keywords + .find((k) => k.name === form.watch("missionKeywordName")) + ?.hpgMissionTypes?.map((missionString) => { + const [name] = missionString.split(":"); + return ( + + {name} + + ); + })} > )} @@ -277,8 +305,6 @@ export const MissionForm = () => { /> )} - - {/* Patienteninformationen Section */} Patienteninformationen { className="textarea textarea-primary textarea-bordered w-full" /> - Du musst noch ein Gebäude auswählen, um den Einsatz zu erstellen. - + {isEditingMission && editingMissionId ? ( { try { - deleteMission(Number(editingMissionId)); - const newMission = await createMission(mission); + const newMission = await editMissionMutation.mutateAsync({ + id: Number(editingMissionId), + mission: + mission as unknown as Partial, + }); toast.success( `Einsatz ${newMission.id} erfolgreich aktualisiert`, ); @@ -327,7 +355,10 @@ export const MissionForm = () => { onClick={form.handleSubmit( async (mission: MissionOptionalDefaults) => { try { - const newMission = await createMission(mission); + const newMission = + await createMissionMutation.mutateAsync( + mission as unknown as Prisma.MissionCreateInput, + ); toast.success(`Einsatz ${newMission.id} erstellt`); // TODO: Einsatz alarmieren setOpen(false); @@ -343,11 +374,15 @@ export const MissionForm = () => { { try { - const newMission = await createMission(mission); + const newMission = + await createMissionMutation.mutateAsync( + mission as unknown as Prisma.MissionCreateInput, + ); + toast.success(`Einsatz ${newMission.id} erstellt`); form.reset(); setOpen(false); diff --git a/apps/dispatch/app/dispatch/_components/pannel/Pannel.tsx b/apps/dispatch/app/dispatch/_components/pannel/Pannel.tsx index fa049460..9b204ae4 100644 --- a/apps/dispatch/app/dispatch/_components/pannel/Pannel.tsx +++ b/apps/dispatch/app/dispatch/_components/pannel/Pannel.tsx @@ -2,10 +2,41 @@ import { usePannelStore } from "_store/pannelStore"; import { cn } from "helpers/cn"; import { MissionForm } from "./MissionForm"; import { Rss, Trash2Icon } from "lucide-react"; +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getMissionsAPI } from "querys/missions"; export const Pannel = () => { const { setOpen, setMissionFormValues } = usePannelStore(); - const { isEditingMission, setEditingMission } = usePannelStore(); + const { isEditingMission, setEditingMission, missionFormValues } = + usePannelStore(); + const missions = useQuery({ + queryKey: ["missions"], + queryFn: () => + getMissionsAPI({ + OR: [{ state: "draft" }, { state: "running" }], + }), + }); + + useEffect(() => { + if (isEditingMission && missionFormValues) { + const mission = missions.data?.find( + (mission) => mission.id === missionFormValues.id, + ); + if (!mission) { + setEditingMission(false, null); + setMissionFormValues({}); + setOpen(false); + } + } + }, [ + isEditingMission, + missions, + setMissionFormValues, + setEditingMission, + setOpen, + missionFormValues, + ]); return ( diff --git a/apps/dispatch/app/dispatch/_components/pannel/action.ts b/apps/dispatch/app/dispatch/_components/pannel/action.ts deleted file mode 100644 index ac5a38e8..00000000 --- a/apps/dispatch/app/dispatch/_components/pannel/action.ts +++ /dev/null @@ -1,15 +0,0 @@ -"use server"; - -import { prisma } from "@repo/db"; - -export const getKeywords = async () => { - const keywords = prisma.keyword.findMany(); - - return keywords; -}; - -export const getStations = async () => { - const stations = await prisma.station.findMany(); - console.log(stations); - return stations; -}; diff --git a/apps/dispatch/app/dispatch/page.tsx b/apps/dispatch/app/dispatch/page.tsx index c3668993..33be9f30 100644 --- a/apps/dispatch/app/dispatch/page.tsx +++ b/apps/dispatch/app/dispatch/page.tsx @@ -4,8 +4,8 @@ import { Pannel } from "dispatch/_components/pannel/Pannel"; import { usePannelStore } from "_store/pannelStore"; import { cn } from "helpers/cn"; import dynamic from "next/dynamic"; -import { Chat } from "./_components/left/Chat"; -import { Report } from "./_components/left/Report"; +import { Chat } from "../_components/left/Chat"; +import { Report } from "../_components/left/Report"; const Map = dynamic(() => import("./_components/map/Map"), { ssr: false }); const DispatchPage = () => { diff --git a/apps/dispatch/app/dispatch/socket.ts b/apps/dispatch/app/dispatch/socket.ts index 1c639bf1..77065b55 100644 --- a/apps/dispatch/app/dispatch/socket.ts +++ b/apps/dispatch/app/dispatch/socket.ts @@ -1,5 +1,6 @@ +import { pilotSocket } from "pilot/socket"; import { io } from "socket.io-client"; -export const socket = io(process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, { +export const dispatchSocket = io(process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, { autoConnect: false, }); diff --git a/apps/dispatch/app/layout.tsx b/apps/dispatch/app/layout.tsx index 7542a8ea..8db38c92 100644 --- a/apps/dispatch/app/layout.tsx +++ b/apps/dispatch/app/layout.tsx @@ -4,6 +4,8 @@ import "./globals.css"; import { NextAuthSessionProvider } from "./_components/AuthSessionProvider"; import { getServerSession } from "./api/auth/[...nextauth]/auth"; import { Toaster } from "react-hot-toast"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryProvider } from "_components/QueryProvider"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -45,9 +47,11 @@ export default async function RootLayout({ position="top-left" reverseOrder={false} /> - - {children} - + + + {children} + +
Du musst noch ein Gebäude auswählen, um den Einsatz zu erstellen.