Release v2.0.4

Release v2.0.4
This commit was merged in pull request #140.
This commit is contained in:
PxlLoewe
2025-12-08 19:48:53 +01:00
committed by GitHub
71 changed files with 2414 additions and 328 deletions

View File

@@ -98,13 +98,25 @@ const removeClosedMissions = async () => {
if (!lastAlertTime) return; 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 // Case 1: Forgotten Mission, last alert more than 3 Hours ago
const now = new Date(); const now = new Date();
if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180) if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180)
return removeMission(mission.id, "inaktivität"); 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 // 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) if (
allStationsInMissionChangedFromStatus4to1Or8to1 &&
lastStatus1or6Log &&
now.getTime() - new Date(lastStatus1or6Log.timeStamp).getTime() > 1000 * 60 * 5
)
return removeMission(mission.id, "dem freimelden aller Stationen"); return removeMission(mission.id, "dem freimelden aller Stationen");
}); });
}; };

View File

@@ -1,7 +1,8 @@
DISPATCH_SERVER_PORT=3002 DISPATCH_SERVER_PORT=3002
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
CORE_SERVER_URL=http://core-server CORE_SERVER_URL=http://localhost:3005
DISPATCH_APP_TOKEN=dispatch DISPATCH_APP_TOKEN=dispatch
LIVEKIT_API_KEY=APIAnsGdtdYp2Ho LIVEKIT_API_KEY=APIAnsGdtdYp2Ho
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC
AUTH_HUB_SECRET=var

View File

@@ -18,7 +18,10 @@ const app = express();
const server = createServer(app); const server = createServer(app);
export const io = new Server(server, { export const io = new Server(server, {
adapter: createAdapter(pubClient, subClient), adapter:
process.env.REDIS_HOST && process.env.REDIS_PORT
? createAdapter(pubClient, subClient)
: undefined,
cors: {}, cors: {},
}); });
io.use(jwtMiddleware); io.use(jwtMiddleware);

View File

@@ -6,8 +6,10 @@ export const sendAlert = async (
id: number, id: number,
{ {
stationId, stationId,
desktopOnly,
}: { }: {
stationId?: number; stationId?: number;
desktopOnly?: boolean;
}, },
user: User | "HPG", user: User | "HPG",
): Promise<{ ): Promise<{
@@ -46,10 +48,13 @@ export const sendAlert = async (
}); });
for (const aircraft of connectedAircrafts) { for (const aircraft of connectedAircrafts) {
io.to(`station:${aircraft.stationId}`).emit("mission-alert", { if (!desktopOnly) {
...mission, io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
Stations, ...mission,
}); Stations,
});
}
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", { io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
missionId: mission.id, missionId: mission.id,
}); });

View File

