49 Commits

Author SHA1 Message Date
PxlLoewe
f48653c82c Wrapper size Dynamic, class cleanup 2025-10-04 19:56:50 +02:00
PxlLoewe
859b8519db closes #136 2025-10-04 19:53:56 +02:00
PxlLoewe
f691eb5f7c Keine Simmulator-Verbundung kommt erst 30 sek nach Verbinden für piloten 2025-10-01 23:20:41 +02:00
PxlLoewe
cd6885c5f2 closes #102 2025-10-01 23:03:03 +02:00
PxlLoewe
eddb3317d5 closes #135 2025-10-01 22:43:30 +02:00
PxlLoewe
ada041bd4a Falsche Zeitzone bei Buchungen und HTTP status codes als Fehlermeldung behoben 2025-10-01 22:23:27 +02:00
PxlLoewe
dc4a3ab4d8 Buchungen werden in Connect Modal angezeigt (Pilot), new Booking form improvement 2025-09-28 12:19:14 +02:00
PxlLoewe
ebeb2cf93a Booking Panel auf Dashboard 2025-09-27 22:25:31 +02:00
PxlLoewe
cf199150fe futher booking stuff 2025-09-20 22:16:23 +02:00
PxlLoewe
ba027957ce Buchungssystem erste überarbeitungen 2025-09-20 00:28:53 +02:00
nocnico
a612cf9951 Add Booking System 2025-09-18 21:49:03 +02:00
PxlLoewe
715cb9ef53 Datenschutzerklärung im Registrierungsformular 2025-09-10 15:58:36 +02:00
PxlLoewe
266ff87fd8 Penalty Nachricht im Admin form, abgelaufen für Timebans in tabelle werden Farblich dargestellt 2025-07-29 15:54:09 -07:00
PxlLoewe
99c3024d85 fixes #132 2025-07-29 15:52:34 -07:00
PxlLoewe
627060e32e Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-29 13:21:51 -07:00
PxlLoewe
23f7671d42 Name des Chatpartners wird nun mit rolle angezeigt 2025-07-29 13:21:47 -07:00
PxlLoewe
e134c9b2fa Huschrauber logging für alle 2025-07-29 12:37:36 -07:00
PxlLoewe
1bdccc46fe Zeitstreafen werden nun beim Verbinden überprüft 2025-07-29 12:34:55 -07:00
nocnico
d01ba24243 Fix changelog text dark on lightmode browser 2025-07-29 11:51:17 +02:00
PxlLoewe
fd50e9c4d5 logging entfernt in NewReport 2025-07-27 20:43:30 -07:00
PxlLoewe
157d2f02e1 fixed NewReport form 2025-07-27 20:23:30 -07:00
PxlLoewe
a3e143145f mit var.User.json in der gitignore kopiert git die auch nicht... 2025-07-27 19:49:57 -07:00
PxlLoewe
1b447edb11 add user migration file to gitignore 2025-07-27 17:15:28 -07:00
PxlLoewe
6b4cc0b58b remove user from repo. Local only 2025-07-27 17:15:09 -07:00
PxlLoewe
f88b0bb56c Improved Station Notification 2025-07-27 16:27:58 -07:00
PxlLoewe
25f56026fc Fixed type bugs in Reports form 2025-07-27 15:02:14 -07:00
PxlLoewe
7fc8749676 staging ci timeout 2025-07-27 14:22:56 -07:00
PxlLoewe
575438e974 link zu neuem Report auf Admin-seite 2025-07-27 14:17:35 -07:00
PxlLoewe
89a0eb7135 Manuelle reports 2025-07-27 13:45:13 -07:00
PxlLoewe
453ad28538 Prometheus zückgänging 2025-07-26 13:59:55 -07:00
PxlLoewe
5a8bd1abe3 vlt jetzt? 2025-07-26 13:43:50 -07:00
PxlLoewe
1736bc79c0 prometheus config 2025-07-26 13:25:33 -07:00
PxlLoewe
efa2ca8412 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-26 12:33:44 -07:00
PxlLoewe
b9a4f5e8d3 added http service to prometheus, fixes sound Bug 2025-07-26 12:33:41 -07:00
PxlLoewe
a09671036d entferne ADMIN_HELIPORT und ADMIN_EVENT berechtigung für CGs 2025-07-26 11:24:45 -07:00
nocnico
f32ed62a76 Fix Chats können nur mit Disponenten eröffnet werden 2025-07-26 12:21:23 +02:00
PxlLoewe
5b57b31706 fixes #118 2025-07-25 22:37:46 -07:00
PxlLoewe
2abdbf168f fixes #117 2025-07-25 21:30:03 -07:00
PxlLoewe
1da23c3412 resolves #116 2025-07-25 21:02:18 -07:00
PxlLoewe
f8e9ad84b9 resolves #103 2025-07-25 20:56:21 -07:00
PxlLoewe
f534bbc902 implemented #112 2025-07-25 16:53:22 -07:00
PxlLoewe
14ea5fcf55 fixed User argument is missing on Discord-connect 2025-07-25 16:49:50 -07:00
PxlLoewe
383e15f38a Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-25 10:32:45 -07:00
PxlLoewe
afe32306d3 fixed #106 2025-07-25 10:32:35 -07:00
PxlLoewe
1b23aede89 fixed #105 2025-07-25 10:04:44 -07:00
PxlLoewe
54e0bc0b12 fixed #104 2025-07-25 09:53:58 -07:00
nocnico
2671571bfb maybe ghostmode fix now? 2025-07-25 17:43:05 +02:00
nocnico
b602a5836c Ghostmode fix now? 2025-07-25 17:19:58 +02:00
nocnico
4eb46cb783 fixed #100 // fix Marker Cluster naming, fix Marker Popup settings not used 2025-07-25 16:39:21 +02:00
84 changed files with 2406 additions and 390 deletions

View File

@@ -40,7 +40,7 @@ jobs:
username: ${{ secrets.SSH_USERNAME }} username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }} password: ${{ secrets.SSH_PASSWORD }}
port: 22 port: 22
command_timeout: 30m command_timeout: 60m
script: | script: |
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh" source "$NVM_DIR/nvm.sh"

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
letsencrypt letsencrypt
# Dependencies # Dependencies
node_modules node_modules
.pnp .pnp

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

@@ -6,121 +6,131 @@ 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<{
connectedAircrafts: ConnectedAircraft[]; connectedAircrafts: ConnectedAircraft[];
mission: Mission; mission: Mission;
}> => { }> => {
const mission = await prisma.mission.findUnique({ try {
where: { id: id }, const mission = await prisma.mission.findUnique({
}); where: { id: id },
const Stations = await prisma.station.findMany({
where: {
id: {
in: mission?.missionStationIds,
},
},
});
if (!mission) {
throw new Error("Mission not found");
}
// connectedAircrafts the alert is sent to
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
stationId: stationId
? stationId
: {
in: mission.missionStationIds,
},
logoutTime: null,
},
include: {
Station: true,
},
});
for (const aircraft of connectedAircrafts) {
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission,
Stations,
}); });
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", { const Stations = await prisma.station.findMany({
missionId: mission.id,
});
const user = await prisma.user.findUnique({
where: { id: aircraft.userId },
});
if (!user) continue;
if (user.settingsNtfyRoom) {
await sendNtfyMission(mission, Stations, aircraft.Station, user.settingsNtfyRoom);
}
const existingMissionOnStationUser = await prisma.missionOnStationUsers.findFirst({
where: { where: {
missionId: mission.id, id: {
userId: aircraft.userId, in: mission?.missionStationIds,
stationId: aircraft.stationId, },
}, },
}); });
if (!existingMissionOnStationUser) if (!mission) {
await prisma.missionOnStationUsers.create({ throw new Error("Mission not found");
data: { }
// connectedAircrafts the alert is sent to
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
stationId: stationId
? stationId
: {
in: mission.missionStationIds,
},
logoutTime: null,
},
include: {
Station: true,
},
});
for (const aircraft of connectedAircrafts) {
if (!desktopOnly) {
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission,
Stations,
});
}
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
missionId: mission.id,
});
const user = await prisma.user.findUnique({
where: { id: aircraft.userId },
});
if (!user) continue;
if (user.settingsNtfyRoom) {
await sendNtfyMission(mission, Stations, aircraft.Station, user.settingsNtfyRoom);
}
const existingMissionOnStationUser = await prisma.missionOnStationUsers.findFirst({
where: {
missionId: mission.id, missionId: mission.id,
userId: aircraft.userId, userId: aircraft.userId,
stationId: aircraft.stationId, stationId: aircraft.stationId,
}, },
}); });
}
// for statistics only if (!existingMissionOnStationUser)
await prisma.missionsOnStations await prisma.missionOnStationUsers.create({
.createMany({ data: {
data: mission.missionStationIds.map((stationId) => ({ missionId: mission.id,
missionId: mission.id, userId: aircraft.userId,
stationId, stationId: aircraft.stationId,
})), },
}) });
.catch((err) => { }
// Ignore if the entry already exists
}); // for statistics only
if (user === "HPG") { await prisma.missionsOnStations
await prisma.mission.update({ .createMany({
where: { id: Number(id) }, data: mission.missionStationIds.map((stationId) => ({
data: { missionId: mission.id,
state: "running", stationId,
missionLog: { })),
push: { })
type: "alert-log", .catch((err) => {
auto: true, // Ignore if the entry already exists
timeStamp: new Date().toISOString(), });
} as any, if (user === "HPG") {
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
missionLog: {
push: {
type: "alert-log",
auto: true,
timeStamp: new Date().toISOString(),
} as any,
},
}, },
}, });
}); } else {
} else { await prisma.mission.update({
await prisma.mission.update({ where: { id: Number(id) },
where: { id: Number(id) }, data: {
data: { state: "running",
state: "running", missionLog: {
missionLog: { push: {
push: { type: "alert-log",
type: "alert-log", auto: false,
auto: false, timeStamp: new Date().toISOString(),
timeStamp: new Date().toISOString(), data: {
data: { stationId: stationId,
stationId: stationId, user: getPublicUser(user, { ignorePrivacy: true }),
user: getPublicUser(user, { ignorePrivacy: true }), },
}, } as any,
} as any, },
}, },
}, });
}); }
return { connectedAircrafts, mission };
} catch (error) {
console.error("Error sending mission alert:", error);
throw new Error("Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug");
} }
return { connectedAircrafts, mission };
}; };

