feat: Implement connected user API and integrate chat and report components

- Added API routes for fetching connected users, keywords, missions, and stations.
- Created a new QueryProvider component for managing query states and socket events.
- Introduced connection stores for dispatch and pilot, managing socket connections and states.
- Updated Prisma schema for connected aircraft model.
- Enhanced UI with toast notifications for status updates and chat interactions.
- Implemented query functions for fetching connected users and keywords with error handling.
This commit is contained in:
PxlLoewe
2025-05-07 00:43:45 -07:00
parent 152b3d4689
commit 50f42e99d3
49 changed files with 1040 additions and 701 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

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

@@ -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<string>("");
const [addTabValue, setAddTabValue] = useState<string>("default");
const [message, setMessage] = useState<string>("");
const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(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 (
<div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}>
<div className="indicator">
@@ -100,8 +76,16 @@ export const Chat = () => {
onChange={(e) => setAddTabValue(e.target.value)}
>
{!connectedUser?.length && (
<option disabled={true}>Keine Chatpartner gefunden</option>
<option disabled value="default">
Keine Chatpartner gefunden
</option>
)}
{connectedUser?.length && (
<option disabled value="default">
Chatpartner auswählen
</option>
)}
{connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}>
{asPublicUser(user.publicUser).fullName}

View File

@@ -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<string>("");
const [selectedPlayer, setSelectedPlayer] = useState<string>("default");
const [message, setMessage] = useState<string>("");
const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(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 (
<div
@@ -83,7 +66,14 @@ export const Report = () => {
onChange={(e) => setSelectedPlayer(e.target.value)}
>
{!connectedUser?.length && (
<option disabled={true}>Keine Spieler gefunden</option>
<option disabled value="default">
Keine Chatpartner gefunden
</option>
)}
{connectedUser?.length && (
<option disabled value="default">
Chatpartner auswählen
</option>
)}
{connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}>

View File

@@ -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<ConnectionStore>((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<ConnectionStore>((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",

View File

@@ -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<ChatStore>((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<ChatStore>((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();

View File

@@ -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<undefined>;
createMission: (mission: MissionOptionalDefaults) => Promise<Mission>;
deleteMission: (id: number) => Promise<void>;
editMission: (id: number, mission: Partial<Mission>) => Promise<void>;
}
export const useMissionsStore = create<MissionStore>((set) => ({
missions: [],
setMissions: (missions) => set({ missions }),
createMission: async (mission) => {
const { data } = await serverApi.put<Mission>("/mission", mission);
set((state) => ({ missions: [...state.missions, data] }));
return data;
},
editMission: async (id, mission) => {
const { data, status } = await serverApi.patch<Mission>(
`/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[]>("/mission", {
filter: {
OR: [{ state: "draft" }, { state: "running" }],
} as Prisma.MissionWhereInput,
});
set({ missions: data });
return undefined;
},
}));
useMissionsStore
.getState()
.getMissions()
.then(() => {
console.log("Missions loaded");
});

View File

@@ -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<void>;
disconnect: () => void;
}
export const useDispatchConnectionStore = create<ConnectionStore>((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,
});
});

View File

@@ -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 }),

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(): Promise<NextResponse> {
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 },
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: NextRequest): Promise<NextResponse> {
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 },
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: NextRequest): Promise<NextResponse> {
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 },
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: NextRequest): Promise<NextResponse> {
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 },
);
}
}

View File

@@ -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<MissionState | "attention", string> =
{
@@ -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 (

View File

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

View File

@@ -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 (
<div className="p-4 text-base-content">
@@ -93,7 +104,7 @@ const Einsatzdetails = ({ mission }: { mission: Mission }) => {
<button
className="btn btn-sm btn-error btn-outline"
onClick={() => {
deleteMission(mission.id);
deleteMissionMutation.mutate(mission.id);
}}
>
<Trash size={18} />
@@ -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<Prisma.MissionUpdateInput>;
}) => editMissionAPI(id, mission),
mutationKey: ["missions"],
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["missions"] });
},
});
if (!session.data?.user) return null;
return (
@@ -220,10 +245,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
},
} as MissionMessageLog,
];
editMission(mission.id, {
editMissionMutation
.mutateAsync({
id: mission.id,
mission: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
missionLog: newMissionLog as any,
}).then(() => {
},
})
.then(() => {
setIsAddingNote(false);
setNote("");
});

View File

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

View File

@@ -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 = () => {

View File

@@ -1,11 +0,0 @@
"use server";
export interface Dispatcher {
userId: string;
lastSeen: string;
loginTime: string;
logoffTime: string;
selectedZone: string;
name: string;
socketId: string;
}

View File

@@ -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<Prisma.MissionUpdateInput>;
}) => 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<Station[]>([]);
const [keywords, setKeywords] = useState<Keyword[]>([]);
useEffect(() => {
getKeywords().then((data) => {
setKeywords(data);
});
getStations().then((data) => {
setStations(data);
});
}, []);
console.log(form.formState.errors);
return (
<form className="space-y-4">
@@ -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,7 +258,8 @@ export const MissionForm = () => {
<option disabled value={""}>
Einsatzstichwort auswählen...
</option>
{keywords
{keywords &&
keywords
.filter(
(k) => k.category === form.watch("missionKeywordCategory"),
)
@@ -251,7 +278,8 @@ export const MissionForm = () => {
<option disabled value="">
Einsatz Szenerie auswählen...
</option>
{keywords
{keywords &&
keywords
.find((k) => k.name === form.watch("missionKeywordName"))
?.hpgMissionTypes?.map((missionString) => {
const [name] = missionString.split(":");
@@ -277,8 +305,6 @@ export const MissionForm = () => {
/>
)}
</div>
{/* Patienteninformationen Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Patienteninformationen</h2>
<textarea
@@ -287,22 +313,24 @@ export const MissionForm = () => {
className="textarea textarea-primary textarea-bordered w-full"
/>
</div>
<p className="text-sm text-error">
Du musst noch ein Gebäude auswählen, um den Einsatz zu erstellen.
</p>
<div className="form-control min-h-[140px] max-w-[320px]">
<div className="form-control min-h-[140px]">
<div className="flex gap-2">
{isEditingMission && editingMissionId ? (
<button
type="button"
className="btn btn-primary btn-block"
className="btn btn-primary flex-1"
onClick={form.handleSubmit(
async (mission: MissionOptionalDefaults) => {
try {
deleteMission(Number(editingMissionId));
const newMission = await createMission(mission);
const newMission = await editMissionMutation.mutateAsync({
id: Number(editingMissionId),
mission:
mission as unknown as Partial<Prisma.MissionUpdateInput>,
});
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 = () => {
</button>
<button
type="submit"
className="btn btn-primary btn-block"
className="btn btn-primary flex-1"
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`);
form.reset();
setOpen(false);

View File

@@ -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 (
<div className={cn("flex-1 max-w-[600px] z-9999999")}>

View File

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

View File

@@ -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 = () => {

View File

@@ -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,
});

View File

@@ -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}
/>
<QueryProvider>
<NextAuthSessionProvider session={session}>
{children}
</NextAuthSessionProvider>
</QueryProvider>
</body>
</html>
);

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useConnectionStore } from "_store/connectionStore";
import { useDispatchConnectionStore } from "_store/pilot/connectionStore";
import {
Disc,
Mic,
@@ -20,7 +20,7 @@ import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms";
export const Audio = () => {
const connection = useConnectionStore();
const connection = useDispatchConnectionStore();
const {
isTalking,
toggleTalking,
@@ -36,7 +36,7 @@ export const Audio = () => {
useEffect(() => {
const joinRoom = async () => {
if (!connection.isConnected) return;
if (connection.status != "connected") return;
if (state === "connected") return;
connect(selectedRoom);
};
@@ -46,7 +46,8 @@ export const Audio = () => {
return () => {
disconnect();
};
}, [connection.isConnected]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection.status]);
return (
<>

View File

@@ -1,226 +0,0 @@
"use client";
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 { cn } from "helpers/cn";
import { getConenctedUsers } from "helpers/axios";
import {
asPublicUser,
ConnectedAircraft,
ConnectedDispatcher,
PublicUser,
} from "@repo/db";
export const Chat = () => {
const {
chatOpen,
setChatOpen,
sendMessage,
addChat,
chats,
setOwnId,
selectedChat,
setSelectedChat,
setChatNotification,
} = useLeftMenuStore();
const [sending, setSending] = useState(false);
const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(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) => {
return (
user.userId !== session.data?.user.id &&
!Object.keys(chats).includes(user.userId)
);
});
setConnectedUser(filteredConnectedUser);
}
if (!addTabValue && data[0]) setAddTabValue(data[0].userId);
};
timeout.current = setInterval(() => {
fetchConnectedUser();
}, 1000000);
fetchConnectedUser();
return () => {
if (timeout.current) {
clearInterval(timeout.current);
timeout.current = null;
console.log("cleared");
}
};
}, [addTabValue, chats, session]);
return (
<div
className={cn("dropdown dropdown-center", chatOpen && "dropdown-open")}
>
<div className="indicator">
{Object.values(chats).some((c) => c.notification) && (
<span className="indicator-item status status-info"></span>
)}
<button
className="btn btn-soft btn-sm btn-primary"
onClick={() => {
console.log("clicked");
setChatOpen(!chatOpen);
if (selectedChat) {
setChatNotification(selectedChat, false);
}
}}
>
<ChatBubbleIcon className="w-4 h-4" />
</button>
</div>
{chatOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100]"
>
<div className="card-body">
<div className="join">
<select
className="select select-sm w-full"
value={addTabValue}
onChange={(e) => setAddTabValue(e.target.value)}
>
{!connectedUser?.length && (
<option disabled={true}>Keine Chatpartner gefunden</option>
)}
{connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}>
{(user.publicUser as unknown as PublicUser).firstname}{" "}
{(user.publicUser as unknown as PublicUser).lastname}
</option>
))}
</select>
<button
className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => {
const user = connectedUser?.find(
(user) => user.userId === addTabValue,
);
if (!user) return;
addChat(addTabValue, asPublicUser(user.publicUser).fullName);
setSelectedChat(addTabValue);
}}
>
<span className="text-xl">+</span>
</button>
</div>
<div className="tabs tabs-lift">
{Object.keys(chats).map((userId) => {
const chat = chats[userId];
if (!chat) return null;
return (
<Fragment key={userId}>
<input
type="radio"
name="my_tabs_3"
className="tab"
aria-label={`<${chat.name}>`}
checked={selectedChat === userId}
onClick={() => {
setChatNotification(userId, false);
}}
onChange={(e) => {
if (e.target.checked) {
// Handle tab change
setSelectedChat(userId);
}
}}
/>
<div className="tab-content bg-base-100 border-base-300 p-6">
{chat.messages.map((chatMessage) => {
const isSender =
chatMessage.senderId === session.data?.user.id;
return (
<div
key={chatMessage.id}
className={`chat ${isSender ? "chat-end" : "chat-start"}`}
>
<p className="chat-footer opacity-50">
{new Date(
chatMessage.timestamp,
).toLocaleTimeString()}
</p>
<div className="chat-bubble">
{chatMessage.text}
</div>
</div>
);
})}
{!chat.messages.length && (
<p className="text-xs opacity-50">
Noch keine Nachrichten
</p>
)}
</div>
</Fragment>
);
})}
</div>
<div className="join">
<div className="w-full">
<label className="input join-item w-full">
<input
type="text"
required
className="w-full"
onChange={(e) => {
setMessage(e.target.value);
}}
value={message}
/>
</label>
</div>
<button
className="btn btn-soft join-item"
onClick={(e) => {
e.preventDefault();
if (message.length < 1) return;
if (!selectedChat) return;
setSending(true);
sendMessage(selectedChat, message)
.then(() => {
setMessage("");
setSending(false);
})
.catch(() => {
setSending(false);
});
return false;
}}
disabled={sending}
role="button"
onSubmit={() => false}
>
{sending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<PaperPlaneIcon />
)}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,28 +1,57 @@
"use client";
import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../_store/connectionStore";
import { useRef, useState } from "react";
import { useDispatchConnectionStore } from "../../../_store/pilot/connectionStore";
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "querys/stations";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({
logoffTime: "",
selectedZone: "LST_01",
const [form, setForm] = useState<{
logoffTime: string | null;
selectedStationId: number | null;
}>({
logoffTime: null,
selectedStationId: null,
});
const { data: stations } = useQuery({
queryKey: ["stations"],
queryFn: () => getStationsAPI(),
});
useEffect(() => {
/* getStations().then((data) => {
setStations(data);
if (data[0]) {
setForm({
...form,
selectedStationId: data[0].id,
});
}
}); */
}, [connection.status, form]);
const session = useSession();
const uid = session.data?.user?.id;
if (!uid) return null;
return (
<>
{!connection.isConnected ? (
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
{connection.message.length > 0 && (
<span className="mx-2 text-error">{connection.message}</span>
)}
{connection.status === "disconnected" && (
<button
className="btn btn-soft btn-info"
className="btn btn-sm btn-soft btn-info "
onClick={() => modalRef.current?.showModal()}
>
Verbinden
</button>
) : (
)}
{connection.status == "connected" && (
<button
className="btn btn-soft btn-success"
onClick={() => modalRef.current?.showModal()}
@@ -33,17 +62,38 @@ export const ConnectionBtn = () => {
<dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center">
{connection.isConnected ? (
{connection.status == "connected" ? (
<h3 className="text-lg font-bold mb-5">
Verbunden als{" "}
<span className="text-info">
&lt;{connection.selectedZone}&gt;
&lt;{connection.selectedStation?.bosCallsign}&gt;
</span>
</h3>
) : (
<h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3>
)}
<fieldset className="fieldset w-full">
<label className="floating-label w-full text-base">
<span>Station</span>
<select
onChange={(e) =>
setForm({
...form,
selectedStationId: parseInt(e.target.value),
})
}
value={form.selectedStationId ?? ""}
className="input w-full"
>
{stations?.map((station) => (
<option key={station.id} value={station.id}>
{station.bosCallsign}
</option>
))}
</select>
</label>
</fieldset>
<fieldset className="fieldset w-full mt-2">
<label className="floating-label w-full text-base">
<span>Logoff Zeit (UTC+1)</span>
<input
@@ -53,12 +103,12 @@ export const ConnectionBtn = () => {
logoffTime: e.target.value,
})
}
value={form.logoffTime}
value={form.logoffTime ?? ""}
type="time"
className="input w-full"
/>
</label>
{!connection.isConnected && (
{connection.status == "disconnected" && (
<p className="fieldset-label">
Du kannst diese Zeit später noch anpassen.
</p>
@@ -67,7 +117,7 @@ export const ConnectionBtn = () => {
<div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between">
<button className="btn btn-soft">Abbrechen</button>
{connection.isConnected ? (
{connection.status == "connected" ? (
<button
className="btn btn-soft btn-error"
type="submit"
@@ -83,7 +133,16 @@ export const ConnectionBtn = () => {
type="submit"
onSubmit={() => false}
onClick={() => {
connection.connect(uid, form.selectedZone, form.logoffTime);
connection.connect(
uid,
form.selectedStationId?.toString() || "",
form.logoffTime || "",
stations?.find(
(station) =>
station.id ===
parseInt(form.selectedStationId?.toString() || ""),
)!,
);
}}
className="btn btn-soft btn-info"
>
@@ -94,7 +153,7 @@ export const ConnectionBtn = () => {
</div>
</div>
</dialog>
</>
</div>
);
};

View File

@@ -5,7 +5,7 @@ import { ThemeSwap } from "./ThemeSwap";
import { Audio } from "./Audio";
import { useState } from "react";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import { Chat } from "./Chat";
import Link from "next/link";
export default function Navbar() {
const [isDark, setIsDark] = useState(false);
@@ -24,11 +24,6 @@ export default function Navbar() {
<div className="flex items-center gap-2">
<a className="btn btn-ghost text-xl">VAR Leitstelle V2</a>
</div>
<div className="bg-base-200 rounded-box flex items-center gap-2 p-1">
<div className="flex items-center gap-2">
<Chat />
</div>
</div>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
@@ -39,12 +34,20 @@ export default function Navbar() {
</div>
<ThemeSwap isDark={isDark} toggleTheme={toggleTheme} />
<div className="flex items-center">
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" />
</button>
</Link>
</div>
</div>
</div>

View File

@@ -1,9 +0,0 @@
export interface Dispatcher {
userId: string;
lastSeen: string;
loginTime: string;
logoffTime: string;
selectedZone: string;
name: string;
socketId: string;
}

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import Navbar from "../dispatch/_components/navbar/Navbar";
import Navbar from "./_components/navbar/Navbar";
import { redirect } from "next/navigation";
import { getServerSession } from "../api/auth/[...nextauth]/auth";

View File

@@ -0,0 +1,37 @@
"use client";
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";
const DispatchPage = () => {
const { isOpen } = usePannelStore();
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full">
{/* <MapToastCard2 /> */}
<div className="flex flex-1 relative">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
<Chat />
<div className="mt-2">
<Report />
</div>
</div>
</div>
<div
className={cn(
"absolute right-0 w-[500px] z-999 transition-transform",
isOpen ? "translate-x-0" : "translate-x-full",
)}
>
<Pannel />
</div>
</div>
);
};
DispatchPage.displayName = "DispatchPage";
export default DispatchPage;

View File

@@ -0,0 +1,6 @@
import { io } from "socket.io-client";
import { dispatchSocket } from "dispatch/socket";
export const pilotSocket = io(process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, {
autoConnect: false,
});

View File

@@ -0,0 +1,19 @@
import { ConnectedAircraft, ConnectedDispatcher, Prisma } from "@repo/db";
import axios from "axios";
export const getConnectedUserAPI = async (
filter?: Prisma.KeywordWhereInput,
) => {
const res = await axios.get<(ConnectedAircraft | ConnectedDispatcher)[]>(
"/api/connected-user",
{
params: {
filter: JSON.stringify(filter),
},
},
);
if (res.status !== 200) {
throw new Error("Failed to fetch Connected User");
}
return res.data;
};

View File

@@ -0,0 +1,14 @@
import { Keyword, Prisma } from "@repo/db";
import axios from "axios";
export const getKeywordsAPI = async (filter?: Prisma.KeywordWhereInput) => {
const res = await axios.get<Keyword[]>("/api/keywords", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch keywords");
}
return res.data;
};

View File

@@ -0,0 +1,32 @@
import { Mission, Prisma } from "@repo/db";
import axios from "axios";
import { serverApi } from "helpers/axios";
export const getMissionsAPI = async (filter?: Prisma.MissionWhereInput) => {
const res = await axios.get<Mission[]>("/api/missions", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};
export const createMissionAPI = async (mission: Prisma.MissionCreateInput) => {
const response = await serverApi.put<Mission>("/mission", mission);
return response.data;
};
export const editMissionAPI = async (
id: number,
mission: Prisma.MissionUpdateInput,
) => {
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data;
};
export const deleteMissionAPI = async (id: number) => {
await serverApi.delete(`/mission/${id}`);
};

View File

@@ -0,0 +1,14 @@
import { Prisma, Station } from "@repo/db";
import axios from "axios";
export const getStationsAPI = async (filter?: Prisma.StationWhereInput) => {
const res = await axios.get<Station[]>("/api/stations", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};

View File

@@ -17,7 +17,7 @@
"@repo/db": "*",
"@repo/ui": "*",
"@tailwindcss/postcss": "^4.0.14",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-query": "^5.75.4",
"axios": "^1.9.0",
"leaflet": "^1.9.4",
"livekit-client": "^2.9.7",

View File

@@ -13,6 +13,7 @@ import {
LightningBoltIcon,
LockOpen1Icon,
HobbyKnifeIcon,
ExclamationTriangleIcon,
} from "@radix-ui/react-icons";
import { Button } from "../../../../../_components/ui/Button";
import { Select } from "../../../../../_components/ui/Select";
@@ -22,6 +23,7 @@ import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable";
import { min } from "date-fns";
import { cn } from "../../../../../../helper/cn";
import { ChartBarBigIcon, PlaneIcon } from "lucide-react";
import Link from "next/link";
interface ProfileFormProps {
user: User;
@@ -150,6 +152,13 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({
<div className="flex-1">
<h2 className="card-title">
<MixerHorizontalIcon className="w-5 h-5" /> Dispo-Verbindungs Historie
<button
onClick={() => {
dispoTableRef.current?.refresh();
}}
>
refresh
</button>
</h2>
<PaginatedTable
ref={dispoTableRef}
@@ -222,7 +231,22 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({
userId: user.id,
}}
prismaModel={"connectedAircraft"}
include={{ Station: true }}
columns={[
{
accessorKey: "Station.bosCallsign",
header: "Station",
cell: ({ row }) => {
return (
<Link
className="link link-hover"
href={`/admin/station/${(row.original as any).id}`}
>
{(row.original as any).Station.bosCallsign}
</Link>
);
},
},
{
accessorKey: "loginTime",
header: "Login",
@@ -392,6 +416,9 @@ export const AdminForm = ({ user, dispoTime, pilotTime }: AdminFormProps) => {
</div>
</div>
</div>
<h2 className="card-title">
<ExclamationTriangleIcon className="w-5 h-5" /> Reports
</h2>
</div>
);
};

View File

@@ -47,6 +47,7 @@ const Page = async ({ params }: { params: { id: string } }) => {
select: {
loginTime: true,
logoutTime: true,
Station: true,
},
});

View File

@@ -37,10 +37,10 @@ export const resetPassword = async (id: string) => {
return { password };
};
export const deleteDispoHistory = async (id: string) => {
return await prisma.connectedDispatcher.deleteMany({
export const deleteDispoHistory = async (id: number) => {
return await prisma.connectedDispatcher.delete({
where: {
userId: id,
id: id,
},
});
};

Binary file not shown.

297
package-lock.json generated
View File

@@ -27,7 +27,7 @@
"@repo/db": "*",
"@repo/ui": "*",
"@tailwindcss/postcss": "^4.0.14",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-query": "^5.75.4",
"axios": "^1.9.0",
"leaflet": "^1.9.4",
"livekit-client": "^2.9.7",
@@ -533,9 +533,9 @@
"license": "MIT"
},
"node_modules/@emnapi/runtime": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
"integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1366,9 +1366,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz",
"integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==",
"cpu": [
"arm64"
],
@@ -1384,13 +1384,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
"@img/sharp-libvips-darwin-arm64": "1.1.0"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz",
"integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==",
"cpu": [
"x64"
],
@@ -1406,13 +1406,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
"@img/sharp-libvips-darwin-x64": "1.1.0"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==",
"cpu": [
"arm64"
],
@@ -1426,9 +1426,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz",
"integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==",
"cpu": [
"x64"
],
@@ -1442,9 +1442,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz",
"integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==",
"cpu": [
"arm"
],
@@ -1458,9 +1458,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz",
"integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==",
"cpu": [
"arm64"
],
@@ -1473,10 +1473,26 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz",
"integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz",
"integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==",
"cpu": [
"s390x"
],
@@ -1490,9 +1506,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz",
"integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==",
"cpu": [
"x64"
],
@@ -1506,9 +1522,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz",
"integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==",
"cpu": [
"arm64"
],
@@ -1522,9 +1538,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz",
"integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==",
"cpu": [
"x64"
],
@@ -1538,9 +1554,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz",
"integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==",
"cpu": [
"arm"
],
@@ -1556,13 +1572,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
"@img/sharp-libvips-linux-arm": "1.1.0"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz",
"integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==",
"cpu": [
"arm64"
],
@@ -1578,13 +1594,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
"@img/sharp-libvips-linux-arm64": "1.1.0"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz",
"integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==",
"cpu": [
"s390x"
],
@@ -1600,13 +1616,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
"@img/sharp-libvips-linux-s390x": "1.1.0"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz",
"integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==",
"cpu": [
"x64"
],
@@ -1622,13 +1638,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
"@img/sharp-libvips-linux-x64": "1.1.0"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz",
"integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==",
"cpu": [
"arm64"
],
@@ -1644,13 +1660,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz",
"integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==",
"cpu": [
"x64"
],
@@ -1666,20 +1682,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
"@img/sharp-libvips-linuxmusl-x64": "1.1.0"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz",
"integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
"@emnapi/runtime": "^1.4.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -1689,9 +1705,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz",
"integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==",
"cpu": [
"ia32"
],
@@ -1708,9 +1724,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz",
"integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==",
"cpu": [
"x64"
],
@@ -1898,9 +1914,9 @@
}
},
"node_modules/@next/env": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.2.tgz",
"integrity": "sha512-yWgopCfA9XDR8ZH3taB5nRKtKJ1Q5fYsTOuYkzIIoS8TJ0UAUKAGF73JnGszbjk2ufAQDj6mDdgsJAFx5CLtYQ==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz",
"integrity": "sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1914,9 +1930,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.2.tgz",
"integrity": "sha512-HNBRnz+bkZ+KfyOExpUxTMR0Ow8nkkcE6IlsdEa9W/rI7gefud19+Sn1xYKwB9pdCdxIP1lPru/ZfjfA+iT8pw==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz",
"integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==",
"cpu": [
"arm64"
],
@@ -1930,9 +1946,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.2.tgz",
"integrity": "sha512-mJOUwp7al63tDpLpEFpKwwg5jwvtL1lhRW2fI1Aog0nYCPAhxbJsaZKdoVyPZCy8MYf/iQVNDuk/+i29iLCzIA==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz",
"integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==",
"cpu": [
"x64"
],
@@ -1946,9 +1962,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.2.tgz",
"integrity": "sha512-5ZZ0Zwy3SgMr7MfWtRE7cQWVssfOvxYfD9O7XHM7KM4nrf5EOeqwq67ZXDgo86LVmffgsu5tPO57EeFKRnrfSQ==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz",
"integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==",
"cpu": [
"arm64"
],
@@ -1962,9 +1978,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.2.tgz",
"integrity": "sha512-cgKWBuFMLlJ4TWcFHl1KOaVVUAF8vy4qEvX5KsNd0Yj5mhu989QFCq1WjuaEbv/tO1ZpsQI6h/0YR8bLwEi+nA==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz",
"integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==",
"cpu": [
"arm64"
],
@@ -1978,9 +1994,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.2.tgz",
"integrity": "sha512-c3kWSOSsVL8rcNBBfOq1+/j2PKs2nsMwJUV4icUxRgGBwUOfppeh7YhN5s79enBQFU+8xRgVatFkhHU1QW7yUA==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz",
"integrity": "sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==",
"cpu": [
"x64"
],
@@ -1994,9 +2010,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.2.tgz",
"integrity": "sha512-PXTW9PLTxdNlVYgPJ0equojcq1kNu5NtwcNjRjHAB+/sdoKZ+X8FBu70fdJFadkxFIGekQTyRvPMFF+SOJaQjw==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.1.tgz",
"integrity": "sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==",
"cpu": [
"x64"
],
@@ -2010,9 +2026,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.2.tgz",
"integrity": "sha512-nG644Es5llSGEcTaXhnGWR/aThM/hIaz0jx4MDg4gWC8GfTCp8eDBWZ77CVuv2ha/uL9Ce+nPTfYkSLG67/sHg==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz",
"integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==",
"cpu": [
"arm64"
],
@@ -2026,9 +2042,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.2.tgz",
"integrity": "sha512-52nWy65S/R6/kejz3jpvHAjZDPKIbEQu4x9jDBzmB9jJfuOy5rspjKu4u77+fI4M/WzLXrrQd57hlFGzz1ubcQ==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz",
"integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==",
"cpu": [
"x64"
],
@@ -2910,9 +2926,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.69.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.69.0.tgz",
"integrity": "sha512-Kn410jq6vs1P8Nm+ZsRj9H+U3C0kjuEkYLxbiCyn3MDEiYor1j2DGVULqAz62SLZtUZ/e9Xt6xMXiJ3NJ65WyQ==",
"version": "5.75.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.4.tgz",
"integrity": "sha512-pcqOUgWG9oGlzkfRQQMMsEFmtQu0wq81A414CtELZGq+ztVwSTAaoB3AZRAXQJs88LmNMk2YpUKuQbrvzNDyRg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2920,12 +2936,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.69.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.69.0.tgz",
"integrity": "sha512-Ift3IUNQqTcaFa1AiIQ7WCb/PPy8aexZdq9pZWLXhfLcLxH0+PZqJ2xFImxCpdDZrFRZhLJrh76geevS5xjRhA==",
"version": "5.75.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.75.4.tgz",
"integrity": "sha512-Vf65pzYRkf8fk9SP1ncIZjvaXszBhtsvpf+h45Y/9kOywOrVZfBGUpCdffdsVzbmBzmz6TCFes9bM0d3pRrIsA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.69.0"
"@tanstack/query-core": "5.75.4"
},
"funding": {
"type": "github",
@@ -10965,12 +10981,12 @@
}
},
"node_modules/next": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/next/-/next-15.2.2.tgz",
"integrity": "sha512-dgp8Kcx5XZRjMw2KNwBtUzhngRaURPioxoNIVl5BOyJbhi9CUgEtKDO7fx5wh8Z8vOVX1nYZ9meawJoRrlASYA==",
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz",
"integrity": "sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==",
"license": "MIT",
"dependencies": {
"@next/env": "15.2.2",
"@next/env": "15.3.1",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
@@ -10985,15 +11001,15 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.2.2",
"@next/swc-darwin-x64": "15.2.2",
"@next/swc-linux-arm64-gnu": "15.2.2",
"@next/swc-linux-arm64-musl": "15.2.2",
"@next/swc-linux-x64-gnu": "15.2.2",
"@next/swc-linux-x64-musl": "15.2.2",
"@next/swc-win32-arm64-msvc": "15.2.2",
"@next/swc-win32-x64-msvc": "15.2.2",
"sharp": "^0.33.5"
"@next/swc-darwin-arm64": "15.3.1",
"@next/swc-darwin-x64": "15.3.1",
"@next/swc-linux-arm64-gnu": "15.3.1",
"@next/swc-linux-arm64-musl": "15.3.1",
"@next/swc-linux-x64-gnu": "15.3.1",
"@next/swc-linux-x64-musl": "15.3.1",
"@next/swc-win32-arm64-msvc": "15.3.1",
"@next/swc-win32-x64-msvc": "15.3.1",
"sharp": "^0.34.1"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -15868,16 +15884,16 @@
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz",
"integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
"semver": "^7.7.1"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -15886,25 +15902,26 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
"@img/sharp-darwin-arm64": "0.34.1",
"@img/sharp-darwin-x64": "0.34.1",
"@img/sharp-libvips-darwin-arm64": "1.1.0",
"@img/sharp-libvips-darwin-x64": "1.1.0",
"@img/sharp-libvips-linux-arm": "1.1.0",
"@img/sharp-libvips-linux-arm64": "1.1.0",
"@img/sharp-libvips-linux-ppc64": "1.1.0",
"@img/sharp-libvips-linux-s390x": "1.1.0",
"@img/sharp-libvips-linux-x64": "1.1.0",
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0",
"@img/sharp-libvips-linuxmusl-x64": "1.1.0",
"@img/sharp-linux-arm": "0.34.1",
"@img/sharp-linux-arm64": "0.34.1",
"@img/sharp-linux-s390x": "0.34.1",
"@img/sharp-linux-x64": "0.34.1",
"@img/sharp-linuxmusl-arm64": "0.34.1",
"@img/sharp-linuxmusl-x64": "0.34.1",
"@img/sharp-wasm32": "0.34.1",
"@img/sharp-win32-ia32": "0.34.1",
"@img/sharp-win32-x64": "0.34.1"
}
},
"node_modules/shebang-command": {

View File

@@ -10,6 +10,6 @@ model ConnectedAircraft {
positionLogIds Int[] @default([])
// relations:
user User @relation(fields: [userId], references: [id])
station Station @relation(fields: [stationId], references: [id])
User User @relation(fields: [userId], references: [id])
Station Station @relation(fields: [stationId], references: [id])
}