Compare commits
52 Commits
revert-147
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c1c816ff | ||
|
|
1b425d82e2 | ||
|
|
5d0a36f967 | ||
|
|
b46ad25bde | ||
|
|
d5a4118025 | ||
|
|
aded6d1492 | ||
|
|
8340c2408c | ||
|
|
31c17e2dda | ||
|
|
2e9bb95d12 | ||
|
|
c8eeee1452 | ||
|
|
824d2e40a9 | ||
|
|
3d1b83cd32 | ||
|
|
cc29ac3e14 | ||
|
|
195f1dc9c0 | ||
|
|
40b11d2501 | ||
|
|
4ae2e93249 | ||
|
|
a60cd67c44 | ||
|
|
9303878d8d | ||
|
|
829d6d8cde | ||
|
|
25692b66be | ||
|
|
f0c138655e | ||
|
|
ac441e908d | ||
|
|
d1c49a3208 | ||
|
|
580dc32ad0 | ||
|
|
2d8a282cec | ||
|
|
5607aacd16 | ||
|
|
8555b901a5 | ||
|
|
76d4355320 | ||
|
|
10af6bf71a | ||
|
|
2154684223 | ||
|
|
ea8d63ce0b | ||
|
|
e4aae9804b | ||
|
|
005509598c | ||
|
|
b250fa46c2 | ||
|
|
e4fa011d96 | ||
|
|
bdc35ea6b3 | ||
|
|
9129652912 | ||
|
|
606379d151 | ||
|
|
ad15f2d942 | ||
|
|
2638ad473f | ||
|
|
da93b5e60c | ||
|
|
15118cac66 | ||
|
|
c254cd0774 | ||
|
|
062e7d44c0 | ||
|
|
8956204a2f | ||
|
|
a5b4696644 | ||
|
|
a9ecf7e7b8 | ||
|
|
547768410f | ||
|
|
2c2eca6084 | ||
|
|
5898dc6477 | ||
|
|
c9499e08be | ||
|
|
0429d8b770 |
@@ -1,7 +1,9 @@
|
||||
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
|
||||
import { io } from "index";
|
||||
import cron from "node-cron";
|
||||
import { setUserStandardNamePermissions } from "routes/helper";
|
||||
import { changeMemberRoles } from "routes/member";
|
||||
import client from "./discord";
|
||||
|
||||
const removeMission = async (id: number, reason: string) => {
|
||||
const log: MissionLog = {
|
||||
@@ -120,6 +122,37 @@ const removeClosedMissions = async () => {
|
||||
return removeMission(mission.id, "dem freimelden aller Stationen");
|
||||
});
|
||||
};
|
||||
|
||||
const syncDiscordImgUrls = async () => {
|
||||
try {
|
||||
const discordAccounts = await prisma.discordAccount.findMany({
|
||||
where: {
|
||||
updatedAt: {
|
||||
lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
User: {
|
||||
isNot: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const account of discordAccounts) {
|
||||
client.users.fetch(account.discordId).then((discordUser) => {
|
||||
const nextAvatar = discordUser?.avatar ?? null;
|
||||
if (typeof nextAvatar !== "string") return;
|
||||
if (nextAvatar === account.avatar) return;
|
||||
prisma.discordAccount.update({
|
||||
where: {
|
||||
id: account.id,
|
||||
},
|
||||
data: {
|
||||
avatar: nextAvatar,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const removeConnectedAircrafts = async () => {
|
||||
const connectedAircrafts = await prisma.connectedAircraft.findMany({
|
||||
where: {
|
||||
@@ -141,59 +174,91 @@ 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,
|
||||
try {
|
||||
const removePermissionsPenaltys = await prisma.penalty.findMany({
|
||||
where: {
|
||||
removePermissionApplied: false,
|
||||
User: {
|
||||
DiscordAccount: { isNot: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
include: {
|
||||
User: {
|
||||
include: {
|
||||
DiscordAccount: true,
|
||||
FormerDiscordAccounts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const penalty of activePenalties) {
|
||||
const user = penalty.User;
|
||||
const addPermissionsPenaltys = await prisma.penalty.findMany({
|
||||
where: {
|
||||
addPermissionApplied: false,
|
||||
User: {
|
||||
DiscordAccount: { isNot: null },
|
||||
},
|
||||
OR: [{ suspended: true }, { until: { lt: new Date().toISOString() } }],
|
||||
},
|
||||
include: {
|
||||
User: {
|
||||
include: {
|
||||
DiscordAccount: true,
|
||||
FormerDiscordAccounts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const penalty of removePermissionsPenaltys) {
|
||||
const user = penalty.User;
|
||||
console.log(`Removing roles for user ${user.id} due to penalty ${penalty.id}`);
|
||||
|
||||
if (user.DiscordAccount) {
|
||||
await changeMemberRoles(
|
||||
user.DiscordAccount.discordId,
|
||||
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",
|
||||
);
|
||||
for (const formerAccount of user.FormerDiscordAccounts) {
|
||||
await changeMemberRoles(
|
||||
formerAccount.discordId,
|
||||
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
|
||||
"remove",
|
||||
);
|
||||
}
|
||||
await prisma.penalty.update({
|
||||
where: { id: penalty.id },
|
||||
data: { removePermissionApplied: true },
|
||||
});
|
||||
}
|
||||
for (const penalty of addPermissionsPenaltys) {
|
||||
console.log(`Restoring roles for user ${penalty.userId} due to penalty ${penalty.id}`);
|
||||
await setUserStandardNamePermissions({
|
||||
memberId: penalty.User.DiscordAccount!.discordId,
|
||||
userId: penalty.userId,
|
||||
});
|
||||
await prisma.penalty.update({
|
||||
where: { id: penalty.id },
|
||||
data: { addPermissionApplied: true },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing permissions for banned users:", error);
|
||||
}
|
||||
};
|
||||
|
||||
cron.schedule("*/5 * * * *", async () => {
|
||||
await removePermissionsForBannedUsers();
|
||||
cron.schedule("0 0 * * *", async () => {
|
||||
try {
|
||||
await syncDiscordImgUrls();
|
||||
} catch (error) {
|
||||
console.error("Error on daily cron job:", error);
|
||||
}
|
||||
});
|
||||
|
||||
cron.schedule("*/1 * * * *", async () => {
|
||||
try {
|
||||
await removePermissionsForBannedUsers();
|
||||
await removeClosedMissions();
|
||||
await removeConnectedAircrafts();
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,22 +7,25 @@ const router: Router = Router();
|
||||
export const eventCompleted = (event: Event, participant?: Participant) => {
|
||||
if (!participant) return false;
|
||||
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
|
||||
if (event.hasPresenceEvents && !participant.attended) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
router.post("/set-standard-name", async (req, res) => {
|
||||
const { memberId, userId } = req.body;
|
||||
|
||||
export const setUserStandardNamePermissions = async ({
|
||||
memberId,
|
||||
userId,
|
||||
}: {
|
||||
memberId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const participant = await prisma.participant.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -63,6 +66,8 @@ router.post("/set-standard-name", async (req, res) => {
|
||||
const publicUser = getPublicUser(user);
|
||||
const member = await getMember(memberId);
|
||||
|
||||
if (!member) throw new Error("Member not found");
|
||||
|
||||
await member.setNickname(`${publicUser.fullName} - ${user.publicId}`);
|
||||
const isPilot = user.permissions.includes("PILOT");
|
||||
const isDispatcher = user.permissions.includes("DISPO");
|
||||
@@ -73,6 +78,17 @@ router.post("/set-standard-name", async (req, res) => {
|
||||
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
|
||||
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/set-standard-name", async (req, res) => {
|
||||
try {
|
||||
const { memberId, userId } = req.body;
|
||||
|
||||
await setUserStandardNamePermissions({ memberId, userId });
|
||||
res.status(200).json({ message: "Standard name and permissions set" });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as unknown as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -9,13 +9,23 @@ if (!GUILD_ID) {
|
||||
const router: Router = Router();
|
||||
|
||||
export const getMember = async (memberId: string) => {
|
||||
const guild = client.guilds.cache.get(GUILD_ID);
|
||||
let guild = client.guilds.cache.get(GUILD_ID);
|
||||
|
||||
if (!guild) {
|
||||
guild = await client.guilds.fetch(GUILD_ID);
|
||||
}
|
||||
|
||||
if (!guild) throw new Error("Guild not found");
|
||||
|
||||
try {
|
||||
return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId));
|
||||
let member = guild.members.cache.get(memberId);
|
||||
if (!member) {
|
||||
member = await guild.members.fetch(memberId).catch((e) => undefined);
|
||||
}
|
||||
return member;
|
||||
} catch (error) {
|
||||
console.error("Error fetching member:", error);
|
||||
throw new Error("Member not found");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,11 +37,15 @@ router.post("/rename", async (req: Request, res: Response) => {
|
||||
}
|
||||
try {
|
||||
const member = await getMember(memberId);
|
||||
if (!member) {
|
||||
res.status(404).json({ error: "Member not found" });
|
||||
return;
|
||||
}
|
||||
await member.setNickname(newName);
|
||||
console.log(`Member ${member.id} renamed to ${newName}`);
|
||||
res.status(200).json({ message: "Member renamed successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error renaming member:", error);
|
||||
console.error("Error renaming member:", (error as Error).message);
|
||||
res.status(500).json({ error: "Failed to rename member" });
|
||||
}
|
||||
});
|
||||
@@ -42,6 +56,9 @@ export const changeMemberRoles = async (
|
||||
action: "add" | "remove",
|
||||
) => {
|
||||
const member = await getMember(memberId);
|
||||
if (!member) {
|
||||
throw new Error("Member not found");
|
||||
}
|
||||
|
||||
const currentRoleIds = member.roles.cache.map((role) => role.id);
|
||||
const filteredRoleIds =
|
||||
@@ -67,7 +84,7 @@ const handleRoleChange = (action: "add" | "remove") => async (req: Request, res:
|
||||
const result = await changeMemberRoles(memberId, roleIds, action);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error(`Error ${action}ing roles:`, error);
|
||||
console.error(`Error ${action}ing roles:`, (error as Error).message);
|
||||
res.status(500).json({ error: `Failed to ${action} roles` });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,14 +43,20 @@ router.post("/admin-embed", async (req, res) => {
|
||||
{ name: "angemeldet als", value: report.reportedUserRole, inline: true },
|
||||
{
|
||||
name: "gemeldet von",
|
||||
value: `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`,
|
||||
value: report.Sender
|
||||
? `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`
|
||||
: "System",
|
||||
},
|
||||
)
|
||||
.setFooter({
|
||||
text: "Bitte reagiere mit 🫡, wenn du den Report bearbeitet hast, oder mit ✅, wenn er abgeschlossen ist.",
|
||||
})
|
||||
.setTimestamp(new Date(report.timestamp))
|
||||
.setColor("DarkRed");
|
||||
.setTimestamp(new Date(report.timestamp));
|
||||
if (report.reviewed) {
|
||||
embed.setColor("DarkGreen");
|
||||
} else {
|
||||
embed.setColor("DarkRed");
|
||||
}
|
||||
|
||||
const reportsChannel = await client.channels.fetch(process.env.DISCORD_REPORT_CHANNEL!);
|
||||
if (!reportsChannel || !reportsChannel.isSendable()) {
|
||||
@@ -59,7 +65,9 @@ router.post("/admin-embed", async (req, res) => {
|
||||
}
|
||||
const message = await reportsChannel.send({ embeds: [embed] });
|
||||
message.react("🫡").catch(console.error);
|
||||
message.react("✅").catch(console.error);
|
||||
if (!report.reviewed) {
|
||||
message.react("✅").catch(console.error);
|
||||
}
|
||||
res.json({
|
||||
message: "Report embed sent to Discord channel",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
const discordAxiosClient = axios.create({
|
||||
baseURL: process.env.CORE_SERVER_URL,
|
||||
@@ -11,7 +11,10 @@ export const renameMember = async (memberId: string, newName: string) => {
|
||||
newName,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error renaming member:", error);
|
||||
console.error(
|
||||
"Error renaming member:",
|
||||
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -22,7 +25,10 @@ export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
|
||||
roleIds,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error adding roles to member:", error);
|
||||
console.error(
|
||||
"Error adding roles to member:",
|
||||
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -33,7 +39,10 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
|
||||
roleIds,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error removing roles from member:", error);
|
||||
console.error(
|
||||
"Error removing roles from member:",
|
||||
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -43,6 +52,9 @@ export const sendReportEmbed = async (reportId: number) => {
|
||||
reportId,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error removing roles from member:", error);
|
||||
console.error(
|
||||
"Error removing roles from member:",
|
||||
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AdminMessage,
|
||||
getPublicUser,
|
||||
MissionLog,
|
||||
MissionSdsStatusLog,
|
||||
NotificationPayload,
|
||||
Prisma,
|
||||
prisma,
|
||||
@@ -130,6 +131,54 @@ router.patch("/:id", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/send-sds-message", async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { sdsMessage } = req.body as { sdsMessage: MissionSdsStatusLog };
|
||||
|
||||
if (!sdsMessage.data.stationId || !id) {
|
||||
res.status(400).json({ error: "Missing aircraftId or stationId" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.mission.updateMany({
|
||||
where: {
|
||||
state: "running",
|
||||
missionStationIds: {
|
||||
has: sdsMessage.data.stationId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
missionLog: {
|
||||
push: sdsMessage as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { publicId: sdsMessage.data.user.publicId, firstname: sdsMessage.data.user.firstname },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
io.to(
|
||||
sdsMessage.data.direction === "to-lst" ? "dispatchers" : `station:${sdsMessage.data.stationId}`,
|
||||
).emit(sdsMessage.data.direction === "to-lst" ? "notification" : "sds-status", {
|
||||
type: "station-status",
|
||||
status: sdsMessage.data.status,
|
||||
message: "SDS Status Message",
|
||||
data: {
|
||||
aircraftId: parseInt(id),
|
||||
stationId: sdsMessage.data.stationId,
|
||||
userId: user.id,
|
||||
},
|
||||
} as NotificationPayload);
|
||||
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
// Kick a connectedAircraft by ID
|
||||
router.delete("/:id", async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
ARG NEXT_PUBLIC_DISPATCH_URL
|
||||
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL
|
||||
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
|
||||
ARG NEXT_PUBLIC_DISPATCH_URL="http://localhost:3001"
|
||||
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL="http://localhost:4001"
|
||||
ARG NEXT_PUBLIC_HUB_URL="http://localhost:3002"
|
||||
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID="1"
|
||||
ARG NEXT_PUBLIC_LIVEKIT_URL="http://localhost:7880"
|
||||
ARG NEXT_PUBLIC_DISCORD_URL="https://discord.com"
|
||||
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
|
||||
@@ -16,13 +16,13 @@ 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
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ENV PNPM_HOME="/usr/local/pnpm"
|
||||
ENV PATH="${PNPM_HOME}:${PATH}"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
RUN pnpm add -g turbo@^2.5
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -31,12 +31,20 @@ WORKDIR /usr/app
|
||||
RUN echo "NEXT_PUBLIC_HUB_URL is: $NEXT_PUBLIC_HUB_URL"
|
||||
RUN echo "NEXT_PUBLIC_DISPATCH_SERVICE_ID is: $NEXT_PUBLIC_DISPATCH_SERVICE_ID"
|
||||
RUN echo "NEXT_PUBLIC_DISPATCH_SERVER_URL is: $NEXT_PUBLIC_DISPATCH_SERVER_URL"
|
||||
RUN echo "NEXT_PUBLIC_LIVEKIT_URL is: $NEXT_PUBLIC_LIVEKIT_URL"
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune dispatch --docker
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
ENV PNPM_HOME="/usr/local/pnpm"
|
||||
ENV PATH="${PNPM_HOME}:${PATH}"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
RUN pnpm add -g turbo@^2.5
|
||||
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -50,19 +58,23 @@ COPY --from=builder /usr/app/out/full/ .
|
||||
|
||||
RUN turbo run build
|
||||
|
||||
FROM base AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /usr/app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/static ./apps/dispatch/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/public ./apps/dispatch/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 3001
|
||||
EXPOSE 3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
CMD ["pnpm", "--dir", "apps/dispatch", "run", "start"]
|
||||
CMD ["node", "apps/dispatch/server.js"]
|
||||
36
apps/dispatch/app/(app)/_components/Navbar.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ExitIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@repo/db";
|
||||
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
|
||||
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
import AdminPanel from "_components/navbar/AdminPanel";
|
||||
|
||||
export default async function Navbar({ children }: { children: React.ReactNode }) {
|
||||
const session = await getServerSession();
|
||||
const latestChangelog = await prisma.changelog.findFirst({
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
|
||||
<ChangelogWrapper latestChangelog={latestChangelog} />
|
||||
</div>
|
||||
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{children}
|
||||
<ModeSwitchDropdown className="dropdown-center" btnClassName="btn-ghost" />
|
||||
<Link href={"/logout"}>
|
||||
<button className="btn btn-ghost">
|
||||
<ExitIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
"use client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useDispatchConnectionStore } from "../../../../../_store/dispatch/connectionStore";
|
||||
import { useDispatchConnectionStore } from "../../../../_store/dispatch/connectionStore";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Prisma } from "@repo/db";
|
||||
@@ -14,7 +14,7 @@ export const ConnectionBtn = () => {
|
||||
const connection = useDispatchConnectionStore((state) => state);
|
||||
const [form, setForm] = useState({
|
||||
logoffTime: "",
|
||||
selectedZone: "LST_01",
|
||||
selectedZone: "VAR_LST_RD_01",
|
||||
ghostMode: false,
|
||||
});
|
||||
const changeDispatcherMutation = useMutation({
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Connection } from "./_components/Connection";
|
||||
import { Audio } from "../../../../_components/Audio/Audio";
|
||||
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { Settings } from "./_components/Settings";
|
||||
import AdminPanel from "_components/navbar/AdminPanel";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
import { WarningAlert } from "_components/navbar/PageAlert";
|
||||
import { Radar } from "lucide-react";
|
||||
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
|
||||
import { prisma } from "@repo/db";
|
||||
|
||||
export default async function Navbar() {
|
||||
const session = await getServerSession();
|
||||
const latestChangelog = await prisma.changelog.findFirst({
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-xl font-semibold normal-case">VAR Leitstelle</p>
|
||||
<ChangelogWrapper latestChangelog={latestChangelog} />
|
||||
</div>
|
||||
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
|
||||
</div>
|
||||
<WarningAlert />
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Audio />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Connection />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Settings />
|
||||
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
|
||||
<button className="btn btn-ghost">
|
||||
<Radar size={19} /> Tracker
|
||||
</button>
|
||||
</Link>
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<button className="btn btn-ghost">
|
||||
<ExternalLinkIcon className="h-4 w-4" /> HUB
|
||||
</button>
|
||||
</Link>
|
||||
<Link href={"/logout"}>
|
||||
<button className="btn btn-ghost">
|
||||
<ExitIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { GearIcon } from "@radix-ui/react-icons";
|
||||
import { SettingsIcon, Volume2 } from "lucide-react";
|
||||
import { Info, SettingsIcon, Volume2 } from "lucide-react";
|
||||
import MicVolumeBar from "_components/MicVolumeIndication";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { editUserAPI, getUserAPI } from "_querys/user";
|
||||
@@ -27,7 +27,6 @@ export const SettingsBtn = () => {
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,10 +81,14 @@ export const SettingsBtn = () => {
|
||||
useEffect(() => {
|
||||
const setDevices = async () => {
|
||||
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
} catch (error) {
|
||||
console.error("Error accessing media devices.", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -214,7 +217,18 @@ export const SettingsBtn = () => {
|
||||
setSettingsPartial({ useHPGAsDispatcher: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
HPG als Disponent verwenden
|
||||
HPG Validierung verwenden{" "}
|
||||
<div
|
||||
className="tooltip tooltip-warning"
|
||||
data-tip="Achtung! Mit der Client Version v2.0.1.0 werden Einsätze auch ohne Validierung an die
|
||||
HPG gesendet. Die Validierung über die HPG kann zu Verzögerungen bei der
|
||||
Einsatzübermittlung führen, insbesondere wenn das HPG script den Einsatz nicht sofort
|
||||
validieren kann. Es wird empfohlen, diese Option nur zu aktivieren, wenn es Probleme
|
||||
mit der Einsatzübermittlung gibt oder wenn die HPG Validierung ausdrücklich gewünscht
|
||||
wird."
|
||||
>
|
||||
<Info className="text-error" size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action flex justify-between">
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
|
||||
|
||||
interface ThemeSwapProps {
|
||||
isDark: boolean;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
|
||||
isDark,
|
||||
toggleTheme,
|
||||
}) => {
|
||||
return (
|
||||
<label className="swap swap-rotate">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="theme-controller"
|
||||
checked={isDark}
|
||||
onChange={toggleTheme}
|
||||
/>
|
||||
<MoonIcon className="swap-off h-5 w-5 fill-current" />
|
||||
<SunIcon className="swap-on h-5 w-5 fill-current" />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import Navbar from "./_components/navbar/Navbar";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
import { Error } from "_components/Error";
|
||||
import Navbar from "(app)/_components/Navbar";
|
||||
import { Audio } from "_components/Audio/Audio";
|
||||
import { Connection } from "./_components/navbar/Connection";
|
||||
import { Settings } from "./_components/navbar/Settings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "VAR: Disponent",
|
||||
@@ -26,7 +29,11 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Navbar>
|
||||
<Audio />
|
||||
<Connection />
|
||||
<Settings />
|
||||
</Navbar>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
29
apps/dispatch/app/(app)/pilot/_components/mrt/Base.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react"; // ...existing code...
|
||||
import { useMrtStore } from "_store/pilot/MrtStore";
|
||||
import Image from "next/image";
|
||||
import DAY_BASE_IMG from "./images/Base_NoScreen_Day.png";
|
||||
import NIGHT_BASE_IMG from "./images/Base_NoScreen_Night.png";
|
||||
|
||||
export const MrtBase = () => {
|
||||
const { nightMode, setNightMode, page } = useMrtStore((state) => state);
|
||||
|
||||
useEffect(() => {
|
||||
const checkNightMode = () => {
|
||||
const currentHour = new Date().getHours();
|
||||
setNightMode(currentHour >= 22 || currentHour < 8);
|
||||
};
|
||||
|
||||
checkNightMode(); // Initial check
|
||||
const intervalId = setInterval(checkNightMode, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(intervalId); // Cleanup on unmount
|
||||
}, [setNightMode]); // ...existing code...
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={nightMode && page !== "off" ? NIGHT_BASE_IMG : DAY_BASE_IMG}
|
||||
alt=""
|
||||
className="z-30 col-span-full row-span-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 366 KiB |
@@ -1,22 +1,9 @@
|
||||
import { CSSProperties } from "react";
|
||||
import MrtImage from "./MRT.png";
|
||||
import MrtMessageImage from "./MRT_MESSAGE.png";
|
||||
import { useButtons } from "./useButtons";
|
||||
import { useSounds } from "./useSounds";
|
||||
import "./mrt.css";
|
||||
import Image from "next/image";
|
||||
import { useMrtStore } from "_store/pilot/MrtStore";
|
||||
|
||||
const MRT_BUTTON_STYLES: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
zIndex: "9999",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
};
|
||||
const MRT_DISPLAYLINE_STYLES: CSSProperties = {
|
||||
color: "white",
|
||||
zIndex: 1,
|
||||
};
|
||||
import { MrtBase } from "./Base";
|
||||
import { MrtDisplay } from "./MrtDisplay";
|
||||
import { MrtButtons } from "./MrtButtons";
|
||||
import { MrtPopups } from "./MrtPopups";
|
||||
|
||||
export interface DisplayLineProps {
|
||||
lineStyle?: CSSProperties;
|
||||
@@ -27,45 +14,7 @@ export interface DisplayLineProps {
|
||||
textSize: "1" | "2" | "3" | "4";
|
||||
}
|
||||
|
||||
const DisplayLine = ({
|
||||
style = {},
|
||||
textLeft,
|
||||
textMid,
|
||||
textRight,
|
||||
textSize,
|
||||
lineStyle,
|
||||
}: DisplayLineProps) => {
|
||||
const INNER_TEXT_PARTS: CSSProperties = {
|
||||
fontFamily: "Melder",
|
||||
flex: "1",
|
||||
flexBasis: "auto",
|
||||
overflowWrap: "break-word",
|
||||
...lineStyle,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-${textSize}`}
|
||||
style={{
|
||||
fontFamily: "Famirids",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span style={INNER_TEXT_PARTS}>{textLeft}</span>
|
||||
<span style={{ textAlign: "center", ...INNER_TEXT_PARTS }}>{textMid}</span>
|
||||
<span style={{ textAlign: "end", ...INNER_TEXT_PARTS }}>{textRight}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Mrt = () => {
|
||||
useSounds();
|
||||
const { handleButton } = useButtons();
|
||||
const { lines, page } = useMrtStore((state) => state);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="mrt-container"
|
||||
@@ -78,150 +27,16 @@ export const Mrt = () => {
|
||||
maxHeight: "100%",
|
||||
maxWidth: "100%",
|
||||
color: "white",
|
||||
gridTemplateColumns: "21.83% 4.43% 24.42% 18.08% 5.93% 1.98% 6.00% 1.69% 6.00% 9.35%",
|
||||
gridTemplateRows: "21.58% 11.87% 3.55% 5.00% 6.84% 0.53% 3.03% 11.84% 3.55% 11.84% 20.39%",
|
||||
gridTemplateColumns:
|
||||
"9.75% 4.23% 8.59% 7.30% 1.16% 7.30% 1.23% 7.16% 1.09% 7.30% 3.68% 4.23% 5.59% 6.07% 1.91% 6.07% 1.84% 6.21% 9.28%",
|
||||
gridTemplateRows:
|
||||
"21.55% 11.83% 3.55% 2.50% 9.46% 2.76% 0.66% 4.99% 6.83% 3.55% 1.97% 9.99% 4.20% 11.04% 5.12%",
|
||||
}}
|
||||
>
|
||||
{page !== "sds" && (
|
||||
<Image
|
||||
src={MrtImage}
|
||||
alt="MrtImage"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
gridArea: "1 / 1 / 13 / 13",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{page === "sds" && (
|
||||
<Image
|
||||
src={MrtMessageImage}
|
||||
alt="MrtImage-Message"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
gridArea: "1 / 1 / 13 / 13",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleButton("home")}
|
||||
style={{ gridArea: "2 / 4 / 3 / 5", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("1")}
|
||||
style={{ gridArea: "2 / 5 / 3 / 6", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("2")}
|
||||
style={{ gridArea: "2 / 7 / 3 / 7", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("3")}
|
||||
style={{ gridArea: "2 / 9 / 3 / 10", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("4")}
|
||||
style={{ gridArea: "4 / 5 / 6 / 6", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("5")}
|
||||
style={{ gridArea: "4 / 7 / 6 / 7", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("6")}
|
||||
style={{ gridArea: "4 / 9 / 6 / 10", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("7")}
|
||||
style={{ gridArea: "8 / 5 / 9 / 6", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("8")}
|
||||
style={{ gridArea: "8 / 7 / 9 / 7", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("9")}
|
||||
style={{ gridArea: "8 / 9 / 9 / 10", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleButton("0")}
|
||||
style={{ gridArea: "10 / 7 / 11 / 8", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
|
||||
{lines[0] && (
|
||||
<DisplayLine
|
||||
{...lines[0]}
|
||||
style={
|
||||
page === "sds"
|
||||
? {
|
||||
gridArea: "2 / 3 / 3 / 4",
|
||||
marginLeft: "9px",
|
||||
marginTop: "auto",
|
||||
|
||||
...MRT_DISPLAYLINE_STYLES,
|
||||
...lines[0]?.style,
|
||||
}
|
||||
: {
|
||||
gridArea: "4 / 3 / 5 / 4",
|
||||
marginLeft: "9px",
|
||||
marginTop: "auto",
|
||||
...MRT_DISPLAYLINE_STYLES,
|
||||
...lines[0]?.style,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{lines[1] && (
|
||||
<DisplayLine
|
||||
lineStyle={{
|
||||
overflowX: "hidden",
|
||||
maxHeight: "100%",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
{...lines[1]}
|
||||
style={
|
||||
page === "sds"
|
||||
? {
|
||||
gridArea: "4 / 2 / 10 / 4",
|
||||
marginLeft: "3px",
|
||||
...MRT_DISPLAYLINE_STYLES,
|
||||
...lines[1].style,
|
||||
}
|
||||
: {
|
||||
gridArea: "5 / 3 / 7 / 4",
|
||||
marginLeft: "3px",
|
||||
marginTop: "auto",
|
||||
...MRT_DISPLAYLINE_STYLES,
|
||||
...lines[1].style,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{lines[2] && (
|
||||
<DisplayLine
|
||||
{...lines[2]}
|
||||
style={{
|
||||
gridArea: "8 / 2 / 9 / 4",
|
||||
...MRT_DISPLAYLINE_STYLES,
|
||||
...lines[2]?.style,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{lines[3] && (
|
||||
<DisplayLine
|
||||
{...lines[3]}
|
||||
style={{
|
||||
gridArea: "9 / 2 / 10 / 4",
|
||||
marginRight: "10px",
|
||||
...MRT_DISPLAYLINE_STYLES,
|
||||
...lines[3]?.style,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MrtPopups />
|
||||
<MrtDisplay />
|
||||
<MrtButtons />
|
||||
<MrtBase />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
150
apps/dispatch/app/(app)/pilot/_components/mrt/MrtButtons.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { CSSProperties, useRef } from "react";
|
||||
import { useButtons } from "./useButtons";
|
||||
|
||||
const MRT_BUTTON_STYLES: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
zIndex: "9999",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
};
|
||||
|
||||
interface MrtButtonProps {
|
||||
onClick: () => void;
|
||||
onHold?: () => void;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const MrtButton = ({ onClick, onHold, style }: MrtButtonProps) => {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseDown = () => {
|
||||
if (!onHold) return;
|
||||
timeoutRef.current = setTimeout(handleTimeoutExpired, 500);
|
||||
};
|
||||
|
||||
const handleTimeoutExpired = () => {
|
||||
timeoutRef.current = null;
|
||||
if (onHold) {
|
||||
onHold();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={() => timeoutRef.current && clearTimeout(timeoutRef.current)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MrtButtons = () => {
|
||||
const { handleHold, handleKlick } = useButtons();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* BELOW DISPLAY */}
|
||||
<MrtButton
|
||||
onClick={handleKlick("arrow-left")}
|
||||
onHold={handleHold("arrow-left")}
|
||||
style={{ gridArea: "14 / 4 / 15 / 5", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onClick={handleKlick("arrow-down")}
|
||||
onHold={handleHold("arrow-down")}
|
||||
style={{ gridArea: "14 / 6 / 15 / 7", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onClick={handleKlick("arrow-up")}
|
||||
onHold={handleHold("arrow-up")}
|
||||
style={{ gridArea: "14 / 8 / 15 / 9", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onClick={handleKlick("arrow-right")}
|
||||
onHold={handleHold("arrow-right")}
|
||||
style={{ gridArea: "14 / 10 / 15 / 11", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
|
||||
<MrtButton
|
||||
onClick={handleKlick("wheel-knob")}
|
||||
onHold={handleHold("wheel-knob")}
|
||||
style={{ gridArea: "14 / 2 / 15 / 4", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
{/* LINE SELECT KEY */}
|
||||
<MrtButton
|
||||
onHold={handleHold("3r")}
|
||||
onClick={handleKlick("3r")}
|
||||
style={{ gridArea: "9 / 12 / 11 / 13", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("3l")}
|
||||
onClick={handleKlick("3l")}
|
||||
style={{ gridArea: "9 / 2 / 11 / 3", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
{/* NUM PAD */}
|
||||
<MrtButton
|
||||
onHold={handleHold("1")}
|
||||
onClick={handleKlick("1")}
|
||||
style={{ gridArea: "2 / 14 / 3 / 15", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("2")}
|
||||
onClick={handleKlick("2")}
|
||||
style={{ gridArea: "2 / 16 / 3 / 17", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("3")}
|
||||
onClick={handleKlick("3")}
|
||||
style={{ gridArea: "2 / 18 / 3 / 19", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("4")}
|
||||
onClick={handleKlick("4")}
|
||||
style={{ gridArea: "4 / 14 / 6 / 15", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("5")}
|
||||
onClick={handleKlick("5")}
|
||||
style={{ gridArea: "4 / 16 / 6 / 17", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("6")}
|
||||
onClick={handleKlick("6")}
|
||||
style={{ gridArea: "4 / 18 / 6 / 19", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("7")}
|
||||
onClick={handleKlick("7")}
|
||||
style={{ gridArea: "8 / 14 / 10 / 15", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("8")}
|
||||
onClick={handleKlick("8")}
|
||||
style={{ gridArea: "8 / 16 / 10 / 17", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("9")}
|
||||
onClick={handleKlick("9")}
|
||||
style={{ gridArea: "8 / 18 / 10 / 19", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("0")}
|
||||
onClick={handleKlick("0")}
|
||||
style={{ gridArea: "11 / 16 / 13 / 17", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
<MrtButton
|
||||
onHold={handleHold("end-call")}
|
||||
onClick={handleKlick("end-call")}
|
||||
style={{ gridArea: "13 / 16 / 15 / 17", ...MRT_BUTTON_STYLES }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
16
apps/dispatch/app/(app)/pilot/_components/mrt/MrtDisplay.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.transition-image {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
|
||||
.transition-image.animate-reveal {
|
||||
animation: revealFromTop 0.6s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes revealFromTop {
|
||||
from {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
270
apps/dispatch/app/(app)/pilot/_components/mrt/MrtDisplay.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { SetPageParams, useMrtStore } from "_store/pilot/MrtStore";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import PAGE_HOME from "./images/PAGE_Home.png";
|
||||
import PAGE_HOME_NO_GROUP from "./images/PAGE_Home_no_group.png";
|
||||
import PAGE_Call from "./images/PAGE_Call.png";
|
||||
import PAGE_Off from "./images/PAGE_Off.png";
|
||||
import PAGE_STARTUP from "./images/PAGE_Startup.png";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn, useDebounce } from "@repo/shared-components";
|
||||
import "./MrtDisplay.css";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||
import { fmsStatusDescriptionShort } from "_data/fmsStatusDescription";
|
||||
import { useAudioStore } from "_store/audioStore";
|
||||
import { ROOMS } from "_data/livekitRooms";
|
||||
|
||||
export const MrtDisplay = () => {
|
||||
const { page, setPage, popup, setPopup, setStringifiedData, stringifiedData } = useMrtStore(
|
||||
(state) => state,
|
||||
);
|
||||
const callEstablishedRef = useRef(false);
|
||||
const session = useSession();
|
||||
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
|
||||
const { room, speakingParticipants, isTalking, state } = useAudioStore((state) => state);
|
||||
const [pageImage, setPageImage] = useState<{
|
||||
src: StaticImageData;
|
||||
name: SetPageParams["page"];
|
||||
}>({
|
||||
src: PAGE_Off,
|
||||
name: "off",
|
||||
});
|
||||
const [nextImage, setNextImage] = useState<
|
||||
| {
|
||||
src: StaticImageData;
|
||||
name: SetPageParams["page"];
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!nextImage) return;
|
||||
setPageImage(nextImage);
|
||||
setNextImage(undefined);
|
||||
},
|
||||
1000,
|
||||
[nextImage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if ((speakingParticipants.length > 0 || isTalking) && page === "home") {
|
||||
setPage({
|
||||
page: "voice-call",
|
||||
});
|
||||
}
|
||||
}, [speakingParticipants, isTalking, page, setPage]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (page === "startup") {
|
||||
setPage({
|
||||
page: "home",
|
||||
});
|
||||
}
|
||||
},
|
||||
6000,
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (page === "startup") {
|
||||
setPopup({
|
||||
popup: "login",
|
||||
});
|
||||
}
|
||||
},
|
||||
7500,
|
||||
[page, setPage],
|
||||
);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (page === "voice-call" && speakingParticipants.length === 0 && !isTalking) {
|
||||
setPage({
|
||||
page: "home",
|
||||
});
|
||||
}
|
||||
},
|
||||
4000,
|
||||
[page, setPage, speakingParticipants, isTalking],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts: NodeJS.Timeout[] = [];
|
||||
if (page === "voice-call") {
|
||||
setStringifiedData({
|
||||
callTextHeader: "Wählen",
|
||||
});
|
||||
timeouts.push(
|
||||
setTimeout(() => {
|
||||
setStringifiedData({
|
||||
callTextHeader: "Anruf...",
|
||||
});
|
||||
}, 500),
|
||||
|
||||
setTimeout(() => {
|
||||
setStringifiedData({
|
||||
callTextHeader: "Gruppenruf",
|
||||
});
|
||||
}, 800),
|
||||
setTimeout(() => {
|
||||
callEstablishedRef.current = true;
|
||||
}, 1500),
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
timeouts.forEach((t) => clearTimeout(t));
|
||||
};
|
||||
}, [page, setStringifiedData]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (isTalking && page === "voice-call") {
|
||||
setStringifiedData({
|
||||
callTextHeader: "Sprechen",
|
||||
});
|
||||
}
|
||||
},
|
||||
1500,
|
||||
[page, isTalking],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTalking && page === "voice-call" && callEstablishedRef.current) {
|
||||
console.log("SET TO SPRECHEN", stringifiedData.callTextHeader);
|
||||
setStringifiedData({
|
||||
callTextHeader: "Sprechen",
|
||||
});
|
||||
} else if (
|
||||
!isTalking &&
|
||||
page === "voice-call" &&
|
||||
stringifiedData.callTextHeader === "Sprechen"
|
||||
) {
|
||||
setStringifiedData({
|
||||
callTextHeader: "Gruppenruf",
|
||||
});
|
||||
}
|
||||
}, [page, stringifiedData.callTextHeader, isTalking, setStringifiedData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== "voice-call") {
|
||||
callEstablishedRef.current = false;
|
||||
}
|
||||
|
||||
switch (page) {
|
||||
case "home":
|
||||
if (state == "connected") {
|
||||
setNextImage({ src: PAGE_HOME, name: "home" });
|
||||
} else {
|
||||
setNextImage({ src: PAGE_HOME_NO_GROUP, name: "home" });
|
||||
}
|
||||
break;
|
||||
case "voice-call":
|
||||
setNextImage({ src: PAGE_Call, name: "voice-call" });
|
||||
break;
|
||||
case "off":
|
||||
setNextImage({ src: PAGE_Off, name: "off" });
|
||||
break;
|
||||
case "startup":
|
||||
setNextImage({ src: PAGE_STARTUP, name: "startup" });
|
||||
break;
|
||||
}
|
||||
}, [page, state]);
|
||||
|
||||
const DisplayText = ({ pageName }: { pageName: SetPageParams["page"] }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("font-semibold text-[#000d60]", !!popup && "filter")}
|
||||
style={{
|
||||
fontFamily: "Bahnschrift",
|
||||
}}
|
||||
>
|
||||
{pageName == "startup" && (
|
||||
<p className="absolute left-[17%] top-[65%] h-[10%] w-[39%] text-center">
|
||||
Bediengerät #{session.data?.user?.publicId}
|
||||
</p>
|
||||
)}
|
||||
{pageName == "home" && (
|
||||
<>
|
||||
<p className="absolute left-[24%] top-[21%] h-[4%] w-[1%] text-xs">
|
||||
{(room?.numParticipants || 1) - 1}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"absolute left-[17%] top-[25%] h-[8%] w-[39%] text-center",
|
||||
!connectedAircraft && "text-red-600",
|
||||
)}
|
||||
>
|
||||
{connectedAircraft && (
|
||||
<>
|
||||
Status {connectedAircraft.fmsStatus} -{" "}
|
||||
{fmsStatusDescriptionShort[connectedAircraft?.fmsStatus || "0"]}
|
||||
</>
|
||||
)}
|
||||
{!connectedAircraft && <>Keine Verbindung</>}
|
||||
</p>
|
||||
<p className="absolute left-[22.7%] top-[37.8%] flex h-[5%] w-[34%] items-center text-xs">
|
||||
{state == "connected" ? room?.name : "Keine RG gewählt"}
|
||||
</p>
|
||||
<p className="absolute left-[28%] top-[44.5%] h-[8%] w-[34%] text-xs">
|
||||
{state == "connected" && ROOMS.find((r) => r.name === room?.name)?.id}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{pageName == "voice-call" && (
|
||||
<div>
|
||||
<p className="absolute left-[18%] top-[18.8%] flex h-[10%] w-[37%] items-center">
|
||||
{stringifiedData.callTextHeader}
|
||||
</p>
|
||||
<p className="absolute left-[18%] top-[35%] h-[8%] w-[38%]">
|
||||
{isTalking && selectedStation?.bosCallsignShort}
|
||||
{speakingParticipants.length > 0 &&
|
||||
speakingParticipants.map((p) => p.attributes.role).join(", ")}
|
||||
</p>
|
||||
<p className="absolute left-[18%] top-[53.5%] h-[8%] w-[38%]">
|
||||
{room?.name || "Keine RG gefunden"}
|
||||
</p>
|
||||
<p className="absolute left-[18%] top-[60%] h-[8%] w-[36.7%] text-right">
|
||||
{ROOMS.find((r) => r.name === room?.name)?.id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={pageImage.src}
|
||||
alt=""
|
||||
width={1000}
|
||||
height={1000}
|
||||
className={cn(popup && "brightness-75 filter", "z-10 col-span-full row-span-full")}
|
||||
/>
|
||||
{nextImage && (
|
||||
<div
|
||||
className={cn(
|
||||
popup && "brightness-75 filter",
|
||||
"transition-image animate-reveal relative z-20 col-span-full row-span-full",
|
||||
)}
|
||||
>
|
||||
<Image src={nextImage.src} alt="" width={1000} height={1000} className="h-full w-full" />
|
||||
<DisplayText pageName={nextImage.name} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
popup && "brightness-75 filter",
|
||||
"relative z-10 col-span-full row-span-full overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<DisplayText pageName={pageImage.name} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
152
apps/dispatch/app/(app)/pilot/_components/mrt/MrtPopups.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { SetPopupParams, useMrtStore } from "_store/pilot/MrtStore";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import IAMGE_POPUP_LOGIN from "./images/POPUP_login.png";
|
||||
import GROUP_SELECTION_POPUP_LOGIN from "./images/POPUP_group_selection.png";
|
||||
import IAMGE_POPUP_SDS_RECEIVED from "./images/POPUP_SDS_incomming.png";
|
||||
import IAMGE_POPUP_SDS_SENT from "./images/POPUP_SDS_sent.png";
|
||||
import IAMGE_POPUP_STATUS_SENT from "./images/POPUP_Status_sent.png";
|
||||
import { ROOMS } from "_data/livekitRooms";
|
||||
import { cn, useDebounce } from "@repo/shared-components";
|
||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||
import { fmsStatusDescription, fmsStatusDescriptionShort } from "_data/fmsStatusDescription";
|
||||
import { pilotSocket } from "(app)/pilot/socket";
|
||||
import { StationStatus } from "@repo/db";
|
||||
import { useSounds } from "./useSounds";
|
||||
import { useButtons } from "./useButtons";
|
||||
import { useAudioStore } from "_store/audioStore";
|
||||
|
||||
export const MrtPopups = () => {
|
||||
const { sdsReceivedSoundRef } = useSounds();
|
||||
const { handleKlick } = useButtons();
|
||||
const { selectedRoom } = useAudioStore();
|
||||
const { popup, page, setPopup, setStringifiedData, stringifiedData } = useMrtStore(
|
||||
(state) => state,
|
||||
);
|
||||
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
|
||||
const [popupImage, setPopupImage] = useState<StaticImageData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
switch (popup) {
|
||||
case "status-sent":
|
||||
setPopupImage(IAMGE_POPUP_STATUS_SENT);
|
||||
break;
|
||||
case "sds-sent":
|
||||
setPopupImage(IAMGE_POPUP_SDS_SENT);
|
||||
break;
|
||||
case "sds-received":
|
||||
setPopupImage(IAMGE_POPUP_SDS_RECEIVED);
|
||||
break;
|
||||
case "login":
|
||||
setPopupImage(IAMGE_POPUP_LOGIN);
|
||||
break;
|
||||
case "group-selection":
|
||||
setPopupImage(GROUP_SELECTION_POPUP_LOGIN);
|
||||
break;
|
||||
case undefined:
|
||||
case null:
|
||||
setPopupImage(null);
|
||||
break;
|
||||
}
|
||||
}, [popup]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (popup == "login") return;
|
||||
if (popup == "sds-received") return;
|
||||
if (popup == "group-selection") return;
|
||||
setPopup(null);
|
||||
},
|
||||
3000,
|
||||
[popup],
|
||||
);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (popup == "group-selection") {
|
||||
if (selectedRoom?.id === stringifiedData.groupSelectionGroupId) {
|
||||
setPopup(null);
|
||||
} else {
|
||||
handleKlick("3l")();
|
||||
}
|
||||
}
|
||||
},
|
||||
5000,
|
||||
[page, stringifiedData.groupSelectionGroupId, selectedRoom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "connecting" && page !== "off" && page !== "startup") {
|
||||
setPopup({ popup: "login" });
|
||||
}
|
||||
}, [status, setPopup, page]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (status === "connected") {
|
||||
setPopup(null);
|
||||
}
|
||||
},
|
||||
5000,
|
||||
[status],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
pilotSocket.on("sds-status", (data: StationStatus) => {
|
||||
setStringifiedData({ sdsText: data.status + " - " + fmsStatusDescriptionShort[data.status] });
|
||||
setPopup({ popup: "sds-received" });
|
||||
if (sdsReceivedSoundRef.current) {
|
||||
sdsReceivedSoundRef.current.currentTime = 0;
|
||||
sdsReceivedSoundRef.current.play();
|
||||
}
|
||||
});
|
||||
}, [setPopup, setStringifiedData, sdsReceivedSoundRef]);
|
||||
|
||||
if (!popupImage || !popup) return null;
|
||||
|
||||
const DisplayText = ({ pageName }: { pageName: SetPopupParams["popup"] }) => {
|
||||
const group = ROOMS.find((r) => r.id === stringifiedData.groupSelectionGroupId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("font-semibold text-[#000d60]", !!popup && "filter")}
|
||||
style={{
|
||||
fontFamily: "Bahnschrift",
|
||||
}}
|
||||
>
|
||||
{pageName == "status-sent" && (
|
||||
<p className="absolute left-[17.5%] top-[44%] h-[10%] w-[39%] text-lg">
|
||||
{fmsStatusDescription[connectedAircraft?.fmsStatus || "0"]}
|
||||
</p>
|
||||
)}
|
||||
{pageName == "sds-sent" && (
|
||||
<p className="absolute left-[17.5%] top-[44%] h-[10%] w-[39%] text-lg">
|
||||
{fmsStatusDescription[stringifiedData.sentSdsText || "0"]}
|
||||
</p>
|
||||
)}
|
||||
{pageName == "sds-received" && (
|
||||
<p className="absolute left-[17.5%] top-[39%] h-[24%] w-[60%] whitespace-normal break-words">
|
||||
{stringifiedData.sdsText}
|
||||
</p>
|
||||
)}
|
||||
{pageName == "group-selection" && (
|
||||
<>
|
||||
<p className="absolute left-[24%] top-[39%] h-[5%] w-[30%]">{group?.name}</p>
|
||||
<p className="absolute left-[24%] top-[50%] flex h-[9%] w-[31%] items-end justify-end">
|
||||
{stringifiedData.groupSelectionGroupId}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image src={popupImage} alt="" className="z-30 col-span-full row-span-full" />
|
||||
<div className="relative z-30 col-span-full row-span-full overflow-hidden">
|
||||
<DisplayText pageName={popup} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
@@ -1,15 +1,58 @@
|
||||
import { Prisma } from "@repo/db";
|
||||
import { getPublicUser, MissionSdsStatusLog, Prisma } from "@repo/db";
|
||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||
import { useMrtStore } from "_store/pilot/MrtStore";
|
||||
import { pilotSocket } from "(app)/pilot/socket";
|
||||
import { editConnectedAircraftAPI } from "_querys/aircrafts";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSounds } from "./useSounds";
|
||||
import { sendSdsStatusMessageAPI } from "_querys/missions";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { ROOMS } from "_data/livekitRooms";
|
||||
import { useAudioStore } from "_store/audioStore";
|
||||
|
||||
type ButtonTypes =
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "0"
|
||||
| "home"
|
||||
| "3l"
|
||||
| "3r"
|
||||
| "wheel-knob"
|
||||
| "arrow-up"
|
||||
| "arrow-down"
|
||||
| "arrow-left"
|
||||
| "arrow-right"
|
||||
| "end-call";
|
||||
|
||||
export const useButtons = () => {
|
||||
const station = usePilotConnectionStore((state) => state.selectedStation);
|
||||
const connectedAircraft = usePilotConnectionStore((state) => state.connectedAircraft);
|
||||
const connectionStatus = usePilotConnectionStore((state) => state.status);
|
||||
const session = useSession();
|
||||
const { connect, setSelectedRoom, selectedRoom } = useAudioStore((state) => state);
|
||||
|
||||
const { longBtnPressSoundRef, statusSentSoundRef } = useSounds();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
status: pilotState,
|
||||
selectedStation,
|
||||
connectedAircraft,
|
||||
} = usePilotConnectionStore((state) => state);
|
||||
|
||||
const sendSdsStatusMutation = useMutation({
|
||||
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
|
||||
if (!connectedAircraft?.id) throw new Error("No connected aircraft");
|
||||
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateAircraftMutation = useMutation({
|
||||
mutationKey: ["edit-pilot-connected-aircraft"],
|
||||
mutationFn: ({
|
||||
@@ -21,56 +64,161 @@ export const useButtons = () => {
|
||||
}) => editConnectedAircraftAPI(aircraftId, data),
|
||||
});
|
||||
|
||||
const { setPage } = useMrtStore((state) => state);
|
||||
const { setPage, setPopup, page, popup, setStringifiedData, stringifiedData } = useMrtStore(
|
||||
(state) => state,
|
||||
);
|
||||
|
||||
const handleButton =
|
||||
(button: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0" | "home") => () => {
|
||||
if (connectionStatus !== "connected") return;
|
||||
if (!station) return;
|
||||
if (!connectedAircraft?.id) return;
|
||||
if (
|
||||
button === "1" ||
|
||||
button === "2" ||
|
||||
button === "3" ||
|
||||
button === "4" ||
|
||||
button === "5" ||
|
||||
button === "6" ||
|
||||
button === "7" ||
|
||||
button === "8" ||
|
||||
button === "9" ||
|
||||
button === "0"
|
||||
) {
|
||||
setPage({ page: "sending-status", station });
|
||||
const role =
|
||||
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
|
||||
session.data?.user?.publicId;
|
||||
|
||||
setTimeout(async () => {
|
||||
await updateAircraftMutation.mutateAsync({
|
||||
aircraftId: connectedAircraft.id,
|
||||
data: {
|
||||
fmsStatus: button,
|
||||
},
|
||||
});
|
||||
setPage({
|
||||
page: "home",
|
||||
station,
|
||||
const handleHold = (button: ButtonTypes) => async () => {
|
||||
/* if (connectionStatus !== "connected") return; */
|
||||
if (button === "end-call") {
|
||||
setPage({ page: "off" });
|
||||
setPopup(null);
|
||||
}
|
||||
if (button === "1" && page === "off") {
|
||||
setPage({ page: "startup" });
|
||||
return;
|
||||
}
|
||||
if (!selectedStation) return;
|
||||
if (!session.data?.user) return;
|
||||
if (!connectedAircraft?.id) return;
|
||||
if (
|
||||
button === "1" ||
|
||||
button === "2" ||
|
||||
button === "3" ||
|
||||
button === "4" ||
|
||||
button === "6" ||
|
||||
button === "7" ||
|
||||
button === "8"
|
||||
) {
|
||||
longBtnPressSoundRef.current?.play();
|
||||
const delay = Math.random() * 1500 + 500;
|
||||
setTimeout(async () => {
|
||||
await updateAircraftMutation.mutateAsync({
|
||||
aircraftId: connectedAircraft.id,
|
||||
data: {
|
||||
fmsStatus: button,
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
setPage({ page: "home", fmsStatus: connectedAircraft.fmsStatus || "6", station });
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
setPopup({ popup: "status-sent" });
|
||||
statusSentSoundRef.current?.play();
|
||||
}, delay);
|
||||
} else if (button === "5" || button === "9" || button === "0") {
|
||||
longBtnPressSoundRef.current?.play();
|
||||
const delay = Math.random() * 1500 + 500;
|
||||
setTimeout(async () => {
|
||||
await sendSdsStatusMutation.mutateAsync({
|
||||
sdsMessage: {
|
||||
type: "sds-status-log",
|
||||
auto: false,
|
||||
timeStamp: new Date().toISOString(),
|
||||
data: {
|
||||
direction: "to-lst",
|
||||
stationId: selectedStation.id,
|
||||
station: selectedStation,
|
||||
user: getPublicUser(session.data?.user),
|
||||
status: button,
|
||||
},
|
||||
},
|
||||
});
|
||||
setStringifiedData({ sentSdsText: button });
|
||||
statusSentSoundRef.current?.play();
|
||||
setPopup({ popup: "sds-sent" });
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKlick = (button: ButtonTypes) => async () => {
|
||||
console.log("Button clicked:", button);
|
||||
//implement Kurzwahl when button is clicked short to dial
|
||||
|
||||
switch (button) {
|
||||
case "0":
|
||||
case "1":
|
||||
case "2":
|
||||
case "3":
|
||||
case "4":
|
||||
case "5":
|
||||
case "6":
|
||||
case "7":
|
||||
case "8":
|
||||
case "9":
|
||||
//handle short press number buttons for kurzwahl
|
||||
if (popup === "group-selection") {
|
||||
if (stringifiedData.groupSelectionGroupId?.length === 4) {
|
||||
setStringifiedData({ groupSelectionGroupId: button });
|
||||
} else {
|
||||
setStringifiedData({
|
||||
groupSelectionGroupId: (stringifiedData.groupSelectionGroupId || "") + button,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (page === "home" && !popup) {
|
||||
setPopup({ popup: "group-selection" });
|
||||
setStringifiedData({ groupSelectionGroupId: button });
|
||||
}
|
||||
break;
|
||||
case "3r":
|
||||
if (popup === "sds-received" || popup === "group-selection") {
|
||||
setPopup(null);
|
||||
} else if (page === "home") {
|
||||
setPopup({ popup: "group-selection" });
|
||||
setStringifiedData({ groupSelectionGroupId: selectedRoom?.id || ROOMS[0]!.id });
|
||||
} else if (page === "voice-call") {
|
||||
setPage({ page: "home" });
|
||||
}
|
||||
break;
|
||||
case "wheel-knob":
|
||||
setPopup(popup === "group-selection" ? null : { popup: "group-selection" });
|
||||
setStringifiedData({ groupSelectionGroupId: selectedRoom?.id || ROOMS[0]!.id });
|
||||
break;
|
||||
case "arrow-right":
|
||||
if (popup === "group-selection") {
|
||||
let currentGroupIndex = ROOMS.findIndex(
|
||||
(r) => r.id === stringifiedData.groupSelectionGroupId,
|
||||
);
|
||||
if (currentGroupIndex === ROOMS.length - 1) currentGroupIndex = -1;
|
||||
const nextGroup = ROOMS[currentGroupIndex + 1];
|
||||
if (nextGroup) {
|
||||
setStringifiedData({ groupSelectionGroupId: nextGroup.id });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "arrow-left":
|
||||
if (popup === "group-selection") {
|
||||
let currentGroupIndex = ROOMS.findIndex(
|
||||
(r) => r.id === stringifiedData.groupSelectionGroupId,
|
||||
);
|
||||
if (currentGroupIndex === 0) currentGroupIndex = ROOMS.length;
|
||||
const previousGroup = ROOMS[currentGroupIndex - 1];
|
||||
if (previousGroup) {
|
||||
setStringifiedData({ groupSelectionGroupId: previousGroup.id });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "3l":
|
||||
if (popup === "group-selection") {
|
||||
const group = ROOMS.find((r) => r.id === stringifiedData.groupSelectionGroupId);
|
||||
if (group && role) {
|
||||
setSelectedRoom(group);
|
||||
connect(group, role);
|
||||
setPopup(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
pilotSocket.on("connect", () => {
|
||||
if (!station) return;
|
||||
setPage({ page: "home", fmsStatus: "6", station });
|
||||
const { page } = useMrtStore.getState();
|
||||
if (!selectedStation || page !== "off") return;
|
||||
setPage({ page: "startup" });
|
||||
});
|
||||
}, [setPage, selectedStation, setPopup]);
|
||||
|
||||
pilotSocket.on("aircraft-update", () => {
|
||||
if (!station) return;
|
||||
setPage({ page: "new-status", station });
|
||||
});
|
||||
}, [setPage, station]);
|
||||
|
||||
return { handleButton };
|
||||
return { handleKlick, handleHold };
|
||||
};
|
||||
|
||||
@@ -1,52 +1,39 @@
|
||||
"use client";
|
||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||
import { useMrtStore } from "_store/pilot/MrtStore";
|
||||
import { useAudioStore } from "_store/audioStore";
|
||||
import { RoomEvent } from "livekit-client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export const useSounds = () => {
|
||||
const mrtState = useMrtStore((state) => state);
|
||||
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
|
||||
|
||||
const setPage = useMrtStore((state) => state.setPage);
|
||||
const MRTstatusSoundRef = useRef<HTMLAudioElement>(null);
|
||||
const MrtMessageReceivedSoundRef = useRef<HTMLAudioElement>(null);
|
||||
const { room } = useAudioStore((state) => state);
|
||||
const longBtnPressSoundRef = useRef<HTMLAudioElement>(null);
|
||||
const statusSentSoundRef = useRef<HTMLAudioElement>(null);
|
||||
const sdsReceivedSoundRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
MRTstatusSoundRef.current = new Audio("/sounds/MRT-status.mp3");
|
||||
MrtMessageReceivedSoundRef.current = new Audio("/sounds/MRT-message-received.mp3");
|
||||
MRTstatusSoundRef.current.onended = () => {
|
||||
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
|
||||
setPage({
|
||||
page: "home",
|
||||
station: selectedStation,
|
||||
fmsStatus: connectedAircraft?.fmsStatus,
|
||||
});
|
||||
};
|
||||
MrtMessageReceivedSoundRef.current.onended = () => {
|
||||
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
|
||||
if (mrtState.page === "sds") return;
|
||||
setPage({
|
||||
page: "home",
|
||||
station: selectedStation,
|
||||
fmsStatus: connectedAircraft?.fmsStatus,
|
||||
});
|
||||
};
|
||||
longBtnPressSoundRef.current = new Audio("/sounds/1504.wav");
|
||||
statusSentSoundRef.current = new Audio("/sounds/403.wav");
|
||||
sdsReceivedSoundRef.current = new Audio("/sounds/775.wav");
|
||||
}
|
||||
}, [connectedAircraft?.fmsStatus, selectedStation, setPage, mrtState.page]);
|
||||
|
||||
const fmsStatus = connectedAircraft?.fmsStatus || "NaN";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedAircraft) return;
|
||||
if (mrtState.page === "new-status") {
|
||||
if (fmsStatus === "J" || fmsStatus === "c") {
|
||||
MrtMessageReceivedSoundRef.current?.play();
|
||||
} else {
|
||||
MRTstatusSoundRef.current?.play();
|
||||
}
|
||||
} else if (mrtState.page === "sds") {
|
||||
MrtMessageReceivedSoundRef.current?.play();
|
||||
}
|
||||
}, [mrtState, fmsStatus, connectedAircraft, selectedStation]);
|
||||
const handleRoomConnected = () => {
|
||||
// Play a sound when connected to the room
|
||||
// connectedSound.play();
|
||||
statusSentSoundRef.current?.play();
|
||||
console.log("Room connected - played sound");
|
||||
};
|
||||
room?.on(RoomEvent.Connected, handleRoomConnected);
|
||||
|
||||
return () => {
|
||||
room?.off(RoomEvent.Connected, handleRoomConnected);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
return {
|
||||
longBtnPressSoundRef,
|
||||
statusSentSoundRef,
|
||||
sdsReceivedSoundRef,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -76,7 +76,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 && (
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Connection } from "./_components/Connection";
|
||||
import { Audio } from "_components/Audio/Audio";
|
||||
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { Settings } from "./_components/Settings";
|
||||
import { WarningAlert } from "_components/navbar/PageAlert";
|
||||
import { Radar } from "lucide-react";
|
||||
import { prisma } from "@repo/db";
|
||||
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
|
||||
|
||||
export default async function Navbar() {
|
||||
const latestChangelog = await prisma.changelog.findFirst({
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
|
||||
<ChangelogWrapper latestChangelog={latestChangelog} />
|
||||
</div>
|
||||
</div>
|
||||
<WarningAlert />
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Audio />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Connection />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Settings />
|
||||
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
|
||||
<button className="btn btn-ghost">
|
||||
<Radar size={19} /> Tracker
|
||||
</button>
|
||||
</Link>
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<button className="btn btn-ghost">
|
||||
<ExternalLinkIcon className="h-4 w-4" /> HUB
|
||||
</button>
|
||||
</Link>
|
||||
<Link href={"/logout"}>
|
||||
<button className="btn btn-ghost">
|
||||
<ExitIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
|
||||
|
||||
interface ThemeSwapProps {
|
||||
isDark: boolean;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
|
||||
isDark,
|
||||
toggleTheme,
|
||||
}) => {
|
||||
return (
|
||||
<label className="swap swap-rotate">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="theme-controller"
|
||||
checked={isDark}
|
||||
onChange={toggleTheme}
|
||||
/>
|
||||
<MoonIcon className="swap-off h-5 w-5 fill-current" />
|
||||
<SunIcon className="swap-on h-5 w-5 fill-current" />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import Navbar from "./_components/navbar/Navbar";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
import { Error } from "_components/Error";
|
||||
import Navbar from "(app)/_components/Navbar";
|
||||
import { Audio } from "_components/Audio/Audio";
|
||||
import { Connection } from "./_components/navbar/Connection";
|
||||
import { Settings } from "./_components/navbar/Settings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "VAR: Pilot",
|
||||
@@ -26,7 +29,11 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Navbar>
|
||||
<Audio />
|
||||
<Connection />
|
||||
<Settings />
|
||||
</Navbar>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ const Map = dynamic(() => import("_components/map/Map"), {
|
||||
});
|
||||
|
||||
const PilotPage = () => {
|
||||
const { connectedAircraft, status, } = usePilotConnectionStore((state) => state);
|
||||
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
|
||||
const { latestMission } = useDmeStore((state) => state);
|
||||
// Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
|
||||
const { data: aircrafts } = useQuery({
|
||||
@@ -94,10 +94,20 @@ const PilotPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-1/3">
|
||||
<div className="flex h-full w-1/3 min-w-[500px]">
|
||||
<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="mb-2 flex items-center justify-end gap-2">
|
||||
<h2 className="card-title">MRT & DME</h2>
|
||||
<a
|
||||
href="https://docs.virtualairrescue.com/allgemein/var-systeme/leitstelle/pilot.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link text-xs text-gray-500 hover:underline"
|
||||
>
|
||||
Hilfe
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="tooltip tooltip-left mb-4"
|
||||
data-tip="Dadurch wird der Einsatz erneut an den Desktop-Client gesendet."
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useSounds } from "_components/Audio/useSounds";
|
||||
|
||||
export const Audio = () => {
|
||||
const {
|
||||
selectedRoom,
|
||||
speakingParticipants,
|
||||
resetSpeakingParticipants,
|
||||
isTalking,
|
||||
@@ -37,8 +38,8 @@ export const Audio = () => {
|
||||
room,
|
||||
message,
|
||||
removeMessage,
|
||||
setSelectedRoom,
|
||||
} = useAudioStore();
|
||||
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
|
||||
|
||||
useSounds({
|
||||
isReceiving: speakingParticipants.length > 0,
|
||||
@@ -48,7 +49,7 @@ export const Audio = () => {
|
||||
});
|
||||
|
||||
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
|
||||
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state);
|
||||
const { status: dispatcherState } = useDispatchConnectionStore((state) => state);
|
||||
const session = useSession();
|
||||
const [isReceivingBlick, setIsReceivingBlick] = useState(false);
|
||||
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
|
||||
@@ -93,7 +94,7 @@ export const Audio = () => {
|
||||
const canStopOtherSpeakers = dispatcherState === "connected";
|
||||
|
||||
const role =
|
||||
(dispatcherState === "connected" && selectedZone) ||
|
||||
(dispatcherState === "connected" && "VAR LST") ||
|
||||
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
|
||||
session.data?.user?.publicId;
|
||||
|
||||
@@ -185,20 +186,20 @@ export const Audio = () => {
|
||||
</summary>
|
||||
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
|
||||
{ROOMS.map((r) => (
|
||||
<li key={r}>
|
||||
<li key={r.id}>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
|
||||
onClick={() => {
|
||||
if (!role) return;
|
||||
if (selectedRoom === r) return;
|
||||
if (selectedRoom?.name === r.name) return;
|
||||
setSelectedRoom(r);
|
||||
connect(r, role);
|
||||
}}
|
||||
>
|
||||
{room?.name === r && (
|
||||
{room?.name === r.name && (
|
||||
<Disc className="text-success absolute left-2 text-sm" width={15} />
|
||||
)}
|
||||
<span className="flex-1 text-center">{r}</span>
|
||||
<span className="flex-1 text-center">{r.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -63,14 +63,15 @@ export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const handleNotification = (notification: NotificationPayload) => {
|
||||
console.log("Received notification:", notification);
|
||||
const playNotificationSound = () => {
|
||||
if (notificationSound.current) {
|
||||
notificationSound.current.currentTime = 0;
|
||||
notificationSound.current
|
||||
.play()
|
||||
.catch((e) => console.error("Notification sound error:", e));
|
||||
}
|
||||
}
|
||||
notificationSound.current.currentTime = 0;
|
||||
notificationSound.current
|
||||
.play()
|
||||
.catch((e) => console.error("Notification sound error:", e));
|
||||
}
|
||||
};
|
||||
|
||||
switch (notification.type) {
|
||||
case "hpg-validation":
|
||||
@@ -90,6 +91,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
break;
|
||||
case "station-status":
|
||||
console.log("station Status", QUICK_RESPONSE[notification.status]);
|
||||
if (!QUICK_RESPONSE[notification.status]) return;
|
||||
toast.custom((e) => <StatusToast event={notification} t={e} />, {
|
||||
duration: 60000,
|
||||
@@ -106,7 +108,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
break;
|
||||
case "mission-closed":
|
||||
toast("Dein aktueller Einsatz wurde geschlossen.");
|
||||
|
||||
|
||||
break;
|
||||
default:
|
||||
toast("unbekanntes Notification-Event");
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Prisma, StationStatus } from "@repo/db";
|
||||
import { getPublicUser, MissionSdsStatusLog, StationStatus } from "@repo/db";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BaseNotification } from "_components/customToasts/BaseNotification";
|
||||
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
|
||||
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||
import { getLivekitRooms } from "_querys/livekit";
|
||||
import { sendSdsStatusMessageAPI } from "_querys/missions";
|
||||
import { getStationsAPI } from "_querys/stations";
|
||||
import { useAudioStore } from "_store/audioStore";
|
||||
import { useMapStore } from "_store/mapStore";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Toast, toast } from "react-hot-toast";
|
||||
|
||||
export const QUICK_RESPONSE: Record<string, string[]> = {
|
||||
@@ -22,6 +24,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
||||
const status5Sounds = useRef<HTMLAudioElement | null>(null);
|
||||
const status9Sounds = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const session = useSession();
|
||||
|
||||
const { data: livekitRooms } = useQuery({
|
||||
queryKey: ["livekit-rooms"],
|
||||
queryFn: () => getLivekitRooms(),
|
||||
@@ -46,7 +50,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
||||
status9Sounds.current = new Audio("/sounds/status-9.mp3");
|
||||
}
|
||||
}, []);
|
||||
const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
|
||||
|
||||
//const mapStore = useMapStore((s) => s);
|
||||
const { setOpenAircraftMarker, setMap } = useMapStore((store) => store);
|
||||
|
||||
@@ -65,28 +69,16 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
||||
const station = stations?.find((s) => s.id === event.data?.stationId);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const changeAircraftMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
update,
|
||||
}: {
|
||||
id: number;
|
||||
update: Prisma.ConnectedAircraftUpdateInput;
|
||||
}) => {
|
||||
await editConnectedAircraftAPI(id, update);
|
||||
const sendSdsStatusMutation = useMutation({
|
||||
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
|
||||
if (!connectedAircraft?.id) throw new Error("No connected aircraft");
|
||||
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["aircrafts"],
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (event.status !== connectedAircraft?.fmsStatus && aircraftDataAcurate) {
|
||||
toast.remove(t.id);
|
||||
} else if (event.status == connectedAircraft?.fmsStatus && !aircraftDataAcurate) {
|
||||
setAircraftDataAccurate(true);
|
||||
}
|
||||
}, [aircraftDataAcurate, connectedAircraft, event.status, t.id]);
|
||||
console.log("Audio Room:", audioRoom, participants, livekitUser, event);
|
||||
|
||||
useEffect(() => {
|
||||
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
|
||||
@@ -103,7 +95,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
||||
default:
|
||||
soundRef = null;
|
||||
}
|
||||
if (audioRoom !== livekitUser?.roomName) {
|
||||
|
||||
if (audioRoom && livekitUser?.roomName && audioRoom !== livekitUser?.roomName) {
|
||||
toast.remove(t.id);
|
||||
return;
|
||||
}
|
||||
@@ -121,7 +114,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
||||
};
|
||||
}, [event.status, livekitUser?.roomName, audioRoom, t.id]);
|
||||
|
||||
if (!connectedAircraft || !station) return null;
|
||||
if (!connectedAircraft || !station || !session.data) return null;
|
||||
return (
|
||||
<BaseNotification>
|
||||
<div className="flex flex-row items-center gap-14">
|
||||
@@ -162,10 +155,18 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
||||
toast.error("Keine Flugzeug-ID gefunden");
|
||||
return;
|
||||
}
|
||||
await changeAircraftMutation.mutateAsync({
|
||||
id: event.data?.aircraftId,
|
||||
update: {
|
||||
fmsStatus: status,
|
||||
await sendSdsStatusMutation.mutateAsync({
|
||||
sdsMessage: {
|
||||
type: "sds-status-log",
|
||||
auto: false,
|
||||
data: {
|
||||
direction: "to-aircraft",
|
||||
stationId: event.data.stationId!,
|
||||
station: station,
|
||||
user: getPublicUser(session.data?.user),
|
||||
status,
|
||||
},
|
||||
timeStamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
toast.remove(t.id);
|
||||
|
||||
@@ -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, {
|
||||
@@ -396,27 +396,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: ["connected-aircrafts", "map"],
|
||||
queryFn: () => getConnectedAircraftsAPI(),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const { setMap } = useMapStore((state) => state);
|
||||
const map = useMap();
|
||||
const {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Mission,
|
||||
MissionLog,
|
||||
MissionSdsLog,
|
||||
MissionSdsStatusLog,
|
||||
MissionStationLog,
|
||||
Prisma,
|
||||
PublicUser,
|
||||
@@ -40,7 +41,7 @@ import {
|
||||
TextSearch,
|
||||
} from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { sendSdsMessageAPI } from "_querys/missions";
|
||||
import { sendSdsMessageAPI, sendSdsStatusMessageAPI } from "_querys/missions";
|
||||
import { getLivekitRooms } from "_querys/livekit";
|
||||
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
|
||||
import { formatDistance } from "date-fns";
|
||||
@@ -54,9 +55,13 @@ const FMSStatusHistory = ({
|
||||
mission?: Mission;
|
||||
}) => {
|
||||
const log = ((mission?.missionLog as unknown as MissionLog[]) || [])
|
||||
.filter((entry) => entry.type === "station-log" && entry.data.stationId === aircraft.Station.id)
|
||||
.filter(
|
||||
(entry) =>
|
||||
(entry.type === "station-log" || entry.type == "sds-status-log") &&
|
||||
entry.data.stationId === aircraft.Station.id,
|
||||
)
|
||||
.reverse()
|
||||
.splice(0, 6) as MissionStationLog[];
|
||||
.splice(0, 6) as (MissionStationLog | MissionSdsStatusLog)[];
|
||||
|
||||
const aircraftUser: PublicUser =
|
||||
typeof aircraft.publicUser === "string" ? JSON.parse(aircraft.publicUser) : aircraft.publicUser;
|
||||
@@ -103,10 +108,13 @@ const FMSStatusHistory = ({
|
||||
<span
|
||||
className="text-base font-bold"
|
||||
style={{
|
||||
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
|
||||
color:
|
||||
FMS_STATUS_TEXT_COLORS[
|
||||
entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus
|
||||
],
|
||||
}}
|
||||
>
|
||||
{entry.data.newFMSstatus}
|
||||
{entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus}
|
||||
</span>
|
||||
<span className="text-base-content">
|
||||
{new Date(entry.timeStamp).toLocaleTimeString([], {
|
||||
@@ -126,6 +134,7 @@ const FMSStatusSelector = ({
|
||||
}: {
|
||||
aircraft: ConnectedAircraft & { Station: Station };
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
|
||||
const [hoveredStatus, setHoveredStatus] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -144,6 +153,20 @@ const FMSStatusSelector = ({
|
||||
},
|
||||
});
|
||||
|
||||
const sendSdsStatusMutation = useMutation({
|
||||
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
|
||||
if (!aircraft?.id) throw new Error("No connected aircraft");
|
||||
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: aircraft?.id });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["missions"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.data?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-base-content mt-2 flex flex-col gap-2 p-4">
|
||||
<div className="flex h-full items-center justify-center gap-2">
|
||||
@@ -213,12 +236,21 @@ const FMSStatusSelector = ({
|
||||
onMouseEnter={() => setHoveredStatus(status)}
|
||||
onMouseLeave={() => setHoveredStatus(null)}
|
||||
onClick={async () => {
|
||||
await changeAircraftMutation.mutateAsync({
|
||||
id: aircraft.id,
|
||||
update: {
|
||||
fmsStatus: status,
|
||||
await sendSdsStatusMutation.mutateAsync({
|
||||
sdsMessage: {
|
||||
type: "sds-status-log",
|
||||
auto: false,
|
||||
timeStamp: new Date().toISOString(),
|
||||
data: {
|
||||
status: status,
|
||||
direction: "to-aircraft",
|
||||
stationId: aircraft.Station.id,
|
||||
station: aircraft.Station,
|
||||
user: getPublicUser(session.data?.user),
|
||||
},
|
||||
},
|
||||
});
|
||||
toast.success(`SDS Status ${status} gesendet`);
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
@@ -378,7 +410,9 @@ const SDSTab = ({
|
||||
?.slice()
|
||||
.reverse()
|
||||
.filter(
|
||||
(entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id,
|
||||
(entry) =>
|
||||
(entry.type === "sds-log" || entry.type == "sds-status-log") &&
|
||||
entry.data.stationId === aircraft.Station.id,
|
||||
) || [],
|
||||
[mission?.missionLog, aircraft.Station.id],
|
||||
);
|
||||
@@ -471,7 +505,7 @@ const SDSTab = ({
|
||||
)}
|
||||
<ul className="max-h-[300px] space-y-2 overflow-x-auto overflow-y-auto">
|
||||
{log.map((entry, index) => {
|
||||
const sdsEntry = entry as MissionSdsLog;
|
||||
const sdsEntry = entry as MissionSdsLog | MissionSdsStatusLog;
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-base-content">
|
||||
@@ -489,7 +523,9 @@ const SDSTab = ({
|
||||
{sdsEntry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
|
||||
{sdsEntry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
|
||||
</span>
|
||||
<span className="text-base-content">{sdsEntry.data.message}</span>
|
||||
<span className="text-base-content">
|
||||
{sdsEntry.type == "sds-log" ? sdsEntry.data.message : sdsEntry.data.status}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -726,7 +726,11 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
||||
<span className="text-base-content">{entry.data.station.bosCallsign}</span>
|
||||
</li>
|
||||
);
|
||||
if (entry.type === "message-log" || entry.type === "sds-log")
|
||||
if (
|
||||
entry.type === "message-log" ||
|
||||
entry.type === "sds-log" ||
|
||||
entry.type === "sds-status-log"
|
||||
)
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-base-content">
|
||||
@@ -741,9 +745,10 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
||||
color: FMS_STATUS_TEXT_COLORS[6],
|
||||
}}
|
||||
>
|
||||
{entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
|
||||
{entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
|
||||
{entry.type === "sds-log" && (
|
||||
{entry.type == "sds-status-log" && entry.data.direction == "to-lst"
|
||||
? entry.data.station.bosCallsignShort
|
||||
: `${entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}${entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}`}
|
||||
{(entry.type === "sds-log" || entry.type === "sds-status-log") && (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -760,11 +765,17 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{entry.data.station.bosCallsignShort}
|
||||
{entry.type == "sds-status-log" && entry.data.direction == "to-aircraft"
|
||||
? entry.data.station.bosCallsignShort
|
||||
: "LST"}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-base-content">{entry.data.message}</span>
|
||||
<span className="text-base-content">
|
||||
{entry.type === "sds-log" || entry.type === "message-log"
|
||||
? entry.data.message
|
||||
: entry.data.status}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
if (
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@repo/shared-components";
|
||||
import { ArrowLeftRight, Plane, Radar, Workflow } from "lucide-react";
|
||||
import { ArrowLeftRight, ExternalLinkIcon, Plane, Radar, Workflow } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function ModeSwitchDropdown({ className }: { className?: string }) {
|
||||
export default function ModeSwitchDropdown({
|
||||
className,
|
||||
btnClassName,
|
||||
}: {
|
||||
className?: string;
|
||||
btnClassName?: string;
|
||||
}) {
|
||||
const path = usePathname();
|
||||
const session = useSession();
|
||||
|
||||
return (
|
||||
<div className={cn("dropdown z-999999", className)}>
|
||||
<div tabIndex={0} role="button" className="btn m-1">
|
||||
<div tabIndex={0} role="button" className={cn("btn", btnClassName)}>
|
||||
<ArrowLeftRight size={22} /> {path.includes("pilot") && "Pilot"}
|
||||
{path.includes("dispatch") && "Leitstelle"}
|
||||
</div>
|
||||
@@ -39,6 +45,15 @@ export default function ModeSwitchDropdown({ className }: { className?: string }
|
||||
<Radar size={22} /> Tracker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon size={22} /> HUB
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,3 +24,30 @@ export const fmsStatusDescription: { [key: string]: string } = {
|
||||
o: "Warten, alle Abfrageplätze belegt",
|
||||
u: "Verstanden",
|
||||
};
|
||||
|
||||
export const fmsStatusDescriptionShort: { [key: string]: string } = {
|
||||
NaN: "Keine D.",
|
||||
"0": "Prio. Sprechen",
|
||||
"1": "E.-bereit Funk",
|
||||
"2": "E.-bereit Wache",
|
||||
"3": "E.-übernahme",
|
||||
"4": "Einsatzort",
|
||||
"5": "Sprechwunsch",
|
||||
"6": "Nicht e.-bereit",
|
||||
"7": "Einsatzgeb.",
|
||||
"8": "Bed. Verfügbar",
|
||||
"9": "F-anmeldung",
|
||||
E: "Einsatzabbruch",
|
||||
C: "Melden Sie Einsatzübernahme",
|
||||
F: "Kommen Sie über Draht",
|
||||
H: "Fahren Sie Wache an",
|
||||
J: "Sprechen Sie",
|
||||
L: "Geben Sie Lagemeldung",
|
||||
P: "Einsatz mit Polizei",
|
||||
U: "Ungültige Statusfolge",
|
||||
c: "Status korrigieren",
|
||||
d: "Nennen Sie Transportziel",
|
||||
h: "Zielklinik verständigt",
|
||||
o: "Warten, alle Abfrageplätze belegt",
|
||||
u: "Verstanden",
|
||||
};
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export const ROOMS = ["LST_01", "LST_02", "LST_03", "LST_04", "LST_05"];
|
||||
export const ROOMS = [
|
||||
{ name: "VAR_LST_RD_01", id: "2201" },
|
||||
{ name: "VAR_LST_RD_02", id: "2202" },
|
||||
{ name: "VAR_LST_RD_03", id: "2203" },
|
||||
{ name: "VAR_LST_RD_04", id: "2204" },
|
||||
{ name: "VAR_LST_RD_05", id: "2205" },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mission, MissionSdsLog, Prisma } from "@repo/db";
|
||||
import { Mission, MissionSdsLog, MissionSdsStatusLog, Prisma } from "@repo/db";
|
||||
import axios from "axios";
|
||||
import { serverApi } from "_helpers/axios";
|
||||
|
||||
@@ -29,6 +29,20 @@ export const editMissionAPI = async (id: number, mission: Prisma.MissionUpdateIn
|
||||
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
|
||||
return respone.data;
|
||||
};
|
||||
|
||||
export const sendSdsStatusMessageAPI = async ({
|
||||
sdsMessage,
|
||||
aircraftId,
|
||||
}: {
|
||||
aircraftId: number;
|
||||
sdsMessage: MissionSdsStatusLog;
|
||||
}) => {
|
||||
const respone = await serverApi.post<Mission>(`/aircrafts/${aircraftId}/send-sds-message`, {
|
||||
sdsMessage,
|
||||
});
|
||||
return respone.data;
|
||||
};
|
||||
|
||||
export const sendSdsMessageAPI = async ({
|
||||
missionId,
|
||||
sdsMessage,
|
||||
|
||||
@@ -21,12 +21,13 @@ import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||
import { changeDispatcherAPI } from "_querys/dispatcher";
|
||||
import { getRadioStream } from "_helpers/radioEffect";
|
||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||
import { ROOMS } from "_data/livekitRooms";
|
||||
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
type TalkState = {
|
||||
addSpeakingParticipant: (participant: Participant) => void;
|
||||
connect: (roomName: string, role: string) => void;
|
||||
connect: (room: (typeof ROOMS)[number] | undefined, role: string) => void;
|
||||
connectionQuality: ConnectionQuality;
|
||||
disconnect: () => void;
|
||||
isTalking: boolean;
|
||||
@@ -44,6 +45,8 @@ type TalkState = {
|
||||
radioVolume: number;
|
||||
dmeVolume: number;
|
||||
};
|
||||
selectedRoom?: (typeof ROOMS)[number];
|
||||
setSelectedRoom: (room: (typeof ROOMS)[number]) => void;
|
||||
speakingParticipants: Participant[];
|
||||
state: "connecting" | "connected" | "disconnected" | "error";
|
||||
toggleTalking: () => void;
|
||||
@@ -72,6 +75,10 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
remoteParticipants: 0,
|
||||
connectionQuality: ConnectionQuality.Unknown,
|
||||
room: null,
|
||||
selectedRoom: ROOMS[0],
|
||||
setSelectedRoom: (room) => {
|
||||
set({ selectedRoom: room });
|
||||
},
|
||||
resetSpeakingParticipants: (source: string) => {
|
||||
set({
|
||||
speakingParticipants: [],
|
||||
@@ -117,11 +124,11 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
|
||||
oldSettings.micVolume !== newSettings.micVolume)
|
||||
) {
|
||||
const { room, disconnect, connect } = get();
|
||||
const { room, disconnect, connect, selectedRoom } = get();
|
||||
const role = room?.localParticipant.attributes.role;
|
||||
if (room?.name || role) {
|
||||
if (selectedRoom || role) {
|
||||
disconnect();
|
||||
connect(room?.name || "", role || "user");
|
||||
connect(selectedRoom, role || "user");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -160,7 +167,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
|
||||
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
|
||||
},
|
||||
connect: async (roomName, role) => {
|
||||
connect: async (_room, role) => {
|
||||
set({ state: "connecting" });
|
||||
|
||||
try {
|
||||
@@ -172,13 +179,16 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
connectedRoom.removeAllListeners();
|
||||
}
|
||||
|
||||
const { selectedRoom } = get();
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
|
||||
if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set");
|
||||
|
||||
const token = await getToken(roomName);
|
||||
const token = await getToken(_room?.name || selectedRoom?.name || "VAR_LST_RD_01");
|
||||
if (!token) throw new Error("Fehlende Berechtigung");
|
||||
const room = new Room({});
|
||||
await room.prepareConnection(url, token);
|
||||
const roomConnectedSound = new Audio("/sounds/403.wav");
|
||||
room
|
||||
// Connection events
|
||||
.on(RoomEvent.Connected, async () => {
|
||||
@@ -186,16 +196,28 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
|
||||
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
|
||||
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
|
||||
zone: roomName,
|
||||
zone: _room?.name || selectedRoom?.name || "VAR_LST_RD_01",
|
||||
ghostMode: dispatchState.ghostMode,
|
||||
});
|
||||
}
|
||||
|
||||
const inputStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: get().settings.micDeviceId ?? undefined,
|
||||
},
|
||||
});
|
||||
let inputStream = await navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
audio: {
|
||||
deviceId: get().settings.micDeviceId ?? undefined,
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
|
||||
return null;
|
||||
});
|
||||
if (!inputStream) {
|
||||
inputStream = await navigator.mediaDevices.getUserMedia({ audio: true }).catch((e) => {
|
||||
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (!inputStream) throw new Error("Konnte das Audio-Gerät nicht öffnen");
|
||||
// Funk-Effekt anwenden
|
||||
const radioStream = getRadioStream(inputStream, get().settings.micVolume);
|
||||
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
|
||||
@@ -208,7 +230,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
||||
source: Track.Source.Microphone,
|
||||
});
|
||||
await publishedTrack.mute();
|
||||
|
||||
roomConnectedSound.play();
|
||||
set({ localRadioTrack: publishedTrack });
|
||||
|
||||
set({ state: "connected", room, isTalking: false, message: null });
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
|
||||
setHideDraftMissions: (hide) => set({ hideDraftMissions: hide }),
|
||||
connectedDispatcher: null,
|
||||
message: "",
|
||||
selectedZone: "LST_01",
|
||||
selectedZone: "VAR_LST_RD_01",
|
||||
logoffTime: "",
|
||||
ghostMode: false,
|
||||
connect: async (uid, selectedZone, logoffTime, ghostMode) =>
|
||||
@@ -48,7 +48,7 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
|
||||
|
||||
dispatchSocket.on("connect", () => {
|
||||
const { logoffTime, selectedZone, ghostMode } = useDispatchConnectionStore.getState();
|
||||
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
|
||||
useAudioStore.getState().connect(undefined, selectedZone || "Leitstelle");
|
||||
dispatchSocket.emit("connect-dispatch", {
|
||||
logoffTime,
|
||||
selectedZone,
|
||||
|
||||
@@ -1,173 +1,92 @@
|
||||
import { MissionSdsLog, Station } from "@repo/db";
|
||||
import { fmsStatusDescription } from "_data/fmsStatusDescription";
|
||||
import { DisplayLineProps } from "(app)/pilot/_components/mrt/Mrt";
|
||||
import { create } from "zustand";
|
||||
import { syncTabs } from "zustand-sync-tabs";
|
||||
|
||||
interface SetSdsPageParams {
|
||||
page: "sds";
|
||||
station: Station;
|
||||
sdsMessage: MissionSdsLog;
|
||||
interface SetOffPageParams {
|
||||
page: "off";
|
||||
}
|
||||
|
||||
interface SetStartupPageParams {
|
||||
page: "startup";
|
||||
}
|
||||
|
||||
interface SetHomePageParams {
|
||||
page: "home";
|
||||
station: Station;
|
||||
fmsStatus: string;
|
||||
}
|
||||
|
||||
interface SetSendingStatusPageParams {
|
||||
page: "sending-status";
|
||||
station: Station;
|
||||
interface SetVoicecallPageParams {
|
||||
page: "voice-call";
|
||||
}
|
||||
interface SetSdsReceivedPopupParams {
|
||||
popup: "sds-received";
|
||||
}
|
||||
|
||||
interface SetNewStatusPageParams {
|
||||
page: "new-status";
|
||||
station: Station;
|
||||
interface SetGroupSelectionPopupParams {
|
||||
popup: "group-selection";
|
||||
}
|
||||
|
||||
type SetPageParams =
|
||||
interface SetStatusSentPopupParams {
|
||||
popup: "status-sent";
|
||||
}
|
||||
|
||||
interface SetLoginPopupParams {
|
||||
popup: "login";
|
||||
}
|
||||
|
||||
interface SetSdsSentPopupParams {
|
||||
popup: "sds-sent";
|
||||
}
|
||||
|
||||
export type SetPageParams =
|
||||
| SetHomePageParams
|
||||
| SetSendingStatusPageParams
|
||||
| SetSdsPageParams
|
||||
| SetNewStatusPageParams;
|
||||
| SetOffPageParams
|
||||
| SetStartupPageParams
|
||||
| SetVoicecallPageParams;
|
||||
|
||||
export type SetPopupParams =
|
||||
| SetStatusSentPopupParams
|
||||
| SetSdsSentPopupParams
|
||||
| SetGroupSelectionPopupParams
|
||||
| SetSdsReceivedPopupParams
|
||||
| SetLoginPopupParams;
|
||||
|
||||
interface StringifiedData {
|
||||
sdsText?: string;
|
||||
sentSdsText?: string;
|
||||
|
||||
groupSelectionGroupId?: string;
|
||||
callTextHeader?: string;
|
||||
}
|
||||
|
||||
interface MrtStore {
|
||||
page: SetPageParams["page"];
|
||||
popup?: SetPopupParams["popup"];
|
||||
|
||||
lines: DisplayLineProps[];
|
||||
stringifiedData: StringifiedData;
|
||||
setStringifiedData: (data: Partial<StringifiedData>) => void;
|
||||
|
||||
setPage: (pageData: SetPageParams) => void;
|
||||
setLines: (lines: MrtStore["lines"]) => void;
|
||||
setPopup: (popupData: SetPopupParams | null) => void;
|
||||
|
||||
// internal
|
||||
updateIntervall?: number;
|
||||
nightMode: boolean;
|
||||
setNightMode: (nightMode: boolean) => void;
|
||||
}
|
||||
|
||||
export const useMrtStore = create<MrtStore>(
|
||||
syncTabs(
|
||||
(set) => ({
|
||||
page: "home",
|
||||
pageData: {
|
||||
message: "",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
textLeft: "VAR.#",
|
||||
textSize: "2",
|
||||
},
|
||||
{
|
||||
textLeft: "No Data",
|
||||
textSize: "3",
|
||||
},
|
||||
],
|
||||
setLines: (lines) => set({ lines }),
|
||||
setPage: (pageData) => {
|
||||
switch (pageData.page) {
|
||||
case "home": {
|
||||
const { station, fmsStatus } = pageData as SetHomePageParams;
|
||||
set({
|
||||
page: "home",
|
||||
lines: [
|
||||
{
|
||||
textLeft: `${station?.bosCallsign}`,
|
||||
style: { fontWeight: "bold" },
|
||||
textSize: "2",
|
||||
},
|
||||
{ textLeft: "ILS VAR#", textSize: "3" },
|
||||
{
|
||||
textLeft: fmsStatus,
|
||||
style: { fontWeight: "extrabold" },
|
||||
textSize: "4",
|
||||
},
|
||||
{
|
||||
textLeft: fmsStatusDescription[fmsStatus],
|
||||
textSize: "1",
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "sending-status": {
|
||||
const { station } = pageData as SetSendingStatusPageParams;
|
||||
set({
|
||||
page: "sending-status",
|
||||
lines: [
|
||||
{
|
||||
textLeft: `${station?.bosCallsign}`,
|
||||
style: { fontWeight: "bold" },
|
||||
textSize: "2",
|
||||
},
|
||||
{ textLeft: "ILS VAR#", textSize: "3" },
|
||||
{
|
||||
textMid: "sending...",
|
||||
style: { fontWeight: "bold" },
|
||||
textSize: "4",
|
||||
},
|
||||
{
|
||||
textLeft: "Status wird gesendet...",
|
||||
textSize: "1",
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "new-status": {
|
||||
const { station } = pageData as SetNewStatusPageParams;
|
||||
set({
|
||||
page: "new-status",
|
||||
lines: [
|
||||
{
|
||||
textLeft: `${station?.bosCallsign}`,
|
||||
style: { fontWeight: "bold" },
|
||||
textSize: "2",
|
||||
},
|
||||
{ textLeft: "ILS VAR#", textSize: "3" },
|
||||
{
|
||||
textLeft: "empfangen",
|
||||
style: { fontWeight: "bold" },
|
||||
textSize: "4",
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "sds": {
|
||||
const { sdsMessage } = pageData as SetSdsPageParams;
|
||||
const msg = sdsMessage.data.message;
|
||||
set({
|
||||
page: "sds",
|
||||
lines: [
|
||||
{
|
||||
textLeft: `SDS-Nachricht`,
|
||||
style: { fontWeight: "bold" },
|
||||
textSize: "2",
|
||||
},
|
||||
{
|
||||
textLeft: msg,
|
||||
style: {
|
||||
whiteSpace: "normal",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
overflow: "auto",
|
||||
textOverflow: "ellipsis",
|
||||
lineHeight: "1.2em",
|
||||
},
|
||||
textSize: "2",
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
set({ page: "home" });
|
||||
break;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "mrt-store", // unique name
|
||||
},
|
||||
),
|
||||
);
|
||||
export const useMrtStore = create<MrtStore>((set) => ({
|
||||
page: "off",
|
||||
nightMode: false,
|
||||
stringifiedData: {
|
||||
groupSelectionGroupId: "2201",
|
||||
},
|
||||
setNightMode: (nightMode) => set({ nightMode }),
|
||||
setStringifiedData: (data) =>
|
||||
set((state) => ({
|
||||
stringifiedData: { ...state.stringifiedData, ...data },
|
||||
})),
|
||||
setPopup: (popupData) => {
|
||||
set({ popup: popupData ? popupData.popup : undefined });
|
||||
},
|
||||
setPage: (pageData) => {
|
||||
set({ page: pageData.page });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -86,7 +86,7 @@ pilotSocket.on("connect", () => {
|
||||
usePilotConnectionStore.setState({ status: "connected", message: "" });
|
||||
const { logoffTime, selectedStation, debug } = usePilotConnectionStore.getState();
|
||||
dispatchSocket.disconnect();
|
||||
useAudioStore.getState().connect("LST_01", selectedStation?.bosCallsignShort || "pilot");
|
||||
useAudioStore.getState().connect(undefined, selectedStation?.bosCallsignShort || "pilot");
|
||||
|
||||
pilotSocket.emit("connect-pilot", {
|
||||
logoffTime,
|
||||
@@ -109,7 +109,7 @@ pilotSocket.on("connect-message", (data) => {
|
||||
});
|
||||
|
||||
pilotSocket.on("disconnect", () => {
|
||||
usePilotConnectionStore.setState({ status: "disconnected" });
|
||||
usePilotConnectionStore.setState({ status: "disconnected", connectedAircraft: null });
|
||||
useAudioStore.getState().disconnect();
|
||||
});
|
||||
|
||||
@@ -142,11 +142,13 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => {
|
||||
});
|
||||
|
||||
pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => {
|
||||
console.log("Received sds-message via socket:", sdsMessage);
|
||||
const station = usePilotConnectionStore.getState().selectedStation;
|
||||
if (!station) return;
|
||||
useMrtStore.getState().setPage({
|
||||
page: "sds",
|
||||
station,
|
||||
sdsMessage,
|
||||
useMrtStore.getState().setPopup({
|
||||
popup: "sds-received",
|
||||
});
|
||||
useMrtStore.getState().setStringifiedData({
|
||||
sdsText: sdsMessage.data.message,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Mission, Station, User } from "@repo/db";
|
||||
import { DisplayLineProps } from "(app)/pilot/_components/dme/Dme";
|
||||
import { create } from "zustand";
|
||||
import { syncTabs } from "zustand-sync-tabs";
|
||||
|
||||
interface SetHomePageParams {
|
||||
page: "home";
|
||||
@@ -45,197 +44,190 @@ interface MrtStore {
|
||||
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
export const useDmeStore = create<MrtStore>(
|
||||
syncTabs(
|
||||
(set) => ({
|
||||
page: "home",
|
||||
pageData: {
|
||||
message: "",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
textLeft: "",
|
||||
},
|
||||
{
|
||||
textMid: "VAR . DME# No Data",
|
||||
textSize: "2",
|
||||
},
|
||||
{
|
||||
textLeft: "",
|
||||
},
|
||||
],
|
||||
setLines: (lines) => set({ lines }),
|
||||
latestMission: null,
|
||||
setPage: (pageData) => {
|
||||
if (interval) clearInterval(interval);
|
||||
switch (pageData.page) {
|
||||
case "home": {
|
||||
const setHomePage = () =>
|
||||
set({
|
||||
page: "home",
|
||||
lines: [
|
||||
{
|
||||
textMid: pageData.station.bosCallsign
|
||||
? `${pageData.station.bosCallsign}`
|
||||
: "no Data",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}),
|
||||
},
|
||||
{
|
||||
textMid: new Date().toLocaleTimeString(),
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: `${pageData.user.lastname} ${pageData.user.firstname}`,
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
setHomePage();
|
||||
|
||||
interval = setInterval(() => {
|
||||
setHomePage();
|
||||
}, 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
case "new-mission": {
|
||||
set({
|
||||
page: "new-mission",
|
||||
lines: [
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: "new mission received",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "mission": {
|
||||
set({
|
||||
latestMission: pageData.mission,
|
||||
page: "mission",
|
||||
lines: [
|
||||
{
|
||||
textLeft: `${pageData.mission.missionKeywordAbbreviation}`,
|
||||
textRight: pageData.mission.Stations.map((s) => s.bosCallsignShort).join(","),
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
...(pageData.mission.type == "primär"
|
||||
? [
|
||||
{
|
||||
textMid: `${pageData.mission.missionKeywordName}`,
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{ textLeft: `${pageData.mission.addressStreet}` },
|
||||
{
|
||||
textLeft: `${pageData.mission.addressZip} ${pageData.mission.addressCity}`,
|
||||
},
|
||||
{
|
||||
textMid: "Weitere Standortinformationen:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.addressAdditionalInfo || "keine Daten",
|
||||
},
|
||||
...(pageData.mission.type === "sekundär"
|
||||
? [
|
||||
{
|
||||
textMid: "Zielort:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.addressMissionDestination || "keine Daten",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pageData.mission.missionPatientInfo &&
|
||||
pageData.mission.missionPatientInfo.length > 0
|
||||
? [
|
||||
{
|
||||
textMid: "Patienteninfos:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.missionPatientInfo,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pageData.mission.missionAdditionalInfo &&
|
||||
pageData.mission.missionAdditionalInfo.length > 0
|
||||
? [
|
||||
{
|
||||
textMid: "Weitere Infos:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.missionAdditionalInfo,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
set({
|
||||
page: "error",
|
||||
lines: [
|
||||
{ textMid: "Fehler:" },
|
||||
{
|
||||
textMid: pageData.error,
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "acknowledge": {
|
||||
set({
|
||||
page: "acknowledge",
|
||||
lines: [
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: "Einsatz angenommen",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
set({
|
||||
page: "error",
|
||||
lines: [
|
||||
{ textMid: "Fehler:" },
|
||||
{
|
||||
textMid: `Unbekannte Seite`,
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
}),
|
||||
export const useDmeStore = create<MrtStore>((set) => ({
|
||||
page: "home",
|
||||
pageData: {
|
||||
message: "",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
name: "dme-store", // unique name
|
||||
textLeft: "",
|
||||
},
|
||||
),
|
||||
);
|
||||
{
|
||||
textMid: "VAR . DME# No Data",
|
||||
textSize: "2",
|
||||
},
|
||||
{
|
||||
textLeft: "",
|
||||
},
|
||||
],
|
||||
setLines: (lines) => set({ lines }),
|
||||
latestMission: null,
|
||||
setPage: (pageData) => {
|
||||
if (interval) clearInterval(interval);
|
||||
switch (pageData.page) {
|
||||
case "home": {
|
||||
const setHomePage = () =>
|
||||
set({
|
||||
page: "home",
|
||||
lines: [
|
||||
{
|
||||
textMid: pageData.station.bosCallsign
|
||||
? `${pageData.station.bosCallsign}`
|
||||
: "no Data",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}),
|
||||
},
|
||||
{
|
||||
textMid: new Date().toLocaleTimeString(),
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: `${pageData.user.lastname} ${pageData.user.firstname}`,
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
setHomePage();
|
||||
|
||||
interval = setInterval(() => {
|
||||
setHomePage();
|
||||
}, 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
case "new-mission": {
|
||||
set({
|
||||
page: "new-mission",
|
||||
lines: [
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: "new mission received",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "mission": {
|
||||
set({
|
||||
latestMission: pageData.mission,
|
||||
page: "mission",
|
||||
lines: [
|
||||
{
|
||||
textLeft: `${pageData.mission.missionKeywordAbbreviation}`,
|
||||
textRight: pageData.mission.Stations.map((s) => s.bosCallsignShort).join(","),
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
...(pageData.mission.type == "primär"
|
||||
? [
|
||||
{
|
||||
textMid: `${pageData.mission.missionKeywordName}`,
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{ textLeft: `${pageData.mission.addressStreet}` },
|
||||
{
|
||||
textLeft: `${pageData.mission.addressZip} ${pageData.mission.addressCity}`,
|
||||
},
|
||||
{
|
||||
textMid: "Weitere Standortinformationen:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.addressAdditionalInfo || "keine Daten",
|
||||
},
|
||||
...(pageData.mission.type === "sekundär"
|
||||
? [
|
||||
{
|
||||
textMid: "Zielort:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.addressMissionDestination || "keine Daten",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pageData.mission.missionPatientInfo &&
|
||||
pageData.mission.missionPatientInfo.length > 0
|
||||
? [
|
||||
{
|
||||
textMid: "Patienteninfos:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.missionPatientInfo,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pageData.mission.missionAdditionalInfo &&
|
||||
pageData.mission.missionAdditionalInfo.length > 0
|
||||
? [
|
||||
{
|
||||
textMid: "Weitere Infos:",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{
|
||||
textLeft: pageData.mission.missionAdditionalInfo,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
set({
|
||||
page: "error",
|
||||
lines: [
|
||||
{ textMid: "Fehler:" },
|
||||
{
|
||||
textMid: pageData.error,
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "acknowledge": {
|
||||
set({
|
||||
page: "acknowledge",
|
||||
lines: [
|
||||
{ textMid: "⠀" },
|
||||
{
|
||||
textMid: "Einsatz angenommen",
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
set({
|
||||
page: "error",
|
||||
lines: [
|
||||
{ textMid: "Fehler:" },
|
||||
{
|
||||
textMid: `Unbekannte Seite`,
|
||||
style: { fontWeight: "bold" },
|
||||
},
|
||||
{ textMid: "⠀" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
src: url("/fonts/MelderV2.ttf") format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Bahnschrift";
|
||||
src: url("/fonts/bahnschrift.ttf") format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-rescuetrack: #46b7a3;
|
||||
--color-rescuetrack-highlight: #ff4500;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5.8.3",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.6",
|
||||
"zustand-sync-tabs": "^0.2.2"
|
||||
"zustand": "^5.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/dispatch/public/fonts/bahnschrift.ttf
Normal file
BIN
apps/dispatch/public/sounds/1504.wav
Normal file
BIN
apps/dispatch/public/sounds/403.wav
Normal file
BIN
apps/dispatch/public/sounds/775.wav
Normal file
@@ -84,6 +84,7 @@ const updateParticipantMoodleResults = async () => {
|
||||
};
|
||||
|
||||
CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true });
|
||||
|
||||
CronJob.from({
|
||||
cronTime: "*/1 * * * *",
|
||||
onTick: async () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
|
||||
console.error("Error removing roles from member:", error);
|
||||
});
|
||||
};
|
||||
|
||||
export const setStandardName = async ({
|
||||
memberId,
|
||||
userId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { renderPasswordChanged } from "./mail-templates/PasswordChanged";
|
||||
import { renderVerificationCode } from "./mail-templates/ConfirmEmail";
|
||||
import { renderBannNotice } from "modules/mail-templates/Bann";
|
||||
import { renderTimeBanNotice } from "modules/mail-templates/TimeBann";
|
||||
import Mail from "nodemailer/lib/mailer";
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
@@ -23,8 +24,9 @@ const initTransporter = () => {
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
transporter.on("error", (err) => {
|
||||
console.error("Mail occurred:", err);
|
||||
console.error("Mail error:", err);
|
||||
});
|
||||
transporter.on("idle", () => {
|
||||
console.log("Mail Idle");
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
|
||||
ENV PNPM_HOME="/usr/local/pnpm"
|
||||
ENV PATH="${PNPM_HOME}:${PATH}"
|
||||
|
||||
ARG NEXT_PUBLIC_HUB_URL
|
||||
ARG NEXT_PUBLIC_HUB_SERVER_URL
|
||||
ARG NEXT_PUBLIC_DISCORD_URL
|
||||
@@ -16,13 +12,13 @@ ENV NEXT_PUBLIC_DISCORD_URL=${NEXT_PUBLIC_DISCORD_URL}
|
||||
ENV NEXT_PUBLIC_MOODLE_URL=${NEXT_PUBLIC_MOODLE_URL}
|
||||
ENV NEXT_PUBLIC_DISPATCH_URL=${NEXT_PUBLIC_DISPATCH_URL}
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
RUN echo "NEXT_PUBLIC_DISCORD_URL=${NEXT_PUBLIC_DISCORD_URL}"
|
||||
RUN pnpm add -g turbo@^2.5
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ENV PNPM_HOME="/usr/local/pnpm"
|
||||
ENV PATH="${PNPM_HOME}:${PATH}"
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN pnpm add -g turbo@^2.5
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -33,6 +29,13 @@ COPY . .
|
||||
RUN turbo prune hub --docker
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
ENV PNPM_HOME="/usr/local/pnpm"
|
||||
ENV PATH="${PNPM_HOME}:${PATH}"
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN pnpm add -g turbo@^2.5
|
||||
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -44,21 +47,25 @@ RUN pnpm install
|
||||
# Build the project
|
||||
COPY --from=builder /usr/app/out/full/ .
|
||||
|
||||
RUN turbo run build
|
||||
RUN turbo run build --filter=hub...
|
||||
|
||||
FROM base AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /usr/app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/hub/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/hub/.next/static ./apps/hub/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/hub/public ./apps/hub/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
CMD ["pnpm", "--dir", "apps/hub", "run", "start"]
|
||||
CMD ["node", "apps/hub/server.js"]
|
||||
@@ -23,15 +23,6 @@ const page = async () => {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
Appointments: {
|
||||
include: {
|
||||
Participants: {
|
||||
where: {
|
||||
appointmentCancelled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,23 +38,13 @@ const page = async () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="col-span-full">
|
||||
<p className="text-xl font-semibold text-left flex items-center gap-2 mb-2 mt-5">
|
||||
<RocketIcon className="w-4 h-4" /> Laufende Events & Kurse
|
||||
<p className="mb-2 mt-5 flex items-center gap-2 text-left text-xl font-semibold">
|
||||
<RocketIcon className="h-4 w-4" /> Laufende Events & Kurse
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{filteredEvents.map((event) => {
|
||||
return (
|
||||
<EventCard
|
||||
appointments={event.Appointments}
|
||||
selectedAppointments={event.Appointments.filter((a) =>
|
||||
a.Participants.find((p) => p.userId == user.id),
|
||||
)}
|
||||
user={user}
|
||||
event={event}
|
||||
key={event.id}
|
||||
/>
|
||||
);
|
||||
return <EventCard user={user} event={event} key={event.id} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,18 +20,18 @@ const PathsOptions = ({
|
||||
<div className="flex gap-6">
|
||||
{/* Disponent Card */}
|
||||
<div
|
||||
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${
|
||||
selected === "disponent" ? "border-info ring-2 ring-info" : "border-base-300"
|
||||
className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
|
||||
selected === "disponent" ? "border-info ring-info ring-2" : "border-base-300"
|
||||
}`}
|
||||
onClick={() => setSelected("disponent")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected === "disponent"}
|
||||
>
|
||||
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center">
|
||||
<h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
|
||||
Disponent <Workflow />
|
||||
</h1>
|
||||
<div className="text-sm text-base-content/70">
|
||||
<div className="text-base-content/70 text-sm">
|
||||
Denkt sich realistische Einsatzszenarien aus, koordiniert deren Ablauf und ist die
|
||||
zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er trägt
|
||||
die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen Durchführung der
|
||||
@@ -43,18 +43,18 @@ const PathsOptions = ({
|
||||
</div>
|
||||
{/* Pilot Card */}
|
||||
<div
|
||||
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${
|
||||
selected === "pilot" ? "border-info ring-2 ring-info" : "border-base-300"
|
||||
className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
|
||||
selected === "pilot" ? "border-info ring-info ring-2" : "border-base-300"
|
||||
}`}
|
||||
onClick={() => setSelected("pilot")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected === "pilot"}
|
||||
>
|
||||
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center">
|
||||
<h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
|
||||
Pilot <Plane />
|
||||
</h1>
|
||||
<div className="text-sm text-base-content/70">
|
||||
<div className="text-base-content/70 text-sm">
|
||||
Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher zum
|
||||
Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf Wetterentwicklungen
|
||||
und sorgt für die Sicherheit seiner Crew im Flug.
|
||||
@@ -76,17 +76,7 @@ const EventSelect = ({ pathSelected }: { pathSelected: "disponent" | "pilot" })
|
||||
const user = useSession().data?.user;
|
||||
if (!user) return null;
|
||||
return events?.map((event) => {
|
||||
return (
|
||||
<EventCard
|
||||
appointments={event.Appointments}
|
||||
selectedAppointments={event.Appointments.filter((a) =>
|
||||
a.Participants.find((p) => p.userId == user.id),
|
||||
)}
|
||||
user={user}
|
||||
event={event}
|
||||
key={event.id}
|
||||
/>
|
||||
);
|
||||
return <EventCard user={user} event={event} key={event.id} />;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -107,14 +97,14 @@ export const FirstPath = () => {
|
||||
return (
|
||||
<dialog ref={modalRef} className="modal">
|
||||
<div className="modal-box w-11/12 max-w-5xl">
|
||||
<h3 className="flex items-center gap-2 text-lg font-bold mb-10">
|
||||
<h3 className="mb-10 flex items-center gap-2 text-lg font-bold">
|
||||
{session?.user.migratedFromV1
|
||||
? "Hallo, hier hat sich einiges geändert!"
|
||||
: "Wähle deinen Einstieg!"}
|
||||
</h3>
|
||||
<h2 className="text-2xl font-bold mb-4 text-center">Willkommen bei Virtual Air Rescue!</h2>
|
||||
<h2 className="mb-4 text-center text-2xl font-bold">Willkommen bei Virtual Air Rescue!</h2>
|
||||
{session?.user.migratedFromV1 ? (
|
||||
<p className="mb-8 text-base text-base-content/80 text-center">
|
||||
<p className="text-base-content/80 mb-8 text-center text-base">
|
||||
Dein Account wurde erfolgreich auf das neue System migriert. Herzlich Willkommen im
|
||||
neuen HUB! Um die Erfahrung für alle Nutzer zu steigern haben wir uns dazu entschlossen,
|
||||
dass alle Nutzer einen Test absolvieren müssen:{" "}
|
||||
@@ -129,12 +119,12 @@ export const FirstPath = () => {
|
||||
ausprobieren, wenn du möchtest.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center m-20">
|
||||
<div className="m-20 flex flex-col items-center justify-center">
|
||||
{page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />}
|
||||
{page === "event-select" && (
|
||||
<div className="flex flex-col gap-3 min-w-[800px]">
|
||||
<div className="flex min-w-[800px] flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-left text-gray-400 text-sm">Wähle dein Einführungs-Event aus:</p>
|
||||
<p className="text-left text-sm text-gray-400">Wähle dein Einführungs-Event aus:</p>
|
||||
</div>
|
||||
<EventSelect pathSelected={selected!} />
|
||||
</div>
|
||||
|
||||
@@ -2,23 +2,17 @@ import Image from "next/image";
|
||||
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
|
||||
import YoutubeSvg from "./youtube_wider.svg";
|
||||
import FacebookSvg from "./facebook.svg";
|
||||
import { ChangelogModalBtn } from "@repo/shared-components";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
import { updateUser } from "(app)/settings/actions";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ChangelogWrapper } from "(app)/_components/ChangelogWrapper";
|
||||
import { prisma } from "@repo/db";
|
||||
|
||||
export const Footer = async () => {
|
||||
const session = await getServerSession();
|
||||
const latestChangelog = await prisma.changelog.findFirst({
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const autoOpen = !session?.user.changelogAck && !!latestChangelog;
|
||||
|
||||
return (
|
||||
<footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md">
|
||||
{/* Left: Impressum & Datenschutz */}
|
||||
@@ -39,7 +33,7 @@ export const Footer = async () => {
|
||||
<div className="flex gap-4">
|
||||
<div className="tooltip tooltip-top" data-tip="Discord">
|
||||
<a
|
||||
href="https://discord.gg/yn7uXmmNnG"
|
||||
href="https://discord.gg/virtualairrescue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from "@repo/db";
|
||||
import { ParticipantForm } from "../../../_components/ParticipantForm";
|
||||
import { Error } from "_components/Error";
|
||||
import Link from "next/link";
|
||||
import { PersonIcon } from "@radix-ui/react-icons";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}) {
|
||||
const { id: eventId, participantId } = await params;
|
||||
console.log(eventId, participantId);
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: parseInt(eventId) },
|
||||
});
|
||||
|
||||
const participant = await prisma.participant.findUnique({
|
||||
where: { id: parseInt(participantId) },
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
return <Error title="Teilnehmer nicht gefunden" statusCode={404} />;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: participant?.userId },
|
||||
});
|
||||
|
||||
if (!event) return <Error title="Event nicht gefunden" statusCode={404} />;
|
||||
|
||||
if (!participant || !user) {
|
||||
return <Error title="Teilnehmer nicht gefunden" statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="my-3">
|
||||
<div className="text-left">
|
||||
<Link href={`/admin/event/${event.id}`} className="link-hover l-0 text-gray-500">
|
||||
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
|
||||
Zurück zum Event
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-left text-2xl font-semibold">
|
||||
<PersonIcon className="mr-2 inline h-5 w-5" /> Event-Übersicht für{" "}
|
||||
{`${user.firstname} ${user.lastname} #${user.publicId}`}
|
||||
</p>
|
||||
</div>
|
||||
<ParticipantForm event={event} participant={participant} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { Event, Participant, Prisma } from "@repo/db";
|
||||
import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { RefObject, useRef } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { PaginatedTable, PaginatedTableRef } from "../../../../_components/PaginatedTable";
|
||||
import { Button } from "../../../../_components/ui/Button";
|
||||
import { DateInput } from "../../../../_components/ui/DateInput";
|
||||
import { upsertParticipant } from "../../../events/actions";
|
||||
import { deleteAppoinement, upsertAppointment } from "../action";
|
||||
import { handleParticipantFinished } from "../../../../../helper/events";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface AppointmentModalProps {
|
||||
event?: Event;
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
participantModal: RefObject<HTMLDialogElement | null>;
|
||||
appointmentsTableRef: React.RefObject<PaginatedTableRef | null>;
|
||||
appointmentForm: UseFormReturn<EventAppointmentOptionalDefaults>;
|
||||
participantForm: UseFormReturn<Participant>;
|
||||
}
|
||||
|
||||
export const AppointmentModal = ({
|
||||
event,
|
||||
ref,
|
||||
participantModal,
|
||||
appointmentsTableRef,
|
||||
appointmentForm,
|
||||
participantForm,
|
||||
}: AppointmentModalProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const participantTableRef = useRef<PaginatedTableRef>(null);
|
||||
|
||||
return (
|
||||
<dialog ref={ref} className="modal">
|
||||
<div className="modal-box min-h-[500px] min-w-[900px]">
|
||||
<form method="dialog">
|
||||
{/* if there is a button in form, it will close the modal */}
|
||||
<button
|
||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onClick={() => ref.current?.close()}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={appointmentForm.handleSubmit(async (values) => {
|
||||
if (!event) return;
|
||||
await upsertAppointment(values);
|
||||
ref.current?.close();
|
||||
appointmentsTableRef.current?.refresh();
|
||||
})}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="mr-7 flex justify-between">
|
||||
<h3 className="text-lg font-bold">Termin {appointmentForm.watch("id")}</h3>
|
||||
<DateInput
|
||||
value={new Date(appointmentForm.watch("appointmentDate") || Date.now())}
|
||||
onChange={(date) => appointmentForm.setValue("appointmentDate", date)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<PaginatedTable
|
||||
supressQuery={appointmentForm.watch("id") === undefined}
|
||||
ref={participantTableRef}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
accessorKey: "User.firstname",
|
||||
header: "Vorname",
|
||||
},
|
||||
{
|
||||
accessorKey: "User.lastname",
|
||||
header: "Nachname",
|
||||
},
|
||||
{
|
||||
accessorKey: "enscriptionDate",
|
||||
header: "Einschreibedatum",
|
||||
cell: ({ row }) => {
|
||||
return <span>{new Date(row.original.enscriptionDate).toLocaleString()}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Anwesend",
|
||||
cell: ({ row }) => {
|
||||
if (row.original.attended) {
|
||||
return <span className="text-green-500">Ja</span>;
|
||||
} else if (row.original.appointmentCancelled) {
|
||||
return <span className="text-red-500">Nein (Termin abgesagt)</span>;
|
||||
} else {
|
||||
return <span>?</span>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Aktion",
|
||||
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
participantForm.reset(row.original);
|
||||
participantModal.current?.showModal();
|
||||
}}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
anzeigen
|
||||
</button>
|
||||
{!row.original.attended && event?.hasPresenceEvents && (
|
||||
<button
|
||||
type="button"
|
||||
onSubmit={() => {}}
|
||||
onClick={async () => {
|
||||
await upsertParticipant({
|
||||
eventId: event!.id,
|
||||
userId: row.original.userId,
|
||||
attended: true,
|
||||
appointmentCancelled: false,
|
||||
});
|
||||
if (!event.finisherMoodleCourseId?.length) {
|
||||
toast(
|
||||
"Teilnehmer hat das event abgeschlossen, workflow ausgeführt",
|
||||
);
|
||||
await handleParticipantFinished(row.original.id.toString());
|
||||
}
|
||||
participantTableRef.current?.refresh();
|
||||
}}
|
||||
className="btn btn-outline btn-info btn-sm"
|
||||
>
|
||||
Anwesend
|
||||
</button>
|
||||
)}
|
||||
{!row.original.appointmentCancelled && event?.hasPresenceEvents && (
|
||||
<button
|
||||
type="button"
|
||||
onSubmit={() => {}}
|
||||
onClick={async () => {
|
||||
await upsertParticipant({
|
||||
eventId: event!.id,
|
||||
userId: row.original.userId,
|
||||
attended: false,
|
||||
appointmentCancelled: true,
|
||||
statusLog: [
|
||||
...(row.original.statusLog as InputJsonValueType[]),
|
||||
{
|
||||
event: "Gefehlt an Event",
|
||||
timestamp: new Date().toISOString(),
|
||||
user: `${session?.user?.firstname} ${session?.user?.lastname} - ${session?.user?.publicId}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
participantTableRef.current?.refresh();
|
||||
}}
|
||||
className="btn btn-outline btn-error btn-sm"
|
||||
>
|
||||
abwesend
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as ColumnDef<Participant>[]
|
||||
}
|
||||
prismaModel={"participant"}
|
||||
getFilter={() =>
|
||||
({
|
||||
eventAppointmentId: appointmentForm.watch("id")!,
|
||||
}) as Prisma.ParticipantWhereInput
|
||||
}
|
||||
include={{ User: true }}
|
||||
leftOfPagination={
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="btn btn-primary">
|
||||
Speichern
|
||||
</Button>
|
||||
{appointmentForm.watch("id") && (
|
||||
<Button
|
||||
type="button"
|
||||
onSubmit={() => {}}
|
||||
onClick={async () => {
|
||||
await deleteAppoinement(appointmentForm.watch("id")!);
|
||||
ref.current?.close();
|
||||
appointmentsTableRef.current?.refresh();
|
||||
}}
|
||||
className="btn btn-error btn-outline"
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,70 +1,37 @@
|
||||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db";
|
||||
import {
|
||||
EventAppointmentOptionalDefaults,
|
||||
EventAppointmentOptionalDefaultsSchema,
|
||||
EventOptionalDefaults,
|
||||
EventOptionalDefaultsSchema,
|
||||
ParticipantSchema,
|
||||
} from "@repo/db/zod";
|
||||
import { Bot, Calendar, FileText, UserIcon } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma } from "@repo/db";
|
||||
import { EventOptionalDefaults, EventOptionalDefaultsSchema } from "@repo/db/zod";
|
||||
import { Bot, FileText, UserIcon } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { PaginatedTable, PaginatedTableRef } from "../../../../_components/PaginatedTable";
|
||||
import { Button } from "../../../../_components/ui/Button";
|
||||
import { Input } from "../../../../_components/ui/Input";
|
||||
import { MarkdownEditor } from "../../../../_components/ui/MDEditor";
|
||||
import { Select } from "../../../../_components/ui/Select";
|
||||
import { Switch } from "../../../../_components/ui/Switch";
|
||||
import { deleteEvent, upsertEvent } from "../action";
|
||||
import { AppointmentModal } from "./AppointmentModal";
|
||||
import { ParticipantModal } from "./ParticipantModal";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import toast from "react-hot-toast";
|
||||
import { PaginatedTable } from "_components/PaginatedTable";
|
||||
import Link from "next/link";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { User } from "next-auth";
|
||||
|
||||
export const Form = ({ event }: { event?: Event }) => {
|
||||
const { data: session } = useSession();
|
||||
const form = useForm<EventOptionalDefaults>({
|
||||
resolver: zodResolver(EventOptionalDefaultsSchema),
|
||||
defaultValues: event
|
||||
? {
|
||||
...event,
|
||||
discordRoleId: event.discordRoleId ?? undefined,
|
||||
maxParticipants: event.maxParticipants ?? undefined,
|
||||
finisherMoodleCourseId: event.finisherMoodleCourseId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const appointmentForm = useForm<EventAppointmentOptionalDefaults>({
|
||||
resolver: zodResolver(EventAppointmentOptionalDefaultsSchema),
|
||||
defaultValues: {
|
||||
eventId: event?.id,
|
||||
presenterId: session?.user?.id,
|
||||
},
|
||||
});
|
||||
const participantForm = useForm<Participant>({
|
||||
resolver: zodResolver(ParticipantSchema),
|
||||
});
|
||||
const appointmentsTableRef = useRef<PaginatedTableRef>(null);
|
||||
const appointmentModal = useRef<HTMLDialogElement>(null);
|
||||
const participantModal = useRef<HTMLDialogElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppointmentModal
|
||||
participantModal={participantModal}
|
||||
participantForm={participantForm}
|
||||
appointmentForm={appointmentForm}
|
||||
ref={appointmentModal}
|
||||
appointmentsTableRef={appointmentsTableRef}
|
||||
event={event}
|
||||
/>
|
||||
<ParticipantModal participantForm={participantForm} ref={participantModal} />
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
await upsertEvent(values, event?.id);
|
||||
@@ -139,223 +106,115 @@ export const Form = ({ event }: { event?: Event }) => {
|
||||
label="Discord Rolle für eingeschriebene Teilnehmer"
|
||||
className="input-sm"
|
||||
/>
|
||||
<Input
|
||||
form={form}
|
||||
label="Maximale Teilnehmer (Nur für live Events)"
|
||||
className="input-sm"
|
||||
{...form.register("maxParticipants", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<Switch form={form} name="hasPresenceEvents" label="Hat Live Event" />
|
||||
<div className="divider w-full" />
|
||||
|
||||
<Switch form={form} name="hidden" label="Event verstecken" />
|
||||
</div>
|
||||
</div>
|
||||
{form.watch("hasPresenceEvents") ? (
|
||||
<div className="card bg-base-200 col-span-6 shadow-xl">
|
||||
<div className="card-body">
|
||||
<div className="card bg-base-200 col-span-6 shadow-xl">
|
||||
<div className="card-body">
|
||||
{
|
||||
<PaginatedTable
|
||||
ref={appointmentsTableRef}
|
||||
prismaModel={"eventAppointment"}
|
||||
getFilter={() =>
|
||||
({
|
||||
eventId: event?.id,
|
||||
}) as Prisma.EventAppointmentWhereInput
|
||||
}
|
||||
include={{
|
||||
Presenter: true,
|
||||
Participants: true,
|
||||
}}
|
||||
leftOfSearch={
|
||||
<h2 className="card-title">
|
||||
<Calendar className="h-5 w-5" /> Termine
|
||||
<UserIcon className="h-5 w-5" /> Teilnehmer
|
||||
</h2>
|
||||
}
|
||||
rightOfSearch={
|
||||
event && (
|
||||
<button
|
||||
className="btn btn-primary btn-outline"
|
||||
onClick={() => {
|
||||
appointmentModal.current?.showModal();
|
||||
appointmentForm.reset({
|
||||
id: undefined,
|
||||
eventId: event.id,
|
||||
presenterId: session?.user?.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
)
|
||||
prismaModel={"participant"}
|
||||
showSearch
|
||||
getFilter={(searchTerm) =>
|
||||
({
|
||||
AND: [{ eventId: event?.id }],
|
||||
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: "Datum",
|
||||
accessorKey: "appointmentDate",
|
||||
accessorFn: (date) => new Date(date.appointmentDate).toLocaleString(),
|
||||
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: "Presenter",
|
||||
accessorKey: "presenter",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center">
|
||||
<span className="ml-2">
|
||||
{row.original.Presenter.firstname} {row.original.Presenter.lastname}
|
||||
</span>
|
||||
</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: "Teilnehmer",
|
||||
accessorKey: "Participants",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center">
|
||||
<UserIcon className="h-5 w-5" />
|
||||
<span className="ml-2">{row.original.Participants.length}</span>
|
||||
</div>
|
||||
),
|
||||
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">
|
||||
<Link
|
||||
href={`/admin/event/${event?.id}/participant/${row.original.id}`}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<button
|
||||
onSubmit={() => false}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
appointmentForm.reset(row.original);
|
||||
appointmentModal.current?.showModal();
|
||||
}}
|
||||
className="btn btn-sm btn-outline"
|
||||
>
|
||||
Bearbeiten
|
||||
Ansehen
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as ColumnDef<
|
||||
EventAppointmentOptionalDefaults & {
|
||||
Presenter: User;
|
||||
Participants: Participant[];
|
||||
}
|
||||
>[]
|
||||
] as ColumnDef<Participant & { User: User }>[]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
{!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}`}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as ColumnDef<Participant & { User: User }>[]
|
||||
}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="card bg-base-200 col-span-6 shadow-xl">
|
||||
<div className="card-body">
|
||||
<div className="flex w-full gap-4">
|
||||
|
||||
213
apps/hub/app/(app)/admin/event/_components/ParticipantForm.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
import { Participant, Event, ParticipantLog, Prisma } from "@repo/db";
|
||||
import { Users, Activity, Bug } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { InputJsonValueType, ParticipantOptionalDefaultsSchema } from "@repo/db/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Switch } from "_components/ui/Switch";
|
||||
import { useState } from "react";
|
||||
import { Button } from "_components/ui/Button";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { upsertParticipant } from "(app)/events/actions";
|
||||
import { deleteParticipant } from "../action";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface ParticipantFormProps {
|
||||
event: Event;
|
||||
participant: Participant;
|
||||
}
|
||||
|
||||
const checkEventCompleted = (participant: Participant, event: Event): boolean => {
|
||||
return event.finisherMoodleCourseId ? participant.finisherMoodleCurseCompleted : false;
|
||||
};
|
||||
|
||||
export const ParticipantForm = ({ event, participant }: ParticipantFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const upsertParticipantMutation = useMutation({
|
||||
mutationKey: ["upsertParticipant"],
|
||||
mutationFn: async (newData: Prisma.ParticipantUncheckedCreateInput) => {
|
||||
const data = await upsertParticipant(newData);
|
||||
await queryClient.invalidateQueries({ queryKey: ["participants", event.id] });
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteParticipantMutation = useMutation({
|
||||
mutationKey: ["deleteParticipant"],
|
||||
mutationFn: async (participantId: number) => {
|
||||
await deleteParticipant(participantId);
|
||||
await queryClient.invalidateQueries({ queryKey: ["participants", event.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const eventCompleted = checkEventCompleted(participant, event);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
|
||||
defaultValues: participant,
|
||||
});
|
||||
|
||||
const handleEventFinished = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await upsertParticipantMutation.mutateAsync({
|
||||
eventId: event.id,
|
||||
userId: participant.userId,
|
||||
});
|
||||
toast.success("Event als beendet markiert");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckMoodle = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
toast.success("Moodle-Check durchgeführt");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (formData) => {
|
||||
const data = await upsertParticipantMutation.mutateAsync({
|
||||
...formData,
|
||||
statusLog: participant.statusLog as unknown as Prisma.ParticipantCreatestatusLogInput,
|
||||
eventId: event.id,
|
||||
userId: participant.userId,
|
||||
});
|
||||
form.reset(data);
|
||||
toast.success("Teilnehmer aktualisiert");
|
||||
})}
|
||||
className="bg-base-100 flex flex-wrap gap-6 p-6"
|
||||
>
|
||||
{/* Status Section */}
|
||||
<div className="card bg-base-200 shadow">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-sm">
|
||||
<Bug className="mr-2 inline h-5 w-5" />
|
||||
Debug
|
||||
</h3>
|
||||
<Switch form={form} name={"attended"} label="Anwesend" />
|
||||
<Switch form={form} name={"appointmentCancelled"} label="Termin Abgesagt" />
|
||||
<Switch
|
||||
form={form}
|
||||
name={"finisherMoodleCurseCompleted"}
|
||||
label="Moodle Kurs abgeschlossen"
|
||||
/>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Info Card */}
|
||||
<div className="card bg-base-200 min-w-[200px] flex-1 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-lg">
|
||||
<Users className="h-5 w-5" /> Informationen
|
||||
</h2>
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Kurs-status</p>
|
||||
<p className="font-semibold">{eventCompleted ? "Abgeschlossen" : "In Bearbeitung"}</p>
|
||||
<p className="text-sm text-gray-600">Einschreibedatum</p>
|
||||
<p className="font-semibold">
|
||||
{participant?.enscriptionDate
|
||||
? new Date(participant.enscriptionDate).toLocaleDateString()
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleEventFinished}
|
||||
isLoading={isLoading}
|
||||
className="btn btn-sm btn-success"
|
||||
>
|
||||
Event beendet
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCheckMoodle}
|
||||
isLoading={isLoading}
|
||||
className="btn btn-sm btn-info"
|
||||
>
|
||||
Moodle-Check
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Log */}
|
||||
<div className="card bg-base-200 min-w-[300px] flex-1 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-lg">
|
||||
<Activity className="h-5 w-5" /> Aktivitätslog
|
||||
</h2>
|
||||
|
||||
<div className="timeline timeline-vertical">
|
||||
<table className="table-sm table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Event</th>
|
||||
<th>User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participant?.statusLog &&
|
||||
Array.isArray(participant.statusLog) &&
|
||||
(participant.statusLog as InputJsonValueType[])
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((log: InputJsonValueType, index: number) => {
|
||||
const logEntry = log as unknown as ParticipantLog;
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
{logEntry.timestamp
|
||||
? new Date(logEntry.timestamp).toLocaleDateString()
|
||||
: "-"}
|
||||
</td>
|
||||
<td>{logEntry.event || "-"}</td>
|
||||
<td>{logEntry.user || "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-base-200 w-full shadow-xl">
|
||||
<div className="card-body">
|
||||
<div className="flex w-full gap-4">
|
||||
<Button
|
||||
disabled={!form.formState.isDirty}
|
||||
isLoading={form.formState.isSubmitting}
|
||||
type="submit"
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
{event && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await deleteParticipantMutation.mutateAsync(participant.id);
|
||||
redirect(`/admin/event/${event.id}`);
|
||||
}}
|
||||
className="btn btn-error"
|
||||
>
|
||||
Austragen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { prisma, Prisma, Event, Participant } from "@repo/db";
|
||||
|
||||
//############# Event //#############
|
||||
export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id"]) => {
|
||||
const newEvent = id
|
||||
? await prisma.event.update({
|
||||
@@ -11,34 +12,22 @@ export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id
|
||||
: await prisma.event.create({ data: event });
|
||||
return newEvent;
|
||||
};
|
||||
|
||||
export const deleteEvent = async (id: Event["id"]) => {
|
||||
await prisma.event.delete({ where: { id: id } });
|
||||
};
|
||||
|
||||
export const upsertAppointment = async (
|
||||
eventAppointment: Prisma.EventAppointmentUncheckedCreateInput,
|
||||
) => {
|
||||
const newEventAppointment = eventAppointment.id
|
||||
? await prisma.eventAppointment.update({
|
||||
where: { id: eventAppointment.id },
|
||||
data: eventAppointment,
|
||||
//############# Participant //#############
|
||||
|
||||
export const upsertParticipant = async (participant: Prisma.ParticipantUncheckedCreateInput) => {
|
||||
const newParticipant = participant.id
|
||||
? await prisma.participant.update({
|
||||
where: { id: participant.id },
|
||||
data: participant,
|
||||
})
|
||||
: await prisma.eventAppointment.create({ data: eventAppointment });
|
||||
return newEventAppointment;
|
||||
: await prisma.participant.create({ data: participant });
|
||||
return newParticipant;
|
||||
};
|
||||
|
||||
export const deleteAppoinement = async (id: Event["id"]) => {
|
||||
await prisma.eventAppointment.delete({ where: { id: id } });
|
||||
prisma.eventAppointment.findMany({
|
||||
where: {
|
||||
eventId: id,
|
||||
},
|
||||
orderBy: {
|
||||
// TODO: add order by in relation to table selected column
|
||||
},
|
||||
});
|
||||
};
|
||||
export const deleteParticipant = async (id: Participant["id"]) => {
|
||||
await prisma.participant.delete({ where: { id: id } });
|
||||
};
|
||||
|
||||
19
apps/hub/app/(app)/admin/log/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Error } from "_components/Error";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
|
||||
const AdminAccountLogLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session) return <Error title="Nicht eingeloggt" statusCode={401} />;
|
||||
|
||||
const user = session.user;
|
||||
|
||||
if (!user?.permissions.includes("ADMIN_USER_ADVANCED"))
|
||||
return <Error title="Keine Berechtigung" statusCode={403} />;
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
AdminAccountLogLayout.displayName = "AdminAccountLogLayout";
|
||||
|
||||
export default AdminAccountLogLayout;
|
||||
91
apps/hub/app/(app)/admin/log/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
import { LogsIcon } from "lucide-react";
|
||||
import { PaginatedTable } from "../../../_components/PaginatedTable";
|
||||
import Link from "next/link";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Log, Prisma, User } from "@repo/db";
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<>
|
||||
<PaginatedTable
|
||||
stickyHeaders
|
||||
initialOrderBy={[{ id: "timestamp", desc: true }]}
|
||||
prismaModel="log"
|
||||
showSearch
|
||||
include={{
|
||||
User: true,
|
||||
}}
|
||||
getFilter={(searchTerm) =>
|
||||
({
|
||||
OR: [
|
||||
{
|
||||
User: {
|
||||
firstname: { contains: searchTerm, mode: "insensitive" },
|
||||
lastname: { contains: searchTerm, mode: "insensitive" },
|
||||
publicId: { contains: searchTerm, mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
{ deviceId: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ ip: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
}) as Prisma.LogWhereInput
|
||||
}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
header: "ID",
|
||||
accessorKey: "id",
|
||||
},
|
||||
{
|
||||
header: "Aktion",
|
||||
accessorKey: "action",
|
||||
cell: ({ row }) => {
|
||||
const action = row.original.type;
|
||||
|
||||
if (action !== "PROFILE_CHANGE") {
|
||||
return <span className="text-blue-500">{action}</span>;
|
||||
} else {
|
||||
return (
|
||||
<span className="text-yellow-500">{`${row.original.field} von "${row.original.oldValue}" zu "${row.original.newValue}"`}</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "IP",
|
||||
accessorKey: "ip",
|
||||
},
|
||||
{
|
||||
header: "Browser-ID",
|
||||
accessorKey: "deviceId",
|
||||
},
|
||||
{
|
||||
header: "Zeitstempel",
|
||||
accessorKey: "timestamp",
|
||||
cell: (info) => new Date(info.getValue<string>()).toLocaleString("de-DE"),
|
||||
},
|
||||
{
|
||||
header: "Benutzer",
|
||||
accessorKey: "userId",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link href={`/admin/user/${row.original.userId}`} className={"link"}>
|
||||
{row.original.User
|
||||
? `${row.original.User.firstname} ${row.original.User.lastname} - ${row.original.User.publicId}`
|
||||
: "Unbekannt"}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as ColumnDef<Log & { User: User }>[]
|
||||
}
|
||||
leftOfSearch={
|
||||
<span className="flex items-center gap-2">
|
||||
<LogsIcon className="h-5 w-5" /> Account Log
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { editReport } from "(app)/admin/report/actions";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Report as IReport, Prisma, User } from "@repo/db";
|
||||
import { ReportSchema, Report as IReportZod } from "@repo/db/zod";
|
||||
import { cn } from "@repo/shared-components";
|
||||
import { PaginatedTable } from "_components/PaginatedTable";
|
||||
import { Button } from "_components/ui/Button";
|
||||
import { Switch } from "_components/ui/Switch";
|
||||
@@ -31,14 +32,23 @@ export const ReportSenderInfo = ({
|
||||
</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"
|
||||
>
|
||||
gemeldet von Nutzer {Sender?.firstname} {Sender?.lastname} ({Sender?.publicId}) am{" "}
|
||||
{new Date(report.timestamp).toLocaleString()}
|
||||
</Link>
|
||||
<div className="textarea w-full whitespace-pre-wrap text-left">{report.text}</div>
|
||||
{Sender ? (
|
||||
<Link
|
||||
href={`/admin/user/${Sender?.id}`}
|
||||
className={cn(
|
||||
"link link-hover text-right text-sm text-gray-600",
|
||||
!Sender && "text-green-300",
|
||||
)}
|
||||
>
|
||||
gemeldet von Nutzer {Sender.firstname} {Sender.lastname} ({Sender.publicId}) am{" "}
|
||||
{new Date(report.timestamp).toLocaleString()}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-right text-sm text-green-300">
|
||||
gemeldet vom System am {new Date(report.timestamp).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,9 +12,9 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
|
||||
return (
|
||||
<div className="text-center">
|
||||
{row.getValue("reviewed") ? (
|
||||
<Check className="text-green-500 w-5 h-5" />
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<X className="text-red-500 w-5 h-5" />
|
||||
<X className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -25,19 +25,23 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
|
||||
header: "Sender",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original.Sender;
|
||||
if (!user) return "Unbekannt";
|
||||
return `${user.firstname} ${user.lastname} (${user.publicId})`;
|
||||
if (!user) return <p className="text-green-300">System</p>;
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/user/${user.id}`}
|
||||
>{`${user.firstname} ${user.lastname} (${user.publicId})`}</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reportedUserRole",
|
||||
header: "Rolle des gemeldeten Nutzers",
|
||||
header: "Rolle",
|
||||
cell: ({ row }) => {
|
||||
const role = row.getValue("reportedUserRole") as string | undefined;
|
||||
const Icon = role ? (role.startsWith("LST") ? Workflow : Plane) : ShieldQuestion;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<Icon className="h-4 w-4" />
|
||||
{role || "Unbekannt"}
|
||||
</span>
|
||||
);
|
||||
@@ -62,7 +66,7 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
|
||||
cell: ({ row }) => (
|
||||
<Link href={`/admin/report/${row.original.id}`}>
|
||||
<button className="btn btn-sm btn-outline btn-info flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" /> Anzeigen
|
||||
<Eye className="h-4 w-4" /> Anzeigen
|
||||
</button>
|
||||
</Link>
|
||||
),
|
||||
|
||||
142
apps/hub/app/(app)/admin/user/[id]/_components/AccountLog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
import { Log, Prisma, User } from "@repo/db";
|
||||
import { cn } from "@repo/shared-components";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable";
|
||||
import { Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export const AccountLog = ({ sameIPLogs, userId }: { sameIPLogs: Log[]; userId: string }) => {
|
||||
const [onlyImportant, setOnlyImportant] = useState(true);
|
||||
const tableRef = useRef<PaginatedTableRef>(null);
|
||||
return (
|
||||
<div className="card-body">
|
||||
<PaginatedTable
|
||||
ref={tableRef}
|
||||
showSearch
|
||||
leftOfSearch={
|
||||
<div className="card-title flex justify-between">
|
||||
<h2 className="flex items-center gap-2">
|
||||
<Printer className="h-5 w-5" /> Account Log
|
||||
</h2>
|
||||
<p className="text-end text-sm text-gray-500">
|
||||
Hier werden Logs angezeigt, die dem Nutzer zugeordnet sind oder von der selben IP
|
||||
stammen.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
rightOfPagination={
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ImportantOnly"
|
||||
checked={onlyImportant}
|
||||
onChange={() => {
|
||||
setOnlyImportant(!onlyImportant);
|
||||
tableRef.current?.refresh();
|
||||
}}
|
||||
className="checkbox checkbox-sm"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ImportantOnly"
|
||||
className="min-w-[210px] cursor-pointer select-none text-sm"
|
||||
>
|
||||
Unauffällige Einträge ausblenden
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
getFilter={(searchTerm) => {
|
||||
if (onlyImportant) {
|
||||
return {
|
||||
AND: [
|
||||
{ ip: { contains: searchTerm } },
|
||||
{ browser: { contains: searchTerm } },
|
||||
{
|
||||
id: {
|
||||
in: sameIPLogs
|
||||
.filter((log) => log.id.toString().includes(searchTerm))
|
||||
.map((log) => log.id),
|
||||
},
|
||||
},
|
||||
],
|
||||
} as Prisma.LogWhereInput;
|
||||
} else {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
AND: [
|
||||
{ ip: { contains: searchTerm } },
|
||||
{ browser: { contains: searchTerm } },
|
||||
{
|
||||
id: {
|
||||
in: sameIPLogs
|
||||
.filter((log) => log.id.toString().includes(searchTerm))
|
||||
.map((log) => log.id),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
userId: userId,
|
||||
},
|
||||
],
|
||||
} as Prisma.LogWhereInput;
|
||||
}
|
||||
}}
|
||||
include={{
|
||||
User: true,
|
||||
}}
|
||||
prismaModel={"log"}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
header: "Zeitstempel",
|
||||
accessorKey: "timestamp",
|
||||
cell: (info) => new Date(info.getValue<string>()).toLocaleString("de-DE"),
|
||||
},
|
||||
{
|
||||
header: "Aktion",
|
||||
accessorKey: "action",
|
||||
cell: ({ row }) => {
|
||||
const action = row.original.type;
|
||||
|
||||
if (action !== "PROFILE_CHANGE") {
|
||||
return <span className="text-blue-500">{action}</span>;
|
||||
} else {
|
||||
return (
|
||||
<span className="text-yellow-500">{`${row.original.field} von "${row.original.oldValue}" zu "${row.original.newValue}"`}</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "IP-Adresse",
|
||||
accessorKey: "ip",
|
||||
},
|
||||
{
|
||||
header: "Browser",
|
||||
accessorKey: "browser",
|
||||
},
|
||||
{
|
||||
header: "Benutzer",
|
||||
accessorKey: "userId",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/user/${row.original.userId}`}
|
||||
className={cn("link", userId !== row.original.userId && "text-red-400")}
|
||||
>
|
||||
{row.original.User
|
||||
? `${row.original.User.firstname} ${row.original.User.lastname} - ${row.original.User.publicId}`
|
||||
: "Unbekannt"}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as ColumnDef<Log & { User: User }>[]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Error } from "_components/Error";
|
||||
import { Error as ErrorComponent } from "_components/Error";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { setStandardName } from "../../../../../../helper/discord";
|
||||
import { penaltyColumns } from "(app)/admin/penalty/columns";
|
||||
@@ -73,7 +73,7 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
|
||||
defaultValues: user,
|
||||
resolver: zodResolver(UserOptionalDefaultsSchema),
|
||||
});
|
||||
if (!user) return <Error title="User not found" statusCode={404} />;
|
||||
if (!user) return <ErrorComponent title="User not found" statusCode={404} />;
|
||||
return (
|
||||
<form
|
||||
className="card-body"
|
||||
@@ -663,16 +663,25 @@ export const AdminForm = ({
|
||||
>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await setStandardName({
|
||||
memberId: discordAccount.discordId,
|
||||
userId: user.id,
|
||||
});
|
||||
toast.success("Standard Name wurde gesetzt!", {
|
||||
style: {
|
||||
background: "var(--color-base-100)",
|
||||
color: "var(--color-base-content)",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await setStandardName({
|
||||
memberId: discordAccount.discordId,
|
||||
userId: user.id,
|
||||
});
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
toast.success("Standard Name wurde gesetzt!", {
|
||||
style: {
|
||||
background: "var(--color-base-100)",
|
||||
color: "var(--color-base-content)",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
"Fehler beim setzen des Standard Namens: " + (error as Error).message,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="btn-sm btn-outline btn-info w-full"
|
||||
>
|
||||
@@ -691,6 +700,21 @@ export const AdminForm = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user.isDeleted && (
|
||||
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Account gelöscht</h3>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Dieser Account ist als gelöscht markiert, der Nutzer kann sich nicht mehr anmelden.
|
||||
</h3>
|
||||
</div>
|
||||
</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">
|
||||
@@ -776,13 +800,19 @@ export const AdminForm = ({
|
||||
{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"
|
||||
/>
|
||||
{discordAccount.avatar ? (
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-500 text-white">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{discordAccount.username}</td>
|
||||
<td>{discordAccount.discordId}</td>
|
||||
@@ -792,15 +822,20 @@ export const AdminForm = ({
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
{account.DiscordAccount &&
|
||||
(account.DiscordAccount.avatar ? (
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-500 text-white">
|
||||
N/A
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td>{account.DiscordAccount?.username || "Unbekannt"}</td>
|
||||
<td>{account.DiscordAccount?.discordId || "Unbekannt"}</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PersonIcon } from "@radix-ui/react-icons";
|
||||
import { prisma } from "@repo/db";
|
||||
import { Log, prisma } from "@repo/db";
|
||||
import {
|
||||
AdminForm,
|
||||
ConnectionHistory,
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from "./_components/forms";
|
||||
import { Error } from "../../../../_components/Error";
|
||||
import { getUserPenaltys } from "@repo/shared-components";
|
||||
import { PaginatedTable } from "_components/PaginatedTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { AccountLog } from "./_components/AccountLog";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@@ -35,6 +38,26 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
});
|
||||
}
|
||||
|
||||
const userLog = await prisma.log.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const sameIpLogs = await prisma.log.findMany({
|
||||
where: {
|
||||
ip: {
|
||||
in: userLog.map((log) => log.ip).filter((ip): ip is string => ip !== null),
|
||||
},
|
||||
userId: {
|
||||
not: user?.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
@@ -153,9 +176,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
/>
|
||||
</div>
|
||||
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
|
||||
<AccountLog sameIPLogs={sameIpLogs} userId={user.id} />
|
||||
</div>
|
||||
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
|
||||
<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 col-span-6 mb-4 shadow-xl xl:col-span-3">
|
||||
<UserPenalties user={user} />
|
||||
</div>
|
||||
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma, Prisma } from "@repo/db";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { sendMailByTemplate } from "../../../../helper/mail";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
import { logAction } from "(auth)/login/_components/action";
|
||||
|
||||
export const getUser = async (where: Prisma.UserWhereInput) => {
|
||||
return await prisma.user.findMany({
|
||||
@@ -58,10 +59,14 @@ export const deletePilotHistory = async (id: number) => {
|
||||
});
|
||||
};
|
||||
export const deleteUser = async (id: string) => {
|
||||
return await prisma.user.delete({
|
||||
await logAction("ACCOUNT_DELETED");
|
||||
return await prisma.user.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
isDeleted: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ const AdminUserPage = () => {
|
||||
if (activePenaltys.length > 0) {
|
||||
return <span className="font-bold text-red-600">AKTIVE STRAFE</span>;
|
||||
}
|
||||
if (props.row.original.isDeleted) {
|
||||
return <span className="font-bold text-yellow-600">GELÖSCHT</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")) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { DrawingPinFilledIcon } from "@radix-ui/react-icons";
|
||||
import { Event, Participant, EventAppointment, User } from "@repo/db";
|
||||
import { Event, Participant, User } from "@repo/db";
|
||||
import ModalBtn from "./Modal";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import { Badge } from "@repo/shared-components";
|
||||
@@ -8,25 +8,18 @@ import { Badge } from "@repo/shared-components";
|
||||
export const EventCard = ({
|
||||
user,
|
||||
event,
|
||||
selectedAppointments,
|
||||
appointments,
|
||||
}: {
|
||||
user: User;
|
||||
event: Event & {
|
||||
Appointments: EventAppointment[];
|
||||
Participants: Participant[];
|
||||
};
|
||||
selectedAppointments: EventAppointment[];
|
||||
appointments: (EventAppointment & {
|
||||
Participants: { userId: string }[];
|
||||
})[];
|
||||
}) => {
|
||||
return (
|
||||
<div className="col-span-full">
|
||||
<div className="card bg-base-200 shadow-xl mb-4">
|
||||
<div className="card bg-base-200 mb-4 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{event.name}</h2>
|
||||
<div className="absolute top-0 right-0 m-4">
|
||||
<div className="absolute right-0 top-0 m-4">
|
||||
{event.type === "COURSE" && (
|
||||
<span className="badge badge-info badge-outline">Zusatzqualifikation</span>
|
||||
)}
|
||||
@@ -36,7 +29,7 @@ export const EventCard = ({
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-4">
|
||||
<div className="text-left text-balance" data-color-mode="dark">
|
||||
<div className="text-balance text-left" data-color-mode="dark">
|
||||
<MDEditor.Markdown
|
||||
source={event.descriptionShort}
|
||||
style={{
|
||||
@@ -45,21 +38,21 @@ export const EventCard = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex col-span-2 justify-end">
|
||||
<div className="col-span-2 flex justify-end">
|
||||
{event.finishedBadges.map((b) => {
|
||||
return <Badge badge={b} key={b} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions flex justify-between items-center mt-5">
|
||||
<div className="card-actions mt-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-600 text-left flex items-center gap-2">
|
||||
<p className="flex items-center gap-2 text-left text-gray-600">
|
||||
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen: </b>
|
||||
{!event.requiredBadges.length && "Keine"}
|
||||
</p>
|
||||
{!!event.requiredBadges.length && (
|
||||
<div className="flex ml-6">
|
||||
<b className="text-gray-600 text-left mr-2">Abzeichen:</b>
|
||||
<div className="ml-6 flex">
|
||||
<b className="mr-2 text-left text-gray-600">Abzeichen:</b>
|
||||
<div className="flex gap-2">
|
||||
{event.requiredBadges.map((badge) => (
|
||||
<div className="badge badge-secondary badge-outline" key={badge}>
|
||||
@@ -71,11 +64,9 @@ export const EventCard = ({
|
||||
)}
|
||||
</div>
|
||||
<ModalBtn
|
||||
selectedAppointments={selectedAppointments}
|
||||
user={user}
|
||||
event={event}
|
||||
title={event.name}
|
||||
dates={appointments}
|
||||
participant={event.Participants[0]}
|
||||
modalId={`${event.name}_modal.${event.id}`}
|
||||
/>
|
||||
|
||||
@@ -1,56 +1,30 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { CheckCircledIcon, EnterIcon, DrawingPinFilledIcon } from "@radix-ui/react-icons";
|
||||
import { Event, EventAppointment, Participant, User } from "@repo/db";
|
||||
import { Event, Participant, User } from "@repo/db";
|
||||
import { cn } from "@repo/shared-components";
|
||||
import { inscribeToMoodleCourse, upsertParticipant } from "../actions";
|
||||
import {
|
||||
BookCheck,
|
||||
Calendar,
|
||||
Check,
|
||||
CirclePlay,
|
||||
Clock10Icon,
|
||||
ExternalLink,
|
||||
EyeIcon,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
InputJsonValueType,
|
||||
ParticipantOptionalDefaults,
|
||||
ParticipantOptionalDefaultsSchema,
|
||||
} from "@repo/db/zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Select } from "../../../_components/ui/Select";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { handleParticipantEnrolled } from "../../../../helper/events";
|
||||
import { eventCompleted } from "@repo/shared-components";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import toast from "react-hot-toast";
|
||||
import { formatDate } from "date-fns";
|
||||
|
||||
interface ModalBtnProps {
|
||||
title: string;
|
||||
event: Event;
|
||||
dates: (EventAppointment & {
|
||||
Participants: { userId: string }[];
|
||||
})[];
|
||||
selectedAppointments: EventAppointment[];
|
||||
participant?: Participant;
|
||||
user: User;
|
||||
modalId: string;
|
||||
}
|
||||
|
||||
const ModalBtn = ({
|
||||
title,
|
||||
dates,
|
||||
modalId,
|
||||
participant,
|
||||
selectedAppointments,
|
||||
event,
|
||||
user,
|
||||
}: ModalBtnProps) => {
|
||||
const ModalBtn = ({ title, modalId, participant, event, user }: ModalBtnProps) => {
|
||||
useEffect(() => {
|
||||
const modal = document.getElementById(modalId) as HTMLDialogElement;
|
||||
const handleOpen = () => {
|
||||
@@ -66,12 +40,6 @@ const ModalBtn = ({
|
||||
modal?.removeEventListener("close", handleClose);
|
||||
};
|
||||
}, [modalId]);
|
||||
const router = useRouter();
|
||||
|
||||
const canSelectDate =
|
||||
event.hasPresenceEvents &&
|
||||
!participant?.attended &&
|
||||
(selectedAppointments.length === 0 || participant?.appointmentCancelled);
|
||||
|
||||
const openModal = () => {
|
||||
const modal = document.getElementById(modalId) as HTMLDialogElement;
|
||||
@@ -82,29 +50,6 @@ const ModalBtn = ({
|
||||
const modal = document.getElementById(modalId) as HTMLDialogElement;
|
||||
modal?.close();
|
||||
};
|
||||
const selectAppointmentForm = useForm<ParticipantOptionalDefaults>({
|
||||
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
|
||||
defaultValues: {
|
||||
eventId: event.id,
|
||||
userId: user.id,
|
||||
...participant,
|
||||
},
|
||||
});
|
||||
const selectedAppointment = selectedAppointments[0];
|
||||
const selectedDate = dates.find(
|
||||
(date) =>
|
||||
date.id === selectAppointmentForm.watch("eventAppointmentId") || selectedAppointment?.id,
|
||||
);
|
||||
const ownIndexInParticipantList = selectedDate?.Participants?.findIndex(
|
||||
(p) => p.userId === user.id,
|
||||
);
|
||||
|
||||
const ownPlaceInParticipantList =
|
||||
typeof ownIndexInParticipantList === "number"
|
||||
? ownIndexInParticipantList === -1
|
||||
? (selectedDate?.Participants?.length ?? 0) + 1
|
||||
: ownIndexInParticipantList + 1
|
||||
: undefined;
|
||||
|
||||
const missingRequirements =
|
||||
event.requiredBadges?.length > 0 &&
|
||||
@@ -163,79 +108,6 @@ const ModalBtn = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{event.hasPresenceEvents && (
|
||||
<div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow">
|
||||
<h2 className="flex gap-2 text-lg font-bold">
|
||||
<Calendar /> Termine
|
||||
</h2>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
{!!dates.length && !selectedDate && (
|
||||
<>
|
||||
<p className="text-info text-center">Melde dich zu einem Termin an</p>
|
||||
<Select
|
||||
form={selectAppointmentForm}
|
||||
options={dates.map((date) => ({
|
||||
label: `${formatDate(date.appointmentDate, "dd.MM.yyyy HH:mm")} - (${date.Participants.length}/${event.maxParticipants})`,
|
||||
value: date.id,
|
||||
}))}
|
||||
name="eventAppointmentId"
|
||||
label={""}
|
||||
placeholder="Wähle einen Termin"
|
||||
className="min-w-[250px]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedAppointment && !participant?.appointmentCancelled && (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<span>Dein ausgewählter Termin (Deutsche Zeit)</span>
|
||||
<div>
|
||||
<button
|
||||
className="input input-border pointer-events-none min-w-[250px]"
|
||||
style={{ anchorName: "--rdp" } as React.CSSProperties}
|
||||
>
|
||||
{new Date(selectedAppointment.appointmentDate).toLocaleString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
{participant?.attended ? (
|
||||
<p className="flex items-center justify-center gap-2 py-4">
|
||||
<CheckCircledIcon className="text-success" />
|
||||
Du hast an dem Presenztermin teilgenommen
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex items-center justify-center gap-2 py-4">
|
||||
Bitte erscheine ~5 minuten vor dem Termin im Discord
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dates.length && (
|
||||
<p className="text-error text-center">Aktuell sind keine Termine verfügbar</p>
|
||||
)}
|
||||
|
||||
{!!selectedDate &&
|
||||
!!event.maxParticipants &&
|
||||
!!ownPlaceInParticipantList &&
|
||||
!!(ownPlaceInParticipantList > event.maxParticipants) && (
|
||||
<p
|
||||
role="alert"
|
||||
className="alert alert-error alert-outline my-5 flex items-center justify-center gap-2 border py-4"
|
||||
>
|
||||
<TriangleAlert className="h-6 w-6 shrink-0 stroke-current" fill="none" />
|
||||
Dieser Termin ist ausgebucht, wahrscheinlich wirst du nicht teilnehmen
|
||||
können. (Listenplatz: {ownPlaceInParticipantList} , max.{" "}
|
||||
{event.maxParticipants})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{event.finisherMoodleCourseId && (
|
||||
<div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow">
|
||||
<h2 className="flex gap-2 text-lg font-bold">
|
||||
@@ -261,64 +133,6 @@ const ModalBtn = ({
|
||||
{!event.requiredBadges.length && "Keine"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
{!!canSelectDate && (
|
||||
<button
|
||||
className={cn(
|
||||
"btn btn-info btn-outline btn-wide",
|
||||
event.type === "COURSE" && "btn-secondary",
|
||||
)}
|
||||
onClick={async () => {
|
||||
const data = selectAppointmentForm.getValues();
|
||||
if (!data.eventAppointmentId) return;
|
||||
|
||||
const participant = await upsertParticipant({
|
||||
...data,
|
||||
enscriptionDate: new Date(),
|
||||
statusLog: data.statusLog?.filter((log) => log !== null),
|
||||
appointmentCancelled: false,
|
||||
});
|
||||
await handleParticipantEnrolled(participant.id.toString());
|
||||
|
||||
router.refresh();
|
||||
closeModal();
|
||||
}}
|
||||
disabled={!selectAppointmentForm.watch("eventAppointmentId")}
|
||||
>
|
||||
<EnterIcon /> Anmelden
|
||||
</button>
|
||||
)}
|
||||
{selectedAppointment &&
|
||||
!participant?.appointmentCancelled &&
|
||||
!participant?.attended && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await upsertParticipant({
|
||||
eventId: event.id,
|
||||
userId: participant!.userId,
|
||||
appointmentCancelled: true,
|
||||
statusLog: [
|
||||
...(participant?.statusLog as unknown as InputJsonValueType[]),
|
||||
{
|
||||
data: {
|
||||
appointmentId: selectedAppointment.id,
|
||||
appointmentDate: selectedAppointment.appointmentDate,
|
||||
},
|
||||
user: `${user?.firstname} ${user?.lastname} - ${user?.publicId}`,
|
||||
event: "Termin abgesagt",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
toast.success("Termin abgesagt");
|
||||
router.refresh();
|
||||
}}
|
||||
className="btn btn-error btn-outline btn-wide"
|
||||
>
|
||||
Termin Absagen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="modal-backdrop" onClick={closeModal}>
|
||||
@@ -335,7 +149,6 @@ const MoodleCourseIndicator = ({
|
||||
completed,
|
||||
moodleCourseId,
|
||||
event,
|
||||
participant,
|
||||
user,
|
||||
}: {
|
||||
user: User;
|
||||
@@ -345,13 +158,6 @@ const MoodleCourseIndicator = ({
|
||||
event: Event;
|
||||
}) => {
|
||||
const courseUrl = `${process.env.NEXT_PUBLIC_MOODLE_URL}/course/view.php?id=${moodleCourseId}`;
|
||||
if (event.hasPresenceEvents && !participant?.attended)
|
||||
return (
|
||||
<p className="flex items-center justify-center gap-2 py-4">
|
||||
<Clock10Icon className="text-error" />
|
||||
Abschlusstest erst nach Teilnahme verfügbar
|
||||
</p>
|
||||
);
|
||||
if (completed)
|
||||
return (
|
||||
<p className="flex items-center justify-center gap-2 py-4">
|
||||
|
||||
@@ -10,63 +10,19 @@ const page = async () => {
|
||||
if (!user) return null;
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
where: {
|
||||
hidden: false,
|
||||
},
|
||||
include: {
|
||||
Appointments: {
|
||||
where: {
|
||||
appointmentDate: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Participants: {
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
});
|
||||
const appointments = await prisma.eventAppointment.findMany({
|
||||
where: {
|
||||
appointmentDate: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Participants: {
|
||||
select: {
|
||||
enscriptionDate: true,
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
appointmentCancelled: false,
|
||||
},
|
||||
orderBy: {
|
||||
enscriptionDate: "asc",
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
Participants: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const userAppointments = await prisma.eventAppointment.findMany({
|
||||
where: {
|
||||
Participants: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -78,15 +34,7 @@ const page = async () => {
|
||||
</div>
|
||||
|
||||
{events.map((event) => {
|
||||
return (
|
||||
<EventCard
|
||||
appointments={appointments}
|
||||
selectedAppointments={userAppointments}
|
||||
user={user}
|
||||
event={event}
|
||||
key={event.id}
|
||||
/>
|
||||
);
|
||||
return <EventCard user={user} event={event} key={event.id} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function () {
|
||||
image={Discord}
|
||||
title="Discord Server"
|
||||
BtnIcon={<DiscordLogoIcon />}
|
||||
btnHref="https://discord.com/invite/x6FAMY7DW6"
|
||||
btnHref="https://discord.gg/virtualairrescue"
|
||||
btnLabel="Beitreten"
|
||||
description="Tritt unserem Discordserver bei, um mit der Community in Kontakt zu bleiben, Unterstützung zu erhalten und über die neuesten Updates informiert zu werden. Wenn du beigetreten bist kannst du in deinen Einstellungen dein VAR-Konto mit deinem Discordkonto verknüpfen und eine Rolle zu erhalten.
|
||||
"
|
||||
|
||||
@@ -23,6 +23,7 @@ import toast from "react-hot-toast";
|
||||
import { CircleAlert, Trash2 } from "lucide-react";
|
||||
import { deleteUser, sendVerificationLink } from "(app)/admin/user/action";
|
||||
import { setStandardName } from "../../../../helper/discord";
|
||||
import { logAction } from "(auth)/login/_components/action";
|
||||
|
||||
export const ProfileForm = ({
|
||||
user,
|
||||
@@ -101,6 +102,35 @@ export const ProfileForm = ({
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
const ip = await fetch("https://api.ipify.org/?format=json")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.ip);
|
||||
|
||||
if (user.firstname !== values.firstname) {
|
||||
await logAction("PROFILE_CHANGE", {
|
||||
ip,
|
||||
field: "firstname",
|
||||
oldValue: user.firstname,
|
||||
newValue: values.firstname,
|
||||
});
|
||||
}
|
||||
if (user.lastname !== values.lastname) {
|
||||
await logAction("PROFILE_CHANGE", {
|
||||
ip,
|
||||
field: "lastname",
|
||||
oldValue: user.lastname,
|
||||
newValue: values.lastname,
|
||||
});
|
||||
}
|
||||
if (user.email !== values.email) {
|
||||
await logAction("PROFILE_CHANGE", {
|
||||
ip,
|
||||
field: "email",
|
||||
oldValue: user.email,
|
||||
newValue: values.email,
|
||||
});
|
||||
}
|
||||
|
||||
form.reset(values);
|
||||
if (user.email !== values.email) {
|
||||
await sendVerificationLink(user.id);
|
||||
@@ -377,8 +407,11 @@ export const DeleteForm = ({
|
||||
export const PasswordForm = (): React.JSX.Element => {
|
||||
const schema = z.object({
|
||||
password: z.string().min(2).max(30),
|
||||
newPassword: z.string().min(2).max(30),
|
||||
newPasswordConfirm: z.string().min(2).max(30),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, { message: "Das Passwort muss mindestens 8 Zeichen lang sein" })
|
||||
.max(30),
|
||||
newPasswordConfirm: z.string().min(8).max(30),
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
type IFormInput = z.infer<typeof schema>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Toaster, toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Button } from "../../../_components/ui/Button";
|
||||
import { useErrorBoundary } from "react-error-boundary";
|
||||
import { logAction } from "./action";
|
||||
|
||||
export const Login = () => {
|
||||
const { showBoundary } = useErrorBoundary();
|
||||
@@ -46,6 +47,12 @@ export const Login = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ip = await fetch("https://api.ipify.org/?format=json")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.ip);
|
||||
|
||||
await logAction("LOGIN", { ip });
|
||||
redirect(searchParams.get("redirect") || "/");
|
||||
} catch (error) {
|
||||
showBoundary(error);
|
||||
|
||||
110
apps/hub/app/(auth)/login/_components/action.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
"use server";
|
||||
import { LOG_TYPE, prisma } from "@repo/db";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
import { randomUUID } from "crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { sendReportEmbed } from "../../../../helper/discord";
|
||||
|
||||
export async function getOrSetDeviceId() {
|
||||
const store = await cookies();
|
||||
let deviceId = store.get("device_id")?.value;
|
||||
|
||||
if (!deviceId) {
|
||||
deviceId = randomUUID();
|
||||
store.set("device_id", deviceId, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 Jahr
|
||||
});
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
export const logAction = async (
|
||||
type: LOG_TYPE,
|
||||
otherValues?: {
|
||||
ip: string;
|
||||
field?: string;
|
||||
oldValue?: string;
|
||||
newValue?: string;
|
||||
userId?: string;
|
||||
},
|
||||
) => {
|
||||
const headersList = await headers();
|
||||
const user = await getServerSession();
|
||||
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
if (type == "LOGIN" || type == "REGISTER") {
|
||||
const existingLogs = await prisma.log.findMany({
|
||||
where: {
|
||||
type: "LOGIN",
|
||||
userId: {
|
||||
not: user?.user.id,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
ip: otherValues?.ip,
|
||||
},
|
||||
{
|
||||
deviceId: deviceId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (existingLogs.length > 0 && user?.user.id) {
|
||||
const existingReport = await prisma.report.findFirst({
|
||||
where: {
|
||||
reportedUserId: user.user.id,
|
||||
reportedUserRole: {
|
||||
contains: "Doppelter Account Verdacht",
|
||||
},
|
||||
},
|
||||
});
|
||||
// keine Doppel-Reports für denselben Nutzer erstellen
|
||||
if (!existingReport) {
|
||||
// Möglicherweise ein doppelter Account, Report erstellen
|
||||
const report = await prisma.report.create({
|
||||
data: {
|
||||
text: `Möglicher doppelter Account erkannt bei Login-Versuch.\n\nÜbereinstimmende Logs:\n${existingLogs
|
||||
.map((log) => `- Log ID: ${log.id}, IP: ${log.ip}, Zeitstempel: ${log.timestamp}`)
|
||||
.join("\n")}`,
|
||||
reportedUserId: user?.user.id,
|
||||
reportedUserRole: "LOGIN - Doppelter Account Verdacht",
|
||||
},
|
||||
});
|
||||
|
||||
await sendReportEmbed(report.id);
|
||||
} else {
|
||||
// Update report and send it again to Discord
|
||||
const updatedReport = await prisma.report.update({
|
||||
where: {
|
||||
id: existingReport.id,
|
||||
},
|
||||
data: {
|
||||
text: `Möglicher doppelter Account erkannt bei Login-Versuch.\n\nÜbereinstimmende Logs:\n${existingLogs
|
||||
.map(
|
||||
(log) =>
|
||||
`- Log ID: ${log.id}, IP: ${log.ip}, Zeitstempel: ${new Date(log.timestamp).toLocaleString("de-DE")}`,
|
||||
)
|
||||
.join("\n")}`,
|
||||
},
|
||||
});
|
||||
await sendReportEmbed(updatedReport.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.log.create({
|
||||
data: {
|
||||
type,
|
||||
browser: headersList.get("user-agent") || "unknown",
|
||||
userId: user?.user.id || otherValues?.userId,
|
||||
deviceId: deviceId,
|
||||
ip: otherValues?.ip,
|
||||
...otherValues,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export const resetPassword = async (email: string) => {
|
||||
let user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
const oldUser = (v1User as OldUser[]).find((u) => u.email.toLowerCase() === email);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useState } from "react";
|
||||
import { Button } from "../../../_components/ui/Button";
|
||||
import { sendVerificationLink } from "(app)/admin/user/action";
|
||||
import toast from "react-hot-toast";
|
||||
import { logAction } from "(auth)/login/_components/action";
|
||||
|
||||
export const Register = () => {
|
||||
const schema = z
|
||||
@@ -48,8 +49,8 @@ export const Register = () => {
|
||||
.refine((val) => val.length === 0 || val.includes(" ") || /^[A-ZÄÖÜ]/.test(val), {
|
||||
message: "Der Nachname muss mit einem Großbuchstaben beginnen",
|
||||
}),
|
||||
password: z.string().min(12, {
|
||||
message: "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
password: z.string().min(8, {
|
||||
message: "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||
}),
|
||||
passwordConfirm: z.string(),
|
||||
})
|
||||
@@ -93,6 +94,14 @@ export const Register = () => {
|
||||
return;
|
||||
}
|
||||
await sendVerificationLink(user.id);
|
||||
const ip = await fetch("https://api.ipify.org/?format=json")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.ip);
|
||||
|
||||
await logAction("REGISTER", {
|
||||
ip: ip,
|
||||
userId: user.id,
|
||||
});
|
||||
await signIn("credentials", {
|
||||
callbackUrl: "/",
|
||||
email: user.email,
|
||||
|
||||
@@ -24,9 +24,6 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingOldUser = (v1User as OldUser[]).find(
|
||||
@@ -34,9 +31,15 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
error: "Ein Nutzer mit dieser E-Mail-Adresse existiert bereits.",
|
||||
};
|
||||
if (existingUser.isDeleted) {
|
||||
return {
|
||||
error: "Diese E-Mail-Adresse kann nicht verwendet werden.",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: "Ein Nutzer mit dieser E-Mail-Adresse existiert bereits.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (existingOldUser) {
|
||||
@@ -53,5 +56,6 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||