View File

@@ -50,10 +50,7 @@ const getRthCallsigns = (mission: Mission, stations: Station[]) => {
return `🚁 RTH${callsigns.length > 1 ? "s" : ""}: ${callsigns.join(" / ")} `; return `🚁 RTH${callsigns.length > 1 ? "s" : ""}: ${callsigns.join(" / ")} `;
}; };
const getNtfyHeader = ( const getNtfyHeader = (mission: Mission, clientStation: Station): NtfyHeader => ({
mission: Mission,
clientStation: Station,
): NtfyHeader => ({
headers: { headers: {
Title: `${clientStation.bosCallsignShort} / ${mission.missionKeywordAbbreviation} / ${mission.missionKeywordCategory}`, Title: `${clientStation.bosCallsignShort} / ${mission.missionKeywordAbbreviation} / ${mission.missionKeywordCategory}`,
Tags: "pager", Tags: "pager",
@@ -76,9 +73,13 @@ export const sendNtfyMission = async (
clientStation: Station, clientStation: Station,
ntfyRoom: string, ntfyRoom: string,
) => { ) => {
axios.post( try {
`https://ntfy.sh/${ntfyRoom}`, await axios.post(
getNtfyData(mission, stations), `https://ntfy.sh/${ntfyRoom}`,
getNtfyHeader(mission, clientStation), getNtfyData(mission, stations),
); getNtfyHeader(mission, clientStation),
);
} catch (error) {
console.error("Error sending Ntfy mission:", error);
}
}; };

View File

@@ -34,7 +34,7 @@ router.patch("/:id", async (req, res) => {
}, },
}); });
if (discordAccount?.id) { if (discordAccount?.id && !disaptcherUpdate.ghostMode) {
await renameMember( await renameMember(
discordAccount.discordId.toString(), discordAccount.discordId.toString(),
`${getPublicUser(newDispatcher.user).fullName}${newDispatcher.zone}`, `${getPublicUser(newDispatcher.user).fullName}${newDispatcher.zone}`,

View File

@@ -113,9 +113,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 +181,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,7 +194,9 @@ router.post("/:id/send-alert", async (req, res) => {
return; return;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).json({ error: "Failed to send mission" }); res.status(500).json({
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
});
return; return;
} }
}); });

View File