@@ -1,13 +1,17 @@
import { createClient, RedisClientType } from "redis"; import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({ 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(); export const subClient: RedisClientType = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => { if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) {
console.log("Redis connected"); 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)); pubClient.on("error", (err) => console.log("Redis Client Error", err));
subClient.on("error", (err) => console.log("Redis Client Error", err)); subClient.on("error", (err) => console.log("Redis Client Error", err));

View File

@@ -87,6 +87,29 @@ router.patch("/:id", async (req, res) => {
data: req.body, data: req.body,
}); });
io.to("dispatchers").emit("update-mission", { updatedMission }); 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); res.json(updatedMission);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -113,9 +136,10 @@ router.delete("/:id", async (req, res) => {
router.post("/:id/send-alert", async (req, res) => { router.post("/:id/send-alert", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { stationId, vehicleName } = req.body as { const { stationId, vehicleName, desktopOnly } = req.body as {
stationId?: number; stationId?: number;
vehicleName?: "RTW" | "POL" | "FW"; vehicleName?: "RTW" | "POL" | "FW";
desktopOnly?: boolean;
}; };
if (!req.user) { if (!req.user) {
@@ -180,7 +204,11 @@ router.post("/:id/send-alert", async (req, res) => {
return; 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); io.to("dispatchers").emit("update-mission", mission);
res.status(200).json({ res.status(200).json({
@@ -189,11 +217,9 @@ router.post("/:id/send-alert", async (req, res) => {
return; return;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res res.status(500).json({
.status(500) error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
.json({ });
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
});
return; return;
} }
}); });

View File

View File

@@ -96,6 +96,8 @@ export const handleConnectPilot =
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined, lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat, posLat: randomPos?.lat,
posLng: randomPos?.lng, posLng: randomPos?.lng,
posXplanePluginActive: debug ? true : undefined,
posH145active: debug ? true : undefined,
}, },
}); });

View File

@@ -3,16 +3,17 @@ import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react"; import { SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication"; 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 { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore"; import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { set } from "date-fns"; import { Button } from "@repo/shared-components";
export const SettingsBtn = () => { export const SettingsBtn = () => {
const session = useSession(); const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]); const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({ const { data: user } = useQuery({
@@ -23,6 +24,10 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({ const editUserMutation = useMutation({
mutationFn: editUserAPI, mutationFn: editUserAPI,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
}); });
useEffect(() => { useEffect(() => {
@@ -40,6 +45,7 @@ export const SettingsBtn = () => {
micVolume: user?.settingsMicVolume || 1, micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8, radioVolume: user?.settingsRadioVolume || 0.8,
autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false, autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false,
useHPGAsDispatcher: user?.settingsUseHPGAsDispatcher || false,
}); });
const { setSettings: setAudioSettings } = useAudioStore((state) => state); const { setSettings: setAudioSettings } = useAudioStore((state) => state);
@@ -57,7 +63,8 @@ export const SettingsBtn = () => {
micDeviceId: user.settingsMicDevice, micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1, micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8, radioVolume: user.settingsRadioVolume || 0.8,
autoCloseMapPopup: user.settingsAutoCloseMapPopup || false, autoCloseMapPopup: user.settingsAutoCloseMapPopup,
useHPGAsDispatcher: user.settingsUseHPGAsDispatcher,
}); });
setUserSettings({ setUserSettings({
settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false, settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
@@ -198,6 +205,17 @@ export const SettingsBtn = () => {
/> />
Popups automatisch schließen Popups automatisch schließen
</div> </div>
<div className="mt-2 flex w-full items-center gap-2">
<input
type="checkbox"
className="toggle"
checked={settings.useHPGAsDispatcher}
onChange={(e) => {
setSettingsPartial({ useHPGAsDispatcher: e.target.checked });
}}
/>
HPG als Disponent verwenden
</div>
<div className="modal-action flex justify-between"> <div className="modal-action flex justify-between">
<button <button
@@ -211,7 +229,7 @@ export const SettingsBtn = () => {
> >
Schließen Schließen
</button> </button>
<button <Button
className="btn btn-soft btn-success" className="btn btn-soft btn-success"
type="submit" type="submit"
onSubmit={() => false} onSubmit={() => false}
@@ -224,6 +242,7 @@ export const SettingsBtn = () => {
settingsMicVolume: settings.micVolume, settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume, settingsRadioVolume: settings.radioVolume,
settingsAutoCloseMapPopup: settings.autoCloseMapPopup, settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
settingsUseHPGAsDispatcher: settings.useHPGAsDispatcher,
}, },
}); });
setAudioSettings({ setAudioSettings({
@@ -239,7 +258,7 @@ export const SettingsBtn = () => {
}} }}
> >
Speichern Speichern
</button> </Button>
</div> </div>
</div> </div>
</dialog> </dialog>

View File

@@ -28,8 +28,11 @@ import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { cn } from "@repo/shared-components"; import { cn } from "@repo/shared-components";
import { StationsSelect } from "(app)/dispatch/_components/StationSelect"; import { StationsSelect } from "(app)/dispatch/_components/StationSelect";
import { getUserAPI } from "_querys/user";
export const MissionForm = () => { export const MissionForm = () => {
const session = useSession();
const { editingMissionId, setEditingMission } = usePannelStore(); const { editingMissionId, setEditingMission } = usePannelStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s); const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s);
@@ -44,6 +47,10 @@ export const MissionForm = () => {
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000, refetchInterval: 10000,
}); });
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const createMissionMutation = useMutation({ const createMissionMutation = useMutation({
mutationFn: createMissionAPI, mutationFn: createMissionAPI,
@@ -81,7 +88,6 @@ export const MissionForm = () => {
}, },
}); });
const session = useSession();
const defaultFormValues = React.useMemo( const defaultFormValues = React.useMemo(
() => () =>
({ ({
@@ -108,6 +114,7 @@ export const MissionForm = () => {
hpgSelectedMissionString: null, hpgSelectedMissionString: null,
hpg: null, hpg: null,
missionLog: [], missionLog: [],
xPlaneObjects: [],
}) as MissionOptionalDefaults, }) as MissionOptionalDefaults,
[session.data?.user.id], [session.data?.user.id],
); );
@@ -116,13 +123,16 @@ export const MissionForm = () => {
resolver: zodResolver(MissionOptionalDefaultsSchema), resolver: zodResolver(MissionOptionalDefaultsSchema),
defaultValues: defaultFormValues, defaultValues: defaultFormValues,
}); });
const { missionFormValues, setOpen } = usePannelStore((state) => state); const { missionFormValues, setOpen, setMissionFormValues } = usePannelStore((state) => state);
const validationRequired = HPGValidationRequired( const validationRequired =
form.watch("missionStationIds"), HPGValidationRequired(
aircrafts, form.watch("missionStationIds"),
form.watch("hpgMissionString"), aircrafts,
); form.watch("hpgMissionString"),
) &&
!form.watch("hpgMissionString")?.startsWith("kein Szenario") &&
user?.settingsUseHPGAsDispatcher;
useEffect(() => { useEffect(() => {
if (session.data?.user.id) { if (session.data?.user.id) {
@@ -144,6 +154,7 @@ export const MissionForm = () => {
return; return;
} }
for (const key in missionFormValues) { for (const key in missionFormValues) {
console.debug(key, missionFormValues[key as keyof MissionOptionalDefaults]);
if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately
form.setValue( form.setValue(
key as keyof MissionOptionalDefaults, key as keyof MissionOptionalDefaults,
@@ -153,6 +164,22 @@ export const MissionForm = () => {
} }
}, [missionFormValues, form, defaultFormValues]); }, [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 ( const saveMission = async (
mission: MissionOptionalDefaults, mission: MissionOptionalDefaults,
{ alertWhenValid = false, createNewMission = false } = {}, { alertWhenValid = false, createNewMission = false } = {},
@@ -369,6 +396,7 @@ export const MissionForm = () => {
<option disabled value="please_select"> <option disabled value="please_select">
Einsatz Szenario auswählen... Einsatz Szenario auswählen...
</option> </option>
<option value={"kein Szenario:3_1_1_1-4_1"}>Kein Szenario</option>
{keywords && {keywords &&
keywords keywords
.find((k) => k.name === form.watch("missionKeywordName")) .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("addressOSMways").length} Gebäude
</p> </p>
<div className="flex items-center justify-between">
<p
className={cn("text-sm text-gray-500", form.watch("xPlaneObjects").length && "text-info")}
>
In diesem Einsatz gibt es {form.watch("xPlaneObjects").length} Objekte
</p>
<button
disabled={!(form.watch("xPlaneObjects")?.length > 0)}
className="btn btn-xs btn-error mt-2"
onClick={() => form.setValue("xPlaneObjects", [])}
>
löschen
</button>
</div>
<div className="form-control min-h-[140px]"> <div className="form-control min-h-[140px]">
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -430,7 +473,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setEditingMission(null); setEditingMission(null);
setContextMenu(null); setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`); if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset(); form.reset();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
@@ -455,7 +502,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setContextMenu(null); setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`); if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset(); form.reset();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {

View File

@@ -6,9 +6,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations"; import { getStationsAPI } from "_querys/stations";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts"; import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { Prisma } from "@repo/db"; import { Prisma } from "@repo/db";
import { Button, getNextDateWithTime } from "@repo/shared-components"; import { Button, cn, getNextDateWithTime } from "@repo/shared-components";
import { Select } from "_components/Select"; import { Select } from "_components/Select";
import { Radio } from "lucide-react"; import { Calendar, Radio } from "lucide-react";
import { getBookingsAPI } from "_querys/bookings";
export const ConnectionBtn = () => { export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
@@ -27,6 +28,19 @@ export const ConnectionBtn = () => {
queryKey: ["stations"], queryKey: ["stations"],
queryFn: () => getStationsAPI(), queryFn: () => getStationsAPI(),
}); });
const { data: bookings } = useQuery({
queryKey: ["bookings"],
queryFn: () =>
getBookingsAPI({
startTime: {
lte: new Date(Date.now() + 8 * 60 * 60 * 1000),
},
endTime: {
gte: new Date(),
},
}),
});
const aircraftMutation = useMutation({ const aircraftMutation = useMutation({
mutationFn: ({ mutationFn: ({
change, change,
@@ -62,6 +76,7 @@ export const ConnectionBtn = () => {
const session = useSession(); const session = useSession();
const uid = session.data?.user?.id; const uid = session.data?.user?.id;
if (!uid) return null; if (!uid) return null;
console.log(bookings);
return ( return (
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1"> <div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && ( {connection.message.length > 0 && (
@@ -117,20 +132,39 @@ export const ConnectionBtn = () => {
(option as { component: React.ReactNode }).component (option as { component: React.ReactNode }).component
} }
options={ options={
stations?.map((station) => ({ stations?.map((station) => {
value: station.id.toString(), const booking = bookings?.find((b) => b.stationId == station.id);
label: station.bosCallsign, return {
component: ( value: station.id.toString(),
<div> label: station.bosCallsign,
<span className="flex items-center gap-2"> component: (
{connectedAircrafts?.find((a) => a.stationId == station.id) && ( <div>
<Radio className="text-warning" size={15} /> <span className="flex items-center gap-2">
)} {connectedAircrafts?.find((a) => a.stationId == station.id) && (
{station.bosCallsign} <Radio className="text-warning" size={15} />
</span> )}
</div> {booking && (
), <div
})) ?? [] className="tooltip tooltip-right"
data-tip={`${new Date(booking.startTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${new Date(booking.endTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} Uhr gebucht von ${booking.userId == session.data?.user?.id ? "dir" : booking.User.fullName}`}
>
<Calendar
className={
cn(
"text-warning",
booking?.userId === session.data?.user?.id,
) && "text-success"
}
size={15}
/>
</div>
)}
{station.bosCallsign}
</span>
</div>
),
};
}) ?? []
} }
/> />
</div> </div>

View File

@@ -3,15 +3,17 @@ import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import { Bell, SettingsIcon, Volume2 } from "lucide-react"; import { Bell, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication"; 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 { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore"; import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => { export const SettingsBtn = () => {
const session = useSession(); const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]); const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({ const { data: user } = useQuery({
@@ -22,6 +24,10 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({ const editUserMutation = useMutation({
mutationFn: editUserAPI, mutationFn: editUserAPI,
mutationKey: ["user", session.data?.user.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
}); });
useEffect(() => { useEffect(() => {
@@ -248,7 +254,7 @@ export const SettingsBtn = () => {
> >
Schließen Schließen
</button> </button>
<button <Button
className="btn btn-soft btn-success" className="btn btn-soft btn-success"
type="submit" type="submit"
onSubmit={() => false} onSubmit={() => false}
@@ -275,7 +281,7 @@ export const SettingsBtn = () => {
}} }}
> >
Speichern Speichern
</button> </Button>
</div> </div>
</div> </div>
</dialog> </dialog>

View File

@@ -6,26 +6,63 @@ import { Report } from "../../_components/left/Report";
import { Dme } from "(app)/pilot/_components/dme/Dme"; import { Dme } from "(app)/pilot/_components/dme/Dme";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher"; 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 { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; 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 { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert";
import { SettingsBoard } from "_components/left/SettingsBoard"; import { SettingsBoard } from "_components/left/SettingsBoard";
import { BugReport } from "_components/left/BugReport"; 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"), { const Map = dynamic(() => import("_components/map/Map"), {
ssr: false, ssr: false,
}); });
const PilotPage = () => { 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 // Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
const { data: aircrafts } = useQuery({ const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"], queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000, 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 ownAircraft = aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false; const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
@@ -47,16 +84,39 @@ const PilotPage = () => {
</div> </div>
<Map /> <Map />
<div className="absolute right-10 top-5 z-20 space-y-2"> <div className="absolute right-10 top-5 z-20 space-y-2">
{!simulatorConnected && status === "connected" && ( {!simulatorConnected &&
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} /> status === "connected" &&
)} connectedAircraft &&
!shortlyConnected && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
<ConnectedDispatcher /> <ConnectedDispatcher />
</div> </div>
</div> </div>
</div> </div>
<div className="flex h-full w-1/3"> <div className="flex h-full w-1/3">
<div className="bg-base-300 flex h-full w-full flex-col p-4"> <div className="bg-base-300 flex h-full w-full flex-col p-4">
<h2 className="card-title mb-2">MRT & DME</h2> <div className="flex justify-between">
<h2 className="card-title mb-2">MRT & DME</h2>
<div
className="tooltip tooltip-left mb-4"
data-tip="Dadurch wird der Einsatz erneut an den Desktop-Client gesendet."
>
<Button
className="btn btn-xs btn-outline"
disabled={!latestMission}
onClick={async () => {
if (!latestMission) return;
await sendAlertMutation.mutateAsync({
id: latestMission.id,
desktopOnly: false,
});
}}
>
Erneut senden
</Button>
</div>
</div>
<div className="card bg-base-200 mb-4 shadow-xl"> <div className="card bg-base-200 mb-4 shadow-xl">
<div className="card-body flex h-full w-full items-center justify-center"> <div className="card-body flex h-full w-full items-center justify-center">
<div className="max-w-150"> <div className="max-w-150">

View File

@@ -10,7 +10,7 @@ export default () => {
}, []); }, []);
return ( return (
<div className="card-body"> <div className="card-body">
<h1 className="text-5xl">logging out...</h1> <h1 className="text-5xl">ausloggen...</h1>
</div> </div>
); );
}; };

View File

@@ -3,7 +3,7 @@
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import { dispatchSocket } from "(app)/dispatch/socket"; import { dispatchSocket } from "(app)/dispatch/socket";
import { NotificationPayload } from "@repo/db"; import { NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
@@ -15,6 +15,7 @@ import { MissionAutoCloseToast } from "_components/customToasts/MissionAutoClose
export function QueryProvider({ children }: { children: ReactNode }) { export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s); const mapStore = useMapStore((s) => s);
const notificationSound = useRef<HTMLAudioElement | null>(null);
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
@@ -22,7 +23,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
defaultOptions: { defaultOptions: {
mutations: { mutations: {
onError: (error) => { onError: (error) => {
toast.error("An error occurred: " + (error as Error).message, { toast.error("Ein Fehler ist aufgetreten: " + (error as Error).message, {
position: "top-right", position: "top-right",
}); });
}, },
@@ -30,6 +31,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}, },
}), }),
); );
useEffect(() => {
notificationSound.current = new Audio("/sounds/notification.mp3");
}, []);
useEffect(() => { useEffect(() => {
const invalidateMission = () => { const invalidateMission = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -59,8 +63,18 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}; };
const handleNotification = (notification: NotificationPayload) => { 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) { switch (notification.type) {
case "hpg-validation": case "hpg-validation":
playNotificationSound();
toast.custom( toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />, (t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{ {
@@ -70,6 +84,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
break; break;
case "admin-message": case "admin-message":
playNotificationSound();
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, { toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999, duration: 999999,
}); });
@@ -81,12 +96,17 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}); });
break; break;
case "mission-auto-close": case "mission-auto-close":
playNotificationSound();
toast.custom( toast.custom(
(t) => <MissionAutoCloseToast event={notification} t={t} mapStore={mapStore} />, (t) => <MissionAutoCloseToast event={notification} t={t} mapStore={mapStore} />,
{ {
duration: 60000, duration: 60000,
}, },
); );
break;
case "mission-closed":
toast("Dein aktueller Einsatz wurde geschlossen.");
break; break;
default: default:
toast("unbekanntes Notification-Event"); toast("unbekanntes Notification-Event");

View File

@@ -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 <BaseNotification icon={<TriangleAlert />} className="flex flex-row">
<div className="flex-1">
<h1 className="font-bold text-red-600">Einsatz nicht HPG-validiert</h1>
<p className="text-sm">Vergleiche die Position des Einsatzes mit der HPG-Position in Hubschrauber</p>
</div>
<div className="ml-11">
<button className="btn" onClick={() => toast.dismiss(_toast.id)}>
schließen
</button>
</div>
</BaseNotification>
}
export const showToast = () => {
toast.custom((t) => {
return (<HPGnotValidatedToast _toast={t} />);
}, {duration: 1000 * 60 * 10}); // 10 minutes
}

View File

@@ -3,15 +3,22 @@ import { OSMWay } from "@repo/db";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { usePannelStore } from "_store/pannelStore"; 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 { getOsmAddress } from "_querys/osm";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Popup, useMap } from "react-leaflet"; import { Popup, useMap } from "react-leaflet";
import { findClosestPolygon } from "_helpers/findClosestPolygon"; import { findClosestPolygon } from "_helpers/findClosestPolygon";
import { xPlaneObjectsAvailable } from "_helpers/xPlaneObjectsAvailable";
import { useQuery } from "@tanstack/react-query";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const ContextMenu = () => { export const ContextMenu = () => {
const map = useMap(); const map = useMap();
const { data: aircrafts } = useQuery({
queryKey: ["connectedAircrafts"],
queryFn: getConnectedAircraftsAPI,
});
const { const {
contextMenu, contextMenu,
searchElements, searchElements,
@@ -26,15 +33,16 @@ export const ContextMenu = () => {
setOpen, setOpen,
isOpen: isPannelOpen, isOpen: isPannelOpen,
} = usePannelStore((state) => state); } = usePannelStore((state) => state);
const [showRulerOptions, setShowRulerOptions] = useState(false); const [showObjectOptions, setShowObjectOptions] = useState(false);
const [rulerHover, setRulerHover] = useState(false); const [rulerHover, setRulerHover] = useState(false);
const [rulerOptionsHover, setRulerOptionsHover] = useState(false); const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
useEffect(() => { useEffect(() => {
setShowRulerOptions(rulerHover || rulerOptionsHover); const showObjectOptions = rulerHover || rulerOptionsHover;
}, [rulerHover, rulerOptionsHover]); setShowObjectOptions(showObjectOptions);
}, [isPannelOpen, rulerHover, rulerOptionsHover, setOpen]);
useEffect(() => { useEffect(() => {
const handleContextMenu = (e: any) => { const handleContextMenu = (e: any) => {
@@ -150,9 +158,12 @@ export const ContextMenu = () => {
style={{ transform: "translateY(-50%)" }} style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)} onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)} onMouseLeave={() => setRulerHover(false)}
disabled disabled={
!isPannelOpen ||
!xPlaneObjectsAvailable(missionFormValues?.missionStationIds, aircrafts)
}
> >
<Ruler size={20} /> <Car size={20} />
</button> </button>
{/* Bottom Button */} {/* Bottom Button */}
<button <button
@@ -178,64 +189,75 @@ export const ContextMenu = () => {
> >
<Search size={20} /> <Search size={20} />
</button> </button>
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */} {/* XPlane Object Options - shown when Ruler button is hovered or options are hovered */}
{showRulerOptions && ( {showObjectOptions && (
<div <div
className="pointer-events-auto absolute flex flex-col items-center" className="pointer-events-auto absolute -left-[100px] top-1/2 z-10 flex h-[200px] w-[120px] -translate-y-1/2 flex-col items-center justify-center py-5"
style={{
left: "-100px", // position to the right of the left button
top: "50%",
transform: "translateY(-50%)",
zIndex: 10,
width: "120px", // Make the hover area wider
height: "200px", // Make the hover area taller
padding: "20px 0", // Add vertical padding
display: "flex",
justifyContent: "center",
pointerEvents: "auto",
}}
onMouseEnter={() => setRulerOptionsHover(true)} onMouseEnter={() => setRulerOptionsHover(true)}
onMouseLeave={() => setRulerOptionsHover(false)} onMouseLeave={() => setRulerOptionsHover(false)}
> >
<div <div className="flex w-full flex-col">
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<button <button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80" className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 ml-[30px] h-10 w-10 opacity-80"
data-tip="Strecke Messen" data-tip="Rettungswagen platzieren"
style={{
transform: "translateX(100%)",
}}
onClick={() => { onClick={() => {
/* ... */ setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "ambulance",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}} }}
> >
<RulerDimensionLine size={20} /> <Ambulance size={20} />
</button> </button>
<button <button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80" className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="Radius Messen" data-tip="LF platzieren"
onClick={() => { onClick={() => {
/* ... */ console.log("Add fire engine");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "fire_engine",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}} }}
> >
<Radius size={20} /> <Flame size={20} />
</button> </button>
<button <button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent h-10 w-10 opacity-80" className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent ml-[30px] h-10 w-10 opacity-80"
data-tip="Fläche Messen" data-tip="Streifenwagen platzieren"
style={{
transform: "translateX(100%)",
}}
onClick={() => { onClick={() => {
/* ... */ console.log("Add police");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "police",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}} }}
> >
<Scan size={20} /> <Siren size={20} />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { Marker } from "react-leaflet"; import { Marker, useMap } from "react-leaflet";
import L from "leaflet"; import L from "leaflet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions"; import { getMissionsAPI } from "_querys/missions";
@@ -8,10 +8,13 @@ import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { XplaneObject } from "@repo/db";
import { useEffect, useState } from "react";
export const MapAdditionals = () => { export const MapAdditionals = () => {
const { isOpen, missionFormValues } = usePannelStore((state) => state); const { isOpen, missionFormValues, setMissionFormValues } = usePannelStore((state) => state);
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status); const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
const { openMissionMarker } = useMapStore((state) => state);
const { data: missions = [] } = useQuery({ const { data: missions = [] } = useQuery({
queryKey: ["missions"], queryKey: ["missions"],
@@ -21,13 +24,28 @@ export const MapAdditionals = () => {
}), }),
refetchInterval: 10_000, refetchInterval: 10_000,
}); });
const mapStore = useMapStore((state) => state); const { setOpenMissionMarker } = useMapStore((state) => state);
const [showDetailedAdditionals, setShowDetailedAdditionals] = useState(false);
const { data: aircrafts } = useQuery({ const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"], queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000, 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( const markersNeedingAttention = missions.filter(
(m) => (m) =>
@@ -37,7 +55,7 @@ export const MapAdditionals = () => {
m.hpgLocationLat && m.hpgLocationLat &&
dispatcherConnectionState === "connected" && dispatcherConnectionState === "connected" &&
m.hpgLocationLng && m.hpgLocationLng &&
mapStore.openMissionMarker.find((openMission) => openMission.id === m.id), openMissionMarker.find((openMission) => openMission.id === m.id),
); );
return ( return (
@@ -50,9 +68,78 @@ export const MapAdditionals = () => {
iconSize: [40, 40], iconSize: [40, 40],
iconAnchor: [20, 35], 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) => (
<Marker
key={`${mission.id}-additional-${index}`}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
interactive={false}
/>
));
})}
{isOpen &&
missionFormValues?.xPlaneObjects &&
(missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => (
<Marker
key={index}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
draggable={true}
eventHandlers={{
dragend: (e) => {
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) => ( {markersNeedingAttention.map((mission) => (
<Marker <Marker
key={mission.id} key={mission.id}
@@ -64,7 +151,7 @@ export const MapAdditionals = () => {
})} })}
eventHandlers={{ eventHandlers={{
click: () => click: () =>
mapStore.setOpenMissionMarker({ setOpenMissionMarker({
open: [ open: [
{ {
id: mission.id, id: mission.id,

View File

@@ -0,0 +1,3 @@
export const XPlaneObjects = () => {
return <div>XPlaneObjects</div>;
};

View File

@@ -296,6 +296,12 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
{aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"} {aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"}
</span> </span>
</span> </span>
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posXplanePluginActive && "text-green-500")}>
{aircraft.posXplanePluginActive ? "X-Plane Plugin Aktiv" : "X-Plane Plugin Inaktiv"}
</span>
</span>
</div> </div>
</div> </div>
); );

View File

@@ -7,6 +7,7 @@ import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/dispatcher
import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit"; import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit";
import { ParticipantInfo } from "livekit-server-sdk"; import { ParticipantInfo } from "livekit-server-sdk";
import { import {
Dot,
LockKeyhole, LockKeyhole,
Plane, Plane,
RedoDot, RedoDot,
@@ -144,7 +145,12 @@ export default function AdminPanel() {
{!livekitParticipant ? ( {!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span> <span className="text-error">Nicht verbunden</span>
) : ( ) : (
<span className="text-success">{livekitParticipant.room}</span> <span className="text-success inline-flex items-center">
{livekitParticipant.room}{" "}
{livekitParticipant?.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
)} )}
</td> </td>
<td className="flex gap-2"> <td className="flex gap-2">
@@ -209,7 +215,12 @@ export default function AdminPanel() {
{!livekitParticipant ? ( {!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span> <span className="text-error">Nicht verbunden</span>
) : ( ) : (
<span className="text-success">{livekitParticipant.room}</span> <span className="text-success inline-flex items-center">
{livekitParticipant.room}{" "}
{livekitParticipant?.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
)} )}
</td> </td>
<td className="flex gap-2"> <td className="flex gap-2">
@@ -274,8 +285,13 @@ export default function AdminPanel() {
<td> <td>
<span className="text-error">Nicht verbunden</span> <span className="text-error">Nicht verbunden</span>
</td> </td>
<td> <td className="flex">
<span className="text-success">{p.room}</span> <span className="text-success inline-flex items-center">
{p.room}
{p.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
</td> </td>
<td className="flex gap-2"> <td className="flex gap-2">
<button <button

View File

@@ -1,5 +1,5 @@
// Helper function for distortion curve generation // Helper function for distortion curve generation
function createDistortionCurve(amount: number): Float32Array { function createDistortionCurve(amount: number): Float32Array<ArrayBuffer> {
const k = typeof amount === "number" ? amount : 50; const k = typeof amount === "number" ? amount : 50;
const nSamples = 44100; const nSamples = 44100;
const curve = new Float32Array(nSamples); const curve = new Float32Array(nSamples);

View File

@@ -0,0 +1,11 @@
import { ConnectedAircraft } from "@repo/db";
export const xPlaneObjectsAvailable = (
missionStationIds?: number[],
aircrafts?: ConnectedAircraft[],
) => {
return missionStationIds?.some((id) => {
const aircraft = aircrafts?.find((a) => a.stationId === id);
return aircraft?.posXplanePluginActive;
});
};

View File

@@ -0,0 +1,36 @@
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<Prisma.BookingCreateInput, "User">) => {
const response = await axios.post("/api/bookings", booking);
if (response.status !== 201) {
console.error("Error creating booking:", response);
throw new Error("Failed to create booking");
}
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;
};

View File

@@ -14,11 +14,14 @@ export const changeDispatcherAPI = async (
}; };
export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatcherWhereInput) => { export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatcherWhereInput) => {
const res = await axios.get<ConnectedDispatcher[]>("/api/dispatcher", { const res = await axios.get<(ConnectedDispatcher & { settingsUseHPGAsDispatcher: boolean })[]>(
params: { "/api/dispatcher",
filter: JSON.stringify(filter), {
params: {
filter: JSON.stringify(filter),
},
}, },
}); );
if (res.status !== 200) { if (res.status !== 200) {
throw new Error("Failed to fetch Connected Dispatcher"); throw new Error("Failed to fetch Connected Dispatcher");
} }

View File

@@ -55,9 +55,11 @@ export const sendMissionAPI = async (
{ {
stationId, stationId,
vehicleName, vehicleName,
desktopOnly,
}: { }: {
stationId?: number; stationId?: number;
vehicleName?: "RTW" | "POL" | "FW"; vehicleName?: "RTW" | "POL" | "FW";
desktopOnly?: boolean;
}, },
) => { ) => {
const respone = await serverApi.post<{ const respone = await serverApi.post<{
@@ -65,6 +67,7 @@ export const sendMissionAPI = async (
}>(`/mission/${id}/send-alert`, { }>(`/mission/${id}/send-alert`, {
stationId, stationId,
vehicleName, vehicleName,
desktopOnly,
}); });
return respone.data; return respone.data;
}; };

View File

@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { dispatchSocket } from "../../(app)/dispatch/socket"; import { dispatchSocket } from "../../(app)/dispatch/socket";
import { ConnectedAircraft, Mission, MissionSdsLog, Station, User } from "@repo/db"; import { ConnectedAircraft, Mission, MissionSdsLog, Station, User } from "@repo/db";
import { showToast } from "../../_components/customToasts/HPGnotValidated";
import { pilotSocket } from "(app)/pilot/socket"; import { pilotSocket } from "(app)/pilot/socket";
import { useDmeStore } from "_store/pilot/dmeStore"; import { useDmeStore } from "_store/pilot/dmeStore";
import { useMrtStore } from "_store/pilot/MrtStore"; import { useMrtStore } from "_store/pilot/MrtStore";
@@ -132,6 +133,12 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => {
useDmeStore.getState().setPage({ useDmeStore.getState().setPage({
page: "new-mission", page: "new-mission",
}); });
if (
data.hpgValidationState === "NOT_VALIDATED" &&
usePilotConnectionStore.getState().connectedAircraft?.posH145active
) {
showToast();
}
}); });
pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => { pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => {

View File

@@ -36,7 +36,7 @@ type SetPageParams =
interface MrtStore { interface MrtStore {
page: SetPageParams["page"]; page: SetPageParams["page"];
latestMission: Mission | null;
lines: DisplayLineProps[]; lines: DisplayLineProps[];
setPage: (pageData: SetPageParams) => void; setPage: (pageData: SetPageParams) => void;
@@ -65,6 +65,7 @@ export const useDmeStore = create<MrtStore>(
}, },
], ],
setLines: (lines) => set({ lines }), setLines: (lines) => set({ lines }),
latestMission: null,
setPage: (pageData) => { setPage: (pageData) => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
switch (pageData.page) { switch (pageData.page) {
@@ -122,6 +123,7 @@ export const useDmeStore = create<MrtStore>(
} }
case "mission": { case "mission": {
set({ set({
latestMission: pageData.mission,
page: "mission", page: "mission",
lines: [ lines: [
{ {

View File

@@ -0,0 +1,42 @@
import { getPublicUser, prisma } from "@repo/db";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { NextRequest, NextResponse } from "next/server";
export const GET = async (req: NextRequest) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const filter = JSON.parse(searchParams.get("filter") || "{}");
const bookings = await prisma.booking.findMany({
where: filter,
include: {
User: true,
Station: {
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
},
},
},
orderBy: {
startTime: "asc",
},
});
return NextResponse.json(
bookings.map((b) => ({
...b,
User: b.User ? getPublicUser(b.User) : null,
})),
);
} catch (error) {
console.error("Error fetching bookings:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@@ -24,6 +24,7 @@ export async function GET(request: Request): Promise<NextResponse> {
...d, ...d,
user: undefined, user: undefined,
publicUser: getPublicUser(d.user), publicUser: getPublicUser(d.user),
settingsUseHPGAsDispatcher: d.user.settingsUseHPGAsDispatcher,
}; };
}), }),
{ {

View File

@@ -21,9 +21,10 @@ export const PUT = async (req: Request) => {
if (!session && !payload) return Response.json({ message: "Unauthorized" }, { status: 401 }); if (!session && !payload) return Response.json({ message: "Unauthorized" }, { status: 401 });
const userId = session?.user.id || payload.id; const userId = session?.user.id || payload.id;
const { position, h145 } = (await req.json()) as { const { position, h145, xPlanePluginActive } = (await req.json()) as {
position: PositionLog; position: PositionLog;
h145: boolean; h145: boolean;
xPlanePluginActive: boolean;
}; };
if (!position) { if (!position) {
return Response.json({ message: "Missing id or position" }); return Response.json({ message: "Missing id or position" });
@@ -61,6 +62,7 @@ export const PUT = async (req: Request) => {
posHeading: position.heading, posHeading: position.heading,
posSpeed: position.speed, posSpeed: position.speed,
posH145active: h145, posH145active: h145,
posXplanePluginActive: xPlanePluginActive,
}, },
}); });

View File

@@ -78,6 +78,15 @@ export const ConnectedDispatcher = () => {
<div>{asPublicUser(d.publicUser).fullName}</div> <div>{asPublicUser(d.publicUser).fullName}</div>
<div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div> <div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div>
</div> </div>
<div className="mr-2 flex flex-col justify-center">
{d.settingsUseHPGAsDispatcher ? (
<span className="badge badge-sm badge-success badge-outline">HPG aktiv</span>
) : (
<span className="badge badge-sm badge-info badge-outline">
HPG deaktiviert
</span>
)}
</div>
<div> <div>
{(() => { {(() => {
const badges = (d.publicUser as unknown as PublicUser).badges const badges = (d.publicUser as unknown as PublicUser).badges

View File

@@ -44,7 +44,7 @@
"livekit-client": "^2.15.3", "livekit-client": "^2.15.3",
"livekit-server-sdk": "^2.13.1", "livekit-server-sdk": "^2.13.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^15.4.2", "next": "^15.4.8",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"npm": "^11.4.2", "npm": "^11.4.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

View File

@@ -8,24 +8,22 @@ export const Badges: () => Promise<JSX.Element> = async () => {
if (!session) return <div />; if (!session) return <div />;
return ( return (
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card-body">
<div className="card-body"> <h2 className="card-title justify-between">
<h2 className="card-title justify-between"> <span className="card-title">
<span className="card-title"> <Award className="h-4 w-4" /> Verdiente Abzeichen
<Award className="w-4 h-4" /> Verdiente Abzeichen </span>
</h2>
<div className="flex flex-wrap gap-2">
{session.user.badges.length === 0 && (
<span className="text-sm text-gray-500">
Noch ziemlich leer hier. Du kannst dir Abzeichen erarbeiten indem du an Events
teilnimmst.
</span> </span>
</h2> )}
<div className="flex flex-wrap gap-2"> {session.user.badges.map((badge, i) => {
{session.user.badges.length === 0 && ( return <Badge badge={badge} key={`${badge} - ${i}`} />;
<span className="text-sm text-gray-500"> })}
Noch ziemlich leer hier. Du kannst dir Abzeichen erarbeiten indem du an Events
teilnimmst.
</span>
)}
{session.user.badges.map((badge, i) => {
return <Badge badge={badge} key={`${badge} - ${i}`} />;
})}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -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<JSX.Element> = 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 <div />;
return (
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<Calendar className="h-4 w-4" /> Zukünftige Buchungen
</span>
</h2>
<div className="flex flex-wrap gap-2">
{futureBookings.length === 0 && (
<span className="text-sm text-gray-500">
Keine zukünftigen Buchungen. Du kannst dir welche im Buchungssystem erstellen.
</span>
)}
{futureBookings.map((booking) => {
return (
<div
key={booking.id}
className={`alert alert-horizontal ${booking.type.startsWith("LST_") ? "alert-success" : "alert-info"} alert-soft px-3 py-2`}
>
<div className="flex items-center gap-3">
<span className="badge badge-outline text-xs">
{booking.type.startsWith("LST_")
? "LST"
: booking.Station?.bosCallsignShort || booking.Station?.bosCallsign}
</span>
<span className="text-sm font-medium">{getPublicUser(booking.User).fullName}</span>
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-xs font-medium">
{formatTimeRange(booking, { includeDate: true })}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -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<Prisma.BookingCreateInput, "User">) => {
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;
};

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

@@ -2,6 +2,7 @@ import Events from "./_components/FeaturedEvents";
import { Stats } from "./_components/Stats"; import { Stats } from "./_components/Stats";
import { Badges } from "./_components/Badges"; import { Badges } from "./_components/Badges";
import { RecentFlights } from "(app)/_components/RecentFlights"; import { RecentFlights } from "(app)/_components/RecentFlights";
import { Bookings } from "(app)/_components/Bookings";
export default async function Home({ export default async function Home({
searchParams, searchParams,
@@ -14,10 +15,15 @@ export default async function Home({
<div> <div>
<Stats stats={view} /> <Stats stats={view} />
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<RecentFlights /> <RecentFlights />
</div> </div>
<Badges /> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<Bookings />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<Badges />
</div>
</div> </div>
<Events /> <Events />
</div> </div>

View File

@@ -1,16 +1,16 @@
'use client'; "use client";
import { signOut } from 'next-auth/react'; import { signOut } from "next-auth/react";
import { useEffect } from 'react'; import { useEffect } from "react";
export default () => { export default () => {
useEffect(() => { useEffect(() => {
signOut({ signOut({
callbackUrl: '/login', callbackUrl: "/login",
}); });
}, []); }, []);
return ( return (
<div className="card-body"> <div className="card-body">
<h1 className="text-5xl">logging out...</h1> <h1 className="text-5xl">ausloggen...</h1>
</div> </div>
); );
}; };

View File

@@ -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 (
<>
<button
className="btn btn-sm btn-ghost tooltip tooltip-bottom"
data-tip="Slot Buchung"
onClick={() => setIsBookingSystemOpen(true)}
>
<CalendarIcon size={20} />
</button>
<BookingSystem
isOpen={isBookingSystemOpen}
onClose={() => setIsBookingSystemOpen(false)}
currentUser={currentUser}
/>
</>
);
};

View File

@@ -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 (
<>
<BookingTimelineModal
key={refreshTimeline}
isOpen={isOpen && !showNewBookingModal}
onClose={handleCloseMain}
onOpenNewBooking={handleOpenNewBooking}
currentUser={currentUser}
/>
<NewBookingModal
isOpen={showNewBookingModal}
onClose={handleCloseNewBooking}
onBookingCreated={handleBookingCreated}
userPermissions={currentUser.permissions}
/>
</>
);
};

View File

@@ -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<ViewMode>("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<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
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<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
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<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
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<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
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 (
<div className="modal modal-open">
<div className="modal-box flex max-h-[83vh] w-11/12 max-w-7xl flex-col">
<div className="mb-4 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold">
<CalendarIcon size={24} />
Slot Buchung
</h3>
<button className="btn btn-sm btn-circle btn-ghost" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Controls */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<button className="btn btn-sm btn-ghost" onClick={() => navigate("prev")}>
<ChevronLeft size={16} />
</button>
<span className="min-w-[200px] text-center font-medium">{formatDateRange()}</span>
<button className="btn btn-sm btn-ghost" onClick={() => navigate("next")}>
<ChevronRight size={16} />
</button>
</div>
<div className="join">
{(["day", "week", "month"] as ViewMode[]).map((mode) => (
<button
key={mode}
className={`btn btn-sm join-item ${viewMode === mode ? "btn-active" : ""}`}
onClick={() => setViewMode(mode)}
>
{mode === "day" ? "Tag" : mode === "week" ? "Woche" : "Monat"}
</button>
))}
</div>
</div>
{isBookingsLoading ? (
<div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : (
<div className="grid max-h-[calc(83vh-200px)] grid-cols-1 gap-3 overflow-y-auto md:grid-cols-2">
{Object.entries(groupedBookings).map(([groupName, resourceBookings]) => (
<div key={groupName} className="card bg-base-200 shadow-sm">
<div className="card-body p-3">
<h4 className="mb-2 text-sm font-medium opacity-70">
{viewMode === "day" ? groupName : groupName}
</h4>
<div className="space-y-1">
{resourceBookings.map((booking) => (
<div
key={booking.id}
className={`alert alert-horizontal ${booking.type.startsWith("LST_") ? "alert-success" : "alert-info"} alert-soft px-3 py-2`}
>
<div className="flex items-center gap-3">
<span className="badge badge-outline text-xs">
{booking.type.startsWith("LST_")
? "LST"
: booking.Station.bosCallsignShort || booking.Station.bosCallsign}
</span>
<span className="text-sm font-medium">{booking.User.fullName}</span>
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-xs font-medium">{formatTimeRange(booking)}</p>
</div>
{canDeleteBooking(booking.User.publicId) && (
<Button
onClick={() => deleteBooking(booking.id)}
className={`btn btn-xs ${
currentUser?.permissions.includes("ADMIN_EVENT") &&
booking.User.publicId !== currentUser.publicId
? "btn-error"
: "btn-neutral"
}`}
title="Buchung löschen"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
))}
</div>
</div>
</div>
))}
{Object.keys(groupedBookings).length === 0 && !isBookingsLoading && (
<div className="col-span-full py-8 text-center opacity-70">
Keine Buchungen im aktuellen Zeitraum gefunden
</div>
)}
</div>
)}
<div className="modal-action">
{canCreateBookings && (
<button className="btn btn-primary" onClick={onOpenNewBooking}>
<Plus size={20} />
Neue Buchung
</button>
)}
<button className="btn" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
);
};

View File

@@ -13,6 +13,7 @@ import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "./Error"; import { Error } from "./Error";
import Image from "next/image"; import Image from "next/image";
import { Plane, Radar, Workflow } from "lucide-react"; import { Plane, Radar, Workflow } from "lucide-react";
import { BookingButton } from "./BookingButton";
export const VerticalNav = async () => { export const VerticalNav = async () => {
const session = await getServerSession(); const session = await getServerSession();
@@ -134,6 +135,9 @@ export const HorizontalNav = async () => {
</div> </div>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
<ul className="flex items-center space-x-2 px-1"> <ul className="flex items-center space-x-2 px-1">
<li>
<BookingButton currentUser={session?.user} />
</li>
<li> <li>
<a <a
href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"} href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"}

View File

@@ -0,0 +1,256 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { X, CalendarIcon, Clock } from "lucide-react";
import toast from "react-hot-toast";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getStationsAPI } from "(app)/_querys/stations";
import { createBookingAPI } from "(app)/_querys/bookings";
import { Button } from "@repo/shared-components";
import { useRouter } from "next/navigation";
import { AxiosError } from "axios";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { DateInput } from "_components/ui/DateInput";
import { Select } from "_components/ui/Select";
interface NewBookingFormData {
type: "STATION" | "LST_01" | "LST_02" | "LST_03" | "LST_04";
stationId?: number;
startTime: string;
endTime: string;
title?: string;
description?: string;
}
interface NewBookingModalProps {
isOpen: boolean;
onClose: () => void;
onBookingCreated: () => void;
userPermissions: string[];
}
export const NewBookingModal = ({
isOpen,
onClose,
onBookingCreated,
userPermissions,
}: NewBookingModalProps) => {
const queryClient = useQueryClient();
const { data: stations, isLoading: isLoadingStations } = useQuery({
queryKey: ["stations"],
queryFn: () => getStationsAPI({}),
});
const router = useRouter();
const { mutate: createBooking, isPending: isCreateBookingLoading } = useMutation({
mutationKey: ["createBooking"],
mutationFn: createBookingAPI,
onMutate() {
// Optionally show a loading toast or indicator
toast.loading("Buchung wird erstellt...", { id: "createBooking" });
},
onError(error: AxiosError) {
const errorData = error.response?.data as { error?: string };
toast.error(errorData?.error || "Fehler beim Erstellen der Buchung", { id: "createBooking" });
},
onSuccess: () => {
toast.success("Buchung erfolgreich erstellt", { id: "createBooking" });
queryClient.invalidateQueries({ queryKey: ["bookings"] });
onBookingCreated();
onClose();
router.refresh();
},
});
const newBookingSchema = z
.object({
type: z.enum(["STATION", "LST_01", "LST_02", "LST_03", "LST_04"], {
message: "Bitte wähle einen Typ aus",
}),
stationId: z.number().optional(),
startTime: z.string(),
endTime: z.string(),
title: z.string().optional(),
description: z.string().optional(),
})
.superRefine((data, ctx) => {
const start = new Date(data.startTime);
const end = new Date(data.endTime);
if (end <= start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Endzeit muss nach der Startzeit liegen",
path: ["endTime"],
});
}
});
const form = useForm<NewBookingFormData>({
resolver: zodResolver(newBookingSchema),
});
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = form;
const selectedType = watch("type");
const hasDISPOPermission = userPermissions.includes("DISPO");
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
reset();
// Set default datetime to current hour
const now = new Date();
const currentHour = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
0,
0,
);
const nextHour = new Date(currentHour.getTime() + 60 * 60 * 1000);
setValue("startTime", currentHour.toISOString().slice(0, 16));
setValue("endTime", nextHour.toISOString().slice(0, 16));
}
}, [isOpen, reset, setValue]);
if (!isOpen) return null;
return (
<div className="modal modal-open">
<div className="modal-box max-w-2xl">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold">
<CalendarIcon size={24} />
Neue Buchung erstellen
</h3>
<button className="btn btn-sm btn-circle btn-ghost" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit((data) => createBooking(data))} className="space-y-6">
{/* Resource Type Selection */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Station *</span>
</label>
<select {...register("type")} className="select select-bordered w-full">
<option value="">Typ auswählen...</option>
<option value="STATION">Station</option>
{hasDISPOPermission && (
<>
<option value="LST_01">Leitstelle</option>
</>
)}
</select>
{errors.type && (
<label className="label">
<span className="label-text-alt text-error">{errors.type.message}</span>
</label>
)}
</div>
{/* Station Selection (only if STATION type is selected) */}
{selectedType === "STATION" && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Station *</span>
</label>
{isLoadingStations ? (
<div className="skeleton h-12 w-full"></div>
) : (
<Select
label="Station"
name="stationId"
form={form}
options={stations?.map((s) => {
return {
value: s.id,
label: `${s.bosCallsign} - ${s.locationState} (${s.aircraft})`,
};
})}
/>
)}
{errors.stationId && (
<label className="label">
<span className="label-text-alt text-error">{errors.stationId.message}</span>
</label>
)}
</div>
)}
{/* Time Selection */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="form-control">
<label className="label">
<span className="label-text flex items-center gap-1 font-semibold">
<Clock size={16} />
Startzeit *
</span>
</label>
<DateInput
type="datetime-local"
value={new Date(watch("startTime") || Date.now())}
onChange={(date) => {
setValue("startTime", date.toISOString());
if (new Date(date) >= new Date(watch("endTime"))) {
const newEndTime = new Date(new Date(date).getTime() + 60 * 60 * 3000);
setValue("endTime", newEndTime.toISOString().slice(0, 16));
}
}}
className="input input-bordered w-full"
/>
{errors.startTime && (
<label className="label">
<span className="label-text-alt text-error">{errors.startTime.message}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text flex items-center gap-1 font-semibold">
<Clock size={16} />
Endzeit *
</span>
</label>
<DateInput
type="datetime-local"
value={new Date(watch("endTime") || Date.now())}
onChange={(date) => {
setValue("endTime", date.toISOString());
}}
className="input input-bordered w-full"
/>
{errors.endTime && (
<label className="label">
<span className="label-text-alt text-error">{errors.endTime.message}</span>
</label>
)}
</div>
</div>
{/* Actions */}
<div className="modal-action">
<Button type="submit" className="btn btn-primary" isLoading={isCreateBookingLoading}>
Buchung erstellen
</Button>
<button type="button" className="btn" onClick={onClose}>
Abbrechen
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,151 @@
/* Timeline Custom Styles for Dark/Light Mode */
/* Light Mode (default) */
.react-calendar-timeline {
background-color: oklch(var(--b1));
color: oklch(var(--bc));
}
.react-calendar-timeline .rct-cursor-line {
background-color: oklch(var(--p));
}
.react-calendar-timeline .rct-sidebar {
background-color: oklch(var(--b2));
border-right: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-sidebar-row {
border-bottom: 1px solid oklch(var(--b3));
color: oklch(var(--bc));
}
.react-calendar-timeline .rct-header {
background-color: oklch(var(--b2)) !important;
border-bottom: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-header-root {
background-color: oklch(var(--b2)) !important;
border-bottom: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-dateHeader {
color: oklch(var(--bc)) !important;
background-color: oklch(var(--b2)) !important;
border-right: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-dateHeader-primary {
background-color: oklch(var(--b2)) !important;
color: oklch(var(--bc)) !important;
border-bottom: 1px solid oklch(var(--b3));
}
/* Fix for any nested date header elements */
.react-calendar-timeline .rct-dateHeader * {
color: oklch(var(--bc)) !important;
background-color: transparent !important;
}
.react-calendar-timeline .rct-scroll {
background-color: oklch(var(--b1));
}
.react-calendar-timeline .rct-items {
background-color: oklch(var(--b1));
}
.react-calendar-timeline .rct-item {
background-color: oklch(var(--p));
color: oklch(var(--pc));
border: 1px solid oklch(var(--pf));
border-radius: 4px;
}
.react-calendar-timeline .rct-item.selected {
background-color: oklch(var(--s));
color: oklch(var(--sc));
border-color: oklch(var(--sf));
}
/* Timeline item type specific colors */
.timeline-item.station {
background-color: oklch(var(--p)) !important;
border-color: oklch(var(--pf)) !important;
}
.timeline-item.lst_01 {
background-color: oklch(var(--s)) !important;
border-color: oklch(var(--sf)) !important;
}
.timeline-item.lst_02 {
background-color: oklch(var(--a)) !important;
border-color: oklch(var(--af)) !important;
}
.timeline-item.lst_03 {
background-color: oklch(var(--n)) !important;
border-color: oklch(var(--nf)) !important;
}
.timeline-item.lst_04 {
background-color: oklch(var(--in)) !important;
border-color: oklch(var(--inf)) !important;
}
/* Station booking colors - dynamic colors for different stations */
.timeline-item.station {
background-color: oklch(var(--p)) !important;
border-color: oklch(var(--pf)) !important;
}
/* Additional station colors for variety when multiple stations are booked */
.timeline-item.station:nth-child(odd) {
background-color: oklch(var(--p)) !important;
border-color: oklch(var(--pf)) !important;
}
.timeline-item.station:nth-child(even) {
background-color: oklch(var(--s)) !important;
border-color: oklch(var(--sf)) !important;
}
/* Vertical lines */
.react-calendar-timeline .rct-vertical-line {
border-left: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-horizontal-line {
border-bottom: 1px solid oklch(var(--b3));
}
/* Today line */
.react-calendar-timeline .rct-today {
background-color: oklch(var(--er) / 0.2);
}
/* Hover effects */
.react-calendar-timeline .rct-item:hover {
filter: brightness(1.1);
}
/* Scrollbar styling for dark mode compatibility */
.react-calendar-timeline ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.react-calendar-timeline ::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
.react-calendar-timeline ::-webkit-scrollbar-thumb {
background: oklch(var(--b3));
border-radius: 4px;
}
.react-calendar-timeline ::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.3);
}

View File

@@ -12,7 +12,7 @@ export const DateInput = ({
<input <input
type="datetime-local" type="datetime-local"
className="input" className="input"
value={formatDate(value || new Date(), "yyyy-MM-dd hh:mm")} value={formatDate(value || new Date(), "yyyy-MM-dd HH:mm")}
onChange={(e) => { onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : null; const date = e.target.value ? new Date(e.target.value) : null;
if (!date) return; if (!date) return;

View File

@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
import { getServerSession } from "../../auth/[...nextauth]/auth";
// DELETE /api/booking/[id] - Delete a booking
export const DELETE = async (req: NextRequest, { params }: { params: { id: string } }) => {
try {
console.log(params);
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const bookingId = params.id;
// Find the booking
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
});
if (!booking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
// Check if user owns the booking or has admin permissions
if (booking.userId !== session.user.id && !session.user.permissions.includes("ADMIN_KICK")) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
// Delete the booking
await prisma.booking.delete({
where: { id: bookingId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting booking:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};
// PUT /api/booking/[id] - Update a booking
export const PUT = async (req: NextRequest, { params }: { params: { id: string } }) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const bookingId = params.id;
const body = await req.json();
const { type, stationId, startTime, endTime } = body;
// Find the booking
const existingBooking = await prisma.booking.findUnique({
where: { id: bookingId },
});
if (!existingBooking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
// Check if user owns the booking or has admin permissions
if (
existingBooking.userId !== session.user.id &&
!session.user.permissions.includes("ADMIN_KICK")
) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
// Validate permissions for LST bookings
const lstTypes = ["LST_01", "LST_02", "LST_03", "LST_04"];
if (lstTypes.includes(type)) {
if (!session.user.permissions.includes("DISPO")) {
return NextResponse.json(
{ error: "Insufficient permissions for LST booking" },
{ status: 403 },
);
}
}
// Check for conflicts (excluding current booking)
const conflictWhere = {
id: { not: bookingId },
type,
OR: [
{
startTime: {
lt: new Date(endTime),
},
endTime: {
gt: new Date(startTime),
},
},
],
...(type === "STATION" && stationId ? { stationId } : {}),
};
const conflictingBooking = await prisma.booking.findFirst({
where: conflictWhere,
});
if (conflictingBooking) {
const resourceName = type === "STATION" ? `Station` : type;
return NextResponse.json(
{ error: `Konflikt erkannt: ${resourceName} ist bereits für diesen Zeitraum gebucht.` },
{ status: 409 },
);
}
// Update the booking
const updatedBooking = await prisma.booking.update({
where: { id: bookingId },
data: {
type,
stationId: type === "STATION" ? stationId : null,
startTime: new Date(startTime),
endTime: new Date(endTime),
},
include: {
User: true,
Station: {
select: {
id: true,
bosCallsignShort: true,
},
},
},
});
return NextResponse.json({ booking: updatedBooking });
} catch (error) {
console.error("Error updating booking:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from "next/server";
import { getPublicUser, prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
// GET /api/booking - Get all bookings for the timeline
export const GET = async (req: NextRequest) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const filter = JSON.parse(searchParams.get("filter") || "{}");
const bookings = await prisma.booking.findMany({
where: filter,
include: {
User: true,
Station: {
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
},
},
},
orderBy: {
startTime: "asc",
},
});
return NextResponse.json(
bookings.map((b) => ({
...b,
User: b.User ? getPublicUser(b.User) : null,
})),
);
} catch (error) {
console.error("Error fetching bookings:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};
// POST /api/booking - Create a new booking
export const POST = async (req: NextRequest) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const body = await req.json();
const { type, stationId, startTime, endTime } = body;
// Convert stationId to integer if provided
const parsedStationId = stationId ? parseInt(stationId, 10) : null;
// Validate required fields
if (!type || !startTime || !endTime) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Validate permissions for LST bookings
const lstTypes = ["LST_01", "LST_02", "LST_03", "LST_04"];
if (lstTypes.includes(type)) {
if (!session.user.permissions.includes("DISPO")) {
return NextResponse.json(
{ error: "Insufficient permissions for LST booking" },
{ status: 403 },
);
}
}
// Validate station requirement for STATION type
if (type === "STATION" && !parsedStationId) {
return NextResponse.json(
{ error: "Station ID required for station booking" },
{ status: 400 },
);
}
// Validate that stationId is a valid integer when provided
if (stationId && (isNaN(parsedStationId!) || parsedStationId! <= 0)) {
return NextResponse.json({ error: "Invalid station ID" }, { status: 400 });
}
// Check for conflicts
const conflictWhere: Record<string, unknown> = {
type,
OR: [
{
startTime: {
lt: new Date(endTime),
},
endTime: {
gt: new Date(startTime),
},
},
],
};
if (type === "STATION" && parsedStationId) {
conflictWhere.stationId = parsedStationId;
}
const existingBooking = await prisma.booking.findFirst({
where: conflictWhere,
});
if (existingBooking) {
const resourceName = type === "STATION" ? `Station` : type;
return NextResponse.json(
{ error: `Konflikt erkannt: ${resourceName} ist bereits für diesen Zeitraum gebucht.` },
{ status: 409 },
);
}
// Create the booking
const booking = await prisma.booking.create({
data: {
userId: session.user.id,
type,
stationId: type === "STATION" ? parsedStationId : null,
startTime: new Date(startTime),
endTime: new Date(endTime),
},
include: {
User: {
select: {
id: true,
firstname: true,
lastname: true,
},
},
Station: {
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
},
},
},
});
return NextResponse.json(booking, { status: 201 });
} catch (error) {
console.error("Error creating booking:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
// GET /api/station - Get all stations for booking selection
export const GET = async () => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const stations = await prisma.station.findMany({
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
locationState: true,
operator: true,
aircraft: true,
},
orderBy: {
bosCallsignShort: "asc",
},
});
return NextResponse.json(stations);
} catch (error) {
console.error("Error fetching stations:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@@ -0,0 +1,11 @@
import { Booking } from "@repo/db";
export const formatTimeRange = (booking: Booking, options?: { includeDate?: boolean }) => {
const start = new Date(booking.startTime);
const end = new Date(booking.endTime);
const timeRange = `${start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}`;
if (options?.includeDate) {
return `${start.toLocaleDateString("de-DE")} ${timeRange}`;
}
return timeRange;
};

View File

@@ -36,12 +36,14 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^15.4.2", "moment": "^2.30.1",
"next": "^15.4.8",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"next-remove-imports": "^1.0.12", "next-remove-imports": "^1.0.12",
"npm": "^11.4.2", "npm": "^11.4.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-calendar-timeline": "0.30.0-beta.3",
"react-datepicker": "^8.4.0", "react-datepicker": "^8.4.0",
"react-day-picker": "^9.8.0", "react-day-picker": "^9.8.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -56,6 +58,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.31.0", "@eslint/js": "^9.31.0",
"@types/react-calendar-timeline": "^0.28.6",
"eslint": "^9.31.0", "eslint": "^9.31.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.37.0" "typescript-eslint": "^8.37.0"

View File

@@ -0,0 +1,7 @@
export interface XplaneObject {
objectName: string;
lat: number;
lon: number;
heading: number;
alt: number;
}

View File

@@ -50,9 +50,20 @@ export type MissionAutoClose = {
}; };
}; };
export type MissionClosed = {
type: "mission-closed";
status: "closed";
message: string;
data: {
missionId: number;
publicMissionId: string;
};
};
export type NotificationPayload = export type NotificationPayload =
| ValidationFailed | ValidationFailed
| ValidationSuccess | ValidationSuccess
| AdminMessage | AdminMessage
| StationStatus | StationStatus
| MissionAutoClose; | MissionAutoClose
| MissionClosed;

View File

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

View File

@@ -0,0 +1,26 @@
enum BOOKING_TYPE {
STATION
LST_01
LST_02
LST_03
LST_04
}
model Booking {
id String @id @default(uuid())
userId String @map(name: "user_id")
type BOOKING_TYPE
stationId Int? @map(name: "station_id")
startTime DateTime @map(name: "start_time")
endTime DateTime @map(name: "end_time")
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
// Relations
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Station Station? @relation(fields: [stationId], references: [id], onDelete: Cascade)
@@index([startTime, endTime])
@@index([type, startTime, endTime])
@@map(name: "bookings")
}

View File

@@ -1,21 +1,22 @@
model ConnectedAircraft { model ConnectedAircraft {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String userId String
publicUser Json publicUser Json
lastHeartbeat DateTime @default(now()) lastHeartbeat DateTime @default(now())
fmsStatus String @default("6") fmsStatus String @default("6")
// position: // position:
posLat Float? posLat Float?
posLng Float? posLng Float?
posAlt Int? posAlt Int?
posSpeed Int? posSpeed Int?
posHeading Int? posHeading Int?
simulator String? simulator String?
posH145active Boolean @default(false) posH145active Boolean @default(false)
stationId Int posXplanePluginActive Boolean @default(false)
loginTime DateTime @default(now()) stationId Int
esimatedLogoutTime DateTime? loginTime DateTime @default(now())
logoutTime DateTime? esimatedLogoutTime DateTime?
logoutTime DateTime?
// relations: // relations:
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "BOOKING_TYPE" AS ENUM ('STATION', 'LST_01', 'LST_02', 'LST_03', 'LST_04');
-- CreateTable
CREATE TABLE "bookings" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" "BOOKING_TYPE" NOT NULL,
"station_id" INTEGER,
"start_time" TIMESTAMP(3) NOT NULL,
"end_time" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "bookings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "bookings_start_time_end_time_idx" ON "bookings"("start_time", "end_time");
-- CreateIndex
CREATE INDEX "bookings_type_start_time_end_time_idx" ON "bookings"("type", "start_time", "end_time");
-- AddForeignKey
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_station_id_fkey" FOREIGN KEY ("station_id") REFERENCES "Station"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PERMISSION" ADD VALUE 'ADMIN_BOOKING';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ConnectedAircraft" ADD COLUMN "posXplanePluginActive" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Mission" ADD COLUMN "xPlaneObjects" JSONB[] DEFAULT ARRAY[]::JSONB[];

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "settings_use_hpg_as_dispatcher" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "settings_use_hpg_as_dispatcher" SET DEFAULT true;

View File

@@ -19,6 +19,7 @@ model Mission {
missionStationIds Int[] @default([]) missionStationIds Int[] @default([])
missionStationUserIds String[] @default([]) missionStationUserIds String[] @default([])
missionLog Json[] @default([]) missionLog Json[] @default([])
xPlaneObjects Json[] @default([])
hpgMissionString String? hpgMissionString String?
hpgSelectedMissionString String? hpgSelectedMissionString String?
hpgAmbulanceState HpgState? @default(NOT_REQUESTED) hpgAmbulanceState HpgState? @default(NOT_REQUESTED)

View File

@@ -41,4 +41,5 @@ model Station {
MissionsOnStations MissionsOnStations[] MissionsOnStations MissionsOnStations[]
MissionOnStationUsers MissionOnStationUsers[] MissionOnStationUsers MissionOnStationUsers[]
ConnectedAircraft ConnectedAircraft[] ConnectedAircraft ConnectedAircraft[]
Bookings Booking[]
} }

View File

@@ -19,6 +19,7 @@ enum PERMISSION {
ADMIN_KICK ADMIN_KICK
ADMIN_HELIPORT ADMIN_HELIPORT
ADMIN_CHANGELOG ADMIN_CHANGELOG
ADMIN_BOOKING
AUDIO AUDIO
PILOT PILOT
DISPO DISPO
@@ -37,15 +38,16 @@ model User {
changelogAck Boolean @default(false) changelogAck Boolean @default(false)
// Settings: // Settings:
pathSelected Boolean @default(false) pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false) migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room") settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device") settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume") settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume") settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume") settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname") settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup") settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
settingsUseHPGAsDispatcher Boolean @default(true) @map(name: "settings_use_hpg_as_dispatcher")
// email Verification: // email Verification:
emailVerificationToken String? @map(name: "email_verification_token") emailVerificationToken String? @map(name: "email_verification_token")
@@ -76,6 +78,7 @@ model User {
PositionLog PositionLog[] PositionLog PositionLog[]
Penaltys Penalty[] Penaltys Penalty[]
CreatedPenalties Penalty[] @relation("CreatedPenalties") CreatedPenalties Penalty[] @relation("CreatedPenalties")
Bookings Booking[]
@@map(name: "users") @@map(name: "users")
} }

430
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,28 @@
packages: packages:
- apps/* - apps/*
- packages/* - packages/*
onlyBuiltDependencies: onlyBuiltDependencies:
- "@prisma/client" - '@prisma/client'
- "@prisma/engines" - '@prisma/engines'
- "@tailwindcss/oxide" - '@tailwindcss/oxide'
- esbuild - esbuild
- prisma - prisma
- sharp - sharp
- unrs-resolver - unrs-resolver
overrides:
'@eslint/plugin-kit@<0.3.4': '>=0.3.4'
axios@>=1.0.0 <1.12.0: '>=1.12.0'
body-parser@>=2.2.0 <2.2.1: '>=2.2.1'
form-data@>=4.0.0 <4.0.4: '>=4.0.4'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
jws@<3.2.3: '>=3.2.3'
mdast-util-to-hast@>=13.0.0 <13.2.1: '>=13.2.1'
next-auth@<4.24.12: '>=4.24.12'
next@>=15.0.0 <=15.4.4: '>=15.4.5'
next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7'
next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8'
nodemailer@<7.0.7: '>=7.0.7'
nodemailer@<=7.0.10: '>=7.0.11'
playwright@<1.55.1: '>=1.55.1'