Compare commits
35 Commits
v2.0.1
...
Responsive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48653c82c | ||
|
|
859b8519db | ||
|
|
f691eb5f7c | ||
|
|
cd6885c5f2 | ||
|
|
eddb3317d5 | ||
|
|
ada041bd4a | ||
|
|
dc4a3ab4d8 | ||
|
|
ebeb2cf93a | ||
|
|
cf199150fe | ||
|
|
ba027957ce | ||
|
|
a612cf9951 | ||
|
|
715cb9ef53 | ||
|
|
266ff87fd8 | ||
|
|
99c3024d85 | ||
|
|
627060e32e | ||
|
|
23f7671d42 | ||
|
|
e134c9b2fa | ||
|
|
1bdccc46fe | ||
|
|
d01ba24243 | ||
|
|
fd50e9c4d5 | ||
|
|
157d2f02e1 | ||
|
|
a3e143145f | ||
|
|
1b447edb11 | ||
|
|
6b4cc0b58b | ||
|
|
f88b0bb56c | ||
|
|
25f56026fc | ||
|
|
7fc8749676 | ||
|
|
575438e974 | ||
|
|
89a0eb7135 | ||
|
|
453ad28538 | ||
|
|
5a8bd1abe3 | ||
|
|
1736bc79c0 | ||
|
|
efa2ca8412 | ||
|
|
b9a4f5e8d3 | ||
|
|
a09671036d |
2
.github/workflows/deploy-staging.yml
vendored
2
.github/workflows/deploy-staging.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
letsencrypt
|
letsencrypt
|
||||||
|
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ export const sendAlert = async (
|
|||||||
id: number,
|
id: number,
|
||||||
{
|
{
|
||||||
stationId,
|
stationId,
|
||||||
|
desktopOnly,
|
||||||
}: {
|
}: {
|
||||||
stationId?: number;
|
stationId?: number;
|
||||||
|
desktopOnly?: boolean;
|
||||||
},
|
},
|
||||||
user: User | "HPG",
|
user: User | "HPG",
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -46,10 +48,13 @@ export const sendAlert = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const aircraft of connectedAircrafts) {
|
for (const aircraft of connectedAircrafts) {
|
||||||
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
|
if (!desktopOnly) {
|
||||||
...mission,
|
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
|
||||||
Stations,
|
...mission,
|
||||||
});
|
Stations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
|
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
|
||||||
missionId: mission.id,
|
missionId: mission.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,11 +194,9 @@ router.post("/:id/send-alert", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res
|
res.status(500).json({
|
||||||
.status(500)
|
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
|
||||||
.json({
|
});
|
||||||
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -39,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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const Chat = () => {
|
|||||||
(d) => d.userId !== session.data?.user.id && !chats[d.userId],
|
(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 && !chats[a.userId],
|
(a) => a.userId !== session.data?.user.id && !chats[a.userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const btnActive = pilotConnected || dispatcherConnected;
|
const btnActive = pilotConnected || dispatcherConnected;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
36
apps/dispatch/app/_querys/bookings.ts
Normal file
36
apps/dispatch/app/_querys/bookings.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -251,10 +251,16 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
set({
|
if (oldSpeakingParticipants.length !== speakingParticipants.length) {
|
||||||
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
|
set({
|
||||||
speakingParticipants,
|
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);
|
||||||
|
|||||||
@@ -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,7 +18,12 @@ 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;
|
removeChat: (userId: string) => void;
|
||||||
@@ -49,12 +56,13 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
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);
|
||||||
@@ -64,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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
42
apps/dispatch/app/api/bookings/route.ts
Normal file
42
apps/dispatch/app/api/bookings/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
67
apps/hub/app/(app)/_components/Bookings.tsx
Normal file
67
apps/hub/app/(app)/_components/Bookings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
apps/hub/app/(app)/_querys/bookings.ts
Normal file
38
apps/hub/app/(app)/_querys/bookings.ts
Normal 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;
|
||||||
|
};
|
||||||
14
apps/hub/app/(app)/_querys/stations.ts
Normal file
14
apps/hub/app/(app)/_querys/stations.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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");
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
113
apps/hub/app/(app)/admin/report/_components/NewReport.tsx
Normal file
113
apps/hub/app/(app)/admin/report/_components/NewReport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
21
apps/hub/app/(app)/admin/report/new/page.tsx
Normal file
21
apps/hub/app/(app)/admin/report/new/page.tsx
Normal 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;
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
40
apps/hub/app/_components/BookingButton.tsx
Normal file
40
apps/hub/app/_components/BookingButton.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
apps/hub/app/_components/BookingSystem.tsx
Normal file
55
apps/hub/app/_components/BookingSystem.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
350
apps/hub/app/_components/BookingTimelineModal.tsx
Normal file
350
apps/hub/app/_components/BookingTimelineModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"}
|
||||||
|
|||||||
258
apps/hub/app/_components/NewBookingModal.tsx
Normal file
258
apps/hub/app/_components/NewBookingModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
apps/hub/app/_components/timeline.css
Normal file
151
apps/hub/app/_components/timeline.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
136
apps/hub/app/api/bookings/[id]/route.ts
Normal file
136
apps/hub/app/api/bookings/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
151
apps/hub/app/api/bookings/route.ts
Normal file
151
apps/hub/app/api/bookings/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
32
apps/hub/app/api/stations/route.ts
Normal file
32
apps/hub/app/api/stations/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
11
apps/hub/helper/timerange.ts
Normal file
11
apps/hub/helper/timerange.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
|||||||
26
packages/database/prisma/schema/booking.prisma
Normal file
26
packages/database/prisma/schema/booking.prisma
Normal 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")
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "PERMISSION" ADD VALUE 'ADMIN_BOOKING';
|
||||||
@@ -41,4 +41,5 @@ model Station {
|
|||||||
MissionsOnStations MissionsOnStations[]
|
MissionsOnStations MissionsOnStations[]
|
||||||
MissionOnStationUsers MissionOnStationUsers[]
|
MissionOnStationUsers MissionOnStationUsers[]
|
||||||
ConnectedAircraft ConnectedAircraft[]
|
ConnectedAircraft ConnectedAircraft[]
|
||||||
|
Bookings Booking[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
# ]
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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">×</span>
|
<span className="text-xl leading-none">×</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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
32
packages/shared-components/helper/penaltys.ts
Normal file
32
packages/shared-components/helper/penaltys.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user