@@ -1,6 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db"; import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components"; import { getNextDateWithTime, getUserPenaltys } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db"; import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
@@ -28,8 +28,17 @@ export const handleConnectDispatch =
return; return;
} }
if (!user.permissions?.includes("DISPO")) { const userPenaltys = await getUserPenaltys(user.id);
socket.emit("error", "You do not have permission to connect to the dispatch server.");
if (
userPenaltys.openTimeban.length > 0 ||
user.isBanned ||
userPenaltys.openBans.length > 0
) {
socket.emit("connect-message", {
message: "Du hast eine aktive Strafe und kannst dich deshalb nicht verbinden.",
});
socket.disconnect();
return; return;
} }
@@ -70,7 +79,7 @@ export const handleConnectDispatch =
userId: user.id, userId: user.id,
}, },
}); });
if (discordAccount?.id) { if (discordAccount?.id && !ghostMode) {
await renameMember( await renameMember(
discordAccount.discordId.toString(), discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${selectedZone}`, `${getPublicUser(user).fullName}${selectedZone}`,

View File

@@ -1,8 +1,8 @@
import { getPublicUser, prisma, User } from "@repo/db"; import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db"; import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
import { getUserPenaltys } from "@repo/shared-components";
export const handleConnectPilot = export const handleConnectPilot =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
@@ -34,6 +34,19 @@ export const handleConnectPilot =
socket.disconnect(); socket.disconnect();
return; return;
} }
const userPenaltys = await getUserPenaltys(userId);
if (
userPenaltys.openTimeban.length > 0 ||
user.isBanned ||
userPenaltys.openBans.length > 0
) {
socket.emit("connect-message", {
message: "Du hast eine aktive Strafe und kannst dich deshalb nicht verbinden.",
});
socket.disconnect();
return;
}
if (!user) return Error("User not found"); if (!user) return Error("User not found");

View File

@@ -5,7 +5,7 @@ import { Server, Socket } from "socket.io";
export const handleSendMessage = export const handleSendMessage =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
async ( async (
{ userId, message }: { userId: string; message: string }, { userId, message, role }: { userId: string; message: string; role: string },
cb: (err: { error?: string }) => void, cb: (err: { error?: string }) => void,
) => { ) => {
const senderId = socket.data.user.id; const senderId = socket.data.user.id;
@@ -24,7 +24,7 @@ export const handleSendMessage =
receiverId: userId, receiverId: userId,
senderId, senderId,
receiverName: `${receiverUser?.firstname} ${receiverUser?.lastname[0]}. - ${receiverUser?.publicId}`, receiverName: `${receiverUser?.firstname} ${receiverUser?.lastname[0]}. - ${receiverUser?.publicId}`,
senderName: `${senderUser?.firstname} ${senderUser?.lastname[0]}. - ${senderUser?.publicId}`, senderName: `${senderUser?.firstname} ${senderUser?.lastname[0]}. - ${role ?? senderUser?.publicId}`,
}, },
}); });

View File

@@ -6,12 +6,14 @@ ARG NEXT_PUBLIC_HUB_URL
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID
ARG NEXT_PUBLIC_LIVEKIT_URL ARG NEXT_PUBLIC_LIVEKIT_URL
ARG NEXT_PUBLIC_DISCORD_URL ARG NEXT_PUBLIC_DISCORD_URL
ARG NEXT_PUBLIC_OPENAIP_ACCESS
ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL
ENV NEXT_PUBLIC_HUB_URL=$NEXT_PUBLIC_HUB_URL ENV NEXT_PUBLIC_HUB_URL=$NEXT_PUBLIC_HUB_URL
ENV NEXT_PUBLIC_DISPATCH_SERVICE_ID=$NEXT_PUBLIC_DISPATCH_SERVICE_ID ENV NEXT_PUBLIC_DISPATCH_SERVICE_ID=$NEXT_PUBLIC_DISPATCH_SERVICE_ID
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
ENV NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL
ENV PNPM_HOME="/usr/local/pnpm" ENV PNPM_HOME="/usr/local/pnpm"

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

@@ -6,13 +6,17 @@ 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,
@@ -20,12 +24,45 @@ const Map = dynamic(() => import("_components/map/Map"), {
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

@@ -25,6 +25,7 @@ import { useSounds } from "_components/Audio/useSounds";
export const Audio = () => { export const Audio = () => {
const { const {
speakingParticipants, speakingParticipants,
resetSpeakingParticipants,
isTalking, isTalking,
toggleTalking, toggleTalking,
transmitBlocked, transmitBlocked,
@@ -38,6 +39,7 @@ export const Audio = () => {
removeMessage, removeMessage,
} = useAudioStore(); } = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01"); const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useSounds({ useSounds({
isReceiving: speakingParticipants.length > 0, isReceiving: speakingParticipants.length > 0,
isTransmitting: isTalking, isTransmitting: isTalking,
@@ -104,7 +106,7 @@ export const Audio = () => {
data-tip="Nachricht entfernen" data-tip="Nachricht entfernen"
> >
<button <button
className={cn("btn btn-sm btn-ghost border-warning bg-transparent ")} className={cn("btn btn-sm btn-ghost border-warning bg-transparent")}
onClick={() => { onClick={() => {
removeMessage(); removeMessage();
}} }}
@@ -123,9 +125,9 @@ export const Audio = () => {
> >
<button <button
className={cn( className={cn(
"btn btn-sm btn-soft bg-transparent border", "btn btn-sm btn-soft border bg-transparent",
canStopOtherSpeakers && speakingParticipants.length > 0 && "hover:bg-error", canStopOtherSpeakers && speakingParticipants.length > 0 && "hover:bg-error",
speakingParticipants.length > 0 && " hover:bg-errorborder", speakingParticipants.length > 0 && "hover:bg-errorborder",
isReceivingBlick && "border-warning", isReceivingBlick && "border-warning",
)} )}
onClick={() => { onClick={() => {
@@ -133,6 +135,7 @@ export const Audio = () => {
const payload = JSON.stringify({ const payload = JSON.stringify({
by: role, by: role,
}); });
resetSpeakingParticipants("dich");
speakingParticipants.forEach(async (p) => { speakingParticipants.forEach(async (p) => {
await room?.localParticipant.performRpc({ await room?.localParticipant.performRpc({
destinationIdentity: p.identity, destinationIdentity: p.identity,
@@ -159,24 +162,24 @@ export const Audio = () => {
transmitBlocked && "bg-yellow-500 hover:bg-yellow-500", transmitBlocked && "bg-yellow-500 hover:bg-yellow-500",
state === "disconnected" && "bg-red-500 hover:bg-red-500", state === "disconnected" && "bg-red-500 hover:bg-red-500",
state === "error" && "bg-red-500 hover:bg-red-500", state === "error" && "bg-red-500 hover:bg-red-500",
state === "connecting" && "bg-yellow-500 hover:bg-yellow-500 cursor-default", state === "connecting" && "cursor-default bg-yellow-500 hover:bg-yellow-500",
)} )}
> >
{state === "connected" && <Mic className="w-5 h-5" />} {state === "connected" && <Mic className="h-5 w-5" />}
{state === "disconnected" && <WifiOff className="w-5 h-5" />} {state === "disconnected" && <WifiOff className="h-5 w-5" />}
{state === "connecting" && <PlugZap className="w-5 h-5" />} {state === "connecting" && <PlugZap className="h-5 w-5" />}
{state === "error" && <ServerCrash className="w-5 h-5" />} {state === "error" && <ServerCrash className="h-5 w-5" />}
</button> </button>
{state === "connected" && ( {state === "connected" && (
<details className="dropdown relative z-[1050]"> <details className="dropdown relative z-[1050]">
<summary className="dropdown btn btn-ghost flex items-center gap-1"> <summary className="dropdown btn btn-ghost flex items-center gap-1">
{connectionQuality === ConnectionQuality.Excellent && <Signal className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Excellent && <Signal className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Good && <SignalMedium className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Good && <SignalMedium className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Poor && <SignalLow className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Poor && <SignalLow className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Lost && <ZapOff className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Lost && <ZapOff className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Unknown && ( {connectionQuality === ConnectionQuality.Unknown && (
<ShieldQuestion className="w-5 h-5" /> <ShieldQuestion className="h-5 w-5" />
)} )}
<div className="badge badge-sm badge-soft badge-success">{remoteParticipants}</div> <div className="badge badge-sm badge-soft badge-success">{remoteParticipants}</div>
</summary> </summary>
@@ -184,7 +187,7 @@ export const Audio = () => {
{ROOMS.map((r) => ( {ROOMS.map((r) => (
<li key={r}> <li key={r}>
<button <button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative" className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
onClick={() => { onClick={() => {
if (!role) return; if (!role) return;
if (selectedRoom === r) return; if (selectedRoom === r) return;
@@ -193,7 +196,7 @@ export const Audio = () => {
}} }}
> >
{room?.name === r && ( {room?.name === r && (
<Disc className="text-success text-sm absolute left-2" width={15} /> <Disc className="text-success absolute left-2 text-sm" width={15} />
)} )}
<span className="flex-1 text-center">{r}</span> <span className="flex-1 text-center">{r}</span>
</button> </button>
@@ -201,12 +204,12 @@ export const Audio = () => {
))} ))}
<li> <li>
<button <button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative" className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
onClick={() => { onClick={() => {
disconnect(); disconnect();
}} }}
> >
<WifiOff className="text-error text-sm absolute left-2" width={15} /> <WifiOff className="text-error absolute left-2 text-sm" width={15} />
<span className="flex-1 text-center">Disconnect</span> <span className="flex-1 text-center">Disconnect</span>
</button> </button>
</li> </li>

View File

@@ -27,7 +27,7 @@ export const useSounds = ({
useEffect(() => { useEffect(() => {
if (!window) return; if (!window) return;
connectionStart.current = new Audio("/sounds/connection_started_sepura.mp3"); connectionStart.current = new Audio("/sounds/connection_started_sepura.mp3");
connectionEnd.current = new Audio("/sounds/connection_stoped_sepura.mp3"); connectionEnd.current = new Audio("/sounds/connection_stopped_sepura.mp3");
ownCallStarted.current = new Audio("/sounds/call_end_sepura.wav"); ownCallStarted.current = new Audio("/sounds/call_end_sepura.wav");
foreignCallStop.current = new Audio("/sounds/call_end_sepura.wav"); foreignCallStop.current = new Audio("/sounds/call_end_sepura.wav");
foreignCallBlocked.current = new Audio("/sounds/call_blocked_sepura.wav"); foreignCallBlocked.current = new Audio("/sounds/call_blocked_sepura.wav");

View File

@@ -25,7 +25,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const { data: livekitRooms } = useQuery({ const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"], queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(), queryFn: () => getLivekitRooms(),
refetchInterval: 10000, refetchInterval: 5000,
}); });
const audioRoom = useAudioStore((s) => s.room?.name); const audioRoom = useAudioStore((s) => s.room?.name);

View File

@@ -10,9 +10,11 @@ import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore"; import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { Trash } from "lucide-react";
export const Chat = () => { export const Chat = () => {
const { const {
removeChat,
setReportTabOpen, setReportTabOpen,
chatOpen, chatOpen,
setChatOpen, setChatOpen,
@@ -50,13 +52,21 @@ export const Chat = () => {
setOwnId(session.data?.user.id); setOwnId(session.data?.user.id);
}, [session.data?.user.id, setOwnId]); }, [session.data?.user.id, setOwnId]);
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id); const filteredDispatcher = dispatcher?.filter(
(d) => d.userId !== session.data?.user.id && !chats[d.userId],
);
const filteredAircrafts = aircrafts?.filter( const filteredAircrafts = aircrafts?.filter(
(a) => a.userId !== session.data?.user.id && dispatcherConnected, (a) => a.userId !== session.data?.user.id && !chats[a.userId],
); );
const btnActive = pilotConnected || dispatcherConnected; const btnActive = pilotConnected || dispatcherConnected;
useEffect(() => {
if (!filteredDispatcher?.length && !filteredAircrafts?.length) {
setAddTabValue("default");
}
}, [filteredDispatcher, filteredAircrafts]);
useEffect(() => { useEffect(() => {
if (!btnActive) { if (!btnActive) {
setChatOpen(false); setChatOpen(false);
@@ -146,13 +156,17 @@ export const Chat = () => {
<button <button
className="btn btn-sm btn-soft btn-primary join-item" className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => { onClick={() => {
if (addTabValue === "default") return;
const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue); const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue);
const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue); const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue);
const user = aircraftUser || dispatcherUser; const user = aircraftUser || dispatcherUser;
console.log("Adding chat for user:", addTabValue, user);
if (!user) return; if (!user) return;
const role = "Station" in user ? user.Station.bosCallsignShort : user.zone; const role = "Station" in user ? user.Station.bosCallsignShort : user.zone;
console.log("Adding chat for user:", addTabValue);
addChat(addTabValue, `${asPublicUser(user.publicUser).fullName} (${role})`); addChat(addTabValue, `${asPublicUser(user.publicUser).fullName} (${role})`);
setSelectedChat(addTabValue); setSelectedChat(addTabValue);
setAddTabValue("default");
}} }}
> >
<span className="text-xl">+</span> <span className="text-xl">+</span>
@@ -164,7 +178,7 @@ export const Chat = () => {
if (!chat) return null; if (!chat) return null;
return ( return (
<Fragment key={userId}> <Fragment key={userId}>
<a <div
className={cn("indicator tab", selectedChat === userId && "tab-active")} className={cn("indicator tab", selectedChat === userId && "tab-active")}
onClick={() => { onClick={() => {
if (selectedChat === userId) { if (selectedChat === userId) {
@@ -176,7 +190,7 @@ export const Chat = () => {
> >
{chat.name} {chat.name}
{chat.notification && <span className="indicator-item status status-info" />} {chat.notification && <span className="indicator-item status status-info" />}
</a> </div>
<div className="tab-content bg-base-100 border-base-300 max-h-[250px] overflow-y-auto p-6"> <div className="tab-content bg-base-100 border-base-300 max-h-[250px] overflow-y-auto p-6">
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */} {/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
{chat.messages.map((chatMessage) => { {chat.messages.map((chatMessage) => {
@@ -208,6 +222,16 @@ export const Chat = () => {
)} )}
{selectedChat && ( {selectedChat && (
<div className="join"> <div className="join">
<button
className="join-item btn btn-error btn-outline"
onClick={(e) => {
e.stopPropagation();
removeChat(selectedChat);
}}
type="button"
>
<Trash size={16} />
</button>
<div className="w-full"> <div className="w-full">
<label className="input join-item w-full"> <label className="input join-item w-full">
<input <input

View File

@@ -17,7 +17,6 @@ import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_q
import { getMissionsAPI } from "_querys/missions"; import { getMissionsAPI } from "_querys/missions";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors"; import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { usePilotConnectionStore } from "_store/pilot/connectionStore"; import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useSession } from "next-auth/react";
const AircraftPopupContent = ({ const AircraftPopupContent = ({
aircraft, aircraft,
@@ -73,7 +72,7 @@ const AircraftPopupContent = ({
} }
}, [currentTab, aircraft, mission]); }, [currentTab, aircraft, mission]);
const { setOpenAircraftMarker, setMap, openAircraftMarker } = useMapStore((state) => state); const { setOpenAircraftMarker, setMap } = useMapStore((state) => state);
const { anchor } = useSmartPopup(); const { anchor } = useSmartPopup();
return ( return (
<> <>
@@ -435,6 +434,9 @@ export const AircraftLayer = () => {
} }
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]); }, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
console.debug("Hubschrauber auf Karte:", filteredAircrafts.length, filteredAircrafts);
console.debug("Daten vom Server:", aircrafts?.length, aircrafts);
return ( return (
<> <>
{filteredAircrafts?.map((aircraft) => { {filteredAircrafts?.map((aircraft) => {

View File

@@ -122,7 +122,7 @@ const HeliportsLayer = () => {
}; };
const filterVisibleHeliports = () => { const filterVisibleHeliports = () => {
const bounds = map.getBounds(); const bounds = map?.getBounds();
if (!heliports?.length) return; if (!heliports?.length) return;
// Filtere die Heliports, die innerhalb der Kartenansicht liegen // Filtere die Heliports, die innerhalb der Kartenansicht liegen
const visibleHeliports = heliports.filter((heliport) => { const visibleHeliports = heliports.filter((heliport) => {

View File

@@ -12,6 +12,7 @@ import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
export const MapAdditionals = () => { export const MapAdditionals = () => {
const { isOpen, missionFormValues } = usePannelStore((state) => state); const { isOpen, missionFormValues } = usePannelStore((state) => state);
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status); const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
const { data: missions = [] } = useQuery({ const { data: missions = [] } = useQuery({
queryKey: ["missions"], queryKey: ["missions"],
queryFn: () => queryFn: () =>
@@ -35,7 +36,8 @@ export const MapAdditionals = () => {
m.state === "draft" && m.state === "draft" &&
m.hpgLocationLat && m.hpgLocationLat &&
dispatcherConnectionState === "connected" && dispatcherConnectionState === "connected" &&
m.hpgLocationLng, m.hpgLocationLng &&
mapStore.openMissionMarker.find((openMission) => openMission.id === m.id),
); );
return ( return (
@@ -56,7 +58,7 @@ export const MapAdditionals = () => {
key={mission.id} key={mission.id}
position={[mission.hpgLocationLat!, mission.hpgLocationLng!]} position={[mission.hpgLocationLat!, mission.hpgLocationLng!]}
icon={L.icon({ icon={L.icon({
iconUrl: "/icons/mapMarker.png", iconUrl: "/icons/mapMarkerAttention.png",
iconSize: [40, 40], iconSize: [40, 40],
iconAnchor: [20, 35], iconAnchor: [20, 35],
})} })}

View File

@@ -23,7 +23,7 @@ export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> =
draft: "#0092b8", draft: "#0092b8",
running: "#155dfc", running: "#155dfc",
finished: "#155dfc", finished: "#155dfc",
attention: "rgb(186,105,0)", attention: "#ba6900",
}; };
export const MISSION_STATUS_TEXT_COLORS: Record<MissionState, string> = { export const MISSION_STATUS_TEXT_COLORS: Record<MissionState, string> = {
@@ -253,7 +253,7 @@ const MissionMarker = ({
tab: "home", tab: "home",
}, },
], ],
close: openMissionMarker?.map((m) => m.id) || [], close: [],
}); });
} }
}; };

View File

@@ -234,7 +234,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
const { data: livekitRooms } = useQuery({ const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"], queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(), queryFn: () => getLivekitRooms(),
refetchInterval: 10000, refetchInterval: 5000,
}); });
const participants = const participants =

View File

@@ -158,7 +158,7 @@ const PopupContent = ({
</span> </span>
<span> <span>
{aircraft.Station.bosCallsign.length > 15 {aircraft.Station.bosCallsign.length > 15
? aircraft.Station.locationStateShort ? aircraft.Station.bosCallsignShort
: aircraft.Station.bosCallsign} : aircraft.Station.bosCallsign}
</span> </span>
</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,
@@ -35,7 +36,7 @@ export default function AdminPanel() {
const { data: livekitRooms } = useQuery({ const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"], queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(), queryFn: () => getLivekitRooms(),
refetchInterval: 10000, refetchInterval: 5000,
}); });
const kickLivekitParticipantMutation = useMutation({ const kickLivekitParticipantMutation = useMutation({
mutationFn: kickLivekitParticipant, mutationFn: kickLivekitParticipant,
@@ -92,11 +93,6 @@ export default function AdminPanel() {
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
console.debug("piloten von API", {
anzahl: pilots?.length,
pilots,
});
return ( return (
<div> <div>
<button <button
@@ -149,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">
@@ -214,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">
@@ -279,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,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

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

@@ -25,28 +25,29 @@ import { usePilotConnectionStore } from "_store/pilot/connectionStore";
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
type TalkState = { type TalkState = {
addSpeakingParticipant: (participant: Participant) => void;
connect: (roomName: string, role: string) => void;
connectionQuality: ConnectionQuality;
disconnect: () => void;
isTalking: boolean;
localRadioTrack: LocalTrackPublication | undefined;
message: string | null;
removeMessage: () => void;
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
remoteParticipants: number;
resetSpeakingParticipants: (source: string) => void;
room: Room | null;
setSettings: (settings: Partial<TalkState["settings"]>) => void;
settings: { settings: {
micDeviceId: string | null; micDeviceId: string | null;
micVolume: number; micVolume: number;
radioVolume: number; radioVolume: number;
dmeVolume: number; dmeVolume: number;
}; };
isTalking: boolean;
transmitBlocked: boolean;
removeMessage: () => void;
state: "connecting" | "connected" | "disconnected" | "error";
message: string | null;
connectionQuality: ConnectionQuality;
remoteParticipants: number;
toggleTalking: () => void;
setSettings: (settings: Partial<TalkState["settings"]>) => void;
connect: (roomName: string, role: string) => void;
disconnect: () => void;
speakingParticipants: Participant[]; speakingParticipants: Participant[];
addSpeakingParticipant: (participant: Participant) => void; state: "connecting" | "connected" | "disconnected" | "error";
removeSpeakingParticipant: (speakingParticipants: Participant) => void; toggleTalking: () => void;
room: Room | null; transmitBlocked: boolean;
localRadioTrack: LocalTrackPublication | undefined;
}; };
const getToken = async (roomName: string) => { const getToken = async (roomName: string) => {
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`); const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
@@ -71,6 +72,15 @@ export const useAudioStore = create<TalkState>((set, get) => ({
remoteParticipants: 0, remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown, connectionQuality: ConnectionQuality.Unknown,
room: null, room: null,
resetSpeakingParticipants: (source: string) => {
set({
speakingParticipants: [],
isTalking: false,
transmitBlocked: false,
message: `Ruf beendet durch ${source || "eine unsichtbare Macht"}`,
});
get().room?.localParticipant.setMicrophoneEnabled(false);
},
addSpeakingParticipant: (participant) => { addSpeakingParticipant: (participant) => {
set((state) => { set((state) => {
if (!state.speakingParticipants.some((p) => p.identity === participant.identity)) { if (!state.speakingParticipants.some((p) => p.identity === participant.identity)) {
@@ -177,6 +187,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) { if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, { changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: roomName, zone: roomName,
ghostMode: dispatchState.ghostMode,
}); });
} }
@@ -200,10 +211,15 @@ export const useAudioStore = create<TalkState>((set, get) => ({
set({ localRadioTrack: publishedTrack }); set({ localRadioTrack: publishedTrack });
set({ state: "connected", room, message: null }); set({ state: "connected", room, isTalking: false, message: null });
}) })
.on(RoomEvent.Disconnected, () => { .on(RoomEvent.Disconnected, () => {
set({ state: "disconnected", speakingParticipants: [], transmitBlocked: false }); set({
state: "disconnected",
speakingParticipants: [],
transmitBlocked: false,
isTalking: false,
});
handleDisconnect(); handleDisconnect();
}) })
@@ -222,18 +238,29 @@ export const useAudioStore = create<TalkState>((set, get) => ({
room.registerRpcMethod("force-mute", async (data: RpcInvocationData) => { room.registerRpcMethod("force-mute", async (data: RpcInvocationData) => {
const { by } = JSON.parse(data.payload); const { by } = JSON.parse(data.payload);
room.localParticipant.setMicrophoneEnabled(false); get().resetSpeakingParticipants(by);
useAudioStore.setState({ return "OK";
isTalking: false,
message: `Ruf beendet durch ${by || "eine unsichtbare Macht"}`,
});
return `Hello, ${data.callerIdentity}!`;
}); });
interval = setInterval(() => { interval = setInterval(() => {
set({ // Filter forgotten participants
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed const oldSpeakingParticipants = get().speakingParticipants;
const speakingParticipants = oldSpeakingParticipants.filter((oP) => {
return Array.from(room.remoteParticipants.values()).find(
(p) => p.identity === oP.identity,
);
}); });
if (oldSpeakingParticipants.length !== speakingParticipants.length) {
set({
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
speakingParticipants,
});
} else {
set({
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
});
}
}, 500); }, 500);
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
console.error("Error occured: ", error); console.error("Error occured: ", error);

View File

@@ -2,6 +2,8 @@ import { create } from "zustand";
import { ChatMessage } from "@repo/db"; import { ChatMessage } from "@repo/db";
import { dispatchSocket } from "(app)/dispatch/socket"; import { dispatchSocket } from "(app)/dispatch/socket";
import { pilotSocket } from "(app)/pilot/socket"; import { pilotSocket } from "(app)/pilot/socket";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
interface ChatStore { interface ChatStore {
situationTabOpen: boolean; situationTabOpen: boolean;
@@ -16,9 +18,15 @@ interface ChatStore {
setOwnId: (id: string) => void; setOwnId: (id: string) => void;
chats: Record<string, { name: string; notification: boolean; messages: ChatMessage[] }>; chats: Record<string, { name: string; notification: boolean; messages: ChatMessage[] }>;
setChatNotification: (userId: string, notification: boolean) => void; setChatNotification: (userId: string, notification: boolean) => void;
sendMessage: (userId: string, message: string) => Promise<void>; sendMessage: (
userId: string,
message: string,
senderName?: string,
receiverName?: string,
) => Promise<void>;
addChat: (userId: string, name: string) => void; addChat: (userId: string, name: string) => void;
addMessage: (userId: string, message: ChatMessage) => void; addMessage: (userId: string, message: ChatMessage) => void;
removeChat: (userId: string) => void;
} }
export const useLeftMenuStore = create<ChatStore>((set, get) => ({ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
@@ -37,14 +45,24 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
setChatNotification(chatId, false); // Set notification to false when chat is selected setChatNotification(chatId, false); // Set notification to false when chat is selected
} }
}, },
removeChat: (userId: string) => {
const { chats, setSelectedChat, selectedChat } = get();
const newChats = { ...chats };
delete newChats[userId];
set({ chats: newChats });
if (selectedChat === userId) {
setSelectedChat(null);
}
},
setOwnId: (id: string) => set({ ownId: id }), setOwnId: (id: string) => set({ ownId: id }),
chats: {}, chats: {},
sendMessage: (userId: string, message: string) => { sendMessage: (userId, message) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (dispatchSocket.connected) { if (dispatchSocket.connected) {
const zone = useDispatchConnectionStore.getState().selectedZone;
dispatchSocket.emit( dispatchSocket.emit(
"send-message", "send-message",
{ userId, message }, { userId, message, role: zone },
({ error }: { error?: string }) => { ({ error }: { error?: string }) => {
if (error) { if (error) {
reject(error); reject(error);
@@ -54,13 +72,19 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
}, },
); );
} else if (pilotSocket.connected) { } else if (pilotSocket.connected) {
pilotSocket.emit("send-message", { userId, message }, ({ error }: { error?: string }) => { const bosCallsign = usePilotConnectionStore.getState().selectedStation?.bosCallsignShort;
if (error) {
reject(error); pilotSocket.emit(
} else { "send-message",
resolve(); { userId, message, role: bosCallsign },
} ({ error }: { error?: string }) => {
}); if (error) {
reject(error);
} else {
resolve();
}
},
);
} }
}); });
}, },

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -7,55 +7,59 @@ export const handleParticipantFinished = async (
participant: Participant, participant: Participant,
user: User, user: User,
) => { ) => {
const discordAccount = await prisma.discordAccount.findFirst({ try {
where: { const discordAccount = await prisma.discordAccount.findFirst({
userId: user.id, where: {
}, userId: user.id,
});
const badgedToAdd = event.finishedBadges.filter((badge) => {
return !user.badges.includes(badge);
});
const permissionsToAdd = event.finishedPermissions.filter((permission) => {
return !user.permissions.includes(permission);
});
await prisma.user.update({
where: {
id: user.id,
},
data: {
badges: {
push: badgedToAdd,
}, },
permissions: {
push: permissionsToAdd,
},
},
});
if (discordAccount) {
await setStandardName({
memberId: discordAccount.discordId,
userId: user.id,
}); });
}
await sendCourseCompletedEmail(user.email, user, event);
await prisma.participant.update({ const badgedToAdd = event.finishedBadges.filter((badge) => {
where: { return !user.badges.includes(badge);
id: participant.id, });
}, const permissionsToAdd = event.finishedPermissions.filter((permission) => {
data: { return !user.permissions.includes(permission);
statusLog: { });
push: {
event: "Berechtigungen und Badges vergeben", await prisma.user.update({
timestamp: new Date(), where: {
user: "system", id: user.id,
},
data: {
badges: {
push: badgedToAdd,
},
permissions: {
push: permissionsToAdd,
}, },
}, },
}, });
});
if (discordAccount) {
await setStandardName({
memberId: discordAccount.discordId,
userId: user.id,
});
}
await sendCourseCompletedEmail(user.email, user, event);
await prisma.participant.update({
where: {
id: participant.id,
},
data: {
statusLog: {
push: {
event: "Berechtigungen und Badges vergeben",
timestamp: new Date(),
user: "system",
},
},
},
});
} catch (error) {
console.error("Error handling participant finished:", error);
}
}; };
export const handleParticipantEnrolled = async ( export const handleParticipantEnrolled = async (

View File

@@ -34,43 +34,61 @@ const initTransporter = () => {
initTransporter(); initTransporter();
export const sendCourseCompletedEmail = async (to: string, user: User, event: Event) => { export const sendCourseCompletedEmail = async (to: string, user: User, event: Event) => {
const emailHtml = await renderCourseCompleted({ user, event }); try {
const emailHtml = await renderCourseCompleted({ user, event });
if (!transporter) { if (!transporter) {
console.error("Transporter is not initialized"); console.error("Transporter is not initialized");
return; return;
}
await sendMail(to, `Kurs ${event.name} erfolgreich abgeschlossen`, emailHtml);
} catch (error) {
console.error("Error sending course completed email:", error);
} }
sendMail(to, `Kurs ${event.name} erfolgreich abgeschlossen`, emailHtml);
}; };
export const sendPasswordChanged = async (to: string, user: User, password: string) => { export const sendPasswordChanged = async (to: string, user: User, password: string) => {
const emailHtml = await renderPasswordChanged({ user, password }); try {
const emailHtml = await renderPasswordChanged({ user, password });
await sendMail(to, `Dein Passwort wurde geändert`, emailHtml); await sendMail(to, `Dein Passwort wurde geändert`, emailHtml);
} catch (error) {}
}; };
export const sendEmailVerification = async (to: string, user: User, code: string) => { export const sendEmailVerification = async (to: string, user: User, code: string) => {
const emailHtml = await renderVerificationCode({ try {
user, const emailHtml = await renderVerificationCode({
code, user,
}); code,
await sendMail(to, "Bestätige deine E-Mail-Adresse", emailHtml); });
await sendMail(to, "Bestätige deine E-Mail-Adresse", emailHtml);
} catch (error) {
console.error("Error sending email verification:", error);
}
}; };
export const sendBannEmail = async (to: string, user: User, staffName: string) => { export const sendBannEmail = async (to: string, user: User, staffName: string) => {
const emailHtml = await renderBannNotice({ try {
user, const emailHtml = await renderBannNotice({
staffName, user,
}); staffName,
await sendMail(to, "Deine Sperrung bei Virtual Air Rescue", emailHtml); });
await sendMail(to, "Deine Sperrung bei Virtual Air Rescue", emailHtml);
} catch (error) {
console.error("Error sending ban email:", error);
}
}; };
export const sendTimebannEmail = async (to: string, user: User, staffName: string) => { export const sendTimebannEmail = async (to: string, user: User, staffName: string) => {
const emailHtml = await renderTimeBanNotice({ try {
user, const emailHtml = await renderTimeBanNotice({
staffName, user,
}); staffName,
await sendMail(to, "Deine vorrübergehende Sperrung bei Virtual Air Rescue", emailHtml); });
await sendMail(to, "Deine vorrübergehende Sperrung bei Virtual Air Rescue", emailHtml);
} catch (error) {
console.error("Error sending time ban email:", error);
}
}; };
export const sendMail = async (to: string, subject: string, html: string) => export const sendMail = async (to: string, subject: string, html: string) =>

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,67 @@
import { Calendar } from "lucide-react";
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { Badge } from "@repo/shared-components";
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: {
userId: session?.user.id,
startTime: {
gte: new Date(),
},
},
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

@@ -11,6 +11,7 @@ import { Button } from "../../../../_components/ui/Button";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { cn } from "@repo/shared-components";
const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false }); const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
@@ -24,6 +25,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string
}, },
}); });
const [skipUserUpdate, setSkipUserUpdate] = useState(false);
const [markdownText, setMarkdownText] = useState(changelog?.text || ""); const [markdownText, setMarkdownText] = useState(changelog?.text || "");
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [showImage, setShowImage] = useState(false); const [showImage, setShowImage] = useState(false);
@@ -61,6 +63,9 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
text: markdownText, text: markdownText,
}, },
changelog?.id, changelog?.id,
{
skipUserUpdate: skipUserUpdate,
},
); );
toast.success("Daten gespeichert"); toast.success("Daten gespeichert");
if (!changelog) redirect(`/admin/changelog`); if (!changelog) redirect(`/admin/changelog`);
@@ -96,6 +101,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
{(() => { {(() => {
if (showImage && isValidImageUrl(previewImage) && !imageError) { if (showImage && isValidImageUrl(previewImage) && !imageError) {
return ( return (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={previewImage} src={previewImage}
alt="Preview" alt="Preview"
@@ -127,6 +133,19 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
</div> </div>
<div className="card bg-base-200 col-span-6 shadow-xl"> <div className="card bg-base-200 col-span-6 shadow-xl">
{!changelog?.id && (
<label className="label mx-6 mt-6 w-full cursor-pointer">
<input
type="checkbox"
className={cn("toggle")}
checked={skipUserUpdate}
onChange={(e) => setSkipUserUpdate(e.target.checked)}
/>
<span className={cn("label-text w-full text-left")}>
Kein Popup für Nutzer für dieses Update anzeigen
</span>
</label>
)}
<div className="card-body"> <div className="card-body">
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<Button <Button

View File

@@ -4,6 +4,9 @@ import { prisma, Prisma, Changelog } from "@repo/db";
export const upsertChangelog = async ( export const upsertChangelog = async (
changelog: Prisma.ChangelogCreateInput, changelog: Prisma.ChangelogCreateInput,
id?: Changelog["id"], id?: Changelog["id"],
options?: {
skipUserUpdate?: boolean;
},
) => { ) => {
const newChangelog = id const newChangelog = id
? await prisma.changelog.update({ ? await prisma.changelog.update({
@@ -12,10 +15,13 @@ export const upsertChangelog = async (
}) })
: await prisma.$transaction(async (prisma) => { : await prisma.$transaction(async (prisma) => {
const createdChangelog = await prisma.changelog.create({ data: changelog }); const createdChangelog = await prisma.changelog.create({ data: changelog });
if (!options?.skipUserUpdate) {
// Update all users to acknowledge the new changelog
await prisma.user.updateMany({ await prisma.user.updateMany({
data: { changelogAck: false }, data: { changelogAck: false },
}); });
}
return createdChangelog; return createdChangelog;
}); });

View File

@@ -17,10 +17,7 @@ export const deleteEvent = async (id: Event["id"]) => {
}; };
export const upsertAppointment = async ( export const upsertAppointment = async (
eventAppointment: Prisma.XOR< eventAppointment: Prisma.EventAppointmentUncheckedCreateInput,
Prisma.EventAppointmentCreateInput,
Prisma.EventAppointmentUncheckedCreateInput
>,
) => { ) => {
const newEventAppointment = eventAppointment.id const newEventAppointment = eventAppointment.id
? await prisma.eventAppointment.update({ ? await prisma.eventAppointment.update({

View File

@@ -5,7 +5,6 @@ import { useForm } from "react-hook-form";
import { KEYWORD_CATEGORY, Keyword } from "@repo/db"; import { KEYWORD_CATEGORY, Keyword } from "@repo/db";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { Input } from "../../../../_components/ui/Input"; import { Input } from "../../../../_components/ui/Input";
import { useState } from "react";
import { deleteKeyword, upsertKeyword } from "../action"; import { deleteKeyword, upsertKeyword } from "../action";
import { Button } from "../../../../_components/ui/Button"; import { Button } from "../../../../_components/ui/Button";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -17,7 +16,6 @@ export const KeywordForm = ({ keyword }: { keyword?: Keyword }) => {
resolver: zodResolver(KeywordOptionalDefaultsSchema), resolver: zodResolver(KeywordOptionalDefaultsSchema),
defaultValues: keyword, defaultValues: keyword,
}); });
const [deleteLoading, setDeleteLoading] = useState(false);
return ( return (
<> <>
<form <form
@@ -28,13 +26,13 @@ export const KeywordForm = ({ keyword }: { keyword?: Keyword }) => {
})} })}
className="grid grid-cols-6 gap-3" className="grid grid-cols-6 gap-3"
> >
<div className="card bg-base-200 shadow-xl col-span-6 "> <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body"> <div className="card-body">
<h2 className="card-title"> <h2 className="card-title">
<FileText className="w-5 h-5" /> Allgemeines <FileText className="h-5 w-5" /> Allgemeines
</h2> </h2>
<label className="form-control w-full "> <label className="form-control w-full">
<span className="label-text text-lg flex items-center gap-2">Kategorie</span> <span className="label-text flex items-center gap-2 text-lg">Kategorie</span>
<select <select
className="input-sm select select-bordered select-sm w-full" className="input-sm select select-bordered select-sm w-full"
{...form.register("category")} {...form.register("category")}
@@ -70,8 +68,8 @@ export const KeywordForm = ({ keyword }: { keyword?: Keyword }) => {
</div> </div>
</div> </div>
<div className="card bg-base-200 shadow-xl col-span-6"> <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body "> <div className="card-body">
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<Button <Button
isLoading={form.formState.isSubmitting} isLoading={form.formState.isSubmitting}
@@ -83,7 +81,6 @@ export const KeywordForm = ({ keyword }: { keyword?: Keyword }) => {
{keyword && ( {keyword && (
<Button <Button
onClick={async () => { onClick={async () => {
setDeleteLoading(true);
await deleteKeyword(keyword.id); await deleteKeyword(keyword.id);
redirect("/admin/keyword"); redirect("/admin/keyword");
}} }}

View File

@@ -30,12 +30,17 @@ export const penaltyColumns: ColumnDef<Penalty & { Report: Report; CreatedUser:
new Date(row.original.until || Date.now()), new Date(row.original.until || Date.now()),
{ locale: de }, { locale: de },
); );
const isExpired = new Date(row.original.until || Date.now()) < new Date();
return ( return (
<div <div
className={cn("text-warning flex gap-3", row.original.suspended && "text-gray-400")} className={cn(
"text-warning flex gap-3",
(row.original.suspended || isExpired) && "text-gray-400",
)}
> >
<Timer /> <Timer />
Zeit Sperre ({length}) {row.original.suspended && "(ausgesetzt)"} Zeit Sperre ({length}) {row.original.suspended && "(ausgesetzt)"}{" "}
{isExpired && !row.original.suspended && "(abgelaufen)"}
</div> </div>
); );
} }
@@ -78,14 +83,14 @@ export const penaltyColumns: ColumnDef<Penalty & { Report: Report; CreatedUser:
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/admin/penalty/${row.original.id}`}> <Link href={`/admin/penalty/${row.original.id}`}>
<button className="btn btn-sm btn-outline btn-info flex items-center gap-2"> <button className="btn btn-sm btn-outline btn-info flex items-center gap-2">
<Shield className="w-4 h-4" /> <Shield className="h-4 w-4" />
Anzeigen Anzeigen
</button> </button>
</Link> </Link>
{report && ( {report && (
<Link href={`/admin/report/${report.id}`}> <Link href={`/admin/report/${report.id}`}>
<button className="btn btn-sm btn-outliney flex items-center gap-2"> <button className="btn btn-sm btn-outliney flex items-center gap-2">
<TriangleAlert className="w-4 h-4" /> <TriangleAlert className="h-4 w-4" />
Report Anzeigen Report Anzeigen
</button> </button>
</Link> </Link>

View File

@@ -10,6 +10,12 @@ export default function ReportPage() {
CreatedUser: true, CreatedUser: true,
Report: true, Report: true,
}} }}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
columns={penaltyColumns} columns={penaltyColumns}
/> />
); );

View File

@@ -0,0 +1,113 @@
"use client";
import { createReport } from "(app)/admin/report/actions";
import { getUser } from "(app)/admin/user/action";
import { useQuery } from "@tanstack/react-query";
import { Select } from "_components/ui/Select";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { TriangleAlert } from "lucide-react";
import toast from "react-hot-toast";
import { Button } from "@repo/shared-components";
import { ReportOptionalDefaults, ReportOptionalDefaultsSchema } from "@repo/db/zod";
import { zodResolver } from "@hookform/resolvers/zod";
export const NewReportForm = ({
defaultValues,
}: {
defaultValues?: Partial<ReportOptionalDefaults>;
}) => {
const session = useSession();
const [search, setSearch] = useState("");
const { data: user } = useQuery({
queryKey: ["newReport"],
queryFn: async () =>
getUser({
OR: [
{ firstname: { contains: search, mode: "insensitive" } },
{ lastname: { contains: search, mode: "insensitive" } },
{ publicId: { contains: search, mode: "insensitive" } },
{ id: defaultValues?.reportedUserId || "" },
],
}),
enabled: search.length > 0,
refetchOnWindowFocus: false,
});
const router = useRouter();
const form = useForm({
resolver: zodResolver(ReportOptionalDefaultsSchema),
defaultValues: {
reportedUserId: defaultValues?.reportedUserId || "",
senderUserId: session.data?.user.id || "",
reviewerComment: null,
reviewerUserId: null,
},
});
return (
<form
className="flex flex-wrap gap-3"
onSubmit={form.handleSubmit(async (values) => {
console.log("Form submitted with values:", values);
const newReport = await createReport(values);
toast.success("Report erfolgreich erstellt!");
router.push(`/admin/report/${newReport.id}`);
})}
>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<h2 className="card-title">
<TriangleAlert /> Neuen Report erstellen
</h2>
<Select
form={form}
options={
user?.map((u) => ({
label: `${u.firstname} ${u.lastname} (${u.publicId})`,
value: u.id,
})) || [
{
label: "Kein Nutzer gefunden",
value: "",
disabled: true,
},
]
}
onInputChange={(v) => setSearch(v)}
name="reportedUserId"
label="Nutzer"
/>
<Select
form={form}
options={[
{ label: "Nutzer", value: "Nutzer" },
{ label: "Pilot", value: "Pilot" },
{ label: "Disponent", value: "Disponent" },
]}
name="reportedUserRole"
label="Rolle des Nutzers"
/>
<textarea
{...form.register("text")}
className="textarea w-full"
placeholder="Beschreibe den Vorfall"
/>
</div>
</div>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Speichern
</Button>
</div>
</div>
</div>
</form>
);
};

View File

@@ -26,15 +26,15 @@ export const ReportSenderInfo = ({
return ( return (
<div className="card-body"> <div className="card-body">
<h2 className="card-title"> <h2 className="card-title">
<Link href={`/admin/user/${Reported?.id}`} className=" link link-hover"> <Link href={`/admin/user/${Reported?.id}`} className="link link-hover">
{Reported?.firstname} {Reported?.lastname} ({Reported?.publicId}) als{" "} {Reported?.firstname} {Reported?.lastname} ({Reported?.publicId}) als{" "}
</Link> </Link>
<span className="text-primary">{report.reportedUserRole}</span> <span className="text-primary">{report.reportedUserRole}</span>
</h2> </h2>
<div className="textarea w-full text-left">{report.text}</div> <div className="textarea w-full text-left">{report.text}</div>
<Link <Link
href={`/admin/user/${Reported?.id}`} href={`/admin/user/${Sender?.id}`}
className="text-sm text-gray-600 text-right link link-hover" className="link link-hover text-right text-sm text-gray-600"
> >
gemeldet von Nutzer {Sender?.firstname} {Sender?.lastname} ({Sender?.publicId}) am{" "} gemeldet von Nutzer {Sender?.firstname} {Sender?.lastname} ({Sender?.publicId}) am{" "}
{new Date(report.timestamp).toLocaleString()} {new Date(report.timestamp).toLocaleString()}
@@ -82,7 +82,7 @@ export const ReportAdmin = ({
> >
<h2 className="card-title">Staff Kommentar</h2> <h2 className="card-title">Staff Kommentar</h2>
<textarea {...form.register("reviewerComment")} className="textarea w-full" placeholder="" /> <textarea {...form.register("reviewerComment")} className="textarea w-full" placeholder="" />
<p className="text-sm text-gray-600 text-right"> <p className="text-right text-sm text-gray-600">
{report.Reviewer && {report.Reviewer &&
`Kommentar von ${Reviewer?.firstname} ${Reviewer?.lastname} (${Reviewer?.publicId})`} `Kommentar von ${Reviewer?.firstname} ${Reviewer?.lastname} (${Reviewer?.publicId})`}
</p> </p>
@@ -141,7 +141,7 @@ export const ReportPenalties = ({
return ( return (
<div className="card-body"> <div className="card-body">
<h2 className="card-title"> <h2 className="card-title">
<Shield className="w-5 h-5" /> Strafen zu diesem Report <Shield className="h-5 w-5" /> Strafen zu diesem Report
</h2> </h2>
<PaginatedTable <PaginatedTable
prismaModel="penalty" prismaModel="penalty"

View File

@@ -1,10 +1,7 @@
"use server"; "use server";
import { Prisma, prisma } from "@repo/db"; import { Prisma, prisma } from "@repo/db";
export const editReport = async ( export const editReport = async (id: number, data: Prisma.ReportUncheckedUpdateInput) => {
id: number,
data: Prisma.ReportUncheckedUpdateInput,
) => {
return await prisma.report.update({ return await prisma.report.update({
where: { where: {
id: id, id: id,
@@ -12,3 +9,11 @@ export const editReport = async (
data, data,
}); });
}; };
export const createReport = async (
data: Prisma.XOR<Prisma.ReportCreateInput, Prisma.ReportUncheckedCreateInput>,
) => {
return await prisma.report.create({
data,
});
};

View File

@@ -0,0 +1,21 @@
import { NewReportForm } from "(app)/admin/report/_components/NewReport";
const Page = async ({
searchParams,
}: {
searchParams?: {
[key: string]: string | undefined;
};
}) => {
const params = await searchParams;
console.log("searchParams", params);
return (
<NewReportForm
defaultValues={{
reportedUserId: params?.reportedUserId || "",
}}
/>
);
};
export default Page;

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { PaginatedTable } from "_components/PaginatedTable"; import { PaginatedTable } from "_components/PaginatedTable";
import { reportColumns } from "(app)/admin/report/columns"; import { reportColumns } from "(app)/admin/report/columns";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
export default function ReportPage() { export default function ReportPage() {
return ( return (
@@ -12,6 +14,18 @@ export default function ReportPage() {
Sender: true, Sender: true,
Reported: true, Reported: true,
}} }}
leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<TriangleAlert className="h-5 w-5" /> Reports
</p>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/report/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>
</p>
}
columns={reportColumns} columns={reportColumns}
/> />
); );

View File

@@ -6,6 +6,7 @@ import {
ConnectedAircraft, ConnectedAircraft,
ConnectedDispatcher, ConnectedDispatcher,
DiscordAccount, DiscordAccount,
Penalty,
PERMISSION, PERMISSION,
Station, Station,
User, User,
@@ -42,9 +43,11 @@ import {
Eye, Eye,
LockKeyhole, LockKeyhole,
PlaneIcon, PlaneIcon,
Plus,
ShieldUser, ShieldUser,
Timer, Timer,
Trash2, Trash2,
TriangleAlert,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -189,20 +192,9 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
type="button" type="button"
className="btn btn-sm btn-outline" className="btn btn-sm btn-outline"
onClick={() => onClick={() =>
form.setValue( form.setValue("permissions", ["LOGIN_NEXTCLOUD", "PILOT", "DISPO", "AUDIO"], {
"permissions", shouldDirty: true,
[ })
"LOGIN_NEXTCLOUD",
"PILOT",
"DISPO",
"AUDIO",
"ADMIN_EVENT",
"ADMIN_HELIPORT",
],
{
shouldDirty: true,
},
)
} }
onSubmit={() => false} onSubmit={() => false}
> >
@@ -265,6 +257,7 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: User }) => { export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: User }) => {
const dispoTableRef = useRef<PaginatedTableRef>(null); const dispoTableRef = useRef<PaginatedTableRef>(null);
const pilotTableRef = useRef<PaginatedTableRef>(null);
return ( return (
<div className="card-body flex-row flex-wrap"> <div className="card-body flex-row flex-wrap">
<div className="flex-1"> <div className="flex-1">
@@ -312,7 +305,7 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div> <div>
<button <Button
className="btn btn-sm btn-error" className="btn btn-sm btn-error"
onClick={async () => { onClick={async () => {
await deleteDispoHistory(row.original.id); await deleteDispoHistory(row.original.id);
@@ -320,7 +313,7 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
}} }}
> >
löschen löschen
</button> </Button>
</div> </div>
); );
}, },
@@ -334,7 +327,7 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
<PlaneIcon className="h-5 w-5" /> Pilot-Verbindungs Historie <PlaneIcon className="h-5 w-5" /> Pilot-Verbindungs Historie
</h2> </h2>
<PaginatedTable <PaginatedTable
ref={dispoTableRef} ref={pilotTableRef}
filter={{ filter={{
userId: user.id, userId: user.id,
}} }}
@@ -385,15 +378,15 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div> <div>
<button <Button
className="btn btn-sm btn-error" className="btn btn-sm btn-error"
onClick={async () => { onClick={async () => {
await deletePilotHistory(row.original.id); await deletePilotHistory(row.original.id);
dispoTableRef.current?.refresh(); pilotTableRef.current?.refresh();
}} }}
> >
löschen löschen
</button> </Button>
</div> </div>
); );
}, },
@@ -418,6 +411,7 @@ export const UserPenalties = ({ user }: { user: User }) => {
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<PenaltyDropdown <PenaltyDropdown
showBtnName
btnName="Zeitstrafe hinzufügen" btnName="Zeitstrafe hinzufügen"
Icon={<Timer size={15} />} Icon={<Timer size={15} />}
onClick={async ({ reason, until }) => { onClick={async ({ reason, until }) => {
@@ -447,6 +441,7 @@ export const UserPenalties = ({ user }: { user: User }) => {
/> />
{session.data?.user.permissions.includes("ADMIN_USER_ADVANCED") && ( {session.data?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<PenaltyDropdown <PenaltyDropdown
showBtnName
btnName="Bannen" btnName="Bannen"
Icon={<LockKeyhole size={15} />} Icon={<LockKeyhole size={15} />}
onClick={async ({ reason }) => { onClick={async ({ reason }) => {
@@ -495,9 +490,16 @@ export const UserPenalties = ({ user }: { user: User }) => {
export const UserReports = ({ user }: { user: User }) => { export const UserReports = ({ user }: { user: User }) => {
return ( return (
<div className="card-body"> <div className="card-body">
<h2 className="card-title"> <div className="card-title flex justify-between">
<ExclamationTriangleIcon className="h-5 w-5" /> Nutzer Reports <h2 className="flex items-center gap-2">
</h2> <ExclamationTriangleIcon className="h-5 w-5" /> Nutzer Reports
</h2>
<Link href={`/admin/report/new?reportedUserId=${user.id}`}>
<button className={"btn btn-xs btn-square btn-soft cursor-pointer"}>
<Plus />
</button>
</Link>
</div>
<PaginatedTable <PaginatedTable
prismaModel="report" prismaModel="report"
filter={{ filter={{
@@ -531,6 +533,12 @@ interface AdminFormProps {
open: number; open: number;
total60Days: number; total60Days: number;
}; };
openBans: (Penalty & {
CreatedUser: User | null;
})[];
openTimebans: (Penalty & {
CreatedUser: User | null;
})[];
} }
export const AdminForm = ({ export const AdminForm = ({
@@ -539,6 +547,8 @@ export const AdminForm = ({
pilotTime, pilotTime,
reports, reports,
discordAccount, discordAccount,
openBans,
openTimebans,
}: AdminFormProps) => { }: AdminFormProps) => {
const router = useRouter(); const router = useRouter();
const { data: session } = useSession(); const { data: session } = useSession();
@@ -630,6 +640,33 @@ export const AdminForm = ({
)} )}
</div> </div>
</div> </div>
{(!!openBans.length || !!openTimebans.length) && (
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
{openBans.map((ban) => (
<div key={ban.id}>
<h3 className="text-lg font-semibold">Permanent ausgeschlossen</h3>
{ban.reason} (von {ban.CreatedUser?.firstname} {ban.CreatedUser?.lastname} -{" "}
{ban.CreatedUser?.publicId})
</div>
))}
{openTimebans.map((timeban) => (
<div key={timeban.id}>
<h3 className="text-lg font-semibold">
Zeitstrafe bis{" "}
{timeban.until ? new Date(timeban.until).toLocaleString("de-DE") : "unbekannt"}
</h3>
{timeban.reason} ({timeban.CreatedUser?.firstname} {timeban.CreatedUser?.lastname} -{" "}
{timeban.CreatedUser?.publicId})
</div>
))}
</div>
<p className="text-sm text-gray-400">
Achtung! Die Strafe(n) sind aktiv, die Rechte des Nutzers müssen nicht angepasst werden!
</p>
</div>
)}
<h2 className="card-title"> <h2 className="card-title">
<ChartBarBigIcon className="h-5 w-5" /> Aktivität <ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2> </h2>

View File

@@ -8,10 +8,10 @@ import {
UserReports, UserReports,
} from "./_components/forms"; } from "./_components/forms";
import { Error } from "../../../../_components/Error"; import { Error } from "../../../../_components/Error";
import { getUserPenaltys } from "@repo/shared-components";
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: id, id: id,
@@ -20,6 +20,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
discordAccounts: true, discordAccounts: true,
}, },
}); });
if (!user) return <Error statusCode={404} title="User not found" />;
const dispoSessions = await prisma.connectedDispatcher.findMany({ const dispoSessions = await prisma.connectedDispatcher.findMany({
where: { where: {
@@ -97,41 +98,43 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
open: totalReportsOpen, open: totalReportsOpen,
total60Days: totalReports60Days, total60Days: totalReports60Days,
}; };
if (!user) return <Error statusCode={404} title="User not found" />; const { openBans, openTimeban } = await getUserPenaltys(user?.id);
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-full flex justify-between items-center"> <div className="col-span-full flex items-center justify-between">
<p className="text-2xl font-semibold text-left flex items-center gap-2"> <p className="flex items-center gap-2 text-left text-2xl font-semibold">
<PersonIcon className="w-5 h-5" /> <PersonIcon className="h-5 w-5" />
{user?.firstname} {user?.lastname} #{user?.publicId} {user?.firstname} {user?.lastname} #{user?.publicId}
</p> </p>
<p <p
className="text-sm text-gray-400 font-thin tooltip tooltip-left" className="tooltip tooltip-left text-sm font-thin text-gray-400"
data-tip="Account erstellt am" data-tip="Account erstellt am"
> >
{new Date(user.createdAt).toLocaleString("de-DE")} {new Date(user.createdAt).toLocaleString("de-DE")}
</p> </p>
</div> </div>
<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">
<ProfileForm user={user} /> <ProfileForm user={user} />
</div> </div>
<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">
<AdminForm <AdminForm
user={user} user={user}
dispoTime={dispoTime} dispoTime={dispoTime}
pilotTime={pilotTime} pilotTime={pilotTime}
reports={reports} reports={reports}
discordAccount={user.discordAccounts[0]} discordAccount={user.discordAccounts[0]}
openBans={openBans}
openTimebans={openTimeban}
/> />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-6"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
<UserReports user={user} /> <UserReports user={user} />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-6"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
<UserPenalties user={user} /> <UserPenalties user={user} />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-6"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
<ConnectionHistory user={user} /> <ConnectionHistory user={user} />
</div> </div>
</div> </div>

View File

@@ -3,6 +3,12 @@ import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { sendMailByTemplate } from "../../../../helper/mail"; import { sendMailByTemplate } from "../../../../helper/mail";
export const getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findMany({
where,
});
};
export const editUser = async (id: string, data: Prisma.UserUpdateInput) => { export const editUser = async (id: string, data: Prisma.UserUpdateInput) => {
return await prisma.user.update({ return await prisma.user.update({
where: { where: {

View File

@@ -24,15 +24,16 @@ export default async function RootLayout({
return ( return (
<div <div
className="hero min-h-screen" className="min-h-screen"
style={{ style={{
backgroundImage: "url('/bg.png')", backgroundImage: "url('/bg.png')",
backgroundSize: "cover",
}} }}
> >
<div className="hero-overlay bg-opacity-30"></div> <div className="absolute h-screen w-screen bg-opacity-30"></div>
{/* Card */} {/* Card */}
<div className="hero-content text-neutral-content m-10 h-full w-full max-w-full text-center"> <div className="text-neutral-content flex h-screen w-full max-w-full items-center justify-center text-center">
<div className="card bg-base-100 ml-24 mr-24 flex h-full max-h-[calc(100vh-13rem)] min-h-full w-full flex-col p-4 shadow-2xl"> <div className="bg-base-100 mx-5 flex max-h-full max-w-[1600px] flex-col rounded-xl p-4 shadow-2xl xl:mx-12">
{/* Top Navbar */} {/* Top Navbar */}
<HorizontalNav /> <HorizontalNav />

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">
<Badges />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<Bookings />
</div>
</div> </div>
<Events /> <Events />
</div> </div>

View File

@@ -2,7 +2,7 @@
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
import { sendMailByTemplate } from "../../../helper/mail"; import { sendMailByTemplate } from "../../../helper/mail";
import OLD_USER from "../../api/auth/[...nextauth]/var.User.json"; import v1User from "../../api/auth/[...nextauth]/var.User.json";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { createNewUserFromOld, OldUser } from "../../../types/oldUser"; import { createNewUserFromOld, OldUser } from "../../../types/oldUser";
@@ -13,7 +13,7 @@ export const resetPassword = async (email: string) => {
email, email,
}, },
}); });
const oldUser = (OLD_USER as OldUser[]).find((u) => u.email.toLowerCase() === email); const oldUser = (v1User as OldUser[]).find((u) => u.email.toLowerCase() === email);
if (!user) { if (!user) {
if (oldUser) { if (oldUser) {
user = await createNewUserFromOld(oldUser); user = await createNewUserFromOld(oldUser);

View File

@@ -235,6 +235,20 @@ export const Register = () => {
? form.formState.errors.passwordConfirm.message ? form.formState.errors.passwordConfirm.message
: ""} : ""}
</p> </p>
<div className="mt-2">
<p className="text-xs opacity-70">
Indem du dich registrierst, stimmst du unseren{" "}
<a
href="https://virtualairrescue.com/datenschutz/"
target="_blank"
rel="noopener noreferrer"
className="link link-accent link-hover"
>
Datenschutzbestimmungen
</a>{" "}
zu.
</p>
</div>
<div className="card-actions mt-6"> <div className="card-actions mt-6">
<Button disabled={isLoading} isLoading={isLoading} className="btn btn-primary btn-block"> <Button disabled={isLoading} isLoading={isLoading} className="btn btn-primary btn-block">
Registrieren Registrieren

View File

@@ -1,7 +1,7 @@
"use server"; "use server";
import { prisma, Prisma } from "@repo/db"; import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import OLD_USER from "../../api/auth/[...nextauth]/var.User.json"; import v1User from "../../api/auth/[...nextauth]/var.User.json";
import { OldUser } from "../../../types/oldUser"; import { OldUser } from "../../../types/oldUser";
export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInput, "publicId">) => { export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInput, "publicId">) => {
@@ -29,7 +29,7 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
}, },
}); });
const existingOldUser = (OLD_USER as OldUser[]).find( const existingOldUser = (v1User as OldUser[]).find(
(u) => u.email.toLocaleLowerCase() === user.email, (u) => u.email.toLocaleLowerCase() === user.email,
); );

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,258 @@
"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";
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 {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = useForm<NewBookingFormData>({
resolver: zodResolver(newBookingSchema),
});
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
{...register("stationId", {
required:
selectedType === "STATION" ? "Bitte wählen Sie eine Station aus" : false,
})}
className="select select-bordered w-full"
>
<option value="">Station auswählen...</option>
{stations?.map((station) => (
<option key={station.id} value={station.id}>
{station.bosCallsignShort} - {station.locationState} ({station.aircraft})
</option>
))}
</select>
)}
{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

@@ -58,7 +58,7 @@ export const GET = async (req: NextRequest) => {
email: discordUser.email, email: discordUser.email,
avatar: discordUser.avatar, avatar: discordUser.avatar,
username: discordUser.username, username: discordUser.username,
globalName: discordUser.global_name, globalName: discordUser.global_name || discordUser.username,
verified: discordUser.verified, verified: discordUser.verified,
tokenType: authData.token_type, tokenType: authData.token_type,
} as DiscordAccount; } as DiscordAccount;

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",
"moment": "^2.30.1",
"next": "^15.4.2", "next": "^15.4.2",
"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

@@ -79,6 +79,7 @@ services:
- NEXT_PUBLIC_DISPATCH_SERVICE_ID=1 - NEXT_PUBLIC_DISPATCH_SERVICE_ID=1
- NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL - NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
- NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL - NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
- NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
env_file: env_file:
- .env.prod - .env.prod
deploy: deploy:

View File

@@ -74,6 +74,7 @@ services:
- NEXT_PUBLIC_DISPATCH_SERVICE_ID=1 - NEXT_PUBLIC_DISPATCH_SERVICE_ID=1
- NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL - NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
- NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL - NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
- NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
env_file: env_file:
- .env.prod - .env.prod
deploy: deploy:

View File

@@ -22,13 +22,16 @@ export const getPublicUser = (
}, },
): PublicUser => { ): PublicUser => {
const lastName = user.lastname const lastName = user.lastname
.trim()
.split(" ") .split(" ")
.map((part) => `${part[0]}.`) .map((part) => `${part[0] || ""}.`)
.join(" "); .join(" ");
return { return {
firstname: user.firstname, firstname: user.firstname,
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName, // Only take the first letter of each section of the last name lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName.trim(), // Only take the first letter of each section of the last name
fullName: `${user.firstname} ${user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName}`, fullName:
`${user.firstname} ${user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName}`.trim(),
publicId: user.publicId, publicId: user.publicId,
badges: user.badges, badges: user.badges,
}; };

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

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

@@ -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
@@ -76,6 +77,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")
} }

View File

@@ -20,3 +20,10 @@ scrape_configs:
- job_name: "traefik" - job_name: "traefik"
static_configs: static_configs:
- targets: ["traefik:8080"] # Traefik dashboard endpoint - targets: ["traefik:8080"] # Traefik dashboard endpoint
# - job_name: "Node Exporter"
# static_configs:
# - targets:
# [
# "var01.virtualairrescue.com:9100/metrics",
# "var01.virtualairrescue.com:9100/probe?target=https://virtualairrescue.com&module=http_2xx",
# ]

View File

@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { Button, cn } from "@repo/shared-components"; import { Button, cn } from "@repo/shared-components";
import MDEditor from "@uiw/react-md-editor"; import MDEditor from "@uiw/react-md-editor";
import { RefreshCw } from "lucide-react"; import { Check, RefreshCw } from "lucide-react";
import { Changelog } from "@repo/db"; import { Changelog } from "@repo/db";
export const ChangelogModal = ({ export const ChangelogModal = ({
@@ -39,7 +39,7 @@ export const ChangelogModal = ({
)} )}
</div> </div>
<div className="text-base-content/80 mb-2 mt-4 text-left"> <div className="text-base-content/80 mb-2 mt-4 text-left" data-color-mode="dark">
<MDEditor.Markdown <MDEditor.Markdown
source={latestChangelog.text} source={latestChangelog.text}
style={{ style={{
@@ -50,7 +50,8 @@ export const ChangelogModal = ({
<div className="modal-action"> <div className="modal-action">
<Button className="btn btn-info btn-outline" onClick={onClose}> <Button className="btn btn-info btn-outline" onClick={onClose}>
Weiter zum HUB <Check size={20} />
gelesen
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -9,12 +9,14 @@ export const PenaltyDropdown = ({
btnTip, btnTip,
btnName, btnName,
Icon, Icon,
showBtnName = false,
}: { }: {
onClick: (data: { reason: string; until: Date | null }) => void; onClick: (data: { reason: string; until: Date | null }) => void;
showDatePicker?: boolean; showDatePicker?: boolean;
btnClassName?: string; btnClassName?: string;
btnName: string; btnName: string;
btnTip?: string; btnTip?: string;
showBtnName?: boolean;
Icon: ReactNode; Icon: ReactNode;
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -25,25 +27,29 @@ export const PenaltyDropdown = ({
<div tabIndex={0} role="button"></div> <div tabIndex={0} role="button"></div>
<div className="indicator"> <div className="indicator">
<button <button
className={cn("btn btn-xs btn-square btn-soft cursor-pointer", btnClassName)} className={cn(
"btn btn-xs btn-soft cursor-pointer",
!showBtnName && "btn-square",
btnClassName,
)}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
{Icon} {Icon} {showBtnName && <span className="hidden md:inline-block">{btnName}</span>}
</button> </button>
</div> </div>
{open && ( {open && (
<div <div
className="dropdown-content bg-base-100 rounded-box z-1 p-4 shadow-sm space-y-4 shadow-md" className="dropdown-content bg-base-100 rounded-box z-1 space-y-4 p-4 shadow-md shadow-sm"
style={{ minWidth: "500px", right: "40px" }} style={{ minWidth: "500px", right: "40px" }}
> >
<button <button
className="absolute top-2 right-2 btn btn-xs btn-circle btn-ghost" className="btn btn-xs btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
type="button" type="button"
> >
<span className="text-xl leading-none">&times;</span> <span className="text-xl leading-none">&times;</span>
</button> </button>
<h2 className="text-xl font-bold text-center">{btnName}</h2> <h2 className="text-center text-xl font-bold">{btnName}</h2>
<textarea <textarea
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
@@ -53,7 +59,7 @@ export const PenaltyDropdown = ({
/> />
{showDatePicker && ( {showDatePicker && (
<select <select
className="select w-full select-bordered" className="select select-bordered w-full"
value={until} value={until}
onChange={(e) => setUntil(e.target.value)} onChange={(e) => setUntil(e.target.value)}
> >
@@ -74,7 +80,7 @@ export const PenaltyDropdown = ({
</select> </select>
)} )}
<button <button
className={cn("btn w-full btn-square btn-soft tooltip tooltip-bottom", btnClassName)} className={cn("btn btn-square btn-soft tooltip tooltip-bottom w-full", btnClassName)}
data-tip={btnTip} data-tip={btnTip}
onClick={() => { onClick={() => {
let untilDate: Date | null = null; let untilDate: Date | null = null;

View File

@@ -4,3 +4,4 @@ export * from "./dates";
export * from "./simulatorConnected"; export * from "./simulatorConnected";
export * from "./useDebounce"; export * from "./useDebounce";
export * from "./useTimeout"; export * from "./useTimeout";
export * from "./penaltys";

View File

@@ -0,0 +1,32 @@
import { prisma } from "@repo/db";
export const getUserPenaltys = async (userId: string) => {
const openTimeban = await prisma.penalty.findMany({
where: {
userId: userId,
until: {
gte: new Date(),
},
suspended: false,
type: "TIME_BAN",
},
include: {
CreatedUser: true,
},
});
const openBans = await prisma.penalty.findMany({
where: {
userId: userId,
suspended: false,
type: "BAN",
},
include: {
CreatedUser: true,
},
});
return {
openTimeban,
openBans,
};
};