1 Commits

Author SHA1 Message Date
PxlLoewe
7321759baf added other badges 2025-07-25 10:39:25 -07:00
156 changed files with 883 additions and 4304 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@@ -1,5 +1,7 @@
{
"recommendations": [
"EthanSK.restore-terminals",
"dbaeumer.vscode-eslint",
"VisualStudioExptTeam.vscodeintellicode"
]
}

View File

@@ -1,7 +1,6 @@
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index";
import cron from "node-cron";
import { changeMemberRoles } from "routes/member";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
@@ -35,6 +34,7 @@ const removeMission = async (id: number, reason: string) => {
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
};
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
@@ -98,25 +98,13 @@ const removeClosedMissions = async () => {
if (!lastAlertTime) return;
const lastStatus1or6Log = (mission.missionLog as unknown as MissionLog[])
.filter((l) => {
return (
l.type === "station-log" && (l.data?.newFMSstatus === "1" || l.data?.newFMSstatus === "6")
);
})
.sort((a, b) => new Date(b.timeStamp).getTime() - new Date(a.timeStamp).getTime())[0];
// Case 1: Forgotten Mission, last alert more than 3 Hours ago
const now = new Date();
if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180)
return removeMission(mission.id, "inaktivität");
// Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6, Status 1/6 change less more 5 minutes ago
if (
allStationsInMissionChangedFromStatus4to1Or8to1 &&
lastStatus1or6Log &&
now.getTime() - new Date(lastStatus1or6Log.timeStamp).getTime() > 1000 * 60 * 5
)
// Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6
if (allStationsInMissionChangedFromStatus4to1Or8to1)
return removeMission(mission.id, "dem freimelden aller Stationen");
});
};
@@ -140,57 +128,6 @@ const removeConnectedAircrafts = async () => {
}
});
};
const removePermissionsForBannedUsers = async () => {
const activePenalties = await prisma.penalty.findMany({
where: {
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of activePenalties) {
const user = penalty.User;
if (user.DiscordAccount) {
await changeMemberRoles(
user.DiscordAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
for (const formerAccount of user.FormerDiscordAccounts) {
await changeMemberRoles(
formerAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
}
};
cron.schedule("*/5 * * * *", async () => {
await removePermissionsForBannedUsers();
});
cron.schedule("*/1 * * * *", async () => {
try {

View File

@@ -32,25 +32,6 @@ router.post("/set-standard-name", async (req, res) => {
},
});
const activePenaltys = await prisma.penalty.findMany({
where: {
userId: user.id,
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
});
participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) {
@@ -67,12 +48,8 @@ router.post("/set-standard-name", async (req, res) => {
const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO");
if (activePenaltys.length > 0) {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], "remove");
} else {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
}
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
});
export default router;

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,13 @@
import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({
url: `redis://${process.env.REDIS_HOST || "localhost"}:${process.env.REDIS_PORT || 6379}`,
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
});
export const subClient: RedisClientType = pubClient.duplicate();
if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) {
console.warn("REDIS_HOST or REDIS_PORT not set, skipping Redis connection");
} else {
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected");
});
}
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected");
});
pubClient.on("error", (err) => console.log("Redis Client Error", err));
subClient.on("error", (err) => console.log("Redis Client Error", err));

View File

@@ -87,29 +87,6 @@ router.patch("/:id", async (req, res) => {
data: req.body,
});
io.to("dispatchers").emit("update-mission", { updatedMission });
if (req.body.state === "finished") {
const missionUsers = await prisma.missionOnStationUsers.findMany({
where: {
missionId: updatedMission.id,
},
select: {
userId: true,
},
});
console.log("Notifying users about mission closure:", missionUsers);
missionUsers?.forEach(({ userId }) => {
io.to(`user:${userId}`).emit("notification", {
type: "mission-closed",
status: "closed",
message: `Einsatz ${updatedMission.publicId} wurde beendet`,
data: {
missionId: updatedMission.id,
publicMissionId: updatedMission.publicId,
},
} as NotificationPayload);
});
}
res.json(updatedMission);
} catch (error) {
console.error(error);
@@ -136,10 +113,9 @@ router.delete("/:id", async (req, res) => {
router.post("/:id/send-alert", async (req, res) => {
const { id } = req.params;
const { stationId, vehicleName, desktopOnly } = req.body as {
const { stationId, vehicleName } = req.body as {
stationId?: number;
vehicleName?: "RTW" | "POL" | "FW";
desktopOnly?: boolean;
};
if (!req.user) {
@@ -204,11 +180,7 @@ router.post("/:id/send-alert", async (req, res) => {
return;
}
const { connectedAircrafts, mission } = await sendAlert(
Number(id),
{ stationId, desktopOnly },
req.user,
);
const { connectedAircrafts, mission } = await sendAlert(Number(id), { stationId }, req.user);
io.to("dispatchers").emit("update-mission", mission);
res.status(200).json({
@@ -217,9 +189,11 @@ router.post("/:id/send-alert", async (req, res) => {
return;
} catch (error) {
console.error(error);
res.status(500).json({
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
});
res
.status(500)
.json({
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
});
return;
}
});

View File

@@ -1,6 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime, getUserPenaltys } from "@repo/shared-components";
import { getNextDateWithTime } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
@@ -28,17 +28,8 @@ export const handleConnectDispatch =
return;
}
const userPenaltys = await getUserPenaltys(user.id);
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();
if (!user.permissions?.includes("DISPO")) {
socket.emit("error", "You do not have permission to connect to the dispatch server.");
return;
}

View File

@@ -1,8 +1,8 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
import { getUserPenaltys } from "@repo/shared-components";
export const handleConnectPilot =
(socket: Socket, io: Server) =>
@@ -34,19 +34,6 @@ export const handleConnectPilot =
socket.disconnect();
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");
@@ -96,8 +83,6 @@ export const handleConnectPilot =
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat,
posLng: randomPos?.lng,
posXplanePluginActive: debug ? true : undefined,
posH145active: debug ? true : undefined,
},
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { Prisma } from "@repo/db";
import { Button, cn, getNextDateWithTime } from "@repo/shared-components";
import { Button, getNextDateWithTime } from "@repo/shared-components";
import { Select } from "_components/Select";
import { Calendar, Radio } from "lucide-react";
import { getBookingsAPI } from "_querys/bookings";
import { Radio } from "lucide-react";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
@@ -28,19 +27,6 @@ export const ConnectionBtn = () => {
queryKey: ["stations"],
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({
mutationFn: ({
change,
@@ -76,7 +62,6 @@ export const ConnectionBtn = () => {
const session = useSession();
const uid = session.data?.user?.id;
if (!uid) return null;
console.log(bookings);
return (
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && (
@@ -132,39 +117,20 @@ export const ConnectionBtn = () => {
(option as { component: React.ReactNode }).component
}
options={
stations?.map((station) => {
const booking = bookings?.find((b) => b.stationId == station.id);
return {
value: station.id.toString(),
label: station.bosCallsign,
component: (
<div>
<span className="flex items-center gap-2">
{connectedAircrafts?.find((a) => a.stationId == station.id) && (
<Radio className="text-warning" size={15} />
)}
{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>
),
};
}) ?? []
stations?.map((station) => ({
value: station.id.toString(),
label: station.bosCallsign,
component: (
<div>
<span className="flex items-center gap-2">
{connectedAircrafts?.find((a) => a.stationId == station.id) && (
<Radio className="text-warning" size={15} />
)}
{station.bosCallsign}
</span>
</div>
),
})) ?? []
}
/>
</div>

View File

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

View File

@@ -6,63 +6,26 @@ import { Report } from "../../_components/left/Report";
import { Dme } from "(app)/pilot/_components/dme/Dme";
import dynamic from "next/dynamic";
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { Button, checkSimulatorConnected, useDebounce } from "@repo/shared-components";
import { checkSimulatorConnected } from "@repo/shared-components";
import { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert";
import { SettingsBoard } from "_components/left/SettingsBoard";
import { BugReport } from "_components/left/BugReport";
import { useEffect, useState } from "react";
import { useDmeStore } from "_store/pilot/dmeStore";
import { sendMissionAPI } from "_querys/missions";
import toast from "react-hot-toast";
const Map = dynamic(() => import("_components/map/Map"), {
ssr: false,
});
const PilotPage = () => {
const { connectedAircraft, status, } = usePilotConnectionStore((state) => state);
const { latestMission } = useDmeStore((state) => state);
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
// Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
});
const sendAlertMutation = useMutation({
mutationKey: ["missions"],
mutationFn: (params: {
id: number;
stationId?: number | undefined;
vehicleName?: "RTW" | "POL" | "FW" | undefined;
desktopOnly?: boolean | undefined;
}) => sendMissionAPI(params.id, params),
onError: (error) => {
console.error(error);
toast.error("Fehler beim Alarmieren");
},
onSuccess: (data) => {
toast.success(data.message);
},
});
const [shortlyConnected, setShortlyConnected] = useState(false);
useDebounce(
() => {
if (status === "connected") {
setShortlyConnected(false);
}
},
30_000,
[status],
);
useEffect(() => {
if (status === "connected") {
setShortlyConnected(true);
}
}, [status]);
const ownAircraft = aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
@@ -84,39 +47,16 @@ const PilotPage = () => {
</div>
<Map />
<div className="absolute right-10 top-5 z-20 space-y-2">
{!simulatorConnected &&
status === "connected" &&
connectedAircraft &&
!shortlyConnected && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
{!simulatorConnected && status === "connected" && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
<ConnectedDispatcher />
</div>
</div>
</div>
<div className="flex h-full w-1/3">
<div className="bg-base-300 flex h-full w-full flex-col p-4">
<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>
<h2 className="card-title mb-2">MRT & DME</h2>
<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="max-w-150">

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export const useSounds = ({
useEffect(() => {
if (!window) return;
connectionStart.current = new Audio("/sounds/connection_started_sepura.mp3");
connectionEnd.current = new Audio("/sounds/connection_stopped_sepura.mp3");
connectionEnd.current = new Audio("/sounds/connection_stoped_sepura.mp3");
ownCallStarted.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");

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react";
import { checkSimulatorConnected, cn } from "@repo/shared-components";
import { cn } from "@repo/shared-components";
import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react";
import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup";
import FMSStatusHistory, {
@@ -17,6 +17,7 @@ import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_q
import { getMissionsAPI } from "_querys/missions";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useSession } from "next-auth/react";
const AircraftPopupContent = ({
aircraft,
@@ -72,7 +73,7 @@ const AircraftPopupContent = ({
}
}, [currentTab, aircraft, mission]);
const { setOpenAircraftMarker, setMap } = useMapStore((state) => state);
const { setOpenAircraftMarker, setMap, openAircraftMarker } = useMapStore((state) => state);
const { anchor } = useSmartPopup();
return (
<>
@@ -396,27 +397,11 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
};
export const AircraftLayer = () => {
const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]);
useEffect(() => {
const fetchAircrafts = async () => {
try {
const res = await fetch("/api/aircrafts");
if (!res.ok) {
throw new Error("Failed to fetch aircrafts");
}
const data: (ConnectedAircraft & { Station: Station })[] = await res.json();
setAircrafts(data.filter((a) => checkSimulatorConnected(a)));
} catch (error) {
console.error("Failed to fetch aircrafts:", error);
}
};
fetchAircrafts();
const interval = setInterval(fetchAircrafts, 10_000);
return () => clearInterval(interval);
}, []);
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
});
const { setMap } = useMapStore((state) => state);
const map = useMap();
const {
@@ -450,11 +435,6 @@ export const AircraftLayer = () => {
}
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
console.debug("Hubschrauber auf Karte:", {
total: aircrafts?.length,
displayed: filteredAircrafts.length,
});
return (
<>
{filteredAircrafts?.map((aircraft) => {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { usePannelStore } from "_store/pannelStore";
import { Marker, useMap } from "react-leaflet";
import { Marker } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
@@ -8,14 +8,10 @@ import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { XplaneObject } from "@repo/db";
import { useEffect, useState } from "react";
export const MapAdditionals = () => {
const { isOpen, missionFormValues, setMissionFormValues } = usePannelStore((state) => state);
const { isOpen, missionFormValues } = usePannelStore((state) => state);
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
const { openMissionMarker } = useMapStore((state) => state);
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
queryFn: () =>
@@ -24,28 +20,13 @@ export const MapAdditionals = () => {
}),
refetchInterval: 10_000,
});
const { setOpenMissionMarker } = useMapStore((state) => state);
const [showDetailedAdditionals, setShowDetailedAdditionals] = useState(false);
const mapStore = useMapStore((state) => state);
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const leafletMap = useMap();
useEffect(() => {
const handleZoomEnd = () => {
const currentZoom = leafletMap.getZoom();
setShowDetailedAdditionals(currentZoom > 10);
};
leafletMap.on("zoomend", handleZoomEnd);
return () => {
leafletMap.off("zoomend", handleZoomEnd);
};
}, [leafletMap]);
const markersNeedingAttention = missions.filter(
(m) =>
@@ -54,8 +35,7 @@ export const MapAdditionals = () => {
m.state === "draft" &&
m.hpgLocationLat &&
dispatcherConnectionState === "connected" &&
m.hpgLocationLng &&
openMissionMarker.find((openMission) => openMission.id === m.id),
m.hpgLocationLng,
);
return (
@@ -68,90 +48,21 @@ export const MapAdditionals = () => {
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
draggable={true}
eventHandlers={{
dragend: (e) => {
const marker = e.target;
const position = marker.getLatLng();
setMissionFormValues({
...missionFormValues,
addressLat: position.lat,
addressLng: position.lng,
});
},
}}
interactive={false}
/>
)}
{showDetailedAdditionals &&
openMissionMarker.map((mission) => {
if (missionFormValues?.id === mission.id) return null;
const missionData = missions.find((m) => m.id === mission.id);
if (!missionData?.addressLat || !missionData?.addressLng) return null;
return (missionData.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => (
<Marker
key={`${mission.id}-additional-${index}`}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
interactive={false}
/>
));
})}
{isOpen &&
missionFormValues?.xPlaneObjects &&
(missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => (
<Marker
key={index}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
draggable={true}
eventHandlers={{
dragend: (e) => {
const marker = e.target;
const position = marker.getLatLng();
console.log("Marker dragged to:", position);
setMissionFormValues({
...missionFormValues,
xPlaneObjects: (missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map(
(obj, objIndex) =>
objIndex === index ? { ...obj, lat: position.lat, lon: position.lng } : obj,
),
});
},
contextmenu: (e) => {
e.originalEvent.preventDefault();
const updatedObjects = (
missionFormValues.xPlaneObjects as unknown as XplaneObject[]
).filter((_, objIndex) => objIndex !== index);
setMissionFormValues({
...missionFormValues,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
xPlaneObjects: updatedObjects as unknown as any[],
});
},
}}
/>
))}
{markersNeedingAttention.map((mission) => (
<Marker
key={mission.id}
position={[mission.hpgLocationLat!, mission.hpgLocationLng!]}
icon={L.icon({
iconUrl: "/icons/mapMarkerAttention.png",
iconUrl: "/icons/mapMarker.png",
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
eventHandlers={{
click: () =>
setOpenMissionMarker({
mapStore.setOpenMissionMarker({
open: [
{
id: mission.id,

View File

@@ -23,7 +23,7 @@ export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> =
draft: "#0092b8",
running: "#155dfc",
finished: "#155dfc",
attention: "#ba6900",
attention: "rgb(186,105,0)",
};
export const MISSION_STATUS_TEXT_COLORS: Record<MissionState, string> = {

View File

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

View File

@@ -234,7 +234,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
refetchInterval: 5000,
refetchInterval: 10000,
});
const participants =
@@ -296,12 +296,6 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
{aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"}
</span>
</span>
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posXplanePluginActive && "text-green-500")}>
{aircraft.posXplanePluginActive ? "X-Plane Plugin Aktiv" : "X-Plane Plugin Inaktiv"}
</span>
</span>
</div>
</div>
);

View File

@@ -7,7 +7,6 @@ import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/dispatcher
import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit";
import { ParticipantInfo } from "livekit-server-sdk";
import {
Dot,
LockKeyhole,
Plane,
RedoDot,
@@ -36,7 +35,7 @@ export default function AdminPanel() {
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
refetchInterval: 5000,
refetchInterval: 10000,
});
const kickLivekitParticipantMutation = useMutation({
mutationFn: kickLivekitParticipant,
@@ -93,6 +92,11 @@ export default function AdminPanel() {
const modalRef = useRef<HTMLDialogElement>(null);
console.debug("piloten von API", {
anzahl: pilots?.length,
pilots,
});
return (
<div>
<button
@@ -145,12 +149,7 @@ export default function AdminPanel() {
{!livekitParticipant ? (
<span className="text-error">Nicht verbunden</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>
<span className="text-success">{livekitParticipant.room}</span>
)}
</td>
<td className="flex gap-2">
@@ -215,12 +214,7 @@ export default function AdminPanel() {
{!livekitParticipant ? (
<span className="text-error">Nicht verbunden</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>
<span className="text-success">{livekitParticipant.room}</span>
)}
</td>
<td className="flex gap-2">
@@ -285,13 +279,8 @@ export default function AdminPanel() {
<td>
<span className="text-error">Nicht verbunden</span>
</td>
<td className="flex">
<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>
<span className="text-success">{p.room}</span>
</td>
<td className="flex gap-2">
<button

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
import { Booking, Prisma, PublicUser, Station } from "@repo/db";
import axios from "axios";
export const getBookingsAPI = async (filter: Prisma.BookingWhereInput) => {
const res = await axios.get<
(Booking & {
Station: Station;
User: PublicUser;
})[]
>("/api/bookings", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};
export const createBookingAPI = async (booking: Omit<Prisma.BookingCreateInput, "User">) => {
const response = await axios.post("/api/bookings", booking);
if (response.status !== 201) {
console.error("Error creating booking:", response);
throw new Error("Failed to create booking");
}
return response.data;
};
export const deleteBookingAPI = async (bookingId: string) => {
const response = await axios.delete(`/api/bookings/${bookingId}`);
if (!response.status.toString().startsWith("2")) {
throw new Error("Failed to delete booking");
}
return bookingId;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

View File

@@ -20,7 +20,7 @@ router.post("/handle-participant-finished", async (req, res) => {
Event: true,
User: {
include: {
DiscordAccount: true,
discordAccounts: true,
},
},
},
@@ -94,7 +94,7 @@ router.post("/handle-participant-enrolled", async (req, res) => {
Event: true,
User: {
include: {
DiscordAccount: true,
discordAccounts: true,
},
},
},

View File

@@ -8,22 +8,24 @@ export const Badges: () => Promise<JSX.Element> = async () => {
if (!session) return <div />;
return (
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<Award className="h-4 w-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.
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<Award className="w-4 h-4" /> Verdiente Abzeichen
</span>
)}
{session.user.badges.map((badge, i) => {
return <Badge badge={badge} key={`${badge} - ${i}`} />;
})}
</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>
)}
{session.user.badges.map((badge, i) => {
return <Badge badge={badge} key={`${badge} - ${i}`} />;
})}
</div>
</div>
</div>
);

View File

@@ -1,66 +0,0 @@
import { Calendar } from "lucide-react";
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { JSX } from "react";
import { getPublicUser, prisma } from "@repo/db";
import { formatTimeRange } from "../../../helper/timerange";
export const Bookings: () => Promise<JSX.Element> = async () => {
const session = await getServerSession();
const futureBookings = await prisma.booking.findMany({
where: {
startTime: {
gte: new Date(),
lte: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
},
orderBy: {
startTime: "asc",
},
include: {
User: true,
Station: true,
},
});
if (!session) return <div />;
return (
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<Calendar className="h-4 w-4" /> Zukünftige Buchungen
</span>
</h2>
<div className="flex flex-wrap gap-2">
{futureBookings.length === 0 && (
<span className="text-sm text-gray-500">
Keine zukünftigen Buchungen. Du kannst dir welche im Buchungssystem erstellen.
</span>
)}
{futureBookings.map((booking) => {
return (
<div
key={booking.id}
className={`alert alert-horizontal ${booking.type.startsWith("LST_") ? "alert-success" : "alert-info"} alert-soft px-3 py-2`}
>
<div className="flex items-center gap-3">
<span className="badge badge-outline text-xs">
{booking.type.startsWith("LST_")
? "LST"
: booking.Station?.bosCallsignShort || booking.Station?.bosCallsign}
</span>
<span className="text-sm font-medium">{getPublicUser(booking.User).fullName}</span>
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-xs font-medium">
{formatTimeRange(booking, { includeDate: true })}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
import { ArrowRight, NotebookText } from "lucide-react";
@@ -12,22 +12,20 @@ export const RecentFlights = () => {
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<NotebookText className="h-4 w-4" /> Logbook
<NotebookText className="w-4 h-4" /> Logbook
</span>
<Link className="badge badge-sm badge-info badge-outline" href="/logbook">
Zum vollständigen Logbook <ArrowRight className="h-4 w-4" />
Zum vollständigen Logbook <ArrowRight className="w-4 h-4" />
</Link>
</h2>
<PaginatedTable
prismaModel={"missionOnStationUsers"}
getFilter={() =>
({
User: { id: session.data?.user.id },
Mission: {
state: { in: ["finished"] },
},
}) as Prisma.MissionOnStationUsersWhereInput
}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
include={{
Station: true,
User: true,

View File

@@ -1,38 +0,0 @@
import { Booking, Prisma, PublicUser, Station } from "@repo/db";
import axios from "axios";
export const getBookingsAPI = async (filter: Prisma.BookingWhereInput) => {
const res = await axios.get<
(Booking & {
Station: Station;
User: PublicUser;
})[]
>("/api/bookings", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};
export const createBookingAPI = async (booking: Omit<Prisma.BookingCreateInput, "User">) => {
const response = await axios.post("/api/bookings", booking);
console.log("Response from createBookingAPI:", response);
if (response.status !== 201) {
console.error("Error creating booking:", response);
throw new Error("Failed to create booking");
}
console.log("Booking created:", response.data);
return response.data;
};
export const deleteBookingAPI = async (bookingId: string) => {
const response = await axios.delete(`/api/bookings/${bookingId}`);
if (!response.status.toString().startsWith("2")) {
throw new Error("Failed to delete booking");
}
return bookingId;
};

View File

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

View File

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

View File

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

View File

@@ -1,42 +1,24 @@
"use client";
import { Check, Cross, DatabaseBackupIcon } from "lucide-react";
import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Changelog, Keyword, Prisma } from "@repo/db";
import { Keyword } from "@repo/db";
export default () => {
return (
<>
<PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "createdAt", desc: true }]}
initialOrderBy={[{ id: "title", desc: true }]}
prismaModel="changelog"
showSearch
getFilter={(search) =>
({
OR: [
{ title: { contains: search, mode: "insensitive" } },
{ text: { contains: search, mode: "insensitive" } },
],
}) as Prisma.ChangelogWhereInput
}
searchFields={["title"]}
columns={
[
{
header: "Title",
accessorKey: "title",
},
{
header: "Auf Changelog Seite anzeigen",
accessorKey: "showOnChangelogPage",
cell: ({ row }) => (row.original.showOnChangelogPage ? <Check /> : <Cross />),
},
{
header: "Erstellt am",
accessorKey: "createdAt",
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
},
{
header: "Aktionen",
cell: ({ row }) => (
@@ -47,7 +29,7 @@ export default () => {
</div>
),
},
] as ColumnDef<Changelog>[]
] as ColumnDef<Keyword>[]
}
leftOfSearch={
<span className="flex items-center gap-2">

View File

@@ -1,4 +1,4 @@
import { Event, Participant, Prisma } from "@repo/db";
import { Event, Participant } from "@repo/db";
import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod";
import { ColumnDef } from "@tanstack/react-table";
import { useSession } from "next-auth/react";
@@ -64,7 +64,7 @@ export const AppointmentModal = ({
</div>
<div>
<PaginatedTable
supressQuery={appointmentForm.watch("id") === undefined}
hide={appointmentForm.watch("id") === undefined}
ref={participantTableRef}
columns={
[
@@ -167,11 +167,9 @@ export const AppointmentModal = ({
] as ColumnDef<Participant>[]
}
prismaModel={"participant"}
getFilter={() =>
({
eventAppointmentId: appointmentForm.watch("id")!,
}) as Prisma.ParticipantWhereInput
}
filter={{
eventAppointmentId: appointmentForm.watch("id"),
}}
include={{ User: true }}
leftOfPagination={
<div className="flex gap-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, User } from "@repo/db";
import {
EventAppointmentOptionalDefaults,
EventAppointmentOptionalDefaultsSchema,
@@ -159,11 +159,9 @@ export const Form = ({ event }: { event?: Event }) => {
<PaginatedTable
ref={appointmentsTableRef}
prismaModel={"eventAppointment"}
getFilter={() =>
({
eventId: event?.id,
}) as Prisma.EventAppointmentWhereInput
}
filter={{
eventId: event?.id,
}}
include={{
Presenter: true,
Participants: true,
@@ -252,107 +250,92 @@ export const Form = ({ event }: { event?: Event }) => {
{!form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
{
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
ref={appointmentsTableRef}
prismaModel={"participant"}
showSearch
getFilter={(searchTerm) =>
({
OR: [
{
User: {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ publicId: { contains: searchTerm, mode: "insensitive" } },
],
},
},
],
}) as Prisma.ParticipantWhereInput
}
include={{
User: true,
}}
supressQuery={!event}
columns={
[
{
header: "Vorname",
accessorKey: "User.firstname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
ref={appointmentsTableRef}
prismaModel={"participant"}
filter={{
eventId: event?.id,
}}
include={{
User: true,
}}
columns={
[
{
header: "Vorname",
accessorKey: "User.firstname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.firstname}
</Link>
);
},
},
{
header: "Nachname",
accessorKey: "User.lastname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
{row.original.User.firstname}
</Link>
);
},
Bearbeiten
</button>
</div>
);
},
{
header: "Nachname",
accessorKey: "User.lastname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
Bearbeiten
</button>
</div>
);
},
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
}
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
</div>
</div>
) : null}

View File

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

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Heliport, Prisma } from "@repo/db";
import { Heliport } from "@repo/db";
const page = () => {
return (
@@ -11,17 +11,7 @@ const page = () => {
<PaginatedTable
stickyHeaders
prismaModel="heliport"
getFilter={(searchTerm) =>
({
OR: [
{ siteName: { contains: searchTerm, mode: "insensitive" } },
{ info: { contains: searchTerm, mode: "insensitive" } },
{ hospital: { contains: searchTerm, mode: "insensitive" } },
{ designator: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.HeliportWhereInput
}
showSearch
searchFields={["siteName", "info", "hospital", "designator"]}
columns={
[
{

View File

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

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword, Prisma } from "@repo/db";
import { Keyword } from "@repo/db";
export default () => {
return (
@@ -12,16 +12,7 @@ export default () => {
stickyHeaders
initialOrderBy={[{ id: "category", desc: true }]}
prismaModel="keyword"
showSearch
getFilter={(searchTerm) =>
({
OR: [
{ name: { contains: searchTerm, mode: "insensitive" } },
{ abreviation: { contains: searchTerm, mode: "insensitive" } },
{ category: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.KeywordWhereInput
}
searchFields={["name", "abreviation", "description"]}
columns={
[
{
@@ -50,11 +41,11 @@ export default () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="h-5 w-5" /> Stichwörter
<DatabaseBackupIcon className="w-5 h-5" /> Stichwörter
</span>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<Link href={"/admin/keyword/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { penaltyColumns as penaltyColumns } from "(app)/admin/penalty/columns";
import { editReport } from "(app)/admin/report/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { Report as IReport, Prisma, User } from "@repo/db";
import { Report as IReport, User } from "@repo/db";
import { ReportSchema, Report as IReportZod } from "@repo/db/zod";
import { PaginatedTable } from "_components/PaginatedTable";
import { Button } from "_components/ui/Button";
@@ -26,15 +26,15 @@ export const ReportSenderInfo = ({
return (
<div className="card-body">
<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{" "}
</Link>
<span className="text-primary">{report.reportedUserRole}</span>
</h2>
<div className="textarea w-full text-left">{report.text}</div>
<Link
href={`/admin/user/${Sender?.id}`}
className="link link-hover text-right text-sm text-gray-600"
href={`/admin/user/${Reported?.id}`}
className="text-sm text-gray-600 text-right link link-hover"
>
gemeldet von Nutzer {Sender?.firstname} {Sender?.lastname} ({Sender?.publicId}) am{" "}
{new Date(report.timestamp).toLocaleString()}
@@ -82,7 +82,7 @@ export const ReportAdmin = ({
>
<h2 className="card-title">Staff Kommentar</h2>
<textarea {...form.register("reviewerComment")} className="textarea w-full" placeholder="" />
<p className="text-right text-sm text-gray-600">
<p className="text-sm text-gray-600 text-right">
{report.Reviewer &&
`Kommentar von ${Reviewer?.firstname} ${Reviewer?.lastname} (${Reviewer?.publicId})`}
</p>
@@ -141,7 +141,7 @@ export const ReportPenalties = ({
return (
<div className="card-body">
<h2 className="card-title">
<Shield className="h-5 w-5" /> Strafen zu diesem Report
<Shield className="w-5 h-5" /> Strafen zu diesem Report
</h2>
<PaginatedTable
prismaModel="penalty"
@@ -149,11 +149,9 @@ export const ReportPenalties = ({
CreatedUser: true,
Report: true,
}}
getFilter={() =>
({
reportId: report.id,
}) as Prisma.PenaltyWhereInput
}
filter={{
reportId: report.id,
}}
columns={penaltyColumns}
/>
</div>

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
"use client";
import { PaginatedTable } from "_components/PaginatedTable";
import { reportColumns } from "(app)/admin/report/columns";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
export default function ReportPage() {
return (
@@ -14,18 +12,6 @@ export default function ReportPage() {
Sender: 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}
/>
);

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { StationOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { BosUse, ConnectedAircraft, Country, Prisma, Station, User } from "@repo/db";
import { BosUse, ConnectedAircraft, Country, Station, User } from "@repo/db";
import { FileText, LocateIcon, PlaneIcon, UserIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { deleteStation, upsertStation } from "../action";
@@ -198,17 +198,10 @@ export const StationForm = ({ station }: { station?: Station }) => {
Verbundene Piloten
</div>
}
getFilter={(searchField) =>
({
stationId: station?.id,
OR: [
{ User: { firstname: { contains: searchField, mode: "insensitive" } } },
{ User: { lastname: { contains: searchField, mode: "insensitive" } } },
{ User: { publicId: { contains: searchField, mode: "insensitive" } } },
],
}) as Prisma.ConnectedAircraftWhereInput
}
showSearch
filter={{
stationId: station?.id,
}}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
prismaModel={"connectedAircraft"}
include={{ Station: true, User: true }}
columns={

View File

@@ -3,22 +3,14 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Prisma, Station } from "@repo/db";
import { Station } from "@repo/db";
const page = () => {
return (
<>
<PaginatedTable
prismaModel="station"
showSearch
getFilter={(searchField) =>
({
OR: [
{ bosCallsign: { contains: searchField, mode: "insensitive" } },
{ operator: { contains: searchField, mode: "insensitive" } },
],
}) as Prisma.StationWhereInput
}
searchFields={["bosCallsign", "operator"]}
stickyHeaders
columns={
[
@@ -52,11 +44,11 @@ const page = () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="h-5 w-5" /> Stationen
<DatabaseBackupIcon className="w-5 h-5" /> Stationen
</span>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<Link href={"/admin/station/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -6,10 +6,7 @@ import {
ConnectedAircraft,
ConnectedDispatcher,
DiscordAccount,
FormerDiscordAccount,
Penalty,
PERMISSION,
Prisma,
Station,
User,
} from "@repo/db";
@@ -45,11 +42,9 @@ import {
Eye,
LockKeyhole,
PlaneIcon,
Plus,
ShieldUser,
Timer,
Trash2,
TriangleAlert,
Users,
} from "lucide-react";
import Link from "next/link";
@@ -61,7 +56,6 @@ import { penaltyColumns } from "(app)/admin/penalty/columns";
import { addPenalty, editPenaltys } from "(app)/admin/penalty/actions";
import { reportColumns } from "(app)/admin/report/columns";
import { sendMailByTemplate } from "../../../../../../helper/mail";
import Image from "next/image";
interface ProfileFormProps {
user: User;
@@ -79,21 +73,6 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
className="card-body"
onSubmit={form.handleSubmit(async (values) => {
if (!values.id) return;
if (values.id === session.data?.user.id && values.permissions !== user.permissions) {
toast.error("Du kannst deine eigenen Berechtigungen nicht ändern.");
return;
}
if (values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt.");
return;
}
const removedPermissions =
user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || [];
if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt.");
return;
}
await editUser(values.id, {
...values,
email: values.email.toLowerCase(),
@@ -210,9 +189,20 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
type="button"
className="btn btn-sm btn-outline"
onClick={() =>
form.setValue("permissions", ["LOGIN_NEXTCLOUD", "PILOT", "DISPO", "AUDIO"], {
shouldDirty: true,
})
form.setValue(
"permissions",
[
"LOGIN_NEXTCLOUD",
"PILOT",
"DISPO",
"AUDIO",
"ADMIN_EVENT",
"ADMIN_HELIPORT",
],
{
shouldDirty: true,
},
)
}
onSubmit={() => false}
>
@@ -275,7 +265,6 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: User }) => {
const dispoTableRef = useRef<PaginatedTableRef>(null);
const pilotTableRef = useRef<PaginatedTableRef>(null);
return (
<div className="card-body flex-row flex-wrap">
<div className="flex-1">
@@ -284,18 +273,10 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={dispoTableRef}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedDispatcherWhereInput
}
filter={{
userId: user.id,
}}
prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -331,7 +312,7 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
cell: ({ row }) => {
return (
<div>
<Button
<button
className="btn btn-sm btn-error"
onClick={async () => {
await deleteDispoHistory(row.original.id);
@@ -339,7 +320,7 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
}}
>
löschen
</Button>
</button>
</div>
);
},
@@ -353,20 +334,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
<PlaneIcon className="h-5 w-5" /> Pilot-Verbindungs Historie
</h2>
<PaginatedTable
ref={pilotTableRef}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedAircraftWhereInput
}
ref={dispoTableRef}
filter={{
userId: user.id,
}}
prismaModel={"connectedAircraft"}
include={{ Station: true }}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -412,15 +385,15 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
cell: ({ row }) => {
return (
<div>
<Button
<button
className="btn btn-sm btn-error"
onClick={async () => {
await deletePilotHistory(row.original.id);
pilotTableRef.current?.refresh();
dispoTableRef.current?.refresh();
}}
>
löschen
</Button>
</button>
</div>
);
},
@@ -445,7 +418,6 @@ export const UserPenalties = ({ user }: { user: User }) => {
</span>
<div className="flex gap-2">
<PenaltyDropdown
showBtnName
btnName="Zeitstrafe hinzufügen"
Icon={<Timer size={15} />}
onClick={async ({ reason, until }) => {
@@ -475,7 +447,6 @@ export const UserPenalties = ({ user }: { user: User }) => {
/>
{session.data?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<PenaltyDropdown
showBtnName
btnName="Bannen"
Icon={<LockKeyhole size={15} />}
onClick={async ({ reason }) => {
@@ -512,7 +483,9 @@ export const UserPenalties = ({ user }: { user: User }) => {
CreatedUser: true,
Report: true,
}}
getFilter={() => ({ userId: user.id }) as Prisma.PenaltyWhereInput}
filter={{
userId: user.id,
}}
columns={penaltyColumns}
/>
</div>
@@ -522,29 +495,14 @@ export const UserPenalties = ({ user }: { user: User }) => {
export const UserReports = ({ user }: { user: User }) => {
return (
<div className="card-body">
<div className="card-title flex justify-between">
<h2 className="flex items-center gap-2">
<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>
<h2 className="card-title">
<ExclamationTriangleIcon className="h-5 w-5" /> Nutzer Reports
</h2>
<PaginatedTable
prismaModel="report"
getFilter={() =>
({
reportedUserId: user.id,
}) as Prisma.ReportWhereInput
}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
filter={{
reportedUserId: user.id,
}}
include={{
Sender: true,
Reported: true,
@@ -557,7 +515,7 @@ export const UserReports = ({ user }: { user: User }) => {
interface AdminFormProps {
discordAccount?: DiscordAccount;
user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null };
user: User;
dispoTime: {
hours: number;
minutes: number;
@@ -568,18 +526,11 @@ interface AdminFormProps {
minutes: number;
lastLogin?: Date;
};
formerDiscordAccounts: (FormerDiscordAccount & { DiscordAccount: DiscordAccount | null })[];
reports: {
total: number;
open: number;
total60Days: number;
};
openBans: (Penalty & {
CreatedUser: User | null;
})[];
openTimebans: (Penalty & {
CreatedUser: User | null;
})[];
}
export const AdminForm = ({
@@ -588,9 +539,6 @@ export const AdminForm = ({
pilotTime,
reports,
discordAccount,
formerDiscordAccounts,
openBans,
openTimebans,
}: AdminFormProps) => {
const router = useRouter();
const { data: session } = useSession();
@@ -681,143 +629,7 @@ export const AdminForm = ({
</div>
)}
</div>
{session?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div className="mt-2 space-y-1">
<Link href={`/admin/user/${user.id}/duplicate`}>
<Button className="btn-sm btn-outline w-full">
<LockKeyhole className="h-4 w-4" /> Duplikat markieren & sperren
</Button>
</Link>
</div>
)}
</div>
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (
<div role="alert" className="alert alert-error alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
<div>
{user.CanonicalUser && (
<div>
<h3 className="text-lg font-semibold">Als Duplikat markiert</h3>
<p>
Dieser Account wurde als Duplikat von{" "}
<Link
href={`/admin/user/${user.CanonicalUser.id}`}
className="link link-hover font-semibold"
>
{user.CanonicalUser.firstname} {user.CanonicalUser.lastname} (
{user.CanonicalUser.publicId})
</Link>{" "}
markiert.
</p>
</div>
)}
{user.Duplicates && user.Duplicates.length > 0 && (
<div className="mt-2">
<h3 className="text-lg font-semibold">Duplikate erkannt</h3>
<p>Folgende Accounts wurden als Duplikate dieses Accounts markiert:</p>
<ul className="ml-4 mt-1 list-inside list-disc">
{user.Duplicates.map((duplicate) => (
<li key={duplicate.id}>
<Link href={`/admin/user/${duplicate.id}`} className="link link-hover">
{duplicate.firstname} {duplicate.lastname} ({duplicate.publicId})
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<p className="text-sm text-gray-400">{user.duplicateReason || "Keine Grund angegeben"}</p>
</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 mt-2">
<DiscordLogoIcon className="h-5 w-5" /> Frühere Discord Accounts
</h2>
<div className="overflow-x-auto">
<table className="table-sm table">
<thead>
<tr>
<th>Avatar</th>
<th>Benutzername</th>
<th>Discord ID</th>
<th>getrennt am</th>
</tr>
</thead>
<tbody>
{discordAccount && (
<tr>
<td>
<Image
src={`https://cdn.discordapp.com/avatars/${discordAccount.discordId}/${discordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
</td>
<td>{discordAccount.username}</td>
<td>{discordAccount.discordId}</td>
<td>N/A (Aktuell verbunden)</td>
</tr>
)}
{formerDiscordAccounts.map((account) => (
<tr key={account.discordId}>
<td>
{account.DiscordAccount && (
<Image
src={`https://cdn.discordapp.com/avatars/${account.DiscordAccount.discordId}/${account.DiscordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
)}
</td>
<td>{account.DiscordAccount?.username || "Unbekannt"}</td>
<td>{account.DiscordAccount?.discordId || "Unbekannt"}</td>
<td>{new Date(account.removedAt).toLocaleDateString()}</td>
</tr>
))}
{!discordAccount && formerDiscordAccounts.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-gray-400">
Keine Discord Accounts verknüpft
</td>
</tr>
)}
</tbody>
</table>
</div>
<h2 className="card-title">
<ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2>

View File

@@ -1,109 +0,0 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { getUser, markDuplicate } from "(app)/admin/user/action";
import { Button } from "@repo/shared-components";
import { Select } from "_components/ui/Select";
import toast from "react-hot-toast";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
const DuplicateSchema = z.object({
canonicalUserId: z.string().min(1, "Bitte Nutzer auswählen"),
reason: z.string().max(500).optional(),
});
export const DuplicateForm = ({ duplicateUserId }: { duplicateUserId: string }) => {
const form = useForm<z.infer<typeof DuplicateSchema>>({
resolver: zodResolver(DuplicateSchema),
defaultValues: { canonicalUserId: "", reason: "" },
});
const [search, setSearch] = useState("");
const { data: users } = useQuery({
queryKey: ["duplicate-search"],
queryFn: async () =>
getUser({
OR: [
{ firstname: { contains: search, mode: "insensitive" } },
{ lastname: { contains: search, mode: "insensitive" } },
{ publicId: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}),
enabled: search.length > 0,
refetchOnWindowFocus: false,
});
return (
<form
className="flex flex-wrap gap-3"
onSubmit={form.handleSubmit(async (values) => {
try {
// find selected canonical user by id to obtain publicId
const canonical = (users || []).find((u) => u.id === values.canonicalUserId);
if (!canonical) {
toast.error("Bitte wähle einen Original-Account aus.");
return;
}
await markDuplicate({
duplicateUserId,
canonicalPublicId: canonical.publicId,
reason: values.reason,
});
toast.success("Duplikat verknüpft und Nutzer gesperrt.");
} catch (e: unknown) {
const message =
typeof e === "object" && e && "message" in e
? (e as { message?: string }).message || "Fehler beim Verknüpfen"
: "Fehler beim Verknüpfen";
toast.error(message);
}
})}
>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<h2 className="card-title">
<TriangleAlert /> Duplikat markieren & sperren
</h2>
<Select
form={form}
name="canonicalUserId"
label="Original-Nutzer suchen & auswählen"
onInputChange={(v) => setSearch(String(v))}
options={
users?.map((u) => ({
label: `${u.firstname} ${u.lastname} (${u.publicId})`,
value: u.id,
})) || [{ label: "Kein Nutzer gefunden", value: "", disabled: true }]
}
/>
<label className="floating-label w-full">
<span className="flex items-center gap-2 text-lg">Grund (optional)</span>
<input
{...form.register("reason")}
type="text"
className="input input-bordered w-full"
placeholder="Begründung/Audit-Hinweis"
/>
</label>
</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"
>
Als Duplikat verknüpfen & sperren
</Button>
</div>
</div>
</div>
</form>
);
};

View File

@@ -1,37 +0,0 @@
import { prisma } from "@repo/db";
import { DuplicateForm } from "./_components/DuplicateForm";
import { PersonIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, firstname: true, lastname: true, publicId: true },
});
if (!user) {
return (
<div className="card bg-base-200 shadow-xl">
<div className="card-body">Nutzer nicht gefunden</div>
</div>
);
}
return (
<>
<div className="my-3">
<div className="text-left">
<Link href={`/admin/user/${user.id}`} className="link-hover l-0 text-gray-500">
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
Zurück zum Nutzer
</Link>
</div>
<p className="text-left text-2xl font-semibold">
<PersonIcon className="mr-2 inline h-5 w-5" /> Duplikat für {user.firstname}{" "}
{user.lastname} #{user.publicId}
</p>
</div>
<DuplicateForm duplicateUserId={user.id} />
</>
);
}

View File

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

View File

@@ -2,13 +2,6 @@
import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs";
import { sendMailByTemplate } from "../../../../helper/mail";
import { getServerSession } from "api/auth/[...nextauth]/auth";
export const getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findMany({
where,
});
};
export const editUser = async (id: string, data: Prisma.UserUpdateInput) => {
return await prisma.user.update({
@@ -83,52 +76,3 @@ export const sendVerificationLink = async (userId: string) => {
code,
});
};
export const markDuplicate = async (params: {
duplicateUserId: string;
canonicalPublicId: string;
reason?: string;
}) => {
// Then in your function:
const session = await getServerSession();
if (!session?.user) throw new Error("Nicht authentifiziert");
const canonical = await prisma.user.findUnique({
where: { publicId: params.canonicalPublicId },
select: { id: true },
});
if (!canonical) throw new Error("Original-Account (canonical) nicht gefunden");
if (canonical.id === params.duplicateUserId)
throw new Error("Duplikat und Original dürfen nicht identisch sein");
const updated = await prisma.user.update({
where: { id: params.duplicateUserId },
data: {
canonicalUserId: canonical.id,
isBanned: true,
duplicateDetectedAt: new Date(),
duplicateReason: params.reason ?? undefined,
},
});
await prisma.penalty.create({
data: {
userId: params.duplicateUserId,
type: "BAN",
reason: `Account als Duplikat von #${params.canonicalPublicId} markiert.`,
createdUserId: session.user.id,
},
});
return updated;
};
export const clearDuplicateLink = async (duplicateUserId: string) => {
const updated = await prisma.user.update({
where: { id: duplicateUserId },
data: {
canonicalUserId: null,
duplicateDetectedAt: null,
duplicateReason: null,
},
});
return updated;
};

View File

@@ -3,34 +3,17 @@ import { User2 } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { DiscordAccount, Penalty, Prisma, User } from "@repo/db";
import { User } from "@repo/db";
import { useSession } from "next-auth/react";
const AdminUserPage = () => {
const { data: session } = useSession();
return (
<>
<PaginatedTable
stickyHeaders
prismaModel="user"
showSearch
getFilter={(searchTerm) => {
return {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{ publicId: { contains: searchTerm, mode: "insensitive" } },
{ DiscordAccount: { username: { contains: searchTerm, mode: "insensitive" } } },
],
} as Prisma.UserWhereInput;
}}
include={{
DiscordAccount: true,
ReceivedReports: true,
Penaltys: true,
}}
searchFields={["publicId", "firstname", "lastname", "email"]}
initialOrderBy={[
{
id: "publicId",
@@ -54,15 +37,6 @@ const AdminUserPage = () => {
{
header: "Berechtigungen",
cell(props) {
const activePenaltys = props.row.original.Penaltys.filter(
(penalty) =>
!penalty.suspended &&
(penalty.type === "BAN" ||
(penalty.type === "TIME_BAN" && penalty!.until! > new Date())),
);
if (activePenaltys.length > 0) {
return <span className="font-bold text-red-600">AKTIVE STRAFE</span>;
}
if (props.row.original.permissions.length === 0) {
return <span className="text-gray-700">Keine</span>;
} else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) {
@@ -77,28 +51,6 @@ const AdminUserPage = () => {
);
},
},
{
header: "Strafen / Reports",
cell(props) {
const penaltyCount = props.row.original.Penaltys.length;
const reportCount = props.row.original.ReceivedReports.length;
return (
<span className="w-full text-center">
{penaltyCount} / {reportCount}
</span>
);
},
},
{
header: "Discord",
cell(props) {
const discord = props.row.original.DiscordAccount;
if (!discord) {
return <span className="text-gray-700">Nicht verbunden</span>;
}
return <span>{discord.username}</span>;
},
},
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
? [
{
@@ -117,13 +69,7 @@ const AdminUserPage = () => {
</div>
),
},
] as ColumnDef<
User & {
DiscordAccount: DiscordAccount;
ReceivedReports: Report[];
Penaltys: Penalty[];
}
>[]
] as ColumnDef<User>[]
} // Define the columns for the user table
leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold">

View File

@@ -1,68 +0,0 @@
"use client";
import MDEditor from "@uiw/react-md-editor";
import Image from "next/image";
export type TimelineEntry = {
id: number;
title: string;
text: string;
previewImage?: string | null;
createdAt: string;
};
const formatReleaseDate = (value: string) =>
new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
export const ChangelogTimeline = ({ entries }: { entries: TimelineEntry[] }) => {
if (!entries.length)
return <p className="text-base-content/70">Es sind noch keine Changelog-Einträge vorhanden.</p>;
return (
<div className="relative mt-6 pl-6">
<div className="bg-base-300 absolute bottom-0 left-2 top-0 w-px" aria-hidden />
<div className="space-y-8">
{entries.map((entry, idx) => (
<article key={entry.id ?? `${entry.title}-${idx}`} className="relative pl-4">
<div className="bg-primary ring-base-100 absolute -left-[9px] top-3 h-4 w-4 rounded-full ring-4" />
<div className="bg-base-200/80 rounded-xl p-5 shadow">
<div className="flex flex-col gap-1 text-left md:flex-row md:justify-between">
<div>
<h3 className="text-lg font-semibold leading-tight">{entry.title}</h3>
<p className="text-base-content/60 text-sm">
Release Date: {formatReleaseDate(entry.createdAt)}
</p>
</div>
{entry.previewImage && (
<div className="absolute right-5 top-5 md:pl-4">
<Image
src={entry.previewImage}
width={300}
height={300}
alt={`${entry.title} preview`}
className="mt-3 max-w-[300px] rounded-lg object-cover md:mt-0"
/>
</div>
)}
</div>
<div className="text-base-content/80 text-left" data-color-mode="dark">
<MDEditor.Markdown
source={entry.text}
style={{
backgroundColor: "transparent",
fontSize: "0.95rem",
}}
/>
</div>
</div>
</article>
))}
</div>
</div>
);
};

View File

@@ -1,26 +0,0 @@
import { prisma } from "@repo/db";
import { ChangelogTimeline } from "./_components/Timeline";
import { ActivityLogIcon } from "@radix-ui/react-icons";
export default async function Page() {
const changelog = await prisma.changelog.findMany({
where: { showOnChangelogPage: true },
orderBy: { createdAt: "desc" },
});
const entries = changelog.map((entry) => ({
...entry,
createdAt: entry.createdAt.toISOString(),
}));
return (
<>
<div className="w-full px-4">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<ActivityLogIcon className="h-5 w-5" /> Changelog
</p>
</div>
<ChangelogTimeline entries={entries} />
</>
);
}

View File

@@ -27,10 +27,6 @@ const page = async () => {
},
},
},
orderBy: {
id: "desc",
},
});
const appointments = await prisma.eventAppointment.findMany({
where: {

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { Error } from "_components/Error";
import { PaginatedTable } from "_components/PaginatedTable";
@@ -14,21 +14,19 @@ const Page = () => {
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<NotebookText className="h-5 w-5" /> Einsatzhistorie
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<NotebookText className="w-5 h-5" /> Einsatzhistorie
</p>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl">
<div className="card bg-base-200 shadow-xl mb-4 col-span-6">
<PaginatedTable
prismaModel={"missionOnStationUsers"}
getFilter={() =>
({
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}) as Prisma.MissionOnStationUsersWhereInput
}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
include={{
Station: true,
User: true,

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { DiscordAccount, Penalty, Report, User } from "@repo/db";
import { DiscordAccount, Penalty, User } from "@repo/db";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -31,7 +31,7 @@ export const ProfileForm = ({
}: {
user: User;
penaltys: Penalty[];
discordAccount: DiscordAccount | null;
discordAccount?: DiscordAccount;
}): React.JSX.Element => {
const canEdit = penaltys.length === 0 && !user.isBanned;
@@ -215,11 +215,9 @@ export const ProfileForm = ({
export const SocialForm = ({
discordAccount,
user,
penaltys,
}: {
discordAccount: DiscordAccount | null;
discordAccount?: DiscordAccount;
user: User;
penaltys: Penalty[];
}): React.JSX.Element | null => {
const [isLoading, setIsLoading] = useState(false);
const [vatsimLoading, setVatsimLoading] = useState(false);
@@ -237,7 +235,6 @@ export const SocialForm = ({
},
resolver: zodResolver(schema),
});
const canUnlinkDiscord = !user.isBanned && penaltys.length === 0;
if (!user) return null;
return (
@@ -265,7 +262,7 @@ export const SocialForm = ({
</h2>
<div>
<div>
{discordAccount && canUnlinkDiscord ? (
{discordAccount ? (
<Button
className="btn-success btn-block btn-outline hover:btn-error group transition-all duration-0"
isLoading={isLoading}
@@ -329,14 +326,7 @@ export const SocialForm = ({
);
};
export const DeleteForm = ({
user,
penaltys,
}: {
user: User;
penaltys: Penalty[];
reports: Report[];
}) => {
export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[] }) => {
const router = useRouter();
const userCanDelete = penaltys.length === 0 && !user.isBanned;
return (
@@ -346,11 +336,10 @@ export const DeleteForm = ({
</h2>
{!userCanDelete && (
<div className="text-left">
<h2 className="text-warning text-lg">Du kannst dein Konto nicht löschen!</h2>
<h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2>
<p className="text-sm text-gray-400">
Da du Strafen hast oder hattest, kannst du deinen Account nicht schen. Um unsere
Community zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein
Support-Ticket, wenn du Fragen dazu hast.
Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst
du einen Account erst schen wenn deine Strafe nicht mehr aktiv ist
</p>
</div>
)}

View File

@@ -4,19 +4,9 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import bcrypt from "bcryptjs";
export const unlinkDiscord = async (userId: string) => {
const discordAccount = await prisma.discordAccount.update({
await prisma.discordAccount.deleteMany({
where: {
userId: userId,
},
data: {
userId: null,
},
});
await prisma.formerDiscordAccount.create({
data: {
userId,
discordId: discordAccount.discordId,
},
});
};

View File

@@ -3,7 +3,6 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { ProfileForm, SocialForm, PasswordForm, DeleteForm } from "./_components/forms";
import { GearIcon } from "@radix-ui/react-icons";
import { Error } from "_components/Error";
import { getUserPenaltys } from "@repo/shared-components";
export default async function Page() {
const session = await getServerSession();
@@ -14,52 +13,43 @@ export default async function Page() {
id: session.user.id,
},
include: {
DiscordAccount: true,
discordAccounts: true,
Penaltys: true,
},
});
const userPenaltys = await prisma.penalty.findMany({
where: {
userId: session.user.id,
until: {
gte: new Date(),
},
type: {
in: ["TIME_BAN", "BAN"],
},
suspended: false,
},
});
const activePenaltys = await getUserPenaltys(session.user.id);
const userReports = await prisma.report.findMany({
where: {
reportedUserId: session.user.id,
},
});
if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />;
const discordAccount = user?.DiscordAccount;
const discordAccount = user?.discordAccounts[0];
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<GearIcon className="h-5 w-5" /> Einstellungen
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<GearIcon className="w-5 h-5" /> Einstellungen
</p>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<ProfileForm
user={user}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<SocialForm
user={user}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<SocialForm discordAccount={discordAccount} user={user} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<PasswordForm />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<DeleteForm user={user} reports={userReports} penaltys={userPenaltys} />
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} />
</div>
</div>
);

View File

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

View File

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

View File

@@ -235,20 +235,6 @@ export const Register = () => {
? form.formState.errors.passwordConfirm.message
: ""}
</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">
<Button disabled={isLoading} isLoading={isLoading} className="btn btn-primary btn-block">
Registrieren

View File

@@ -1,7 +1,7 @@
"use server";
import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs";
import v1User from "../../api/auth/[...nextauth]/var.User.json";
import OLD_USER from "../../api/auth/[...nextauth]/var.User.json";
import { OldUser } from "../../../types/oldUser";
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 = (v1User as OldUser[]).find(
const existingOldUser = (OLD_USER as OldUser[]).find(
(u) => u.email.toLocaleLowerCase() === user.email,
);

View File

@@ -1,40 +0,0 @@
"use client";
import { useState } from "react";
import { CalendarIcon } from "lucide-react";
import { BookingSystem } from "./BookingSystem";
import { User } from "@repo/db";
interface BookingButtonProps {
currentUser: User;
}
export const BookingButton = ({ currentUser }: BookingButtonProps) => {
const [isBookingSystemOpen, setIsBookingSystemOpen] = useState(false);
// Check if user can access booking system
const canAccessBookingSystem = currentUser && currentUser.emailVerified && !currentUser.isBanned;
// Don't render the button if user doesn't have access
if (!canAccessBookingSystem) {
return null;
}
return (
<>
<button
className="btn btn-sm btn-ghost tooltip tooltip-bottom"
data-tip="Slot Buchung"
onClick={() => setIsBookingSystemOpen(true)}
>
<CalendarIcon size={20} />
</button>
<BookingSystem
isOpen={isBookingSystemOpen}
onClose={() => setIsBookingSystemOpen(false)}
currentUser={currentUser}
/>
</>
);
};

View File

@@ -1,55 +0,0 @@
"use client";
import { useState } from "react";
import { BookingTimelineModal } from "./BookingTimelineModal";
import { NewBookingModal } from "./NewBookingModal";
import { User } from "@repo/db";
interface BookingSystemProps {
isOpen: boolean;
onClose: () => void;
currentUser: User;
}
export const BookingSystem = ({ isOpen, onClose, currentUser }: BookingSystemProps) => {
const [showNewBookingModal, setShowNewBookingModal] = useState(false);
const [refreshTimeline, setRefreshTimeline] = useState(0);
const handleOpenNewBooking = () => {
setShowNewBookingModal(true);
};
const handleCloseNewBooking = () => {
setShowNewBookingModal(false);
};
const handleBookingCreated = () => {
// Trigger a refresh of the timeline
setRefreshTimeline((prev) => prev + 1);
setShowNewBookingModal(false);
};
const handleCloseMain = () => {
setShowNewBookingModal(false);
onClose();
};
return (
<>
<BookingTimelineModal
key={refreshTimeline}
isOpen={isOpen && !showNewBookingModal}
onClose={handleCloseMain}
onOpenNewBooking={handleOpenNewBooking}
currentUser={currentUser}
/>
<NewBookingModal
isOpen={showNewBookingModal}
onClose={handleCloseNewBooking}
onBookingCreated={handleBookingCreated}
userPermissions={currentUser.permissions}
/>
</>
);
};

View File

@@ -1,361 +0,0 @@
"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";
import Link from "next/link";
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>
{currentUser?.permissions.includes("ADMIN_USER") ? (
<Link
href={`/admin/user/${booking.User.publicId}`}
className="link link-hover text-xs opacity-70"
>
{booking.User.fullName}
</Link>
) : (
<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>
);
};

Some files were not shown because too many files have changed in this diff Show More