diff --git a/apps/core-server/modules/chron.ts b/apps/core-server/modules/chron.ts index 8cfe33c6..f0cfde22 100644 --- a/apps/core-server/modules/chron.ts +++ b/apps/core-server/modules/chron.ts @@ -98,13 +98,25 @@ const removeClosedMissions = async () => { if (!lastAlertTime) return; + const lastStatus1or6Log = (mission.missionLog as unknown as MissionLog[]) + .filter((l) => { + return ( + l.type === "station-log" && (l.data?.newFMSstatus === "1" || l.data?.newFMSstatus === "6") + ); + }) + .sort((a, b) => new Date(b.timeStamp).getTime() - new Date(a.timeStamp).getTime())[0]; + // Case 1: Forgotten Mission, last alert more than 3 Hours ago const now = new Date(); if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180) return removeMission(mission.id, "inaktivität"); - // Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6 - if (allStationsInMissionChangedFromStatus4to1Or8to1) + // Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6, Status 1/6 change less more 5 minutes ago + if ( + allStationsInMissionChangedFromStatus4to1Or8to1 && + lastStatus1or6Log && + now.getTime() - new Date(lastStatus1or6Log.timeStamp).getTime() > 1000 * 60 * 5 + ) return removeMission(mission.id, "dem freimelden aller Stationen"); }); }; diff --git a/apps/dispatch-server/.env.example b/apps/dispatch-server/.env.example index e6706f70..62eff941 100644 --- a/apps/dispatch-server/.env.example +++ b/apps/dispatch-server/.env.example @@ -1,7 +1,8 @@ DISPATCH_SERVER_PORT=3002 REDIS_HOST=localhost REDIS_PORT=6379 -CORE_SERVER_URL=http://core-server +CORE_SERVER_URL=http://localhost:3005 DISPATCH_APP_TOKEN=dispatch LIVEKIT_API_KEY=APIAnsGdtdYp2Ho -LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC \ No newline at end of file +LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC +AUTH_HUB_SECRET=var \ No newline at end of file diff --git a/apps/dispatch-server/index.ts b/apps/dispatch-server/index.ts index eeb3fe46..51cf6191 100644 --- a/apps/dispatch-server/index.ts +++ b/apps/dispatch-server/index.ts @@ -18,7 +18,10 @@ const app = express(); const server = createServer(app); export const io = new Server(server, { - adapter: createAdapter(pubClient, subClient), + adapter: + process.env.REDIS_HOST && process.env.REDIS_PORT + ? createAdapter(pubClient, subClient) + : undefined, cors: {}, }); io.use(jwtMiddleware); diff --git a/apps/dispatch-server/modules/mission.ts b/apps/dispatch-server/modules/mission.ts index 4f9fe07c..bebb8aee 100644 --- a/apps/dispatch-server/modules/mission.ts +++ b/apps/dispatch-server/modules/mission.ts @@ -6,8 +6,10 @@ export const sendAlert = async ( id: number, { stationId, + desktopOnly, }: { stationId?: number; + desktopOnly?: boolean; }, user: User | "HPG", ): Promise<{ @@ -46,10 +48,13 @@ export const sendAlert = async ( }); for (const aircraft of connectedAircrafts) { - io.to(`station:${aircraft.stationId}`).emit("mission-alert", { - ...mission, - Stations, - }); + if (!desktopOnly) { + io.to(`station:${aircraft.stationId}`).emit("mission-alert", { + ...mission, + Stations, + }); + } + io.to(`desktop:${aircraft.userId}`).emit("mission-alert", { missionId: mission.id, }); diff --git a/apps/dispatch-server/modules/redis.ts b/apps/dispatch-server/modules/redis.ts index a35cadb6..ffa878b0 100644 --- a/apps/dispatch-server/modules/redis.ts +++ b/apps/dispatch-server/modules/redis.ts @@ -1,13 +1,17 @@ import { createClient, RedisClientType } from "redis"; export const pubClient: RedisClientType = createClient({ - url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, + url: `redis://${process.env.REDIS_HOST || "localhost"}:${process.env.REDIS_PORT || 6379}`, }); export const subClient: RedisClientType = pubClient.duplicate(); -Promise.all([pubClient.connect(), subClient.connect()]).then(() => { - console.log("Redis connected"); -}); +if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) { + console.warn("REDIS_HOST or REDIS_PORT not set, skipping Redis connection"); +} else { + Promise.all([pubClient.connect(), subClient.connect()]).then(() => { + console.log("Redis connected"); + }); +} pubClient.on("error", (err) => console.log("Redis Client Error", err)); subClient.on("error", (err) => console.log("Redis Client Error", err)); diff --git a/apps/dispatch-server/routes/mission.ts b/apps/dispatch-server/routes/mission.ts index e487e59f..b6758b82 100644 --- a/apps/dispatch-server/routes/mission.ts +++ b/apps/dispatch-server/routes/mission.ts @@ -87,6 +87,29 @@ router.patch("/:id", async (req, res) => { data: req.body, }); io.to("dispatchers").emit("update-mission", { updatedMission }); + if (req.body.state === "finished") { + const missionUsers = await prisma.missionOnStationUsers.findMany({ + where: { + missionId: updatedMission.id, + }, + select: { + userId: true, + }, + }); + console.log("Notifying users about mission closure:", missionUsers); + missionUsers?.forEach(({ userId }) => { + io.to(`user:${userId}`).emit("notification", { + type: "mission-closed", + status: "closed", + message: `Einsatz ${updatedMission.publicId} wurde beendet`, + data: { + missionId: updatedMission.id, + publicMissionId: updatedMission.publicId, + }, + } as NotificationPayload); + }); + } + res.json(updatedMission); } catch (error) { console.error(error); @@ -113,9 +136,10 @@ router.delete("/:id", async (req, res) => { router.post("/:id/send-alert", async (req, res) => { const { id } = req.params; - const { stationId, vehicleName } = req.body as { + const { stationId, vehicleName, desktopOnly } = req.body as { stationId?: number; vehicleName?: "RTW" | "POL" | "FW"; + desktopOnly?: boolean; }; if (!req.user) { @@ -180,7 +204,11 @@ router.post("/:id/send-alert", async (req, res) => { return; } - const { connectedAircrafts, mission } = await sendAlert(Number(id), { stationId }, req.user); + const { connectedAircrafts, mission } = await sendAlert( + Number(id), + { stationId, desktopOnly }, + req.user, + ); io.to("dispatchers").emit("update-mission", mission); res.status(200).json({ @@ -189,11 +217,9 @@ router.post("/:id/send-alert", async (req, res) => { return; } catch (error) { console.error(error); - res - .status(500) - .json({ - error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`, - }); + res.status(500).json({ + error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`, + }); return; } }); diff --git a/apps/dispatch-server/routes/settings.ts b/apps/dispatch-server/routes/settings.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/dispatch-server/socket-events/connect-pilot.ts b/apps/dispatch-server/socket-events/connect-pilot.ts index aaa936cf..bd9fcc85 100644 --- a/apps/dispatch-server/socket-events/connect-pilot.ts +++ b/apps/dispatch-server/socket-events/connect-pilot.ts @@ -96,6 +96,8 @@ export const handleConnectPilot = lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined, posLat: randomPos?.lat, posLng: randomPos?.lng, + posXplanePluginActive: debug ? true : undefined, + posH145active: debug ? true : undefined, }, }); diff --git a/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Settings.tsx b/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Settings.tsx index c83288b8..d2a7402d 100644 --- a/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Settings.tsx +++ b/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Settings.tsx @@ -3,16 +3,17 @@ import { useEffect, useRef, useState } from "react"; import { GearIcon } from "@radix-ui/react-icons"; import { SettingsIcon, Volume2 } from "lucide-react"; import MicVolumeBar from "_components/MicVolumeIndication"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { editUserAPI, getUserAPI } from "_querys/user"; import { useSession } from "next-auth/react"; import { useAudioStore } from "_store/audioStore"; import toast from "react-hot-toast"; import { useMapStore } from "_store/mapStore"; -import { set } from "date-fns"; +import { Button } from "@repo/shared-components"; export const SettingsBtn = () => { const session = useSession(); + const queryClient = useQueryClient(); const [inputDevices, setInputDevices] = useState([]); const { data: user } = useQuery({ @@ -23,6 +24,10 @@ export const SettingsBtn = () => { const editUserMutation = useMutation({ mutationFn: editUserAPI, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] }); + }, + }); useEffect(() => { @@ -40,6 +45,7 @@ export const SettingsBtn = () => { micVolume: user?.settingsMicVolume || 1, radioVolume: user?.settingsRadioVolume || 0.8, autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false, + useHPGAsDispatcher: user?.settingsUseHPGAsDispatcher || false, }); const { setSettings: setAudioSettings } = useAudioStore((state) => state); @@ -57,7 +63,8 @@ export const SettingsBtn = () => { micDeviceId: user.settingsMicDevice, micVolume: user.settingsMicVolume || 1, radioVolume: user.settingsRadioVolume || 0.8, - autoCloseMapPopup: user.settingsAutoCloseMapPopup || false, + autoCloseMapPopup: user.settingsAutoCloseMapPopup, + useHPGAsDispatcher: user.settingsUseHPGAsDispatcher, }); setUserSettings({ settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false, @@ -198,6 +205,17 @@ export const SettingsBtn = () => { /> Popups automatisch schließen +
+ { + setSettingsPartial({ useHPGAsDispatcher: e.target.checked }); + }} + /> + HPG als Disponent verwenden +
- +
diff --git a/apps/dispatch/app/(app)/dispatch/_components/pannel/MissionForm.tsx b/apps/dispatch/app/(app)/dispatch/_components/pannel/MissionForm.tsx index 40543dba..49279607 100644 --- a/apps/dispatch/app/(app)/dispatch/_components/pannel/MissionForm.tsx +++ b/apps/dispatch/app/(app)/dispatch/_components/pannel/MissionForm.tsx @@ -28,8 +28,11 @@ import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission"; import { AxiosError } from "axios"; import { cn } from "@repo/shared-components"; import { StationsSelect } from "(app)/dispatch/_components/StationSelect"; +import { getUserAPI } from "_querys/user"; export const MissionForm = () => { + const session = useSession(); + const { editingMissionId, setEditingMission } = usePannelStore(); const queryClient = useQueryClient(); const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s); @@ -44,6 +47,10 @@ export const MissionForm = () => { queryFn: () => getConnectedAircraftsAPI(), refetchInterval: 10000, }); + const { data: user } = useQuery({ + queryKey: ["user", session.data?.user.id], + queryFn: () => getUserAPI(session.data!.user.id), + }); const createMissionMutation = useMutation({ mutationFn: createMissionAPI, @@ -81,7 +88,6 @@ export const MissionForm = () => { }, }); - const session = useSession(); const defaultFormValues = React.useMemo( () => ({ @@ -108,6 +114,7 @@ export const MissionForm = () => { hpgSelectedMissionString: null, hpg: null, missionLog: [], + xPlaneObjects: [], }) as MissionOptionalDefaults, [session.data?.user.id], ); @@ -116,13 +123,16 @@ export const MissionForm = () => { resolver: zodResolver(MissionOptionalDefaultsSchema), defaultValues: defaultFormValues, }); - const { missionFormValues, setOpen } = usePannelStore((state) => state); + const { missionFormValues, setOpen, setMissionFormValues } = usePannelStore((state) => state); - const validationRequired = HPGValidationRequired( - form.watch("missionStationIds"), - aircrafts, - form.watch("hpgMissionString"), - ); + const validationRequired = + HPGValidationRequired( + form.watch("missionStationIds"), + aircrafts, + form.watch("hpgMissionString"), + ) && + !form.watch("hpgMissionString")?.startsWith("kein Szenario") && + user?.settingsUseHPGAsDispatcher; useEffect(() => { if (session.data?.user.id) { @@ -144,6 +154,7 @@ export const MissionForm = () => { return; } for (const key in missionFormValues) { + console.debug(key, missionFormValues[key as keyof MissionOptionalDefaults]); if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately form.setValue( key as keyof MissionOptionalDefaults, @@ -153,6 +164,22 @@ export const MissionForm = () => { } }, [missionFormValues, form, defaultFormValues]); + // Sync form state to store (avoid infinity loops by using watch) + useEffect(() => { + const subscription = form.watch((values) => { + // Only update store if values actually changed to prevent loops + const currentStoreValues = JSON.stringify(missionFormValues); + const newFormValues = JSON.stringify(values); + + if (currentStoreValues !== newFormValues) { + console.debug("Updating store missionFormValues", values); + setMissionFormValues(values as MissionOptionalDefaults); + } + }); + + return () => subscription.unsubscribe(); + }, [form, setMissionFormValues, missionFormValues]); + const saveMission = async ( mission: MissionOptionalDefaults, { alertWhenValid = false, createNewMission = false } = {}, @@ -369,6 +396,7 @@ export const MissionForm = () => { + {keywords && keywords .find((k) => k.name === form.watch("missionKeywordName")) @@ -415,6 +443,21 @@ export const MissionForm = () => { In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude

+
+

+ In diesem Einsatz gibt es {form.watch("xPlaneObjects").length} Objekte +

+ +
+
- +
diff --git a/apps/dispatch/app/(app)/pilot/page.tsx b/apps/dispatch/app/(app)/pilot/page.tsx index f68a358b..5ec04be4 100644 --- a/apps/dispatch/app/(app)/pilot/page.tsx +++ b/apps/dispatch/app/(app)/pilot/page.tsx @@ -6,26 +6,63 @@ import { Report } from "../../_components/left/Report"; import { Dme } from "(app)/pilot/_components/dme/Dme"; import dynamic from "next/dynamic"; import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { usePilotConnectionStore } from "_store/pilot/connectionStore"; import { getConnectedAircraftsAPI } from "_querys/aircrafts"; -import { checkSimulatorConnected } from "@repo/shared-components"; +import { Button, checkSimulatorConnected, useDebounce } from "@repo/shared-components"; import { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert"; import { SettingsBoard } from "_components/left/SettingsBoard"; import { BugReport } from "_components/left/BugReport"; +import { useEffect, useState } from "react"; +import { useDmeStore } from "_store/pilot/dmeStore"; +import { sendMissionAPI } from "_querys/missions"; +import toast from "react-hot-toast"; const Map = dynamic(() => import("_components/map/Map"), { ssr: false, }); const PilotPage = () => { - const { connectedAircraft, status } = usePilotConnectionStore((state) => state); + const { connectedAircraft, status, } = usePilotConnectionStore((state) => state); + const { latestMission } = useDmeStore((state) => state); // Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning const { data: aircrafts } = useQuery({ queryKey: ["aircrafts"], queryFn: () => getConnectedAircraftsAPI(), refetchInterval: 10_000, }); + const sendAlertMutation = useMutation({ + mutationKey: ["missions"], + mutationFn: (params: { + id: number; + stationId?: number | undefined; + vehicleName?: "RTW" | "POL" | "FW" | undefined; + desktopOnly?: boolean | undefined; + }) => sendMissionAPI(params.id, params), + onError: (error) => { + console.error(error); + toast.error("Fehler beim Alarmieren"); + }, + onSuccess: (data) => { + toast.success(data.message); + }, + }); + const [shortlyConnected, setShortlyConnected] = useState(false); + useDebounce( + () => { + if (status === "connected") { + setShortlyConnected(false); + } + }, + 30_000, + [status], + ); + + useEffect(() => { + if (status === "connected") { + setShortlyConnected(true); + } + }, [status]); const ownAircraft = aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id); const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false; @@ -47,16 +84,39 @@ const PilotPage = () => {
- {!simulatorConnected && status === "connected" && ( - - )} + {!simulatorConnected && + status === "connected" && + connectedAircraft && + !shortlyConnected && ( + + )}
-

MRT & DME

+
+

MRT & DME

+
+ +
+
diff --git a/apps/dispatch/app/(auth)/logout/page.tsx b/apps/dispatch/app/(auth)/logout/page.tsx index 8090a370..5732aff0 100644 --- a/apps/dispatch/app/(auth)/logout/page.tsx +++ b/apps/dispatch/app/(auth)/logout/page.tsx @@ -10,7 +10,7 @@ export default () => { }, []); return (
-

logging out...

+

ausloggen...

); }; diff --git a/apps/dispatch/app/_components/QueryProvider.tsx b/apps/dispatch/app/_components/QueryProvider.tsx index 2b2af938..d5b2799a 100644 --- a/apps/dispatch/app/_components/QueryProvider.tsx +++ b/apps/dispatch/app/_components/QueryProvider.tsx @@ -3,7 +3,7 @@ import { toast } from "react-hot-toast"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactNode, useEffect, useState } from "react"; +import { ReactNode, useEffect, useRef, useState } from "react"; import { dispatchSocket } from "(app)/dispatch/socket"; import { NotificationPayload } from "@repo/db"; import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; @@ -15,6 +15,7 @@ import { MissionAutoCloseToast } from "_components/customToasts/MissionAutoClose export function QueryProvider({ children }: { children: ReactNode }) { const mapStore = useMapStore((s) => s); + const notificationSound = useRef(null); const [queryClient] = useState( () => @@ -22,7 +23,7 @@ export function QueryProvider({ children }: { children: ReactNode }) { defaultOptions: { mutations: { onError: (error) => { - toast.error("An error occurred: " + (error as Error).message, { + toast.error("Ein Fehler ist aufgetreten: " + (error as Error).message, { position: "top-right", }); }, @@ -30,6 +31,9 @@ export function QueryProvider({ children }: { children: ReactNode }) { }, }), ); + useEffect(() => { + notificationSound.current = new Audio("/sounds/notification.mp3"); + }, []); useEffect(() => { const invalidateMission = () => { queryClient.invalidateQueries({ @@ -59,8 +63,18 @@ export function QueryProvider({ children }: { children: ReactNode }) { }; const handleNotification = (notification: NotificationPayload) => { + const playNotificationSound = () => { + if (notificationSound.current) { + notificationSound.current.currentTime = 0; + notificationSound.current + .play() + .catch((e) => console.error("Notification sound error:", e)); + } + } + switch (notification.type) { case "hpg-validation": + playNotificationSound(); toast.custom( (t) => , { @@ -70,6 +84,7 @@ export function QueryProvider({ children }: { children: ReactNode }) { break; case "admin-message": + playNotificationSound(); toast.custom((t) => , { duration: 999999, }); @@ -81,12 +96,17 @@ export function QueryProvider({ children }: { children: ReactNode }) { }); break; case "mission-auto-close": + playNotificationSound(); toast.custom( (t) => , { duration: 60000, }, ); + break; + case "mission-closed": + toast("Dein aktueller Einsatz wurde geschlossen."); + break; default: toast("unbekanntes Notification-Event"); diff --git a/apps/dispatch/app/_components/customToasts/HPGnotValidated.tsx b/apps/dispatch/app/_components/customToasts/HPGnotValidated.tsx new file mode 100644 index 00000000..4b6e6e4e --- /dev/null +++ b/apps/dispatch/app/_components/customToasts/HPGnotValidated.tsx @@ -0,0 +1,24 @@ +import { BaseNotification } from "_components/customToasts/BaseNotification" +import { TriangleAlert } from "lucide-react" +import toast, { Toast } from "react-hot-toast" + + +export const HPGnotValidatedToast = ({_toast}: {_toast: Toast}) => { + return } className="flex flex-row"> +
+

Einsatz nicht HPG-validiert

+

Vergleiche die Position des Einsatzes mit der HPG-Position in Hubschrauber

+
+
+ +
+
+} + +export const showToast = () => { + toast.custom((t) => { + return (); + }, {duration: 1000 * 60 * 10}); // 10 minutes +} \ No newline at end of file diff --git a/apps/dispatch/app/_components/map/ContextMenu.tsx b/apps/dispatch/app/_components/map/ContextMenu.tsx index 962286cc..b5a1d132 100644 --- a/apps/dispatch/app/_components/map/ContextMenu.tsx +++ b/apps/dispatch/app/_components/map/ContextMenu.tsx @@ -3,15 +3,22 @@ import { OSMWay } from "@repo/db"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useMapStore } from "_store/mapStore"; import { usePannelStore } from "_store/pannelStore"; -import { MapPin, MapPinned, Radius, Ruler, Search, RulerDimensionLine, Scan } from "lucide-react"; +import { MapPin, MapPinned, Search, Car, Ambulance, Siren, Flame } from "lucide-react"; import { getOsmAddress } from "_querys/osm"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { Popup, useMap } from "react-leaflet"; import { findClosestPolygon } from "_helpers/findClosestPolygon"; +import { xPlaneObjectsAvailable } from "_helpers/xPlaneObjectsAvailable"; +import { useQuery } from "@tanstack/react-query"; +import { getConnectedAircraftsAPI } from "_querys/aircrafts"; export const ContextMenu = () => { const map = useMap(); + const { data: aircrafts } = useQuery({ + queryKey: ["connectedAircrafts"], + queryFn: getConnectedAircraftsAPI, + }); const { contextMenu, searchElements, @@ -26,15 +33,16 @@ export const ContextMenu = () => { setOpen, isOpen: isPannelOpen, } = usePannelStore((state) => state); - const [showRulerOptions, setShowRulerOptions] = useState(false); + const [showObjectOptions, setShowObjectOptions] = useState(false); const [rulerHover, setRulerHover] = useState(false); const [rulerOptionsHover, setRulerOptionsHover] = useState(false); const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; useEffect(() => { - setShowRulerOptions(rulerHover || rulerOptionsHover); - }, [rulerHover, rulerOptionsHover]); + const showObjectOptions = rulerHover || rulerOptionsHover; + setShowObjectOptions(showObjectOptions); + }, [isPannelOpen, rulerHover, rulerOptionsHover, setOpen]); useEffect(() => { const handleContextMenu = (e: any) => { @@ -150,9 +158,12 @@ export const ContextMenu = () => { style={{ transform: "translateY(-50%)" }} onMouseEnter={() => setRulerHover(true)} onMouseLeave={() => setRulerHover(false)} - disabled + disabled={ + !isPannelOpen || + !xPlaneObjectsAvailable(missionFormValues?.missionStationIds, aircrafts) + } > - + {/* Bottom Button */} - {/* Ruler Options - shown when Ruler button is hovered or options are hovered */} - {showRulerOptions && ( + {/* XPlane Object Options - shown when Ruler button is hovered or options are hovered */} + {showObjectOptions && (
setRulerOptionsHover(true)} onMouseLeave={() => setRulerOptionsHover(false)} > -
+
diff --git a/apps/dispatch/app/_components/map/MapAdditionals.tsx b/apps/dispatch/app/_components/map/MapAdditionals.tsx index f60a2829..f2c8b7df 100644 --- a/apps/dispatch/app/_components/map/MapAdditionals.tsx +++ b/apps/dispatch/app/_components/map/MapAdditionals.tsx @@ -1,6 +1,6 @@ "use client"; import { usePannelStore } from "_store/pannelStore"; -import { Marker } from "react-leaflet"; +import { Marker, useMap } from "react-leaflet"; import L from "leaflet"; import { useQuery } from "@tanstack/react-query"; import { getMissionsAPI } from "_querys/missions"; @@ -8,10 +8,13 @@ import { HPGValidationRequired } from "_helpers/hpgValidationRequired"; import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { useMapStore } from "_store/mapStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; +import { XplaneObject } from "@repo/db"; +import { useEffect, useState } from "react"; export const MapAdditionals = () => { - const { isOpen, missionFormValues } = usePannelStore((state) => state); + const { isOpen, missionFormValues, setMissionFormValues } = usePannelStore((state) => state); const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status); + const { openMissionMarker } = useMapStore((state) => state); const { data: missions = [] } = useQuery({ queryKey: ["missions"], @@ -21,13 +24,28 @@ export const MapAdditionals = () => { }), refetchInterval: 10_000, }); - const mapStore = useMapStore((state) => state); + const { setOpenMissionMarker } = useMapStore((state) => state); + const [showDetailedAdditionals, setShowDetailedAdditionals] = useState(false); const { data: aircrafts } = useQuery({ queryKey: ["aircrafts"], queryFn: () => getConnectedAircraftsAPI(), refetchInterval: 10000, }); + const leafletMap = useMap(); + + useEffect(() => { + const handleZoomEnd = () => { + const currentZoom = leafletMap.getZoom(); + setShowDetailedAdditionals(currentZoom > 10); + }; + + leafletMap.on("zoomend", handleZoomEnd); + + return () => { + leafletMap.off("zoomend", handleZoomEnd); + }; + }, [leafletMap]); const markersNeedingAttention = missions.filter( (m) => @@ -37,7 +55,7 @@ export const MapAdditionals = () => { m.hpgLocationLat && dispatcherConnectionState === "connected" && m.hpgLocationLng && - mapStore.openMissionMarker.find((openMission) => openMission.id === m.id), + openMissionMarker.find((openMission) => openMission.id === m.id), ); return ( @@ -50,9 +68,78 @@ export const MapAdditionals = () => { iconSize: [40, 40], iconAnchor: [20, 35], })} - interactive={false} + draggable={true} + eventHandlers={{ + dragend: (e) => { + const marker = e.target; + const position = marker.getLatLng(); + setMissionFormValues({ + ...missionFormValues, + addressLat: position.lat, + addressLng: position.lng, + }); + }, + }} /> )} + {showDetailedAdditionals && + openMissionMarker.map((mission) => { + if (missionFormValues?.id === mission.id) return null; + const missionData = missions.find((m) => m.id === mission.id); + if (!missionData?.addressLat || !missionData?.addressLng) return null; + return (missionData.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => ( + + )); + })} + {isOpen && + missionFormValues?.xPlaneObjects && + (missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => ( + { + const marker = e.target; + const position = marker.getLatLng(); + console.log("Marker dragged to:", position); + setMissionFormValues({ + ...missionFormValues, + xPlaneObjects: (missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map( + (obj, objIndex) => + objIndex === index ? { ...obj, lat: position.lat, lon: position.lng } : obj, + ), + }); + }, + + contextmenu: (e) => { + e.originalEvent.preventDefault(); + const updatedObjects = ( + missionFormValues.xPlaneObjects as unknown as XplaneObject[] + ).filter((_, objIndex) => objIndex !== index); + setMissionFormValues({ + ...missionFormValues, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + xPlaneObjects: updatedObjects as unknown as any[], + }); + }, + }} + /> + ))} {markersNeedingAttention.map((mission) => ( { })} eventHandlers={{ click: () => - mapStore.setOpenMissionMarker({ + setOpenMissionMarker({ open: [ { id: mission.id, diff --git a/apps/dispatch/app/_components/map/XPlaneObject.tsx b/apps/dispatch/app/_components/map/XPlaneObject.tsx new file mode 100644 index 00000000..c1f17230 --- /dev/null +++ b/apps/dispatch/app/_components/map/XPlaneObject.tsx @@ -0,0 +1,3 @@ +export const XPlaneObjects = () => { + return
XPlaneObjects
; +}; diff --git a/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx index 94484eb2..97e529ab 100644 --- a/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx +++ b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx @@ -296,6 +296,12 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta {aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"} + + {" "} + + {aircraft.posXplanePluginActive ? "X-Plane Plugin Aktiv" : "X-Plane Plugin Inaktiv"} + +
); diff --git a/apps/dispatch/app/_components/navbar/AdminPanel.tsx b/apps/dispatch/app/_components/navbar/AdminPanel.tsx index 43af976b..ae2d9396 100644 --- a/apps/dispatch/app/_components/navbar/AdminPanel.tsx +++ b/apps/dispatch/app/_components/navbar/AdminPanel.tsx @@ -7,6 +7,7 @@ import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/dispatcher import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit"; import { ParticipantInfo } from "livekit-server-sdk"; import { + Dot, LockKeyhole, Plane, RedoDot, @@ -144,7 +145,12 @@ export default function AdminPanel() { {!livekitParticipant ? ( Nicht verbunden ) : ( - {livekitParticipant.room} + + {livekitParticipant.room}{" "} + {livekitParticipant?.participant.tracks.some((t) => !t.muted) && ( + + )} + )} @@ -209,7 +215,12 @@ export default function AdminPanel() { {!livekitParticipant ? ( Nicht verbunden ) : ( - {livekitParticipant.room} + + {livekitParticipant.room}{" "} + {livekitParticipant?.participant.tracks.some((t) => !t.muted) && ( + + )} + )} @@ -274,8 +285,13 @@ export default function AdminPanel() { Nicht verbunden - - {p.room} + + + {p.room} + {p.participant.tracks.some((t) => !t.muted) && ( + + )} +
+
+ {d.settingsUseHPGAsDispatcher ? ( + HPG aktiv + ) : ( + + HPG deaktiviert + + )} +
{(() => { const badges = (d.publicUser as unknown as PublicUser).badges diff --git a/apps/dispatch/package.json b/apps/dispatch/package.json index e195c34c..914efa9f 100644 --- a/apps/dispatch/package.json +++ b/apps/dispatch/package.json @@ -44,7 +44,7 @@ "livekit-client": "^2.15.3", "livekit-server-sdk": "^2.13.1", "lucide-react": "^0.525.0", - "next": "^15.4.2", + "next": "^15.4.8", "next-auth": "^4.24.11", "npm": "^11.4.2", "postcss": "^8.5.6", diff --git a/apps/dispatch/public/icons/ambulance.png b/apps/dispatch/public/icons/ambulance.png new file mode 100644 index 00000000..114a9abf Binary files /dev/null and b/apps/dispatch/public/icons/ambulance.png differ diff --git a/apps/dispatch/public/icons/fire_engine.png b/apps/dispatch/public/icons/fire_engine.png new file mode 100644 index 00000000..2f11c4e4 Binary files /dev/null and b/apps/dispatch/public/icons/fire_engine.png differ diff --git a/apps/dispatch/public/icons/police.png b/apps/dispatch/public/icons/police.png new file mode 100644 index 00000000..bd0132c4 Binary files /dev/null and b/apps/dispatch/public/icons/police.png differ diff --git a/apps/dispatch/public/sounds/notification.mp3 b/apps/dispatch/public/sounds/notification.mp3 new file mode 100644 index 00000000..57f5dddc Binary files /dev/null and b/apps/dispatch/public/sounds/notification.mp3 differ diff --git a/apps/hub/app/(app)/_components/Badges.tsx b/apps/hub/app/(app)/_components/Badges.tsx index 1d044ece..b35cfe0b 100644 --- a/apps/hub/app/(app)/_components/Badges.tsx +++ b/apps/hub/app/(app)/_components/Badges.tsx @@ -8,24 +8,22 @@ export const Badges: () => Promise = async () => { if (!session) return
; return ( -
-
-

- - Verdiente Abzeichen +
+

+ + Verdiente Abzeichen + +

+
+ {session.user.badges.length === 0 && ( + + Noch ziemlich leer hier. Du kannst dir Abzeichen erarbeiten indem du an Events + teilnimmst. -

-
- {session.user.badges.length === 0 && ( - - Noch ziemlich leer hier. Du kannst dir Abzeichen erarbeiten indem du an Events - teilnimmst. - - )} - {session.user.badges.map((badge, i) => { - return ; - })} -
+ )} + {session.user.badges.map((badge, i) => { + return ; + })}
); diff --git a/apps/hub/app/(app)/_components/Bookings.tsx b/apps/hub/app/(app)/_components/Bookings.tsx new file mode 100644 index 00000000..1373fbd1 --- /dev/null +++ b/apps/hub/app/(app)/_components/Bookings.tsx @@ -0,0 +1,66 @@ +import { Calendar } from "lucide-react"; +import { getServerSession } from "../../api/auth/[...nextauth]/auth"; +import { JSX } from "react"; +import { getPublicUser, prisma } from "@repo/db"; +import { formatTimeRange } from "../../../helper/timerange"; + +export const Bookings: () => Promise = async () => { + const session = await getServerSession(); + const futureBookings = await prisma.booking.findMany({ + where: { + startTime: { + gte: new Date(), + lte: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }, + orderBy: { + startTime: "asc", + }, + include: { + User: true, + Station: true, + }, + }); + if (!session) return
; + + return ( +
+

+ + Zukünftige Buchungen + +

+
+ {futureBookings.length === 0 && ( + + Keine zukünftigen Buchungen. Du kannst dir welche im Buchungssystem erstellen. + + )} + {futureBookings.map((booking) => { + return ( +
+
+ + {booking.type.startsWith("LST_") + ? "LST" + : booking.Station?.bosCallsignShort || booking.Station?.bosCallsign} + + {getPublicUser(booking.User).fullName} +
+
+
+

+ {formatTimeRange(booking, { includeDate: true })} +

+
+
+
+ ); + })} +
+
+ ); +}; diff --git a/apps/hub/app/(app)/_querys/bookings.ts b/apps/hub/app/(app)/_querys/bookings.ts new file mode 100644 index 00000000..44d90ca9 --- /dev/null +++ b/apps/hub/app/(app)/_querys/bookings.ts @@ -0,0 +1,38 @@ +import { Booking, Prisma, PublicUser, Station } from "@repo/db"; +import axios from "axios"; + +export const getBookingsAPI = async (filter: Prisma.BookingWhereInput) => { + const res = await axios.get< + (Booking & { + Station: Station; + User: PublicUser; + })[] + >("/api/bookings", { + params: { + filter: JSON.stringify(filter), + }, + }); + if (res.status !== 200) { + throw new Error("Failed to fetch stations"); + } + return res.data; +}; + +export const createBookingAPI = async (booking: Omit) => { + const response = await axios.post("/api/bookings", booking); + console.log("Response from createBookingAPI:", response); + if (response.status !== 201) { + console.error("Error creating booking:", response); + throw new Error("Failed to create booking"); + } + console.log("Booking created:", response.data); + return response.data; +}; + +export const deleteBookingAPI = async (bookingId: string) => { + const response = await axios.delete(`/api/bookings/${bookingId}`); + if (!response.status.toString().startsWith("2")) { + throw new Error("Failed to delete booking"); + } + return bookingId; +}; diff --git a/apps/hub/app/(app)/_querys/stations.ts b/apps/hub/app/(app)/_querys/stations.ts new file mode 100644 index 00000000..ad16cf2e --- /dev/null +++ b/apps/hub/app/(app)/_querys/stations.ts @@ -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("/api/stations", { + params: { + filter: JSON.stringify(filter), + }, + }); + if (res.status !== 200) { + throw new Error("Failed to fetch stations"); + } + return res.data; +}; diff --git a/apps/hub/app/(app)/page.tsx b/apps/hub/app/(app)/page.tsx index 85a96c45..692a5f25 100644 --- a/apps/hub/app/(app)/page.tsx +++ b/apps/hub/app/(app)/page.tsx @@ -2,6 +2,7 @@ import Events from "./_components/FeaturedEvents"; import { Stats } from "./_components/Stats"; import { Badges } from "./_components/Badges"; import { RecentFlights } from "(app)/_components/RecentFlights"; +import { Bookings } from "(app)/_components/Bookings"; export default async function Home({ searchParams, @@ -14,10 +15,15 @@ export default async function Home({
-
+
- +
+ +
+
+ +
diff --git a/apps/hub/app/(auth)/logout/page.tsx b/apps/hub/app/(auth)/logout/page.tsx index 8214e355..5732aff0 100644 --- a/apps/hub/app/(auth)/logout/page.tsx +++ b/apps/hub/app/(auth)/logout/page.tsx @@ -1,16 +1,16 @@ -'use client'; -import { signOut } from 'next-auth/react'; -import { useEffect } from 'react'; +"use client"; +import { signOut } from "next-auth/react"; +import { useEffect } from "react"; export default () => { - useEffect(() => { - signOut({ - callbackUrl: '/login', - }); - }, []); - return ( -
-

logging out...

-
- ); + useEffect(() => { + signOut({ + callbackUrl: "/login", + }); + }, []); + return ( +
+

ausloggen...

+
+ ); }; diff --git a/apps/hub/app/_components/BookingButton.tsx b/apps/hub/app/_components/BookingButton.tsx new file mode 100644 index 00000000..c34f9a0c --- /dev/null +++ b/apps/hub/app/_components/BookingButton.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { BookingSystem } from "./BookingSystem"; +import { User } from "@repo/db"; + +interface BookingButtonProps { + currentUser: User; +} + +export const BookingButton = ({ currentUser }: BookingButtonProps) => { + const [isBookingSystemOpen, setIsBookingSystemOpen] = useState(false); + + // Check if user can access booking system + const canAccessBookingSystem = currentUser && currentUser.emailVerified && !currentUser.isBanned; + + // Don't render the button if user doesn't have access + if (!canAccessBookingSystem) { + return null; + } + + return ( + <> + + + setIsBookingSystemOpen(false)} + currentUser={currentUser} + /> + + ); +}; diff --git a/apps/hub/app/_components/BookingSystem.tsx b/apps/hub/app/_components/BookingSystem.tsx new file mode 100644 index 00000000..f24edb92 --- /dev/null +++ b/apps/hub/app/_components/BookingSystem.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { BookingTimelineModal } from "./BookingTimelineModal"; +import { NewBookingModal } from "./NewBookingModal"; +import { User } from "@repo/db"; + +interface BookingSystemProps { + isOpen: boolean; + onClose: () => void; + currentUser: User; +} + +export const BookingSystem = ({ isOpen, onClose, currentUser }: BookingSystemProps) => { + const [showNewBookingModal, setShowNewBookingModal] = useState(false); + const [refreshTimeline, setRefreshTimeline] = useState(0); + + const handleOpenNewBooking = () => { + setShowNewBookingModal(true); + }; + + const handleCloseNewBooking = () => { + setShowNewBookingModal(false); + }; + + const handleBookingCreated = () => { + // Trigger a refresh of the timeline + setRefreshTimeline((prev) => prev + 1); + setShowNewBookingModal(false); + }; + + const handleCloseMain = () => { + setShowNewBookingModal(false); + onClose(); + }; + + return ( + <> + + + + + ); +}; diff --git a/apps/hub/app/_components/BookingTimelineModal.tsx b/apps/hub/app/_components/BookingTimelineModal.tsx new file mode 100644 index 00000000..34cb1bdc --- /dev/null +++ b/apps/hub/app/_components/BookingTimelineModal.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon, Plus, X, ChevronLeft, ChevronRight, Trash2 } from "lucide-react"; +import { Booking, PublicUser, Station, User } from "@repo/db"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { deleteBookingAPI, getBookingsAPI } from "(app)/_querys/bookings"; +import { Button } from "@repo/shared-components"; +import { formatTimeRange } from "../../helper/timerange"; +import toast from "react-hot-toast"; + +interface BookingTimelineModalProps { + isOpen: boolean; + onClose: () => void; + onOpenNewBooking: () => void; + currentUser: User; +} + +type ViewMode = "day" | "week" | "month"; + +export const BookingTimelineModal = ({ + isOpen, + onClose, + onOpenNewBooking, + currentUser, +}: BookingTimelineModalProps) => { + const queryClient = useQueryClient(); + const [currentDate, setCurrentDate] = useState(new Date()); + const [viewMode, setViewMode] = useState("day"); + const getTimeRange = () => { + const start = new Date(currentDate); + const end = new Date(currentDate); + + switch (viewMode) { + case "day": + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + break; + case "week": { + const dayOfWeek = start.getDay(); + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + start.setDate(start.getDate() + mondayOffset); + start.setHours(0, 0, 0, 0); + end.setDate(start.getDate() + 6); + end.setHours(23, 59, 59, 999); + break; + } + case "month": + start.setDate(1); + start.setHours(0, 0, 0, 0); + end.setMonth(end.getMonth() + 1); + end.setDate(0); + end.setHours(23, 59, 59, 999); + break; + } + + return { start, end }; + }; + + const { data: bookings, isLoading: isBookingsLoading } = useQuery({ + queryKey: ["bookings", getTimeRange().start, getTimeRange().end], + queryFn: () => + getBookingsAPI({ + startTime: { + gte: getTimeRange().start, + }, + endTime: { + lte: getTimeRange().end, + }, + }), + }); + + const { mutate: deleteBooking } = useMutation({ + mutationKey: ["deleteBooking"], + mutationFn: async (bookingId: string) => { + await deleteBookingAPI(bookingId); + queryClient.invalidateQueries({ queryKey: ["bookings"] }); + }, + onSuccess: () => { + toast.success("Buchung erfolgreich gelöscht"); + }, + }); + + // Check if user can create bookings + const canCreateBookings = + currentUser && + currentUser.emailVerified && + !currentUser.isBanned && + (currentUser.permissions.includes("PILOT") || currentUser.permissions.includes("DISPO")); + + // Check if user can delete a booking + const canDeleteBooking = (bookingUserPublicId: string) => { + if (!currentUser) return false; + if (currentUser.permissions.includes("ADMIN_BOOKING")) return true; + + // User can delete their own bookings if they meet basic requirements + if (bookingUserPublicId === currentUser.publicId) { + return true; + } + + // Admins can delete any booking + return false; + }; + + const navigate = (direction: "prev" | "next") => { + const newDate = new Date(currentDate); + + switch (viewMode) { + case "day": + newDate.setDate(newDate.getDate() + (direction === "next" ? 1 : -1)); + break; + case "week": + newDate.setDate(newDate.getDate() + (direction === "next" ? 7 : -7)); + break; + case "month": + newDate.setMonth(newDate.getMonth() + (direction === "next" ? 1 : -1)); + break; + } + + setCurrentDate(newDate); + }; + + const formatDateRange = () => { + const { start, end } = getTimeRange(); + const options: Intl.DateTimeFormatOptions = { + day: "2-digit", + month: "2-digit", + year: "numeric", + }; + + switch (viewMode) { + case "day": + return start.toLocaleDateString("de-DE", options); + case "week": + return `${start.toLocaleDateString("de-DE", options)} - ${end.toLocaleDateString("de-DE", options)}`; + case "month": + return start.toLocaleDateString("de-DE", { month: "long", year: "numeric" }); + } + }; + + const groupBookingsByResource = () => { + if (!bookings) return {}; + // For day view, group by resource (station/type) + if (viewMode === "day") { + const groups: Record = {}; + + bookings.forEach((booking) => { + let key: string = booking.type; + if (booking.Station) { + key = `${booking.Station.bosCallsign}`; + } + + if (!groups[key]) { + groups[key] = []; + } + groups[key]!.push(booking); + }); + + // Sort bookings within each group, LST_ types first, then alphanumerical + Object.keys(groups).forEach((key) => { + groups[key] = groups[key]!.sort((a, b) => { + const aIsLST = a.type.startsWith("LST_"); + const bIsLST = b.type.startsWith("LST_"); + if (aIsLST && !bIsLST) return -1; + if (!aIsLST && bIsLST) return 1; + // Within same category (both LST_ or both non-LST_), sort alphanumerically by type + return a.type.localeCompare(b.type); + }); + }); + + // Sort the groups themselves - LST_ types first, then alphabetical + const sortedGroups: Record = {}; + Object.keys(groups) + .sort((a, b) => { + // Check if groups contain LST_ types + const aHasLST = groups[a]?.some((booking) => booking.type.startsWith("LST_")); + const bHasLST = groups[b]?.some((booking) => booking.type.startsWith("LST_")); + if (aHasLST && !bHasLST) return -1; + if (!aHasLST && bHasLST) return 1; + // Within same category, sort alphabetically by group name + return a.localeCompare(b); + }) + .forEach((key) => { + sortedGroups[key] = groups[key]!; + }); + + return sortedGroups; + } + + // For week and month views, group by date + const groups: Record = {}; + + bookings.forEach((booking) => { + const dateKey = new Date(booking.startTime).toLocaleDateString("de-DE", { + weekday: "long", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + + if (!groups[dateKey]) { + groups[dateKey] = []; + } + groups[dateKey]!.push(booking); + }); + + // Sort groups by date for week/month view and sort bookings within each group + const sortedGroups: Record = {}; + Object.keys(groups) + .sort((a, b) => { + // Extract date from the formatted string and compare + const dateA = groups[a]?.[0]?.startTime; + const dateB = groups[b]?.[0]?.startTime; + if (!dateA || !dateB) return 0; + return new Date(dateA).getTime() - new Date(dateB).getTime(); + }) + .forEach((key) => { + const bookingsForKey = groups[key]; + if (bookingsForKey) { + // Sort bookings within each date group, LST_ types first, then alphanumerical + sortedGroups[key] = bookingsForKey.sort((a, b) => { + const aIsLST = a.type.startsWith("LST_"); + const bIsLST = b.type.startsWith("LST_"); + if (aIsLST && !bIsLST) return -1; + if (!aIsLST && bIsLST) return 1; + // Within same category (both LST_ or both non-LST_), sort alphanumerically by type + return a.type.localeCompare(b.type); + }); + } + }); + + return sortedGroups; + }; + + if (!isOpen) return null; + + const groupedBookings = groupBookingsByResource(); + + return ( +
+
+
+

+ + Slot Buchung +

+ +
+ + {/* Controls */} +
+
+ + {formatDateRange()} + +
+ +
+ {(["day", "week", "month"] as ViewMode[]).map((mode) => ( + + ))} +
+
+ + {isBookingsLoading ? ( +
+ +
+ ) : ( +
+ {Object.entries(groupedBookings).map(([groupName, resourceBookings]) => ( +
+
+

+ {viewMode === "day" ? groupName : groupName} +

+
+ {resourceBookings.map((booking) => ( +
+
+ + {booking.type.startsWith("LST_") + ? "LST" + : booking.Station.bosCallsignShort || booking.Station.bosCallsign} + + {booking.User.fullName} +
+
+
+

{formatTimeRange(booking)}

+
+ {canDeleteBooking(booking.User.publicId) && ( + + )} +
+
+ ))} +
+
+
+ ))} + {Object.keys(groupedBookings).length === 0 && !isBookingsLoading && ( +
+ Keine Buchungen im aktuellen Zeitraum gefunden +
+ )} +
+ )} + +
+ {canCreateBookings && ( + + )} + +
+
+
+ ); +}; diff --git a/apps/hub/app/_components/Nav.tsx b/apps/hub/app/_components/Nav.tsx index 3f34bbea..bd5f29fd 100644 --- a/apps/hub/app/_components/Nav.tsx +++ b/apps/hub/app/_components/Nav.tsx @@ -13,6 +13,7 @@ import { getServerSession } from "api/auth/[...nextauth]/auth"; import { Error } from "./Error"; import Image from "next/image"; import { Plane, Radar, Workflow } from "lucide-react"; +import { BookingButton } from "./BookingButton"; export const VerticalNav = async () => { const session = await getServerSession(); @@ -134,6 +135,9 @@ export const HorizontalNav = async () => {