This commit is contained in:
lucuswolfius
2025-06-13 19:59:14 -07:00
226 changed files with 9959 additions and 1732 deletions

82
.env.example Normal file
View File

@@ -0,0 +1,82 @@
# ───────────────────────────────────────────────
# 🔐 Authentifizierung & Cookies
# ───────────────────────────────────────────────
AUTH_DISPATCH_SECRET=
AUTH_HUB_SECRET=
AUTH_DISPATCH_COOKIE_PREFIX=
AUTH_HUB_COOKIE_PREFIX=
AUTH_DISPATCH_URL=
AUTH_HUB_URL=
NEXT_PUBLIC_DISPATCH_SERVICE_ID=
# ───────────────────────────────────────────────
# 🌐 Öffentliche URLs
# ───────────────────────────────────────────────
NEXT_PUBLIC_HUB_URL=
NEXT_PUBLIC_HUB_SERVER_URL=
NEXT_PUBLIC_DISPATCH_URL=
NEXT_PUBLIC_DISPATCH_SERVER_URL=
DISCORD_SERVER_URL=
NEXT_PUBLIC_ESRI_ACCESS_TOKEN=
NEXT_PUBLIC_OPENAIP_ACCESS=
# ───────────────────────────────────────────────
# 🗄️ Datenbank
# ───────────────────────────────────────────────
DATABASE_URL=
# ───────────────────────────────────────────────
# 📡 LiveKit Konfiguration
# ───────────────────────────────────────────────
NEXT_PUBLIC_LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# ───────────────────────────────────────────────
# 🚦 Dispatch Server (Backend)
# ───────────────────────────────────────────────
DISPATCH_SERVER_PORT=
DISPATCH_APP_TOKEN=
REDIS_HOST=
REDIS_PORT=
# ───────────────────────────────────────────────
# 🧠 HUB Server (Backend)
# ───────────────────────────────────────────────
HUB_SERVER_PORT=
DISCORD_SERVER_PORT=
# ───────────────────────────────────────────────
# 📚 Moodle
# ───────────────────────────────────────────────
MOODLE_URL=
MOODLE_API_TOKEN=
MOODLE_USER_PASSWORD=
NEXT_PUBLIC_MOODLE_URL=
# ───────────────────────────────────────────────
# 📧 E-Mail Einstellungen (nur HUB Server)
# ───────────────────────────────────────────────
MAIL_SERVER=
MAIL_PORT=
MAIL_USER=
MAIL_PASSWORD=
# ───────────────────────────────────────────────
# 🕹️ Discord OAuth (optional)
# ───────────────────────────────────────────────
DISCORD_GUILD_ID=
DISCORD_OAUTH_CLIENT_ID=
DISCORD_OAUTH_SECRET=
DISCORD_BOT_TOKEN=
DISCORD_REDIRECT_URL=
NEXT_PUBLIC_DISCORD_URL=

View File

@@ -1,77 +1,82 @@
# ───────────────────────────────────────────────
# 🔐 Authentifizierung & Cookies
# ───────────────────────────────────────────────
AUTH_DISPATCH_SECRET=dispatch
AUTH_HUB_SECRET=var
AUTH_DISPATCH_SECRET=
AUTH_HUB_SECRET=
AUTH_DISPATCH_COOKIE_PREFIX=DISPATCH
AUTH_HUB_COOKIE_PREFIX=HUB
AUTH_DISPATCH_COOKIE_PREFIX=
AUTH_HUB_COOKIE_PREFIX=
AUTH_DISPATCH_URL=https://dispatch.premiumag.de
AUTH_HUB_URL=https://hub.premiumag.de
NEXT_PUBLIC_DISPATCH_SERVICE_ID=1
AUTH_DISPATCH_URL=
AUTH_HUB_URL=
NEXT_PUBLIC_DISPATCH_SERVICE_ID=
# ───────────────────────────────────────────────
# 🌐 Öffentliche URLs
# ───────────────────────────────────────────────
NEXT_PUBLIC_HUB_URL=https://hub.premiumag.de
NEXT_PUBLIC_HUB_SERVER_URL=https://api.hub.premiumag.de
NEXT_PUBLIC_DISPATCH_URL=https://dispatch.premiumag.de
NEXT_PUBLIC_DISPATCH_SERVER_URL=https://api.dispatch.premiumag.de
NEXT_PUBLIC_HUB_URL=
NEXT_PUBLIC_HUB_SERVER_URL=
NEXT_PUBLIC_DISPATCH_URL=
NEXT_PUBLIC_DISPATCH_SERVER_URL=
DISCORD_SERVER_URL=
NEXT_PUBLIC_ESRI_ACCESS_TOKEN=
NEXT_PUBLIC_OPENAIP_ACCESS=6e85069940543ef02f8615b737059d98
NEXT_PUBLIC_OPENAIP_ACCESS=
# ───────────────────────────────────────────────
# 🗄️ Datenbank
# ───────────────────────────────────────────────
DATABASE_URL=postgresql://persistant-data:persistant-data-pw@postgres:5432/var
DATABASE_URL=
# ───────────────────────────────────────────────
# 📡 LiveKit Konfiguration
# ───────────────────────────────────────────────
NEXT_PUBLIC_LIVEKIT_URL=wss://livekit.premiumag.de
LIVEKIT_API_KEY=APIAnsGdtdYp2Ho
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC
NEXT_PUBLIC_LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# ───────────────────────────────────────────────
# 🚦 Dispatch Server (Backend)
# ───────────────────────────────────────────────
DISPATCH_SERVER_PORT=3000
DISPATCH_APP_TOKEN=dispatch
DISPATCH_SERVER_PORT=
DISPATCH_APP_TOKEN=
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_HOST=
REDIS_PORT=
# ───────────────────────────────────────────────
# 🧠 HUB Server (Backend)
# ───────────────────────────────────────────────
HUB_SERVER_PORT=3000
HUB_SERVER_PORT=
DISCORD_SERVER_PORT=
# ───────────────────────────────────────────────
# 📚 Moodle
# ───────────────────────────────────────────────
MOODLE_URL=https://02.premiumag.de:8081
MOODLE_API_TOKEN=ac346f0324647b68488d13fd52a9bbe8
MOODLE_USER_PASSWORD=var-api-user-P1
NEXT_PUBLIC_MOODLE_URL=https://02.premiumag.de:8081
MOODLE_URL=
MOODLE_API_TOKEN=
MOODLE_USER_PASSWORD=
NEXT_PUBLIC_MOODLE_URL=
# ───────────────────────────────────────────────
# 📧 E-Mail Einstellungen (nur HUB Server)
# ───────────────────────────────────────────────
MAIL_SERVER=asmtp.mail.hostpoint.ch
MAIL_PORT=465
MAIL_USER=noreply@virtualairrescue.com
MAIL_PASSWORD=b7316PB8aDPCC%-&
MAIL_SERVER=
MAIL_PORT=
MAIL_USER=
MAIL_PASSWORD=
# ───────────────────────────────────────────────
# 🕹️ Discord OAuth (optional)
# ───────────────────────────────────────────────
DISCORD_OAUTH_CLIENT_ID=930384053344034846
DISCORD_OAUTH_SECRET=96aSvmIePqFTbGc54mad0QsZfDnYwhl1
DISCORD_BOT_TOKEN=OTMwMzg0MDUzMzQ0MDM0ODQ2.G7zIy-._hE3dTbtUv6sd7nIP2PUn3d8s-2MFk0x3nYMg8
DISCORD_REDIRECT_URL=https://hub.premiumag.de/api/discord-redirect
NEXT_PUBLIC_DISCORD_URL=https://discord.com/oauth2/authorize?client_id=930384053344034846&response_type=code&redirect_uri=https%3A%2F%2Fhub.premiumag.de%2Fapi%2Fdiscord-redirect&scope=identify+guilds+email
DISCORD_GUILD_ID=
DISCORD_OAUTH_CLIENT_ID=
DISCORD_OAUTH_SECRET=
DISCORD_BOT_TOKEN=
DISCORD_REDIRECT_URL=
NEXT_PUBLIC_DISCORD_URL=

7
.gitignore vendored
View File

@@ -1,5 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
letsencrypt
moodle/*
# Grafana
grafana
@@ -15,9 +18,7 @@ mkcert
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.prod
# Testing
coverage

View File

@@ -7,3 +7,7 @@ Um lokal Docker-Images zu bauen, gib die `.env`-Datei mit folgendem Befehl an `d
```sh
docker compose --env-file .env.prod -f 'docker-compose.prod.yml' up -d
```
## Moodle-dev
Damit die Moodle integration funktioniert muss ein Service token erstellt sein und ein Custom OAuth-Service für moodle angelegt werden

19
apps/discord-server/.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare module "next-auth/jwt" {
interface JWT {
uid: string;
firstname: string;
lastname: string;
email: string;
}
}
declare module "cookie-parser";
import type { User } from "@repo/db";
declare global {
namespace Express {
interface Request {
user?: User | null;
}
}
}

View File

@@ -0,0 +1,6 @@
node_modules
Dockerfile
.dockerignore
nodemon.json
.env
.env.example

View File

@@ -0,0 +1,7 @@
DISCORD_SERVER_PORT=3005
DISCORD_GUILD_ID=1077269395019141140
DISCORD_OAUTH_CLIENT_ID=930384053344034846
DISCORD_OAUTH_SECRET=96aSvmIePqFTbGc54mad0QsZfDnYwhl1
DISCORD_BOT_TOKEN=OTMwMzg0MDUzMzQ0MDM0ODQ2.G7zIy-._hE3dTbtUv6sd7nIP2PUn3d8s-2MFk0x3nYMg8
DISCORD_REDIRECT_URL=https://hub.premiumag.de/api/discord-redirect
NEXT_PUBLIC_DISCORD_URL=https://discord.com/oauth2/authorize?client_id=930384053344034846&response_type=code&redirect_uri=https%3A%2F%2Fhub.premiumag.de%2Fapi%2Fdiscord-redirect&scope=identify+guilds+email

View File

@@ -0,0 +1,50 @@
FROM node:22-alpine AS base
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
WORKDIR /usr/app
COPY . .
RUN ls -lh
RUN turbo prune discord-server --docker
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /usr/app
COPY --from=builder /usr/app/out/json/ .
RUN pnpm install
# Build the project
COPY --from=builder /usr/app/out/full/ .
RUN turbo run build
FROM base 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/ ./
# Expose the application port
EXPOSE 3003
CMD ["pnpm", "--dir", "apps/discord-server", "run", "start"]

View File

@@ -0,0 +1,16 @@
import "dotenv/config";
import express from "express";
import { createServer } from "http";
import router from "routes/router";
import cors from "cors";
const app = express();
const server = createServer(app);
app.use(cors());
app.use(express.json());
app.use(router);
server.listen(process.env.DISCORD_SERVER_PORT, () => {
console.log(`Server running on port ${process.env.DISCORD_SERVER_PORT}`);
});

View File

@@ -0,0 +1,19 @@
import { Client, GatewayIntentBits } from "discord.js";
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
const token = process.env.DISCORD_BOT_TOKEN;
if (!token) {
throw new Error("DISCORD_BOT_TOKEN environment variable is not set.");
}
client.login(token);
client.on("ready", () => {
console.log(`Logged in as ${client.user?.tag}`);
});
export default client;

View File

@@ -0,0 +1,5 @@
{
"watch": ["."],
"ext": "ts",
"exec": "tsx index.ts"
}

View File

@@ -0,0 +1,34 @@
{
"name": "discord-server",
"exports": {
"helpers": "./helper"
},
"scripts": {
"dev": "nodemon --signal SIGINT",
"start": "tsx index.ts --transpile-only",
"build": "tsc"
},
"packageManager": "pnpm@10.11.0",
"devDependencies": {
"@repo/db": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2",
"typescript": "latest"
},
"dependencies": {
"axios": "^1.9.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"discord.js": "^14.19.3",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"node-cron": "^4.1.0",
"nodemon": "^3.1.10",
"react": "^19.1.0",
"tsx": "^4.19.4"
}
}

View File

@@ -0,0 +1,56 @@
import { DISCORD_ROLES, Event, getPublicUser, Participant, prisma } from "@repo/db";
import { Router } from "express";
import { changeMemberRoles, getMember } from "routes/member";
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;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
console.log(`Setting standard name for user ${userId} (${user?.publicId}) to member ${memberId}`);
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const participant = await prisma.participant.findMany({
where: {
userId: user.id,
},
include: {
Event: true,
},
});
participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) {
await changeMemberRoles(memberId, [p.Event.discordRoleId], "remove");
} else {
await changeMemberRoles(memberId, [p.Event.discordRoleId], "add");
}
});
const publicUser = getPublicUser(user);
const member = await getMember(memberId);
await member.setNickname(`${publicUser.fullName} - ${user.publicId}`);
const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO");
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
});
export default router;

View File

@@ -0,0 +1,78 @@
import { Router, Request, Response } from "express";
import client from "modules/discord";
const GUILD_ID = process.env.DISCORD_GUILD_ID;
if (!GUILD_ID) {
throw new Error("DISCORD_GUILD_ID environment variable is not set.");
}
const router: Router = Router();
export const getMember = async (memberId: string) => {
const guild = client.guilds.cache.get(GUILD_ID);
if (!guild) throw new Error("Guild not found");
try {
return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId));
} catch (error) {
console.error("Error fetching member:", error);
throw new Error("Member not found");
}
};
router.post("/rename", async (req: Request, res: Response) => {
const { newName, memberId } = req.body;
if (typeof newName !== "string" || !memberId) {
res.status(400).json({ error: "Invalid or missing newName or memberId" });
return;
}
try {
const member = await getMember(memberId);
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);
res.status(500).json({ error: "Failed to rename member" });
}
});
export const changeMemberRoles = async (
memberId: string,
roleIds: string[],
action: "add" | "remove",
) => {
const member = await getMember(memberId);
const currentRoleIds = member.roles.cache.map((role) => role.id);
const filteredRoleIds =
action === "add"
? roleIds.filter((id: string) => !currentRoleIds.includes(id))
: roleIds.filter((id: string) => currentRoleIds.includes(id));
if (filteredRoleIds.length === 0) {
return { message: `No roles to ${action}` };
}
await member.roles[action](filteredRoleIds);
return { message: `Roles ${action}ed successfully` };
};
const handleRoleChange = (action: "add" | "remove") => async (req: Request, res: Response) => {
const { roleIds, memberId } = req.body;
if (!Array.isArray(roleIds) || !memberId) {
res.status(400).json({ error: "Invalid or missing roleIds or memberId" });
return;
}
try {
const result = await changeMemberRoles(memberId, roleIds, action);
res.status(200).json(result);
} catch (error) {
console.error(`Error ${action}ing roles:`, error);
res.status(500).json({ error: `Failed to ${action} roles` });
}
};
router.post("/add-role", handleRoleChange("add"));
router.post("/remove-role", handleRoleChange("remove"));
export default router;

View File

@@ -0,0 +1,10 @@
import { Router } from "express";
import memberRouter from "./member";
import helperRouter from "./helper";
const router: Router = Router();
router.use("/member", memberRouter);
router.use("/helper", helperRouter);
export default router;

View File

@@ -0,0 +1,11 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"allowImportingTsExtensions": false,
"baseUrl": ".",
"jsx": "react"
},
"include": ["**/*.ts", "./index.ts", "**/*.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,6 +1,7 @@
DISPATCH_SERVER_PORT=3002
REDIS_HOST=localhost
REDIS_PORT=6379
DISCORD_SERVER_URL=http://discord-server
DISPATCH_APP_TOKEN=dispatch
LIVEKIT_API_KEY=APIAnsGdtdYp2Ho
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC

View File

@@ -0,0 +1,38 @@
import axios from "axios";
const discordAxiosClient = axios.create({
baseURL: process.env.DISCORD_SERVER_URL || "https://discord.com/api/v10",
});
export const renameMember = async (memberId: string, newName: string) => {
discordAxiosClient
.post("/member/rename", {
memberId,
newName,
})
.catch((error) => {
console.error("Error renaming member:", error);
});
};
export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/add-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error adding roles to member:", error);
});
};
export const removeRolesFromMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/remove-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
});
};

View File

@@ -49,6 +49,10 @@ export const sendAlert = async (
...mission,
Stations,
});
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
missionId: mission.id,
});
const user = await prisma.user.findUnique({
where: { id: aircraft.userId },
});

View File

@@ -1,6 +1,7 @@
import { ExtendedError, Server, Socket } from "socket.io";
import { prisma } from "@repo/db";
if (!process.env.DISPATCH_APP_TOKEN) throw new Error("DISPATCH_APP_TOKEN is not defined");
import jwt from "jsonwebtoken";
/* if (!process.env.DISPATCH_APP_TOKEN) throw new Error("DISPATCH_APP_TOKEN is not defined"); */
export const jwtMiddleware = async (socket: Socket, next: (err?: ExtendedError) => void) => {
try {
@@ -18,7 +19,6 @@ export const jwtMiddleware = async (socket: Socket, next: (err?: ExtendedError)
next();
} catch (err) {
console.error(err);
next(new Error("Authentication error"));
}
};

View File

@@ -24,6 +24,7 @@
"@react-email/components": "^0.0.41",
"@redis/json": "^5.1.1",
"@socket.io/redis-adapter": "^8.3.0",
"@types/jsonwebtoken": "^9.0.9",
"axios": "^1.9.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",

View File

@@ -1,4 +1,11 @@
import { ConnectedAircraft, getPublicUser, MissionLog, Prisma, prisma } from "@repo/db";
import {
AdminMessage,
ConnectedAircraft,
getPublicUser,
MissionLog,
Prisma,
prisma,
} from "@repo/db";
import { Router } from "express";
import { io } from "../index";
@@ -95,6 +102,7 @@ router.patch("/:id", async (req, res) => {
// When change is only the estimated logout time, we don't need to emit an event
if (Object.keys(aircraftUpdate).length === 1 && aircraftUpdate.esimatedLogoutTime) return;
io.to("dispatchers").emit("update-connectedAircraft", updatedConnectedAircraft);
io.to(`user:${updatedConnectedAircraft.userId}`).emit(
"aircraft-update",
updatedConnectedAircraft,
@@ -105,18 +113,61 @@ router.patch("/:id", async (req, res) => {
}
});
// Delete a connectedAircraft by ID
// Kick a connectedAircraft by ID
router.delete("/:id", async (req, res) => {
const { id } = req.params;
const bann = req.body?.bann as boolean;
const requiredPermission = bann ? "ADMIN_USER" : "ADMIN_KICK";
if (!req.user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
if (!req.user.permissions.includes(requiredPermission)) {
res.status(403).json({ error: "Forbidden" });
return;
}
try {
await prisma.connectedAircraft.delete({
const aircraft = await prisma.connectedAircraft.update({
where: { id: Number(id) },
data: { logoutTime: new Date() },
include: bann ? { User: true } : undefined,
});
io.to("dispatchers").emit("delete-connectedAircraft", id);
if (!aircraft) {
res.status(404).json({ error: "ConnectedAircraft not found" });
return;
}
const status = bann ? "ban" : "kick";
io.to(`user:${aircraft.userId}`).emit("notification", {
type: "admin-message",
message: "Verbindung durch einen Administrator getrennt",
status,
data: { admin: getPublicUser(req.user) },
} as AdminMessage);
io.in(`user:${aircraft.userId}`).disconnectSockets(true);
if (bann) {
await prisma.user.update({
where: { id: aircraft.userId },
data: {
permissions: {
set: req.user.permissions.filter((p) => p !== "PILOT"),
},
},
});
}
res.status(204).send();
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to delete connectedAircraft" });
res.status(500).json({ error: "Failed to disconnect pilot" });
}
});

View File

@@ -1,5 +1,6 @@
import { Prisma, prisma } from "@repo/db";
import { AdminMessage, getPublicUser, Prisma, prisma } from "@repo/db";
import { Router } from "express";
import { io } from "index";
import { pubClient } from "modules/redis";
const router: Router = Router();
@@ -28,4 +29,63 @@ router.patch("/:id", async (req, res) => {
res.json(newDispatcher);
});
import { Request, Response } from "express";
router.delete("/:id", async (req, res) => {
const { id } = req.params;
const bann = req.body?.bann as boolean;
const requiredPermission = bann ? "ADMIN_USER" : "ADMIN_KICK";
if (!req.user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
if (!req.user.permissions.includes(requiredPermission)) {
res.status(403).json({ error: "Forbidden" });
return;
}
try {
const dispatcher = await prisma.connectedDispatcher.update({
where: { id: Number(id) },
data: { logoutTime: new Date() },
include: bann ? { user: true } : undefined,
});
if (!dispatcher) {
res.status(404).json({ error: "ConnectedDispatcher not found" });
return;
}
const status = bann ? "ban" : "kick";
io.to(`user:${dispatcher.userId}`).emit("notification", {
type: "admin-message",
message: "Verbindung durch einen Administrator getrennt",
status,
data: { admin: getPublicUser(req.user) },
} as AdminMessage);
io.in(`user:${dispatcher.userId}`).disconnectSockets(true);
if (bann) {
await prisma.user.update({
where: { id: dispatcher.userId },
data: {
permissions: {
set: req.user.permissions.filter((p) => p !== "DISPO"),
},
},
});
}
res.status(204).send();
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to disconnect dispatcher" });
}
});
export default router;

View File

@@ -1,17 +1,13 @@
import {
getPublicUser,
HpgValidationState,
MissionAlertLog,
MissionSdsLog,
MissionStationLog,
NotificationPayload,
Prisma,
prisma,
User,
} from "@repo/db";
import { Router } from "express";
import { io } from "../index";
import { sendNtfyMission } from "modules/ntfy";
import { sendAlert } from "modules/mission";
const router: Router = Router();
@@ -118,7 +114,7 @@ router.post("/:id/send-alert", async (req, res) => {
const { id } = req.params;
const { stationId, vehicleName } = req.body as {
stationId?: number;
vehicleName?: "ambulance" | "police" | "firebrigade";
vehicleName?: "RTW" | "POL" | "FW";
};
if (!req.user) {
@@ -136,81 +132,143 @@ router.post("/:id/send-alert", async (req, res) => {
},
});
const newMission = await prisma.mission.update({
where: {
id: Number(id),
},
data: {
hpgAmbulanceState: vehicleName === "ambulance" ? "DISPATCHED" : undefined,
hpgFireEngineState: vehicleName === "firebrigade" ? "DISPATCHED" : undefined,
hpgPoliceState: vehicleName === "police" ? "DISPATCHED" : undefined,
missionLog: {
push: {
type: "alert-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
vehicle: vehicleName,
user: getPublicUser(req.user as User),
},
} as any,
const updateData: any = {
missionLog: {
push: {
type: "alert-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
vehicle: vehicleName,
user: getPublicUser(req.user as User, { ignorePrivacy: true }),
},
},
},
};
if (vehicleName === "RTW") updateData.hpgAmbulanceState = "DISPATCHED";
if (vehicleName === "FW") updateData.hpgFireEngineState = "DISPATCHED";
if (vehicleName === "POL") updateData.hpgPoliceState = "DISPATCHED";
const newMission = await prisma.mission.update({
where: { id: Number(id) },
data: updateData,
});
hpgAircrafts.forEach((aircraft) => {
io.to(`desktop:${aircraft.userId}`).emit("hpg-vehicle-update", {
missionId: id,
vehicleData: {
ambulanceState: newMission.hpgAmbulanceState,
fireEngineState: newMission.hpgFireEngineState,
policeState: newMission.hpgPoliceState,
},
ambulanceState: newMission.hpgAmbulanceState,
fireEngineState: newMission.hpgFireEngineState,
policeState: newMission.hpgPoliceState,
});
});
io.to("dispatchers").emit("update-mission", newMission);
res.status(200).json({
message: `Rettungsmittel disponiert (${hpgAircrafts.length} Nutzer)`,
});
io.to("dispatchers").emit("update-mission", newMission);
return;
}
const { connectedAircrafts, mission } = await sendAlert(
Number(id),
{
stationId,
},
req.user,
);
res.status(200).json({
message: `Einsatz gesendet (${connectedAircrafts.length} Nutzer) `,
});
const { connectedAircrafts, mission } = await sendAlert(Number(id), { stationId }, req.user);
io.to("dispatchers").emit("update-mission", mission);
res.status(200).json({
message: `Einsatz gesendet (${connectedAircrafts.length} Nutzer)`,
});
return;
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to send mission" });
return;
}
});
router.post("/:id/send-sds", async (req, res) => {
const sdsMessage = req.body as MissionSdsLog;
const newMission = await prisma.mission.update({
where: {
id: Number(req.params.id),
},
data: {
missionLog: {
push: sdsMessage as any,
},
},
});
router.post("/send-sds", async (req, res) => {
const { sdsMessage, missionId } = req.body as {
missionId?: number;
sdsMessage: MissionSdsLog;
};
io.to(`station:${sdsMessage.data.stationId}`).emit("sds-message", sdsMessage);
res.json({
message: "SDS message sent",
mission: newMission,
});
io.to("dispatchers").emit("update-mission", newMission);
if (missionId) {
const newMission = await prisma.mission.update({
where: {
id: Number(missionId),
},
data: {
missionLog: {
push: sdsMessage as any,
},
},
});
res.json({
message: "SDS message sent",
mission: newMission,
});
io.to("dispatchers").emit("update-mission", newMission);
} else {
res.json({
message: "SDS message sent",
});
}
});
router.post("/:id/hpg-validation-result", async (req, res) => {
try {
const missionId = req.params.id;
const result = req.body as {
state: HpgValidationState;
lat: number;
lng: number;
alertWhenValid?: boolean;
userId?: number;
};
if (!result) return;
const newMission = await prisma.mission.update({
where: { id: Number(missionId) },
data: {
// save position of new mission
addressLat: result.state === "POSITION_AMANDED" ? result.lat : undefined,
addressLng: result.state === "POSITION_AMANDED" ? result.lng : undefined,
hpgLocationLat: result.lat,
hpgLocationLng: result.lng,
hpgValidationState: result.state,
},
});
io.to("dispatchers").emit("update-mission", newMission);
const noActionRequired = result.state === "VALID";
if (noActionRequired) {
io.to(`user:${result.userId}`).emit("notification", {
type: "hpg-validation",
status: "success",
message: `HPG Validierung erfolgreich`,
} as NotificationPayload);
if (result.alertWhenValid) {
if (!req.user) return;
sendAlert(Number(missionId), {}, req.user);
}
} else {
io.to(`user:${result.userId}`).emit("notification", {
type: "hpg-validation",
status: "failed",
message: result.state,
} as NotificationPayload);
}
res.json({
message: `HPG Validation result processed`,
});
} catch (error) {
console.error("Error in HPG validation result:", error);
res.status(500).json({ error: "Failed to process HPG validation result" });
return;
}
});
router.post("/:id/validate-hpg", async (req, res) => {
@@ -252,53 +310,25 @@ router.post("/:id/validate-hpg", async (req, res) => {
});
return;
}
const newMission = await prisma.mission.update({
where: {
id: Number(id),
},
data: {
hpgValidationState: "PENDING",
},
});
io.to("dispatchers").emit("update-mission", newMission);
res.json({
message: "HPG validierung gestartet",
});
io.to(`desktop:${activeAircraftinMission}`).emit(
"hpg-validation",
{
hpgMissionType: mission?.hpgMissionString,
lat: mission?.addressLat,
lng: mission?.addressLng,
},
async (result: { state: HpgValidationState; lat: number; lng: number }) => {
console.log("response from user:", result);
const newMission = await prisma.mission.update({
where: { id: Number(id) },
data: {
// save position of new mission
addressLat: result.state === "POSITION_AMANDED" ? result.lat : mission.addressLat,
addressLng: result.state === "POSITION_AMANDED" ? result.lng : mission.addressLng,
hpgLocationLat: result.lat,
hpgLocationLng: result.lng,
hpgValidationState: result.state,
},
});
io.to("dispatchers").emit("update-mission", newMission);
const noActionRequired = result.state === "VALID";
if (noActionRequired) {
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "success",
message: `HPG Validierung erfolgreich`,
} as NotificationPayload);
if (config?.alertWhenValid) {
if (!req.user) return;
sendAlert(Number(id), {}, req.user);
}
} else {
io.to(`user:${req.user?.id}`).emit("notification", {
type: "hpg-validation",
status: "failed",
message: `HPG Validation fehlgeschlagen`,
} as NotificationPayload);
}
},
);
io.to(`desktop:${activeAircraftinMission?.userId}`).emit("hpg-validation", {
missionId: parseInt(id),
userId: req.user?.id,
alertWhenValid: config?.alertWhenValid || false,
});
} catch (error) {
console.error(error);
res.json({ error: (error as Error).message || "Failed to validate HPG" });

View File

@@ -14,32 +14,5 @@ export const handleConnectDesktop = (socket: Socket, io: Server) => () => {
socket.on("ptt", async (data: PTTData) => {
socket.to(`user:${user.id}`).emit("ptt", data);
const connectedAircraft = await prisma.connectedAircraft.findFirst({
where: {
userId: user.id,
logoutTime: null,
},
include: {
Station: true,
},
});
const connectedDispatcher = await prisma.connectedDispatcher.findFirst({
where: {
userId: user.id,
logoutTime: null,
},
});
const otherPttData = {
publicUser: getPublicUser(user),
source:
connectedAircraft?.Station.bosCallsignShort || connectedDispatcher
? "Leitstelle"
: user.publicId,
};
if (data.shouldTransmit) {
socket.to("dispatchers").emit("other-ptt", otherPttData);
socket.to("pilots").emit("other-ptt", otherPttData);
}
});
};

View File

@@ -1,4 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
export const handleConnectDispatch =
@@ -67,6 +69,21 @@ export const handleConnectDispatch =
},
});
const discordAccount = await prisma.discordAccount.findFirst({
where: {
userId: user.id,
},
});
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${selectedZone}`,
);
await addRolesToMember(discordAccount.discordId.toString(), [
DISCORD_ROLES.ONLINE_DISPATCHER,
]);
}
socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten
socket.join(`user:${user.id}`); // Dem User-Raum beitreten
@@ -74,31 +91,6 @@ export const handleConnectDispatch =
io.to("dispatchers").emit("dispatchers-update", connectedDispatcherEntry);
io.to("pilots").emit("dispatchers-update", connectedDispatcherEntry);
socket.on("stop-other-transmition", async ({ ownRole, otherRole }) => {
const aircrafts = await prisma.connectedAircraft.findMany({
where: {
Station: {
bosCallsignShort: otherRole,
},
logoutTime: null,
},
include: {
Station: true,
},
});
const dispatchers = await prisma.connectedDispatcher.findMany({
where: {
zone: otherRole,
logoutTime: null,
},
});
[...aircrafts, ...dispatchers].forEach((entry) => {
io.to(`user:${entry.userId}`).emit("force-end-transmission", {
by: ownRole,
});
});
});
socket.on("disconnect", async () => {
await prisma.connectedDispatcher.update({
where: {
@@ -110,6 +102,15 @@ export const handleConnectDispatch =
});
io.to("dispatchers").emit("dispatchers-update");
io.to("pilots").emit("dispatchers-update");
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName} - ${user.publicId}`,
);
await removeRolesFromMember(discordAccount.discordId.toString(), [
DISCORD_ROLES.ONLINE_DISPATCHER,
]);
}
});
} catch (error) {
console.error("Error connecting to dispatch server:", error);

View File

@@ -1,5 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { channel } from "diagnostics_channel";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
export const handleConnectPilot =
@@ -18,7 +19,6 @@ export const handleConnectPilot =
if (!user) return Error("User not found");
console.log("Pilot connected:", user.publicId);
const existingConnection = await prisma.connectedAircraft.findFirst({
where: {
userId: user.id,
@@ -55,20 +55,50 @@ export const handleConnectPilot =
}
}
// Set "now" to 2 hours in the future
const nowPlus2h = new Date();
nowPlus2h.setHours(nowPlus2h.getHours() + 2);
// Generate a random position in Germany (approximate bounding box)
function getRandomGermanPosition() {
const minLat = 47.2701;
const maxLat = 55.0581;
const minLng = 5.8663;
const maxLng = 15.0419;
const lat = Math.random() * (maxLat - minLat) + minLat;
const lng = Math.random() * (maxLng - minLng) + minLng;
return { lat, lng };
}
const randomPos =
process.env.environment === "development" ? getRandomGermanPosition() : undefined;
const connectedAircraftEntry = await prisma.connectedAircraft.create({
data: {
publicUser: getPublicUser(user) as any,
esimatedLogoutTime: parsedLogoffDate?.toISOString() || null,
lastHeartbeat: new Date().toISOString(),
userId: userId,
loginTime: new Date().toISOString(),
stationId: parseInt(stationId),
// TODO: remove this after testing
posLat: 51.45,
posLng: 9.77,
posH145active: true,
lastHeartbeat:
process.env.environment === "development" ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat,
posLng: randomPos?.lng,
},
});
const discordAccount = await prisma.discordAccount.findFirst({
where: {
userId: user.id,
},
});
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${Station?.bosCallsignShort}`,
);
await addRolesToMember(discordAccount.discordId.toString(), [DISCORD_ROLES.ONLINE_PILOT]);
}
socket.join("dispatchers"); // Join the dispatchers room
socket.join(`user:${userId}`); // Join the user-specific room
socket.join(`station:${stationId}`); // Join the station-specific room
@@ -81,22 +111,6 @@ export const handleConnectPilot =
connectedAircraftEntry,
);
// Add a listener for station-specific events
socket.on("ptt", async ({ shouldTransmit, channel }) => {
if (shouldTransmit) {
io.to("dispatchers").emit("other-ptt", {
publicUser: getPublicUser(user),
channel,
source: Station?.bosCallsignShort,
});
io.to("piots").emit("other-ptt", {
publicUser: getPublicUser(user),
channel,
source: Station?.bosCallsignShort,
});
}
});
socket.on("disconnect", async () => {
await prisma.connectedAircraft
.update({
@@ -110,6 +124,15 @@ export const handleConnectPilot =
.catch(console.error);
io.to("dispatchers").emit("update-connectedAircraft");
io.to("pilots").emit("pilots-update");
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName} - ${user.publicId}`,
);
await removeRolesFromMember(discordAccount.discordId.toString(), [
DISCORD_ROLES.ONLINE_PILOT,
]);
}
});
} catch (error) {
console.error("Error connecting to dispatch server:", error);

View File

@@ -5,12 +5,14 @@ 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
ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL
ENV NEXT_PUBLIC_HUB_URL=$NEXT_PUBLIC_HUB_URL
ENV NEXT_PUBLIC_DISPATCH_SERVICE_ID=$NEXT_PUBLIC_DISPATCH_SERVICE_ID
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"

View File

@@ -20,7 +20,6 @@ import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useSession } from "next-auth/react";
import { dispatchSocket } from "dispatch/socket";
import { useSounds } from "_components/Audio/useSounds";
export const Audio = () => {
@@ -28,6 +27,7 @@ export const Audio = () => {
speakingParticipants,
isTalking,
toggleTalking,
transmitBlocked,
connect,
state,
connectionQuality,
@@ -42,13 +42,13 @@ export const Audio = () => {
isReceiving: speakingParticipants.length > 0,
isTransmitting: isTalking,
unpausedTracks: speakingParticipants,
transmitBlocked,
});
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession();
const [isReceivingBlick, setIsReceivingBlick] = useState(false);
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
useEffect(() => {
@@ -63,6 +63,20 @@ export const Audio = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speakingParticipants]);
useEffect(() => {
if (speakingParticipants.length > 0) {
setIsReceivingBlick(true);
const timeout = setInterval(() => {
setIsReceivingBlick((s) => !s);
}, 1000);
return () => {
clearTimeout(timeout);
setIsReceivingBlick(false);
};
}
}, [setIsReceivingBlick, speakingParticipants]);
useEffect(() => {
if (message && state !== "error") {
const timeout = setTimeout(() => {
@@ -93,39 +107,37 @@ export const Audio = () => {
className={cn("btn btn-sm btn-ghost border-warning bg-transparent ")}
onClick={() => {
removeMessage();
// Probably via socket event to set ppt = false for participant
if (!canStopOtherSpeakers) return;
speakingParticipants.forEach((p) => {
dispatchSocket.emit("stop-other-transmition", {
ownRole: role,
otherRole: p.attributes.role,
});
});
}}
>
{message}
</button>
</div>
)}
{(displayedSpeakers.length || message) && (
{displayedSpeakers.length > 0 && (
<div
className={cn(
"tooltip-left tooltip-error font-semibold",
canStopOtherSpeakers && "tooltip",
canStopOtherSpeakers && speakingParticipants.length > 0 && "tooltip",
)}
data-tip="Funkspruch unterbrechen"
>
<button
className={cn(
"btn btn-sm btn-soft border-none bg-transparent",
canStopOtherSpeakers && "hover:bg-error",
"btn btn-sm btn-soft bg-transparent border",
canStopOtherSpeakers && speakingParticipants.length > 0 && "hover:bg-error",
speakingParticipants.length > 0 && " hover:bg-errorborder",
isReceivingBlick && "border-warning",
)}
onClick={() => {
if (!canStopOtherSpeakers) return;
speakingParticipants.forEach((p) => {
dispatchSocket.emit("stop-other-transmition", {
ownRole: role,
otherRole: p.attributes.role,
const payload = JSON.stringify({
by: role,
});
speakingParticipants.forEach(async (p) => {
await room?.localParticipant.performRpc({
destinationIdentity: p.identity,
method: "force-mute",
payload,
});
});
}}
@@ -144,6 +156,7 @@ export const Audio = () => {
"btn btn-sm btn-soft border-none hover:bg-inherit",
!isTalking && "bg-transparent hover:bg-sky-400/20",
isTalking && "bg-green-700 hover:bg-green-600",
transmitBlocked && "bg-yellow-500 hover:bg-yellow-500",
state === "disconnected" && "bg-red-500 hover:bg-red-500",
state === "error" && "bg-red-500 hover:bg-red-500",
state === "connecting" && "bg-yellow-500 hover:bg-yellow-500 cursor-default",

View File

@@ -1,16 +1,20 @@
"use client";
import { useDebounce } from "_helpers/useDebounce";
import { useAudioStore } from "_store/audioStore";
import { useEffect, useRef, useState } from "react";
export const useSounds = ({
isReceiving,
isTransmitting,
unpausedTracks,
transmitBlocked,
}: {
isReceiving: boolean;
isTransmitting: boolean;
unpausedTracks: unknown[];
transmitBlocked?: boolean;
}) => {
const { room } = useAudioStore();
// Sounds as refs
const connectionStart = useRef<HTMLAudioElement | null>(null);
const connectionEnd = useRef<HTMLAudioElement | null>(null);
@@ -54,6 +58,17 @@ export const useSounds = ({
}
}, [isReceiving, isTransmitting, soundConnectionStarted]);
useEffect(() => {
if (transmitBlocked && foreignCallBlocked.current) {
foreignCallBlocked.current.volume = 0.2;
foreignCallBlocked.current.currentTime = 0;
foreignCallBlocked.current.loop = true;
foreignCallBlocked.current.play().catch(() => {});
} else if (foreignCallBlocked.current) {
foreignCallBlocked.current.pause();
}
}, [transmitBlocked]);
useEffect(() => {
if (isTransmitting && connectionStart.current!.paused) {
ownCallStarted.current!.volume = 0.2;
@@ -70,10 +85,10 @@ export const useSounds = ({
}
}, [isReceiving]);
// Hotmic warning after 30 seconds
// Hotmic warning after 25 seconds
useEffect(() => {
if (isTransmitting) {
const timeout = setTimeout(() => {
const soundsTimeout = setTimeout(() => {
if (isTransmitting) {
callToLong.current!.loop = true;
callToLong.current!.currentTime = 0;
@@ -81,8 +96,19 @@ export const useSounds = ({
callToLong.current!.play();
}
}, 25000);
const forceEndTransmitionTimeout = setTimeout(() => {
if (isTransmitting) {
room?.localParticipant.setMicrophoneEnabled(false);
useAudioStore.setState({
isTalking: false,
message: "Hotmic erkannt! Ruf wurde beendet.",
});
}
}, 25000 + 10000);
return () => {
clearTimeout(timeout);
clearTimeout(soundsTimeout);
clearTimeout(forceEndTransmitionTimeout);
callToLong.current!.pause();
};
}

View File

@@ -8,6 +8,8 @@ import { dispatchSocket } from "dispatch/socket";
import { Mission, NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
import { useMapStore } from "_store/mapStore";
import { AdminMessageToast } from "_components/customToasts/AdminMessage";
import { pilotSocket } from "pilot/socket";
export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s);
@@ -39,6 +41,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries({
queryKey: ["aircrafts"],
});
queryClient.invalidateQueries({
queryKey: ["dispatchers"],
});
};
const invalidateConenctedAircrafts = () => {
@@ -58,13 +63,18 @@ export function QueryProvider({ children }: { children: ReactNode }) {
toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{
duration: 9999,
duration: 15000,
},
);
break;
case "admin-message":
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999,
});
break;
default:
toast(notification.message);
toast("unbekanntes Notification-Event");
break;
}
};
@@ -76,6 +86,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
dispatchSocket.on("pilots-update", invalidateConnectedUsers);
dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts);
dispatchSocket.on("notification", handleNotification);
pilotSocket.on("notification", handleNotification);
return () => {
dispatchSocket.off("update-mission", invalidateMission);

View File

@@ -0,0 +1,34 @@
import { AdminMessage } from "@repo/db";
import { BaseNotification } from "_components/customToasts/BaseNotification";
import { cn } from "_helpers/cn";
import { TriangleAlert } from "lucide-react";
import toast, { Toast } from "react-hot-toast";
export const AdminMessageToast = ({ event, t }: { event: AdminMessage; t: Toast }) => {
const handleClick = () => {
toast.dismiss(t.id);
};
return (
<BaseNotification icon={<TriangleAlert />} className="flex flex-row">
<div className="flex-1">
<h1
className={cn(
"font-bold",
event.status == "ban" && "text-red-500 ",
event.status == "kick" && "text-yellow-500 ",
)}
>
Du wurdes durch den Admin {event.data?.admin.publicId}{" "}
{event.status == "ban" ? "gebannt" : "gekickt"}!
</h1>
<p>{event.message}</p>
</div>
<div className="ml-11">
<button className="btn" onClick={handleClick}>
OK
</button>
</div>
</BaseNotification>
);
};

View File

@@ -1,4 +1,4 @@
import { NotificationPayload } from "@repo/db";
import { NotificationPayload, ValidationFailed, ValidationSuccess } from "@repo/db";
import { BaseNotification } from "_components/customToasts/BaseNotification";
import { MapStore, useMapStore } from "_store/mapStore";
import { Check, Cross } from "lucide-react";
@@ -9,7 +9,7 @@ export const HPGnotificationToast = ({
t,
mapStore,
}: {
event: NotificationPayload;
event: ValidationFailed | ValidationSuccess;
t: Toast;
mapStore: MapStore;
}) => {

View File

@@ -6,7 +6,8 @@ import { Fragment, useEffect, useState } from "react";
import { cn } from "_helpers/cn";
import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { getConnectedUserAPI } from "_querys/connected-user";
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const Chat = () => {
const {
@@ -26,23 +27,27 @@ export const Chat = () => {
const [addTabValue, setAddTabValue] = useState<string>("default");
const [message, setMessage] = useState<string>("");
const { data: connectedUser } = useQuery({
queryKey: ["connected-users"],
queryFn: async () => {
const user = await getConnectedUserAPI();
return user.filter((u) => u.userId !== session.data?.user.id);
},
const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"],
queryFn: () => getConnectedDispatcherAPI(),
refetchInterval: 10000,
});
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
useEffect(() => {
if (!session.data?.user.id) return;
setOwnId(session.data.user.id);
}, [session, setOwnId]);
setOwnId(session.data?.user.id);
}, [session.data?.user.id]);
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
return (
<div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}>
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
<div className="indicator">
{Object.values(chats).some((c) => c.notification) && (
<span className="indicator-item status status-info"></span>
@@ -63,9 +68,9 @@ export const Chat = () => {
{chatOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100] ml-2 border-1 border-primary"
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100] max-h-[400px] ml-2 border-1 border-primary"
>
<div className="card-body">
<div className="card-body overflow-y-auto">
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<ChatBubbleIcon /> Chat
</h2>
@@ -75,62 +80,59 @@ export const Chat = () => {
value={addTabValue}
onChange={(e) => setAddTabValue(e.target.value)}
>
{!connectedUser?.length && (
{!filteredDispatcher?.length && !filteredAircrafts?.length && (
<option disabled value="default">
Keine Chatpartner gefunden
</option>
)}
{connectedUser?.length && (
{(filteredDispatcher?.length || filteredAircrafts?.length) && (
<option disabled value="default">
Chatpartner auswählen
</option>
)}
{[
...(connectedUser?.filter(
(user, idx, arr) => arr.findIndex((u) => u.userId === user.userId) === idx,
) || []),
].map((user) => (
<option key={user.userId} value={user.userId}>
{asPublicUser(user.publicUser).fullName}
{filteredDispatcher?.map((dispatcher) => (
<option key={dispatcher.userId} value={dispatcher.userId}>
{dispatcher.zone} - {asPublicUser(dispatcher.publicUser).fullName}
</option>
))}
{filteredAircrafts?.map((aircraft) => (
<option key={aircraft.userId} value={aircraft.userId}>
{aircraft.Station.bosCallsignShort} -{" "}
{asPublicUser(aircraft.publicUser).fullName}
</option>
))}
</select>
<button
className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => {
const user = connectedUser?.find((user) => user.userId === addTabValue);
const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue);
const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue);
const user = aircraftUser || dispatcherUser;
if (!user) return;
addChat(addTabValue, asPublicUser(user.publicUser).fullName);
let role = "Station" in user ? user.Station.bosCallsignShort : user.zone;
addChat(addTabValue, `${asPublicUser(user.publicUser).fullName} (${role})`);
setSelectedChat(addTabValue);
}}
>
<span className="text-xl">+</span>
</button>
</div>
<div className="tabs tabs-lift">
<div className="tabs tabs-lift max-h-full">
{Object.keys(chats).map((userId) => {
const chat = chats[userId];
if (!chat) return null;
return (
<Fragment key={userId}>
<input
type="radio"
name="my_tabs_3"
className="tab"
aria-label={`<${chat.name}>`}
checked={selectedChat === userId}
onClick={() => {
setChatNotification(userId, false);
}}
onChange={(e) => {
if (e.target.checked) {
// Handle tab change
setSelectedChat(userId);
}
}}
/>
<div className="tab-content bg-base-100 border-base-300 p-6">
<a
className={cn("indicator tab", selectedChat === userId && "tab-active")}
onClick={() => setSelectedChat(userId)}
>
{chat.name}
{chat.notification && <span className="indicator-item status status-info" />}
</a>
<div className="tab-content bg-base-100 border-base-300 p-6 overflow-y-auto">
{chat.messages.map((chatMessage) => {
const isSender = chatMessage.senderId === session.data?.user.id;
return (
@@ -153,48 +155,71 @@ export const Chat = () => {
);
})}
</div>
<div className="join">
<div className="w-full">
<label className="input join-item w-full">
<input
type="text"
required
className="w-full"
onChange={(e) => {
setMessage(e.target.value);
}}
value={message}
/>
</label>
{!selectedChat && (
<div role="alert" className="alert alert-info alert-outline">
<span>Wähle einen Nutzer aus und drücke auf + um einen Chat zu starten</span>
</div>
<button
className="btn btn-soft join-item"
onClick={(e) => {
e.preventDefault();
if (message.length < 1) return;
if (!selectedChat) return;
setSending(true);
sendMessage(selectedChat, message)
.then(() => {
setMessage("");
setSending(false);
})
.catch(() => {
setSending(false);
});
return false;
}}
disabled={sending}
role="button"
onSubmit={() => false} // prevent submit event for react hook form
>
{sending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<PaperPlaneIcon />
)}
</button>
</div>
)}
{selectedChat && (
<div className="join">
<div className="w-full">
<label className="input join-item w-full">
<input
type="text"
required
className="w-full"
onChange={(e) => {
setMessage(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (message.length < 1) return;
if (!selectedChat) return;
setSending(true);
sendMessage(selectedChat, message)
.then(() => {
setMessage("");
setSending(false);
})
.catch(() => {
setSending(false);
});
}
}}
value={message}
/>
</label>
</div>
<button
className="btn btn-soft join-item"
onClick={(e) => {
e.preventDefault();
if (message.length < 1) return;
if (!selectedChat) return;
setSending(true);
sendMessage(selectedChat, message)
.then(() => {
setMessage("");
setSending(false);
})
.catch(() => {
setSending(false);
});
return false;
}}
disabled={sending}
role="button"
onSubmit={() => false} // prevent submit event for react hook form
>
{sending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<PaperPlaneIcon />
)}
</button>
</div>
)}
</div>
</div>
)}

View File

@@ -7,8 +7,9 @@ import { toast } from "react-hot-toast";
import { useLeftMenuStore } from "_store/leftMenuStore";
import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { getConnectedUserAPI } from "_querys/connected-user";
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { sendReportAPI } from "_querys/report";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const Report = () => {
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore();
@@ -22,18 +23,24 @@ export const Report = () => {
setOwnId(session.data.user.id);
}, [session, setOwnId]);
const { data: connectedUser } = useQuery({
queryKey: ["connected-users"],
queryFn: async () => {
const user = await getConnectedUserAPI();
return user.filter((u) => u.userId !== session.data?.user.id);
},
const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"],
queryFn: () => getConnectedDispatcherAPI(),
refetchInterval: 10000,
});
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
return (
<div className={cn("dropdown dropdown-right", reportTabOpen && "dropdown-open")}>
<div
className={cn("dropdown dropdown-right dropdown-center", reportTabOpen && "dropdown-open")}
>
<div className="indicator">
<button
className="btn btn-soft btn-sm btn-error"
@@ -60,23 +67,26 @@ export const Report = () => {
value={selectedPlayer}
onChange={(e) => setSelectedPlayer(e.target.value)}
>
{!connectedUser?.length && (
{!filteredDispatcher?.length && !filteredAircrafts?.length && (
<option disabled value="default">
Kein Nutzer verbunden
Keine Nutzer gefunden
</option>
)}
{connectedUser?.length && (
{(filteredDispatcher?.length || filteredAircrafts?.length) && (
<option disabled value="default">
Kein Nutzer auswählen
Nutzer auswählen
</option>
)}
{[
...(connectedUser?.filter(
(user, idx, arr) => arr.findIndex((u) => u.userId === user.userId) === idx,
) || []),
].map((user) => (
<option key={user.userId} value={user.userId}>
{asPublicUser(user.publicUser).fullName}
{filteredDispatcher?.map((dispatcher) => (
<option key={dispatcher.userId} value={dispatcher.userId}>
{dispatcher.zone} - {asPublicUser(dispatcher.publicUser).fullName}
</option>
))}
{filteredAircrafts?.map((aircraft) => (
<option key={aircraft.userId} value={aircraft.userId}>
{aircraft.Station.bosCallsignShort} -{" "}
{asPublicUser(aircraft.publicUser).fullName}
</option>
))}
</select>

View File

@@ -1,19 +1,22 @@
"use client";
import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react";
import { cn } from "_helpers/cn";
import { ListCollapse, Plane } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
import { MissionsOnStations, Station } from "@repo/db";
import { Station } from "@repo/db";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_components/map/AircraftMarker";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
export const SituationBoard = () => {
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
const { status, setHideDraftMissions, hideDraftMissions } = useDispatchConnectionStore(
(state) => state,
);
const dispatcherConnected = status === "connected";
const { data: missions } = useQuery({
queryKey: ["missions", "missions-on-stations"],
queryFn: () =>
@@ -35,14 +38,17 @@ export const SituationBoard = () => {
},
),
});
const filteredMissions = missions?.filter(
(mission) => !hideDraftMissions || mission.state !== "draft",
);
const { data: connectedAircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
});
const { setOpenAircraftMarker, setOpenMissionMarker, setMap } = useMapStore((state) => state);
console.log("station", connectedAircrafts);
return (
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
<div className="indicator">
@@ -63,54 +69,69 @@ export const SituationBoard = () => {
<div className="card-body flex flex-row gap-4">
<div className="flex-1">
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<ListCollapse /> Einsatzliste
<ListCollapse /> Einsatzliste{" "}
</h2>
<div>
<div className="form-control mb-2">
<label className="label cursor-pointer">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={hideDraftMissions}
onChange={() => setHideDraftMissions(!hideDraftMissions)}
/>
<span className="label-text text-sm">vorgeplante Einsätze verbergen</span>
</label>
</div>
</div>
<div className="overflow-x-auto">
<table className="table table-xs">
{/* head */}
<thead>
<tr>
<th>ID</th>
<th>E-Nr.</th>
<th>Stichwort</th>
<th>Stadt</th>
<th>Stationen</th>
</tr>
</thead>
<tbody>
{/* row 1 */}
{missions?.map((mission) => (
<tr
onDoubleClick={() => {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
});
setMap({
center: {
lat: mission.addressLat,
lng: mission.addressLng,
},
zoom: 14,
});
}}
key={mission.id}
className={cn(mission.state === "draft" && "bg-base-300")}
>
<td>{mission.publicId}</td>
<td>{mission.missionKeywordAbbreviation}</td>
<td>{mission.addressCity}</td>
<td>
{(mission as any).MissionsOnStations?.map(
(mos: { Station: Station }) => mos.Station?.bosCallsignShort,
).join(", ")}
</td>
</tr>
))}
{filteredMissions?.map(
(mission) =>
(dispatcherConnected || mission.state !== "draft") && (
<tr
onDoubleClick={() => {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
});
setMap({
center: {
lat: mission.addressLat,
lng: mission.addressLng,
},
zoom: 14,
});
}}
key={mission.id}
className={cn(mission.state === "draft" && "missionListItem")}
>
<td>{mission.publicId}</td>
<td>{mission.missionKeywordAbbreviation}</td>
<td>{mission.addressCity}</td>
<td>
{(mission as any).MissionsOnStations?.map(
(mos: { Station: Station }) => mos.Station?.bosCallsignShort,
).join(", ")}
</td>
</tr>
),
)}
</tbody>
</table>
</div>

View File

@@ -1,4 +1,4 @@
import { Marker, useMap } from "react-leaflet";
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";
@@ -13,56 +13,9 @@ import FMSStatusHistory, {
} from "./_components/AircraftMarkerTabs";
import { ConnectedAircraft, Station } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getMissionsAPI } from "_querys/missions";
import { checkSimulatorConnected } from "_helpers/simulatorConnected";
export const FMS_STATUS_COLORS: { [key: string]: string } = {
"0": "rgb(140,10,10)",
"1": "rgb(10,134,25)",
"2": "rgb(10,134,25)",
"3": "rgb(140,10,10)",
"4": "rgb(140,10,10)",
"5": "rgb(231,77,22)",
"6": "rgb(85,85,85)",
"7": "rgb(140,10,10)",
"8": "rgb(186,105,0)",
"9": "rgb(10,134,25)",
E: "rgb(186,105,0)",
C: "rgb(186,105,0)",
F: "rgb(186,105,0)",
J: "rgb(186,105,0)",
L: "rgb(186,105,0)",
c: "rgb(186,105,0)",
d: "rgb(186,105,0)",
h: "rgb(186,105,0)",
o: "rgb(186,105,0)",
u: "rgb(186,105,0)",
};
export const FMS_STATUS_TEXT_COLORS: { [key: string]: string } = {
"0": "rgb(243,27,25)",
"1": "rgb(9,212,33)",
"2": "rgb(9,212,33)",
"3": "rgb(243,27,25)",
"4": "rgb(243,27,25)",
"5": "rgb(251,176,158)",
"6": "rgb(153,153,153)",
"7": "rgb(243,27,25)",
"8": "rgb(255,143,0)",
"9": "rgb(9,212,33)",
N: "rgb(9,212,33)",
E: "rgb(255,143,0)",
C: "rgb(255,143,0)",
F: "rgb(255,143,0)",
J: "rgb(255,143,0)",
L: "rgb(255,143,0)",
c: "rgb(255,143,0)",
d: "rgb(255,143,0)",
h: "rgb(255,143,0)",
o: "rgb(255,143,0)",
u: "rgb(255,143,0)",
};
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
const AircraftPopupContent = ({
aircraft,
@@ -263,9 +216,18 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
const popupRef = useRef<LPopup>(null);
const { openAircraftMarker, setOpenAircraftMarker } = useMapStore((store) => store);
const { data: positionLog } = useQuery({
queryKey: ["positionlog", aircraft.id],
queryFn: () =>
getConnectedAircraftPositionLogAPI({
id: aircraft.id,
}),
refetchInterval: 10000,
});
useEffect(() => {
const handleClick = () => {
console.log("Marker clicked", aircraft.id);
const open = openAircraftMarker.some((m) => m.id === aircraft.id);
if (open) {
setOpenAircraftMarker({
@@ -289,7 +251,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
return () => {
marker?.off("click", handleClick);
};
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker]);
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker, markerRef.current]);
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
"topleft",
@@ -372,38 +334,46 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
</div>`;
};
if (!aircraft.posLat || !aircraft.posLng) return null;
return (
<Fragment key={aircraft.id}>
{
<Marker
ref={markerRef}
position={[aircraft.posLat!, aircraft.posLng!]}
icon={
new DivIcon({
iconAnchor: [0, 0],
html: getMarkerHTML(aircraft, anchor),
})
}
/>
}
<Marker
ref={markerRef}
position={[aircraft.posLat, aircraft.posLng]}
icon={
new DivIcon({
iconAnchor: [0, 0],
html: getMarkerHTML(aircraft, anchor),
})
}
/>
{openAircraftMarker.some((m) => m.id === aircraft.id) && !hideMarker && (
<SmartPopup
options={{
ignoreCluster: true,
}}
id={`aircraft-${aircraft.id}`}
ref={popupRef}
position={[aircraft.posLat!, aircraft.posLng!]}
autoClose={false}
closeOnClick={false}
autoPan={false}
wrapperClassName="relative"
className="w-[502px]"
>
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
<AircraftPopupContent aircraft={aircraft} />
</div>
</SmartPopup>
<>
<SmartPopup
options={{
ignoreCluster: true,
}}
id={`aircraft-${aircraft.id}`}
ref={popupRef}
position={[aircraft.posLat!, aircraft.posLng!]}
autoClose={false}
closeOnClick={false}
autoPan={false}
wrapperClassName="relative"
className="w-[502px]"
>
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
<AircraftPopupContent aircraft={aircraft} />
</div>
</SmartPopup>
<Polyline
pathOptions={{
color: "var(--color-rescuetrack)",
weight: 3,
}}
positions={positionLog?.map((pos) => [pos.lat, pos.lng]) || []}
/>
</>
)}
</Fragment>
);
@@ -413,16 +383,14 @@ export const AircraftLayer = () => {
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: getConnectedAircraftsAPI,
refetchInterval: 10000,
refetchInterval: 10_000,
});
return (
<>
{aircrafts
?.filter((a) => checkSimulatorConnected(a.lastHeartbeat))
?.map((aircraft) => {
return <AircraftMarker key={aircraft.id} aircraft={aircraft} />;
})}
{aircrafts?.map((aircraft) => {
return <AircraftMarker key={aircraft.id} aircraft={aircraft} />;
})}
</>
);
};

View File

@@ -11,7 +11,14 @@ import { Popup, useMap } from "react-leaflet";
export const ContextMenu = () => {
const map = useMap();
const { contextMenu, setContextMenu, setSearchElements, setSearchPopup } = useMapStore();
const {
contextMenu,
searchElements,
setContextMenu,
setSearchElements,
setSearchPopup,
toggleSearchElementSelection,
} = useMapStore();
const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore(
(state) => state,
);
@@ -64,7 +71,9 @@ export const ContextMenu = () => {
const parsed: OSMWay[] = data.elements
.filter((e: any) => e.type === "way")
.map((e: any) => {
const elementInMap = searchElements.find((el) => el.wayID === e.id);
return {
isSelected: elementInMap?.isSelected ?? false,
wayID: e.id,
nodes: e.nodes.map((nodeId: string) => {
const node = data.elements.find((element: any) => element.id === nodeId);
@@ -125,12 +134,15 @@ export const ContextMenu = () => {
nodeWay.push([node.lat, node.lon]),
);
if (closestToContext) {
toggleSearchElementSelection(closestToContext.wayID);
}
setMissionFormValues({
...parsed,
state: "draft",
addressLat: contextMenu.lat,
addressLng: contextMenu.lng,
addressOSMways: [closestToContext],
});
map.setView([contextMenu.lat, contextMenu.lng], 18, {
@@ -146,6 +158,7 @@ export const ContextMenu = () => {
style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)}
disabled
>
<Ruler size={20} />
</button>

View File

@@ -1,5 +1,6 @@
"use client";
import "leaflet/dist/leaflet.css";
import "./mapStyles.css";
import { useMapStore } from "_store/mapStore";
import { MapContainer } from "react-leaflet";
import { BaseMaps } from "_components/map/BaseMaps";
@@ -10,6 +11,7 @@ import { AircraftLayer } from "_components/map/AircraftMarker";
import { MarkerCluster } from "_components/map/_components/MarkerCluster";
import { useEffect, useRef } from "react";
import { Map as TMap } from "leaflet";
import { DistanceLayer } from "_components/map/Measurement";
const Map = () => {
const ref = useRef<TMap | null>(null);
@@ -38,7 +40,7 @@ const Map = () => {
return (
<MapContainer
ref={ref}
className="flex-1"
className="flex-1 bg-base-200"
center={map.center}
zoom={map.zoom}
fadeAnimation={false}
@@ -49,6 +51,7 @@ const Map = () => {
<MarkerCluster />
<MissionLayer />
<AircraftLayer />
<DistanceLayer />
</MapContainer>
);
};

View File

@@ -0,0 +1,102 @@
import "leaflet.polylinemeasure";
import "leaflet.polylinemeasure/Leaflet.PolylineMeasure.css";
import { useEffect, useRef } from "react";
import { useMap } from "react-leaflet";
import L from "leaflet";
export const DistanceLayer = () => {
const map = useMap();
const added = useRef(false);
useEffect(() => {
if (added.current) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(L.control as any)
.polylineMeasure({
position: "topleft",
unit: "metres", // Show imperial or metric distances. Values: 'metres', 'landmiles', 'nauticalmiles'
clearMeasurementsOnStop: true, // Clear all the measurements when the control is unselected
showBearings: true, // Whether bearings are displayed within the tooltips
bearingTextIn: "In", // language dependend label for inbound bearings
bearingTextOut: "Out", // language dependend label for outbound bearings
tooltipTextFinish: "Klicken zum <b>Beenden</b><br>",
tooltipTextDelete: "SHIFT+Click zum <b>Löschen</b>",
tooltipTextMove: "Klicken und ziehen zum <b>Verschieben</b><br>",
tooltipTextResume: "<br>CTRL+Click zum <b>Forsetzen</b>",
tooltipTextAdd: "CTRL+Click zum <b>Hinzufügen</b>",
// language dependend labels for point's tooltips
measureControlTitleOn: "Messung starten", // Title for the control going to be switched on
measureControlTitleOff: "Messung beenden", // Title for the control going to be switched off
measureControlLabel: "&#128207", // Label of the Measure control (maybe a unicode symbol)
measureControlClasses: ["pointer"], // Classes to apply to the Measure control
showClearControl: true, // Show a control to clear all the measurements
clearControlTitle: "Messung löschen", // Title text to show on the clear measurements control button
clearControlLabel: "&times", // Label of the Clear control (maybe a unicode symbol)
clearControlClasses: ["pointer"], // Classes to apply to clear control button
unitControlClasses: ["pointer"],
showUnitControl: true, // Show a control to change the units of measurements
unitControlTitle: {
// Title texts to show on the Unit Control
text: "Einheit ändern",
kilometres: "Kilometer",
nauticalmiles: "Nautische Meilen",
},
unitControlLabel: {
// Unit symbols to show in the Unit Control and measurement labels
metres: "m",
kilometres: "km",
feet: "ft",
landmiles: "mi",
nauticalmiles: "nm",
},
tempLine: {
// Styling settings for the temporary dashed line
color: "#00f", // Dashed line color
weight: 2, // Dashed line weight
},
fixedLine: {
// Styling for the solid line
color: "#006", // Solid line color
weight: 2, // Solid line weight
},
arrow: {
// Styling of the midway arrow
color: "#000", // Color of the arrow
},
startCircle: {
// Style settings for circle marker indicating the starting point of the polyline
color: "#000000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#000000", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
intermedCircle: {
// Style settings for all circle markers between startCircle and endCircle
color: "#000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#000000", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
currentCircle: {
// Style settings for circle marker indicating the latest point of the polyline during drawing a line
color: "#000000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#FFFFFF", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
endCircle: {
// Style settings for circle marker indicating the last point of the polyline
color: "#000", // Color of the border of the circle
weight: 1, // Weight of the circle
fillColor: "#ffffff", // Fill color of the circle
fillOpacity: 1, // Fill opacity of the circle
radius: 3, // Radius of the circle
},
})
.addTo(map);
added.current = true;
}, [map]);
return null;
};

View File

@@ -175,7 +175,7 @@ const MissionPopupContent = ({
hpgLocationLat: mission.hpgLocationLat ?? undefined,
hpgLocationLng: mission.hpgLocationLng ?? undefined,
});
setEditingMission(true, String(mission.id));
setEditingMission(true, mission.id);
setOpen(true);
}}
>
@@ -365,21 +365,25 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
};
export const MissionLayer = () => {
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const dispatchState = useDispatchConnectionStore((s) => s);
const dispatcherConnected = dispatchState.status === "connected";
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
queryFn: () =>
getMissionsAPI({
OR: [{ state: "draft" }, { state: "running" }],
}),
refetchInterval: 10_000,
});
const filteredMissions = useMemo(() => {
if (!dispatcherConnected) {
return missions.filter((m: Mission) => m.state === "running");
}
return missions;
}, [missions, dispatcherConnected]);
return missions.filter((m: Mission) => {
if (m.state === "draft" && !dispatcherConnected) return false;
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
return true;
});
}, [missions, dispatcherConnected, dispatchState.hideDraftMissions]);
// IDEA: Add Marker to Map Layer / LayerGroup
return (

View File

@@ -1,18 +1,15 @@
import { useMapStore } from "_store/mapStore";
import { Marker as LMarker } from "leaflet";
import { Fragment, useEffect, useRef } from "react";
import { Marker, Polygon, Popup } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
import { OSMWay } from "@repo/db";
import { usePannelStore } from "_store/pannelStore";
import { OSMWay } from "@repo/db";
import { useEffect } from "react";
export const SearchElements = () => {
const { searchElements, searchPopup, setSearchPopup, setContextMenu, openMissionMarker } =
useMapStore();
const { missionFormValues, setMissionFormValues } = usePannelStore((state) => state);
const missions = useQuery({
const { searchElements, openMissionMarker, setSearchElements } = useMapStore();
const { isOpen: pannelOpen, editingMissionId } = usePannelStore((state) => state);
const { data: missions } = useQuery({
queryKey: ["missions"],
queryFn: () =>
getMissionsAPI({
@@ -26,131 +23,56 @@ export const SearchElements = () => {
],
}),
});
const poppupRef = useRef<LMarker>(null);
const searchPopupElement = searchElements.find(
(element) => element.wayID === searchPopup?.elementId,
);
const SearchElement = ({
element,
isActive = false,
}: {
element: (typeof searchElements)[1];
isActive?: boolean;
}) => {
const ref = useRef<L.Polygon>(null);
useEffect(() => {
if (ref.current) {
ref.current.on("click", () => {
const center = ref.current?.getBounds().getCenter();
if (center && searchPopup?.elementId !== element.wayID) {
setSearchPopup({
lat: center.lat,
lng: center.lng,
elementId: element.wayID,
});
} else {
setSearchPopup(null);
}
setContextMenu(null);
});
useEffect(() => {
if (pannelOpen) {
const missionEdited = missions?.find((m) => m.id === editingMissionId);
if (missionEdited) {
const elements = missionEdited.addressOSMways.map((e) => ({
...(e as unknown as OSMWay),
isSelected: true,
}));
setSearchElements(elements);
}
}, [element.wayID]);
} else {
const openMissions = openMissionMarker.map((m) => {
const mission = missions?.find((mi) => mi.id === m.id);
return mission;
});
const elements = openMissions
.filter((m) => m?.addressOSMways)
.flatMap((m) =>
m?.addressOSMways.map((e) => ({
...(e as unknown as OSMWay),
isSelected: true,
})),
);
setSearchElements(elements.filter((e) => !!e));
}
}, [openMissionMarker, pannelOpen, missions]);
const SearchElement = ({ element }: { element: OSMWay }) => {
const { toggleSearchElementSelection } = useMapStore();
if (!element.nodes) return null;
return (
<Polygon
positions={element.nodes.map((node) => [node.lat, node.lon])}
color={searchPopup?.elementId === element.wayID || isActive ? "#ff4500" : "#46b7a3"}
color={element.isSelected ? "#ff4500" : "#46b7a3"}
eventHandlers={{
click: () => {
const addressOSMways = missionFormValues?.addressOSMways || [];
addressOSMways.push(JSON.parse(JSON.stringify(element)));
setMissionFormValues({
...missionFormValues,
addressOSMways,
});
if (!pannelOpen) return;
toggleSearchElementSelection(element.wayID);
},
}}
ref={ref}
/>
);
};
const SearchElementPopup = ({ element }: { element: (typeof searchElements)[1] }) => {
if (!searchPopup) return null;
return (
<Popup
autoPan={false}
position={[searchPopup.lat, searchPopup.lng]}
autoClose={false}
closeOnClick={false}
>
<div className="bg-base-100/70 border border-rescuetrack w-[250px] text-white pointer-events-auto p-2">
<h3 className="text-lg font-bold">
{element.tags?.building === "yes" ? "Gebäude" : element.tags?.building}
{!element.tags?.building && "unbekannt"}
</h3>
<p className="">
{element.tags?.["addr:street"]} {element.tags?.["addr:housenumber"]}
</p>
<p className="">
{element.tags?.["addr:suburb"]} {element.tags?.["addr:postcode"]}
</p>
<div className="flex flex-col gap-2 mt-2">
<button className="btn bg-rescuetrack-highlight">Zum Einsatz Hinzufügen</button>
</div>
</div>
</Popup>
);
};
return (
<>
{openMissionMarker.map(({ id }) => {
const mission = missions.data?.find((m) => m.id === id);
if (!mission) return null;
return (
<Fragment key={`mission-osm-${mission.id}`}>
{(mission.addressOSMways as (OSMWay | null)[])
.filter((element): element is OSMWay => element !== null)
.map((element: OSMWay, i) => (
<SearchElement
key={`mission-elem-${element.wayID}-${i}`}
element={element}
isActive
/>
))}
</Fragment>
);
})}
{searchElements.map((element, i) => {
if (
missions.data?.some(
(mission) =>
(mission.addressOSMways as (OSMWay | null)[])
.filter((e): e is OSMWay => e !== null)
.some((e) => e.wayID === element.wayID) &&
openMissionMarker.some((m) => m.id === mission.id),
)
)
return null;
return <SearchElement key={`mission-elem-${element.wayID}-${i}`} element={element} />;
})}
{searchPopup && (
<Marker
position={[searchPopup.lat, searchPopup.lng]}
ref={poppupRef}
icon={new L.DivIcon()}
opacity={0}
>
{!searchPopupElement && <div className="w-20 border border-rescuetrack"></div>}
</Marker>
)}
{searchPopupElement && <SearchElementPopup element={searchPopupElement} />}
</>
);
};

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "../AircraftMarker";
import React, { useEffect, useMemo, useState } from "react";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import {
ConnectedAircraft,
getPublicUser,
@@ -13,7 +13,7 @@ import {
Station,
} from "@repo/db";
import { toast } from "react-hot-toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { editConnectedAircraftAPI } from "_querys/aircrafts";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { cn } from "_helpers/cn";
@@ -29,6 +29,7 @@ import {
Hash,
ListCollapse,
LocateFixed,
Lollipop,
MapPin,
Mountain,
Navigation,
@@ -38,7 +39,9 @@ import {
TextSearch,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { editMissionAPI, sendSdsMessageAPI } from "_querys/missions";
import { sendSdsMessageAPI } from "_querys/missions";
import { getLivekitRooms } from "_querys/livekit";
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
const FMSStatusHistory = ({
aircraft,
@@ -215,14 +218,35 @@ const RettungsmittelTab = ({
aircraft: ConnectedAircraft & { Station: Station };
}) => {
const station = aircraft.Station;
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
refetchInterval: 10000,
});
const participants =
livekitRooms?.flatMap((room) =>
room.participants.map((p) => ({
...p,
roomName: room.room.name,
})),
) || [];
const livekitUser = participants.find((p) => (p.attributes.userId = aircraft.userId));
const lstName = useMemo(() => {
if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea;
return findLeitstelleForPosition(aircraft.posLng, aircraft.posLat);
}, [aircraft.posLng, aircraft.posLat]);
return (
<div className="p-4 text-base-content">
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<Component size={16} /> Aktuelle Rufgruppe: LST_01
<Component size={16} /> Aktuelle Rufgruppe: {livekitUser?.roomName || "Nicht verbunden"}
</li>
<li className="flex items-center gap-2 mb-1">
<RadioTower size={16} /> Leitstellenbereich: Florian Berlin
<RadioTower size={16} /> Leitstellenbereich: {lstName || station.bosRadioArea}
</li>
</ul>
<div className="divider mt-0 mb-0" />
@@ -252,6 +276,14 @@ const RettungsmittelTab = ({
<CircleGaugeIcon size={16} /> ALT: {aircraft.posAlt} ft
</span>
</div>
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posH145active && "text-green-500")}>
{aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"}
</span>
</span>
</div>
</div>
);
};
@@ -302,25 +334,42 @@ const SDSTab = ({
const [isChatOpen, setIsChatOpen] = useState(false);
const [note, setNote] = useState("");
const queryClient = useQueryClient();
const textInputRef = React.useRef<HTMLInputElement>(null);
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const sendSdsMutation = useMutation({
mutationFn: async ({ id, message }: { id: number; message: MissionSdsLog }) => {
await sendSdsMessageAPI(id, message);
mutationFn: async ({
missionId,
sdsMessage,
}: {
missionId?: number;
sdsMessage: MissionSdsLog;
}) => {
await sendSdsMessageAPI({ missionId, sdsMessage });
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
const log =
(mission?.missionLog as unknown as MissionLog[])
?.slice()
.reverse()
.filter(
(entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id,
) || [];
const log = useMemo(
() =>
(mission?.missionLog as unknown as MissionLog[])
?.slice()
.reverse()
.filter(
(entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id,
) || [],
[mission?.missionLog, aircraft.Station.id],
);
useEffect(() => {
const interval = setInterval(() => {
textInputRef.current?.focus();
}, 100);
return () => clearInterval(interval);
});
return (
<div className="p-4">
@@ -333,26 +382,27 @@ const SDSTab = ({
onClick={() => setIsChatOpen(true)}
>
<span className="flex items-center gap-2">
<Plus size={18} /> Notiz hinzufügen
<Plus size={18} /> SDS senden
</span>
</button>
) : (
<div className="flex items-center gap-2 w-full">
<input
autoFocus
type="text"
placeholder=""
className="input input-sm text-base-content flex-1"
value={note}
onChange={(e) => setNote(e.target.value)}
ref={textInputRef}
/>
<button
className="btn btn-sm btn-primary btn-outline"
onClick={() => {
if (!mission) return;
sendSdsMutation
.mutateAsync({
id: mission.id,
message: {
missionId: mission?.id,
sdsMessage: {
type: "sds-log",
auto: false,
timeStamp: new Date().toISOString(),
@@ -360,11 +410,14 @@ const SDSTab = ({
stationId: aircraft.Station.id,
station: aircraft.Station,
message: note,
user: getPublicUser(session.data!.user),
user: getPublicUser(session.data!.user, {
ignorePrivacy: true,
}),
},
},
})
.then(() => {
toast.success("SDS-Nachricht gesendet");
setIsChatOpen(false);
setNote("");
});

View File

@@ -3,10 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { SmartPopup, useSmartPopup } from "_components/SmartPopup";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useMapStore } from "_store/mapStore";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_components/map/AircraftMarker";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { MISSION_STATUS_COLORS, MISSION_STATUS_TEXT_COLORS } from "_components/map/MissionMarkers";
import { cn } from "_helpers/cn";
import { checkSimulatorConnected } from "_helpers/simulatorConnected";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getMissionsAPI } from "_querys/missions";
import { useEffect, useMemo, useState } from "react";
@@ -96,41 +95,39 @@ const PopupContent = ({
</div>
);
})}
{aircrafts
.filter((a) => checkSimulatorConnected(a.lastHeartbeat))
.map((aircraft) => (
<div
key={aircraft.id}
className="relative w-auto inline-flex items-center gap-2 text-nowrap cursor-pointer"
{aircrafts.map((aircraft) => (
<div
key={aircraft.id}
className="relative w-auto inline-flex items-center gap-2 text-nowrap cursor-pointer"
style={{
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
}}
onClick={() => {
setOpenAircraftMarker({
open: [
{
id: aircraft.id,
tab: "aircraft",
},
],
close: [],
});
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
animate: true,
});
}}
>
<span
className="mx-2 my-0.5 text-gt font-bold"
style={{
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
}}
onClick={() => {
setOpenAircraftMarker({
open: [
{
id: aircraft.id,
tab: "aircraft",
},
],
close: [],
});
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
animate: true,
});
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
}}
>
<span
className="mx-2 my-0.5 text-gt font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
}}
>
{aircraft.fmsStatus}
</span>
<span>{aircraft.Station.bosCallsign}</span>
</div>
))}
{aircraft.fmsStatus}
</span>
<span>{aircraft.Station.bosCallsign}</span>
</div>
))}
</div>
</>
);
@@ -138,12 +135,14 @@ const PopupContent = ({
export const MarkerCluster = () => {
const map = useMap();
const dispatchState = useDispatchConnectionStore((s) => s);
const dispatcherConnected = dispatchState.status === "connected";
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: getConnectedAircraftsAPI,
refetchInterval: 10_000,
});
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
queryFn: () =>
@@ -153,11 +152,12 @@ export const MarkerCluster = () => {
});
const filteredMissions = useMemo(() => {
if (!dispatcherConnected) {
return missions.filter((m: Mission) => m.state === "running");
}
return missions;
}, [missions, dispatcherConnected]);
return missions.filter((m: Mission) => {
if (m.state === "draft" && !dispatcherConnected) return false;
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
return true;
});
}, [missions, dispatcherConnected, dispatchState.hideDraftMissions]);
// Track zoom level in state
const [zoom, setZoom] = useState(() => map.getZoom());
@@ -178,38 +178,36 @@ export const MarkerCluster = () => {
lat: number;
lng: number;
}[] = [];
aircrafts
?.filter((a) => checkSimulatorConnected(a.lastHeartbeat))
.forEach((aircraft) => {
const lat = aircraft.posLat!;
const lng = aircraft.posLng!;
aircrafts?.forEach((aircraft) => {
const lat = aircraft.posLat!;
const lng = aircraft.posLng!;
const existingClusterIndex = newCluster.findIndex(
(c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1,
);
const existingCluster = newCluster[existingClusterIndex];
if (existingCluster) {
newCluster = [...newCluster].map((c, i) => {
if (i === existingClusterIndex) {
return {
...c,
aircrafts: [...c.aircrafts, aircraft],
};
}
return c;
});
} else {
newCluster = [
...newCluster,
{
aircrafts: [aircraft],
missions: [],
lat,
lng,
},
];
}
});
const existingClusterIndex = newCluster.findIndex(
(c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1,
);
const existingCluster = newCluster[existingClusterIndex];
if (existingCluster) {
newCluster = [...newCluster].map((c, i) => {
if (i === existingClusterIndex) {
return {
...c,
aircrafts: [...c.aircrafts, aircraft],
};
}
return c;
});
} else {
newCluster = [
...newCluster,
{
aircrafts: [aircraft],
missions: [],
lat,
lng,
},
];
}
});
filteredMissions?.forEach((mission) => {
const lat = mission.addressLat;
const lng = mission.addressLng;

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import { FMS_STATUS_TEXT_COLORS } from "../AircraftMarker";
import { FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { toast } from "react-hot-toast";
import {
Ban,
@@ -18,6 +18,7 @@ import {
SmartphoneNfc,
CheckCheck,
Cross,
Radio,
} from "lucide-react";
import {
getPublicUser,
@@ -34,13 +35,19 @@ import {
import { usePannelStore } from "_store/pannelStore";
import { useSession } from "next-auth/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { deleteMissionAPI, editMissionAPI, sendMissionAPI } from "_querys/missions";
import {
deleteMissionAPI,
editMissionAPI,
sendMissionAPI,
startHpgValidation,
} from "_querys/missions";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getStationsAPI } from "_querys/stations";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getOsmAddress } from "_querys/osm";
import { hpgStateToFMSStatus } from "_helpers/hpgStateToFmsStatus";
import { cn } from "_helpers/cn";
const Einsatzdetails = ({
mission,
@@ -60,10 +67,6 @@ const Einsatzdetails = ({
});
},
});
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: getConnectedAircraftsAPI,
});
const sendAlertMutation = useMutation({
mutationKey: ["missions"],
mutationFn: (id: number) => sendMissionAPI(id, {}),
@@ -89,6 +92,7 @@ const Einsatzdetails = ({
});
},
});
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state);
const [ignoreHpg, setIgnoreHpg] = useState(false);
@@ -191,12 +195,8 @@ const Einsatzdetails = ({
<div>
<div className="divider mt-0 mb-0" />
{HPGValidationRequired(
mission.missionStationIds,
aircrafts,
mission.hpgMissionString,
) && (
<div className="form-control mb-2">
{true && (
<div className="form-control mb-2 flex justify-between items-center">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -208,6 +208,18 @@ const Einsatzdetails = ({
Ohne HPG-Mission alarmieren
</span>
</label>
<button
className="btn btn-xs btn-outline btn-primary font-thin"
onClick={async () => {
await startHpgValidation(mission.id).catch((error) => {
toast.error(
error.response.data.error || "Fehler beim Validieren der HPG-Mission",
);
});
}}
>
Validierung anstoßen
</button>
</div>
)}
@@ -346,10 +358,10 @@ const Patientdetails = ({ mission }: { mission: Mission }) => {
const Rettungsmittel = ({ mission }: { mission: Mission }) => {
const queryClient = useQueryClient();
const [selectedStation, setSelectedStation] = useState<
Station | "ambulance" | "police" | "firebrigade" | null
>(null);
const { data: conenctedAircrafts } = useQuery({
const [selectedStation, setSelectedStation] = useState<number | "RTW" | "POL" | "FW" | null>(
null,
);
const { data: connectedAircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: getConnectedAircraftsAPI,
});
@@ -388,17 +400,6 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
queryFn: () => getStationsAPI(),
});
useEffect(() => {
if (allStations) {
const stationsNotItMission = allStations.filter(
(s) => !mission.missionStationIds.includes(s.id),
);
if (stationsNotItMission[0]) {
setSelectedStation(stationsNotItMission[0]);
}
}
}, [allStations, mission.missionStationIds]);
const sendAlertMutation = useMutation({
mutationKey: ["missions"],
mutationFn: ({
@@ -408,7 +409,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
}: {
id: number;
stationId?: number;
vehicleName?: "ambulance" | "police" | "firebrigade";
vehicleName?: "RTW" | "POL" | "FW";
}) => sendMissionAPI(id, { stationId, vehicleName }),
onError: (error) => {
console.error(error);
@@ -419,6 +420,39 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
},
});
const stationsOptions = [
...(allStations
?.filter((s) => !mission.missionStationIds.includes(s.id))
?.map((station) => ({
label: station.bosCallsign,
value: station.id,
type: "station" as const,
isOnline: !!connectedAircrafts?.find((a) => a.stationId === station.id),
})) || []),
...(!mission.hpgFireEngineState || mission.hpgFireEngineState === "NOT_REQUESTED"
? [{ label: "Feuerwehr", value: "FW", type: "vehicle" as const }]
: []),
...(!mission.hpgAmbulanceState || mission.hpgAmbulanceState === "NOT_REQUESTED"
? [{ label: "Rettungsdienst", value: "RTW", type: "vehicle" as const }]
: []),
...(!mission.hpgPoliceState || mission.hpgPoliceState === "NOT_REQUESTED"
? [{ label: "Polizei", value: "POL", type: "vehicle" as const }]
: []),
].sort((a, b) => {
// 1. Vehicles first
if (a.type === "vehicle" && b.type !== "vehicle") return -1;
if (a.type !== "vehicle" && b.type === "vehicle") return 1;
// 2. Online stations before offline stations
if (a.type === "station" && b.type === "station") {
if (a.isOnline && !b.isOnline) return -1;
if (!a.isOnline && b.isOnline) return 1;
}
// 3. Otherwise, sort alphabetically by label
return a.label.localeCompare(b.label);
});
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const HPGVehicle = ({ state, name }: { state: HpgState; name: string }) => (
@@ -466,7 +500,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
</div>
<ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto">
{missionStations?.map((station, index) => {
const connectedAircraft = conenctedAircrafts?.find(
const connectedAircraft = connectedAircrafts?.find(
(aircraft) => aircraft.stationId === station.id,
);
@@ -510,32 +544,28 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
<select
className="select select-sm select-primary select-bordered flex-1"
onChange={(e) => {
const selected = allStations?.find((s) => s.id.toString() === e.target.value);
if (selected) {
setSelectedStation(selected);
} else {
setSelectedStation(e.target.value as "ambulance" | "police" | "firebrigade");
}
const value = e.target.value;
const parsedValue = !isNaN(Number(value)) ? parseInt(value, 10) : value;
setSelectedStation(parsedValue as number | "RTW" | "POL" | "FW" | null);
}}
value={typeof selectedStation === "string" ? selectedStation : selectedStation?.id}
value={selectedStation || "default"}
>
{allStations
?.filter((s) => !mission.missionStationIds.includes(s.id))
?.map((station) => (
<option
key={station.id}
value={station.id}
onClick={() => {
setSelectedStation(station);
}}
>
{station.bosCallsign}
</option>
))}
<option disabled>Fahrzeuge:</option>
<option value="firebrigade">Feuerwehr</option>
<option value="ambulance">RTW</option>
<option value="police">Polizei</option>
<option disabled value={"default"}>
Rettungsmittel auswählen
</option>
{stationsOptions.map((option) => (
<option
key={option.value}
value={option.value}
className={cn(
"flex gap-2",
"isOnline" in option && option?.isOnline && "text-green-500",
)}
>
{option.label}
{"isOnline" in option && option?.isOnline && " (Online)"}
</option>
))}
</select>
<button
className="btn btn-sm btn-primary btn-outline"
@@ -546,18 +576,18 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
vehicleName: selectedStation,
});
} else {
if (!selectedStation?.id) return;
if (!selectedStation) return;
await updateMissionMutation.mutateAsync({
id: mission.id,
missionEdit: {
missionStationIds: {
push: selectedStation?.id,
push: selectedStation,
},
},
});
await sendAlertMutation.mutate({
id: mission.id,
stationId: selectedStation?.id ?? 0,
stationId: selectedStation,
});
}
}}
@@ -578,6 +608,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
const [isAddingNote, setIsAddingNote] = useState(false);
const [note, setNote] = useState("");
const queryClient = useQueryClient();
const textInputRef = React.useRef<HTMLInputElement>(null);
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
@@ -590,6 +621,13 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
},
});
useEffect(() => {
const interval = setInterval(() => {
textInputRef.current?.focus();
}, 100);
return () => clearInterval(interval);
});
if (!session.data?.user) return null;
return (
<div className="p-4">
@@ -613,6 +651,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
className="input input-sm text-base-content flex-1"
value={note}
onChange={(e) => setNote(e.target.value)}
ref={textInputRef}
/>
<button
className="btn btn-sm btn-primary btn-outline"

View File

@@ -1,106 +1,3 @@
.no-pointer {
cursor: unset;
}
.pointer {
cursor: pointer;
}
.leaflet-tooltip-aircraft {
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
color: rgb(254, 254, 254) !important;
left: 35px !important;
top: 40px !important;
border: none !important;
box-shadow: none !important;
}
.leaflet-popup-content-wrapper {
background-color: var(--dark-background);
}
@keyframes fade-in {
from {
right: -20%;
opacity: 0;
}
to {
right: 0;
opacity: 1;
}
}
@keyframes fade-out {
from {
right: 0;
opacity: 1;
}
to {
right: -20%;
opacity: 0;
}
}
.leaflet-tooltip-aircraft:before {
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
color: rgb(255, 255, 255) !important;
left: 0px !important;
top: 0px !important;
border: none !important;
box-shadow: none !important;
}
.custom-tooltip-bg {
background: transparent !important;
}
.custom-tooltip-bg:before {
background: transparent !important;
}
.no-pointer {
cursor: unset;
}
.pointer {
cursor: pointer;
}
.leaflet-tooltip-aircraft {
padding: 0 !important;
border: 0 !important;
background-color: var(--surface) !important;
color: var(--on-surface) !important;
}
.leaflet-tooltip-aircraft:before {
border-bottom-color: var(--dark-surface) !important;
}
.modal-box {
position: fixed;
bottom: 50%;
right: 50px;
background-color: rgba(255, 255, 255, 0.75);
border: 5px solid #243671;
border-radius: 8px;
padding: 10px;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-width: 300px;
word-wrap: break-word;
overflow-wrap: break-word;
text-align: center;
}
.modal-box-close {
position: absolute;
top: 5px;
right: 5px;
font-size: 18px;
cursor: pointer;
color: #243671;
.leaflet-container {
background: var(--color-base-200) !important;
}

View File

@@ -0,0 +1,324 @@
"use client";
import { PublicUser } from "@repo/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getConnectedAircraftsAPI, kickAircraftAPI } from "_querys/aircrafts";
import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/dispatcher";
import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit";
import { editUserAPI } from "_querys/user";
import { ParticipantInfo } from "livekit-server-sdk";
import {
Eye,
LockKeyhole,
Plane,
RedoDot,
Shield,
ShieldAlert,
Speaker,
User,
UserCheck,
Workflow,
} from "lucide-react";
import { useRef } from "react";
import toast from "react-hot-toast";
export default function AdminPanel() {
const queryClient = useQueryClient();
const { data: pilots } = useQuery({
queryKey: ["pilots"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"],
queryFn: () => getConnectedDispatcherAPI(),
refetchInterval: 10000,
});
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
refetchInterval: 10000,
});
const kickLivekitParticipantMutation = useMutation({
mutationFn: kickLivekitParticipant,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["livekit-rooms"] });
},
});
const editUSerMutation = useMutation({
mutationFn: editUserAPI,
});
const kickPilotMutation = useMutation({
mutationFn: kickAircraftAPI,
onSuccess: () => {
toast.success("Pilot wurde erfolgreich gekickt");
queryClient.invalidateQueries({
queryKey: ["aircrafts"],
});
queryClient.invalidateQueries({
queryKey: ["connected-audio-users"],
});
},
});
const kickDispatchMutation = useMutation({
mutationFn: kickDispatcherAPI,
onSuccess: () => {
toast.success("Disponent wurde erfolgreich gekickt");
queryClient.invalidateQueries({
queryKey: ["dispatcher"],
});
queryClient.invalidateQueries({
queryKey: ["connected-audio-users"],
});
},
});
const participants: { participant: ParticipantInfo; room: string }[] = [];
if (livekitRooms) {
livekitRooms?.forEach((room) => {
room.participants.forEach((participant) => {
participants.push({
participant,
room: room.room.name,
});
});
});
}
const livekitUserNotConnected = participants.filter((p) => {
const pilot = pilots?.find(
(d) => (d.publicUser as unknown as PublicUser).publicId === p.participant.identity,
);
const fDispatcher = dispatcher?.find(
(d) => (d.publicUser as unknown as PublicUser).publicId === p.participant.identity,
);
return !pilot && !fDispatcher;
});
const modalRef = useRef<HTMLDialogElement>(null);
return (
<div>
<button
className="btn btn-soft btn-primary btn-sm flex items-center gap-2"
onSubmit={() => false}
onClick={() => {
modalRef.current?.showModal();
}}
>
<Shield size={18} /> Admin Panel
</button>
<dialog ref={modalRef} className="modal min-w-[500px]">
<div className="modal-box w-11/12 max-w-7xl">
<form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 className="font-bold text-lg flex items-center gap-2">
<Shield size={22} /> Admin Panel
</h3>
<div className="flex gap-2 mt-4 w-full">
<div className="card bg-base-300 shadow-md w-full h-96 overflow-y-auto">
<div className="card-body">
<div className="card-title flex items-center gap-2">
<UserCheck size={20} /> Verbundene Clients
</div>
<table className="table w-full">
<thead>
<tr>
<th>VAR #</th>
<th>Name</th>
<th>Station</th>
<th>Voice</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{pilots?.map((p) => {
const publicUser = p.publicUser as unknown as PublicUser;
const livekitParticipant = participants.find(
(p) => p.participant.identity === publicUser.publicId,
);
return (
<tr key={p.id}>
<td className="flex items-center gap-2">
<Plane /> {publicUser.publicId}
</td>
<td>{publicUser.fullName}</td>
<td>{p.Station.bosCallsign}</td>
<td>
{!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span>
) : (
<span className="text-success">{livekitParticipant.room}</span>
)}
</td>
<td className="flex gap-2">
<button
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning"
data-tip="Kick"
onClick={() => kickPilotMutation.mutate({ id: p.id })}
>
<RedoDot size={15} />
</button>
<button
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error"
data-tip="Ban"
onClick={() => {
kickPilotMutation.mutate({ id: p.id, bann: true });
}}
>
<LockKeyhole size={15} />
</button>
<a
href={`${process.env.NEXT_PUBLIC_HUB_URL}/admin/user/${p.userId}`}
target="_blank"
rel="noopener noreferrer"
>
<button
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info"
data-tip="Profil"
>
<User size={15} />
</button>
</a>
</td>
</tr>
);
})}
{dispatcher?.map((d) => {
const publicUser = d.publicUser as unknown as PublicUser;
const livekitParticipant = participants.find(
(p) => p.participant.identity === publicUser.publicId,
);
return (
<tr key={d.id}>
<td className="flex items-center gap-2">
<Workflow /> {publicUser.publicId}
</td>
<td>{publicUser.fullName}</td>
<td>{d.zone}</td>
<td>
{!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span>
) : (
<span className="text-success">{livekitParticipant.room}</span>
)}
</td>
<td className="flex gap-2">
<button
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning"
data-tip="Kick"
onClick={() => kickDispatchMutation.mutate({ id: d.id })}
>
<RedoDot size={15} />
</button>
<button
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error"
data-tip="Ban"
onClick={() => {
kickDispatchMutation.mutate({ id: d.id, bann: true });
}}
>
<LockKeyhole size={15} />
</button>
<a
href={`${process.env.NEXT_PUBLIC_HUB_URL}/admin/user/${d.userId}`}
target="_blank"
rel="noopener noreferrer"
>
<button
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info"
data-tip="Profil"
>
<User size={15} />
</button>
</a>
</td>
</tr>
);
})}
{livekitUserNotConnected.map((p) => {
const publicUser = JSON.parse(
p.participant.attributes.publicUser || "{}",
) as PublicUser;
return (
<tr key={p.participant.identity}>
<td className="flex items-center gap-2">
<Speaker /> {p.participant.identity}
</td>
<td>{publicUser?.fullName}</td>
<td>
<span className="text-error">Nicht verbunden</span>
</td>
<td>
<span className="text-success">{p.room}</span>
</td>
<td className="flex gap-2">
<button
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning"
data-tip="Kick"
onClick={() =>
kickLivekitParticipantMutation.mutate({
roomName: p.room,
identity: p.participant.identity,
})
}
>
<RedoDot size={15} />
</button>
<button
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error"
data-tip="Ban"
>
<LockKeyhole size={15} />
</button>
<a
href={`${process.env.NEXT_PUBLIC_HUB_URL}/admin/user/${p.participant.attributes.userId}`}
target="_blank"
rel="noopener noreferrer"
>
<button
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info"
data-tip="Profil"
>
<User size={15} />
</button>
</a>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{/* <div className="card bg-base-300 shadow-md w-full mt-4 max-h-48 overflow-y-auto">
<div className="card-body">
<div className="card-title flex items-center gap-2">
<ShieldAlert size={20} /> Allgemeine Befehle
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-left gap-2">
<button className="btn btn-soft btn-error mt-2">Kick Everybody</button>
<button className="btn btn-soft btn-warning mt-2">
Wartungsmodus einschalten
</button>
<button className="btn btn-soft btn-info mt-2">Delete All Missions</button>
</div>
<button className="btn btn-outline btn-info mt-2 flex items-center gap-2">
<Eye size={22} />
Als Observer verbinden
</button>
</div>
</div>
</div> */}
</div>
<form method="dialog" className="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
);
}

View File

@@ -1,30 +1,45 @@
"use client";
import { ArrowLeftRight, Plane, Workflow } from "lucide-react";
import { cn } from "_helpers/cn";
import { ArrowLeftRight, 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() {
export default function ModeSwitchDropdown({ className }: { className?: string }) {
const path = usePathname();
const session = useSession();
return (
<details className="dropdown z-[9999]">
<summary className="btn m-1">
<ArrowLeftRight /> {path.includes("pilot") && "Pilot"}
<div className={cn("dropdown z-999999", className)}>
<div tabIndex={0} role="button" className="btn m-1">
<ArrowLeftRight size={22} /> {path.includes("pilot") && "Pilot"}
{path.includes("dispatch") && "Leitstelle"}
</summary>
<ul className="menu dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm">
</div>
<ul
tabIndex={0}
className="menu dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm"
>
{session.data?.user.permissions?.includes("DISPO") && (
<li>
<Link href={"/dispatch"}>
<Workflow size={22} /> Leitstelle
</Link>
</li>
)}
{session.data?.user.permissions?.includes("PILOT") && (
<li>
<Link href={"/pilot"}>
<Plane size={22} /> Pilot
</Link>
</li>
)}
<li>
<Link href={"/dispatch"}>
<Workflow /> Leitstelle
</Link>
</li>
<li>
<Link href={"/pilot"}>
<Plane /> Pilot
<Link href={"/tracker"}>
<Radar size={22} /> Tracker
</Link>
</li>
</ul>
</details>
</div>
);
}

View File

@@ -19,8 +19,7 @@ export const SettingsBtn = () => {
const testSoundRef = useRef<HTMLAudioElement | null>(null);
const editUserMutation = useMutation({
mutationFn: ({ user }: { user: Prisma.UserUpdateInput }) =>
editUserAPI(session.data!.user.id, user),
mutationFn: editUserAPI,
});
useEffect(() => {
@@ -37,6 +36,7 @@ export const SettingsBtn = () => {
);
const [showIndication, setShowIndication] = useState<boolean>(false);
const [micVol, setMicVol] = useState<number>(1);
const [funkVolume, setFunkVol] = useState<number>(0.8);
const [dmeVolume, setDmeVol] = useState<number>(0.8);
const setMic = useAudioStore((state) => state.setMic);
@@ -46,14 +46,17 @@ export const SettingsBtn = () => {
setSelectedDevice(user.settingsMicDevice);
setMic(user.settingsMicDevice, user.settingsMicVolume || 1);
setMicVol(user.settingsMicVolume || 1);
setFunkVol(user.settingsRadioVolume || 0.8);
setDmeVol(user.settingsDmeVolume || 0.8);
}
}, [user, setMic]);
useEffect(() => {
navigator.mediaDevices.enumerateDevices().then((devices) => {
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
});
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
navigator.mediaDevices.enumerateDevices().then((devices) => {
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
});
}
}, []);
return (
@@ -74,7 +77,7 @@ export const SettingsBtn = () => {
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset w-full">
<fieldset className="fieldset w-full mb-2">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
@@ -96,8 +99,8 @@ export const SettingsBtn = () => {
</select>
</label>
</fieldset>
<p className="flex items-center gap-2 text-base mb-2">
<Volume2 size={20} /> Microfonlautstärke
<p className="flex items-center gap-2 text-base mb-2 justify-start w-full">
<Volume2 size={20} /> Eingabelautstärke
</p>
<div className="w-full">
<input
@@ -114,10 +117,11 @@ export const SettingsBtn = () => {
className="range range-xs range-accent w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<span>0</span>
<span>100</span>
<span>200</span>
<span>300</span>
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
{showIndication && (
@@ -125,6 +129,33 @@ export const SettingsBtn = () => {
)}
<div className="divider w-full" />
</div>
<p className="flex items-center gap-2 text-base mb-2">
<Volume2 size={20} /> Funk Lautstärke
</p>
<div className="w-full mb-2">
<input
type="range"
min={0}
max={1}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setFunkVol(value);
}}
value={funkVolume}
className="range range-xs range-primary w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
<div className="flex justify-center w-full">
<div className="divider w-1/2" />
</div>
<p className="flex items-center gap-2 text-base mb-2">
<Volume2 size={20} /> Melder Lautstärke
</p>
@@ -142,11 +173,14 @@ export const SettingsBtn = () => {
testSoundRef.current.play();
}}
value={dmeVolume}
className="range range-xs range-accent w-full"
className="range range-xs range-primary w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<span>0</span>
<span>100</span>
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
@@ -168,10 +202,12 @@ export const SettingsBtn = () => {
onSubmit={() => false}
onClick={async () => {
testSoundRef.current?.pause();
const res = await editUserMutation.mutateAsync({
await editUserMutation.mutateAsync({
id: session.data!.user.id,
user: {
settingsMicDevice: selectedDevice,
settingsMicVolume: micVol,
settingsRadioVolume: funkVolume,
settingsDmeVolume: dmeVolume,
},
});

View File

@@ -0,0 +1,9 @@
import { RoomServiceClient } from "livekit-server-sdk";
if (!process.env.NEXT_PUBLIC_LIVEKIT_URL) throw new Error("NEXT_PUBLIC_LIVEKIT_URL is not defined");
export const RoomManager = new RoomServiceClient(
process.env.NEXT_PUBLIC_LIVEKIT_URL!,
process.env.LIVEKIT_API_KEY,
process.env.LIVEKIT_API_SECRET,
);

View File

@@ -0,0 +1,29 @@
import { point, multiPolygon, booleanPointInPolygon, booleanIntersects, polygon } from "@turf/turf";
import leitstellenGeoJSON from "../_components/map/_geojson/Leitstellen.json"; // Pfad anpassen
export function findLeitstelleForPosition(lat: number, lng: number) {
const heliPoint = point([lat, lng]);
for (const feature of (leitstellenGeoJSON as any).features) {
const geom = feature.geometry;
if (geom.type === "Polygon") {
const turfPolygon = polygon(geom.coordinates);
if (booleanPointInPolygon(heliPoint, turfPolygon)) {
return feature.properties?.name ?? "Unbenannte Leitstelle";
}
} else if (geom.type === "MultiPolygon") {
// MultiPolygon: check each polygon
for (const coords of geom.coordinates) {
const turfPolygon = polygon(coords);
if (booleanPointInPolygon(heliPoint, turfPolygon)) {
return feature.properties?.name ?? "Unbenannte Leitstelle";
}
}
} else {
continue; // Andere Geometrietypen ignorieren
}
}
return null;
}

View File

@@ -0,0 +1,46 @@
export const FMS_STATUS_COLORS: { [key: string]: string } = {
"0": "rgb(140,10,10)",
"1": "rgb(10,134,25)",
"2": "rgb(10,134,25)",
"3": "rgb(140,10,10)",
"4": "rgb(140,10,10)",
"5": "rgb(231,77,22)",
"6": "rgb(85,85,85)",
"7": "rgb(140,10,10)",
"8": "rgb(186,105,0)",
"9": "rgb(10,134,25)",
E: "rgb(186,105,0)",
C: "rgb(186,105,0)",
F: "rgb(186,105,0)",
J: "rgb(186,105,0)",
L: "rgb(186,105,0)",
c: "rgb(186,105,0)",
d: "rgb(186,105,0)",
h: "rgb(186,105,0)",
o: "rgb(186,105,0)",
u: "rgb(186,105,0)",
};
export const FMS_STATUS_TEXT_COLORS: { [key: string]: string } = {
"0": "rgb(243,27,25)",
"1": "rgb(9,212,33)",
"2": "rgb(9,212,33)",
"3": "rgb(243,27,25)",
"4": "rgb(243,27,25)",
"5": "rgb(251,176,158)",
"6": "rgb(153,153,153)",
"7": "rgb(243,27,25)",
"8": "rgb(255,143,0)",
"9": "rgb(9,212,33)",
N: "rgb(9,212,33)",
E: "rgb(255,143,0)",
C: "rgb(255,143,0)",
F: "rgb(255,143,0)",
J: "rgb(255,143,0)",
L: "rgb(255,143,0)",
c: "rgb(255,143,0)",
d: "rgb(255,143,0)",
h: "rgb(255,143,0)",
o: "rgb(255,143,0)",
u: "rgb(255,143,0)",
};

View File

@@ -20,13 +20,9 @@ export const handleTrackSubscribed = (
}
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
console.log("Track unmuted:", track);
});
track.on("muted", () => {
useAudioStore.getState().removeSpeakingParticipant(participant);
console.log("Track muted:", track);
});
if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) {
// attach it to a new HTMLVideoElement or HTMLAudioElement

View File

@@ -1,2 +1,7 @@
export const checkSimulatorConnected = (date: Date) =>
date && Date.now() - new Date(date).getTime() <= 3000_000;
import { ConnectedAircraft } from "@repo/db";
export const checkSimulatorConnected = (a: ConnectedAircraft) => {
if (!a.lastHeartbeat || Date.now() - new Date(a.lastHeartbeat).getTime() > 30_000) return false; // 30 seconds
if (!a.posLat || !a.posLng) return false;
return true;
};

View File

@@ -1,13 +1,14 @@
import { ConnectedAircraft, Prisma, PublicUser, Station } from "@repo/db";
import { ConnectedAircraft, PositionLog, Prisma, PublicUser, Station } from "@repo/db";
import axios from "axios";
import { serverApi } from "_helpers/axios";
import { checkSimulatorConnected } from "_helpers/simulatorConnected";
export const getConnectedAircraftsAPI = async () => {
const res = await axios.get<(ConnectedAircraft & { Station: Station })[]>("/api/aircrafts"); // return only connected aircrafts
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
return res.data.filter((a) => checkSimulatorConnected(a));
};
export const editConnectedAircraftAPI = async (
@@ -17,3 +18,24 @@ export const editConnectedAircraftAPI = async (
const respone = await serverApi.patch<ConnectedAircraft>(`/aircrafts/${id}`, mission);
return respone.data;
};
export const getConnectedAircraftPositionLogAPI = async ({ id }: { id: number }) => {
const res = await axios.get<PositionLog[]>("/api/aircrafts/positionlog", {
params: { connectedAircraftId: id },
});
if (res.status !== 200) {
throw new Error("Failed to fetch aircraft position log");
}
return res.data;
};
export const kickAircraftAPI = async ({ id, bann }: { id: number; bann?: boolean }) => {
const res = await serverApi.delete(`/aircrafts/${id}`, {
data: { bann },
});
console.log(res.status);
if (res.status != 204) {
throw new Error("Failed to kick aircraft");
}
return res.data;
};

View File

@@ -2,17 +2,6 @@ import { ConnectedAircraft, ConnectedDispatcher, Prisma } from "@repo/db";
import { serverApi } from "_helpers/axios";
import axios from "axios";
export const getConnectedUserAPI = async () => {
const res = await axios.get<(ConnectedAircraft | ConnectedDispatcher)[]>(
"/api/connected-user",
{},
);
if (res.status !== 200) {
throw new Error("Failed to fetch Connected User");
}
return res.data;
};
export const changeDispatcherAPI = async (
id: number,
data: Prisma.ConnectedDispatcherUpdateInput,
@@ -35,3 +24,14 @@ export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatc
}
return res.data;
};
export const kickDispatcherAPI = async ({ id, bann }: { id: number; bann?: boolean }) => {
const res = await serverApi.delete(`/dispatcher/${id}`, {
data: { bann },
});
console.log(res.status);
if (res.status != 204) {
throw new Error("Failed to kick aircraft");
}
return res.data;
};

View File

@@ -0,0 +1,28 @@
import axios from "axios";
import { Room } from "livekit-client";
import { ParticipantInfo } from "livekit-server-sdk";
export const getLivekitRooms = async () => {
const res = await axios.get<
{
room: Room;
participants: ParticipantInfo[];
}[]
>("/api/livekit-participant");
if (res.status !== 200) {
throw new Error("Failed to fetch keywords");
}
return res.data;
};
export const kickLivekitParticipant = async (body: { identity: string; roomName: string }) => {
const res = await axios.delete("/api/livekit-participant", {
params: body,
});
if (res.status !== 200) {
throw new Error("Failed to kick participant");
}
return res.data;
};

View File

@@ -29,8 +29,14 @@ export const editMissionAPI = async (id: number, mission: Prisma.MissionUpdateIn
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data;
};
export const sendSdsMessageAPI = async (id: number, sdsMessage: MissionSdsLog) => {
const respone = await serverApi.post<Mission>(`/mission/${id}/send-sds`, sdsMessage);
export const sendSdsMessageAPI = async ({
missionId,
sdsMessage,
}: {
missionId?: number;
sdsMessage: MissionSdsLog;
}) => {
const respone = await serverApi.post<Mission>(`/mission/send-sds`, { sdsMessage, missionId });
return respone.data;
};
@@ -51,7 +57,7 @@ export const sendMissionAPI = async (
vehicleName,
}: {
stationId?: number;
vehicleName?: "ambulance" | "police" | "firebrigade";
vehicleName?: "RTW" | "POL" | "FW";
},
) => {
const respone = await serverApi.post<{

View File

@@ -1,7 +1,7 @@
import { Prisma, User } from "@repo/db";
import axios from "axios";
export const editUserAPI = async (id: string, user: Prisma.UserUpdateInput) => {
export const editUserAPI = async ({ id, user }: { id: string; user: Prisma.UserUpdateInput }) => {
const response = await axios.post<User>(`/api/user?id=${id}`, user);
return response.data;
};

View File

@@ -1,16 +1,16 @@
import { PublicUser } from "@repo/db";
import { dispatchSocket } from "dispatch/socket";
import { serverApi } from "_helpers/axios";
import {
handleDisconnect,
handleLocalTrackUnpublished,
handleTrackSubscribed,
handleTrackUnsubscribed,
} from "_helpers/liveKitEventHandler";
import { ConnectionQuality, Participant, Room, RoomEvent } from "livekit-client";
import { ConnectionQuality, Participant, Room, RoomEvent, RpcInvocationData } from "livekit-client";
import { pilotSocket } from "pilot/socket";
import { create } from "zustand";
import axios from "axios";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { changeDispatcherAPI } from "_querys/dispatcher";
let interval: NodeJS.Timeout;
@@ -18,6 +18,7 @@ type TalkState = {
micDeviceId: string | null;
micVolume: number;
isTalking: boolean;
transmitBlocked: boolean;
removeMessage: () => void;
state: "connecting" | "connected" | "disconnected" | "error";
message: string | null;
@@ -40,12 +41,12 @@ const getToken = async (roomName: string) => {
export const useAudioStore = create<TalkState>((set, get) => ({
isTalking: false,
transmitBlocked: false,
message: null,
micDeviceId: null,
speakingParticipants: [],
micVolume: 1,
state: "disconnected",
source: "",
state: "disconnected" as const,
remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown,
room: null,
@@ -61,43 +62,49 @@ export const useAudioStore = create<TalkState>((set, get) => ({
set({ message: null });
},
removeSpeakingParticipant: (participant) => {
const newSpeaktingParticipants = get().speakingParticipants.filter(
(p) => !(p.identity === participant.identity),
);
set((state) => ({
speakingParticipants: state.speakingParticipants.filter(
(p) => !(p.identity === participant.identity),
),
speakingParticipants: newSpeaktingParticipants,
}));
if (newSpeaktingParticipants.length === 0 && get().transmitBlocked) {
get().room?.localParticipant.setMicrophoneEnabled(true);
set({ transmitBlocked: false, message: null, isTalking: true });
}
},
setMic: (micDeviceId, micVolume) => {
set({ micDeviceId, micVolume });
},
toggleTalking: () => {
const { room, isTalking, micDeviceId, micVolume } = get();
const { room, isTalking, micDeviceId, micVolume, speakingParticipants, transmitBlocked } =
get();
if (!room) return;
if (speakingParticipants.length > 0 && !isTalking && !transmitBlocked) {
// Wenn andere sprechen, nicht reden
set({
message: "Rufgruppe besetzt",
transmitBlocked: true,
});
return;
} else if (!isTalking && transmitBlocked) {
set({
message: null,
transmitBlocked: false,
});
return;
}
// Todo: use micVolume
room.localParticipant.setMicrophoneEnabled(!isTalking, {
deviceId: micDeviceId ?? undefined,
});
if (!isTalking) {
// If old status was not talking, we need to emit the PTT event
if (pilotSocket.connected) {
pilotSocket.emit("ptt", {
shouldTransmit: true,
channel: room.name,
});
}
if (dispatchSocket.connected)
dispatchSocket.emit("ptt", {
shouldTransmit: true,
channel: room.name,
});
}
set((state) => ({ isTalking: !state.isTalking }));
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
},
connect: async (roomName, role) => {
set({ state: "connecting" });
console.log("Connecting to room: ", roomName);
try {
// Clean old room
const connectedRoom = get().room;
@@ -116,7 +123,15 @@ export const useAudioStore = create<TalkState>((set, get) => ({
await room.prepareConnection(url, token);
room
// Connection events
.on(RoomEvent.Connected, () => {
.on(RoomEvent.Connected, async () => {
const dispatchState = useDispatchConnectionStore.getState();
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: roomName,
});
}
set({ state: "connected", room, message: null });
})
.on(RoomEvent.Disconnected, () => {
@@ -131,11 +146,22 @@ export const useAudioStore = create<TalkState>((set, get) => ({
.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished);
await room.connect(url, token, {});
room.localParticipant.setAttributes({
role,
});
set({ room });
room.registerRpcMethod("force-mute", async (data: RpcInvocationData) => {
const { by } = JSON.parse(data.payload);
room.localParticipant.setMicrophoneEnabled(false);
useAudioStore.setState({
isTalking: false,
message: `Ruf beendet durch ${by || "eine unsichtbare Macht"}`,
});
return `Hello, ${data.callerIdentity}!`;
});
interval = setInterval(() => {
set({
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
@@ -162,11 +188,21 @@ interface PTTData {
const handlePTT = (data: PTTData) => {
const { shouldTransmit, source } = data;
const { room } = useAudioStore.getState();
const { room, speakingParticipants } = useAudioStore.getState();
if (!room) return;
if (speakingParticipants.length > 0 && shouldTransmit) {
// Wenn andere sprechen, nicht reden
useAudioStore.setState({
message: "Rufgruppe besetzt",
transmitBlocked: true,
});
return;
}
useAudioStore.setState({
isTalking: shouldTransmit,
transmitBlocked: false,
});
if (shouldTransmit) {
@@ -176,19 +212,5 @@ const handlePTT = (data: PTTData) => {
}
};
const handleForceEndTransmission = ({ by }: { by?: string }) => {
const { room } = useAudioStore.getState();
if (!room) return;
room.localParticipant.setMicrophoneEnabled(false);
useAudioStore.setState({
isTalking: false,
message: `Ruf beendet durch ${by || "unknown"}`,
});
};
pilotSocket.on("ptt", handlePTT);
dispatchSocket.on("ptt", handlePTT);
pilotSocket.on("force-end-transmission", handleForceEndTransmission);
dispatchSocket.on("force-end-transmission", handleForceEndTransmission);

View File

@@ -5,6 +5,8 @@ import { ConnectedDispatcher } from "@repo/db";
interface ConnectionStore {
status: "connected" | "disconnected" | "connecting" | "error";
hideDraftMissions: boolean;
setHideDraftMissions: (hide: boolean) => void;
connectedDispatcher: ConnectedDispatcher | null;
message: string;
selectedZone: string;
@@ -15,6 +17,8 @@ interface ConnectionStore {
export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
status: "disconnected",
hideDraftMissions: false,
setHideDraftMissions: (hide) => set({ hideDraftMissions: hide }),
connectedDispatcher: null,
message: "",
selectedZone: "LST_01",
@@ -65,7 +69,6 @@ dispatchSocket.on("force-disconnect", (reason: string) => {
});
});
dispatchSocket.on("dispatchers-update", (dispatch: ConnectedDispatcher) => {
console.log("dispatchers-update", dispatch);
useDispatchConnectionStore.setState({
connectedDispatcher: dispatch,
});

View File

@@ -30,18 +30,38 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
chatOpen: false,
selectedChat: null,
setChatOpen: (open: boolean) => set({ chatOpen: open }),
setSelectedChat: (chatId: string | null) => set({ selectedChat: chatId }),
setSelectedChat: (chatId: string | null) => {
const { setChatNotification } = get();
set({ selectedChat: chatId });
if (chatId) {
setChatNotification(chatId, false); // Set notification to false when chat is selected
}
},
setOwnId: (id: string) => set({ ownId: id }),
chats: {},
sendMessage: (userId: string, message: string) => {
return new Promise((resolve, reject) => {
dispatchSocket.emit("send-message", { userId, message }, ({ error }: { error?: string }) => {
if (error) {
reject(error);
} else {
resolve();
}
});
if (dispatchSocket.connected) {
dispatchSocket.emit(
"send-message",
{ userId, message },
({ error }: { error?: string }) => {
if (error) {
reject(error);
} else {
resolve();
}
},
);
} else if (pilotSocket.connected) {
pilotSocket.emit("send-message", { userId, message }, ({ error }: { error?: string }) => {
if (error) {
reject(error);
} else {
resolve();
}
});
}
});
},
addChat: (userId, name) => {
@@ -55,7 +75,6 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
setChatNotification: (userId, notification) => {
const chat = get().chats[userId];
if (!chat) return;
console.log("setChatNotification", userId, notification);
set((state) => {
return {
chats: {
@@ -84,7 +103,7 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
[userId]: {
...user,
name: isSender ? message.receiverName : message.senderName,
notification: !isSender && (state.selectedChat !== userId || !state.chatOpen),
notification: state.selectedChat !== userId || !state.chatOpen,
messages: [...user.messages, message], // Neuen Zustand erzeugen
},
},

View File

@@ -15,10 +15,7 @@ export interface MapStore {
id: number;
tab: "home" | "details" | "patient" | "log";
}[];
setOpenMissionMarker: (mission: {
open: MapStore["openMissionMarker"];
close: number[];
}) => void;
setOpenMissionMarker: (mission: { open: MapStore["openMissionMarker"]; close: number[] }) => void;
openAircraftMarker: {
id: number;
tab: "home" | "fms" | "aircraft" | "mission" | "chat";
@@ -29,6 +26,7 @@ export interface MapStore {
}) => void;
searchElements: OSMWay[];
setSearchElements: (elements: MapStore["searchElements"]) => void;
toggleSearchElementSelection: (elementId: number) => void;
setContextMenu: (popup: MapStore["contextMenu"]) => void;
searchPopup: {
lat: number;
@@ -39,10 +37,7 @@ export interface MapStore {
aircraftTabs: {
[aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat";
};
setAircraftTab: (
aircraftId: number,
tab: MapStore["aircraftTabs"][number],
) => void;
setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void;
}
export const useMapStore = create<MapStore>((set, get) => ({
@@ -88,6 +83,15 @@ export const useMapStore = create<MapStore>((set, get) => ({
set(() => ({
searchElements: elements,
})),
toggleSearchElementSelection: (elementId) => {
const searchElements = get().searchElements;
const element = searchElements.find((e) => e.wayID === elementId);
if (!element) return;
element.isSelected = !element.isSelected;
set(() => ({
searchElements: [...searchElements],
}));
},
aircraftTabs: {},
setAircraftTab: (aircraftId, tab) =>
set((state) => ({

View File

@@ -8,8 +8,8 @@ interface PannelStore {
missionFormValues?: Partial<MissionOptionalDefaults>;
setMissionFormValues: (values: Partial<MissionOptionalDefaults>) => void;
isEditingMission: boolean;
editingMissionId: string | null;
setEditingMission: (isEditing: boolean, missionId: string | null) => void;
editingMissionId: number | null;
setEditingMission: (isEditing: boolean, missionId: number | null) => void;
}
export const usePannelStore = create<PannelStore>((set) => ({

View File

@@ -122,7 +122,7 @@ export const useMrtStore = create<MrtStore>(
},
{ textLeft: "ILS VAR#", textSize: "3" },
{
textLeft: "new status received",
textLeft: "empfangen",
style: { fontWeight: "bold" },
textSize: "4",
},
@@ -132,18 +132,29 @@ export const useMrtStore = create<MrtStore>(
}
case "sds": {
const { sdsMessage } = pageData as SetSdsPageParams;
const msg = sdsMessage.data.message;
set({
page: "sds",
lines: [
{
textLeft: `neue SDS-Nachricht`,
textLeft: `SDS-Nachricht`,
style: { fontWeight: "bold" },
textSize: "2",
},
{
textLeft: sdsMessage.data.message,
style: {},
textSize: "1",
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",
},
],
});

View File

@@ -9,6 +9,7 @@ import { useAudioStore } from "_store/audioStore";
interface ConnectionStore {
status: "connected" | "disconnected" | "connecting" | "error";
message: string;
logoffTime: string;
selectedStation: Station | null;
connectedAircraft: ConnectedAircraft | null;
@@ -34,6 +35,7 @@ export const usePilotConnectionStore = create<ConnectionStore>((set) => ({
selectedStation: null,
connectedAircraft: null,
activeMission: null,
connect: async (uid, stationId, logoffTime, station, user) =>
new Promise((resolve) => {
set({

View File

@@ -0,0 +1,33 @@
import { prisma, Prisma } from "@repo/db";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
const connectedAircraftId = request.nextUrl.searchParams.get("connectedAircraftId");
const aircraft = await prisma.connectedAircraft.findUnique({
where: {
id: connectedAircraftId ? parseInt(connectedAircraftId) : undefined,
},
});
if (!aircraft) return NextResponse.json({ error: "Aircraft not found" }, { status: 404 });
const positionLog = await prisma.positionLog.findMany({
where: {
id: {
in: aircraft.positionLogIds,
},
timestamp: {
gte: new Date(Date.now() - 2 * 60 * 60 * 1000), // Last 2 hours
},
},
});
return NextResponse.json(positionLog, {
status: 200,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "Failed to fetch Aircrafts" }, { status: 500 });
}
}

View File

@@ -0,0 +1,82 @@
import { prisma } from "@repo/db";
import { RoomManager } from "_helpers/LivekitRoomManager";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) => {
const session = await getServerSession();
if (!session) return Response.json({ message: "Unauthorized" }, { status: 401 });
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
});
const rooms = await RoomManager.listRooms();
const roomsWithParticipants = rooms.map(async (room) => {
const participants = await RoomManager.listParticipants(room.name);
return {
room,
participants,
};
});
return Response.json(await Promise.all(roomsWithParticipants), { status: 200 });
};
export const DELETE = async (request: NextRequest) => {
try {
const identity = request.nextUrl.searchParams.get("identity");
const roomName = request.nextUrl.searchParams.get("roomName");
const ban = request.nextUrl.searchParams.get("ban");
if (!identity) return Response.json({ message: "Missing User identity" }, { status: 400 });
if (!roomName) return Response.json({ message: "Missing roomName" }, { status: 400 });
const session = await getServerSession();
if (!session) return Response.json({ message: "Unauthorized" }, { status: 401 });
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
});
if (!user || !user.permissions.includes("AUDIO_ADMIN"))
return Response.json({ message: "Missing permissions" }, { status: 401 });
if (ban && !user.permissions.includes("ADMIN_USER")) {
return Response.json({ message: "Missing permissions to ban user" }, { status: 401 });
}
if (ban) {
const participant = await RoomManager.getParticipant(roomName, identity);
const pUser = await prisma.user.findUnique({
where: {
id: participant.attributes.userId,
},
});
if (!pUser) return;
// If the user is banned, we need to remove their permissions
await prisma.user.update({
where: { id: session.user.id },
data: {
permissions: {
set: pUser.permissions.filter((p) => p !== "AUDIO"),
},
},
});
}
await RoomManager.removeParticipant(roomName, identity);
return Response.json(
{ message: `User ${identity} kicked from room ${roomName}` },
{ status: 200 },
);
} catch (error) {
console.error("Error in DELETE /api/livekit-participant:", error);
return Response.json({ message: "Internal Server Error" }, { status: 500 });
}
};

View File

@@ -25,12 +25,9 @@ export const GET = async (request: NextRequest) => {
if (!user || !user.permissions.includes("AUDIO"))
return Response.json({ message: "Missing permissions" }, { status: 401 });
const participantName = user.publicId;
const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, {
identity: participantName,
// Token to expire after 10 minutes
ttl: "1d",
identity: user.publicId,
ttl: "1h",
});
at.addGrant({
@@ -43,6 +40,8 @@ export const GET = async (request: NextRequest) => {
at.attributes = {
publicId: user.publicId,
publicUser: JSON.stringify(getPublicUser(user)),
userId: user.id,
};
const token = await at.toJwt();

View File

@@ -24,13 +24,10 @@ export const PUT = async (req: Request) => {
position: PositionLog;
h145: boolean;
};
console.log("position", userId);
if (!position) {
return Response.json({ message: "Missing id or position" });
}
console.log("position", position);
const activeAircraft = await prisma.connectedAircraft.findFirst({
where: {
userId,

View File

@@ -1,5 +1,3 @@
"use client";
import { Connection } from "./_components/Connection";
/* import { ThemeSwap } from "./_components/ThemeSwap"; */
import { Audio } from "../../../_components/Audio/Audio";
@@ -8,22 +6,18 @@ import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Settings } from "_components/navbar/Settings";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import AdminPanel from "_components/navbar/AdminPanel";
import { getServerSession } from "api/auth/[...nextauth]/auth";
export default function Navbar() {
/* const [isDark, setIsDark] = useState(false);
const toggleTheme = () => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.setAttribute(
"data-theme",
newTheme ? "nord" : "dark",
);
}; */
export default async function Navbar() {
const session = await getServerSession();
return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
<ModeSwitchDropdown />
<div className="flex items-center gap-2">
<ModeSwitchDropdown />
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />

View File

@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useMutation } from "@tanstack/react-query";
import { Prisma } from "@repo/db";
import { changeDispatcherAPI } from "_querys/connected-user";
import { changeDispatcherAPI } from "_querys/dispatcher";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
@@ -49,6 +49,13 @@ export const ConnectionBtn = () => {
};
}, [form.logoffTime, connection.connectedDispatcher]);
useEffect(() => {
// Disconnect the socket when the component unmounts
return () => {
connection.disconnect();
};
}, [connection.disconnect]);
return (
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
{connection.message.length > 0 && (

View File

@@ -2,10 +2,14 @@
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { BellRing, BookmarkPlus } from "lucide-react";
import { BellRing, BookmarkPlus, Radio } from "lucide-react";
import { Select } from "_components/Select";
import { KEYWORD_CATEGORY, Mission, missionType, Prisma } from "@repo/db";
import { MissionOptionalDefaults, MissionOptionalDefaultsSchema } from "@repo/db/zod";
import {
JsonValueType,
MissionOptionalDefaults,
MissionOptionalDefaultsSchema,
} from "@repo/db/zod";
import { usePannelStore } from "_store/pannelStore";
import { useSession } from "next-auth/react";
import { toast } from "react-hot-toast";
@@ -23,11 +27,12 @@ import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission";
import { AxiosError } from "axios";
import { cn } from "_helpers/cn";
export const MissionForm = () => {
const { isEditingMission, editingMissionId, setEditingMission } = usePannelStore();
const queryClient = useQueryClient();
const setSeachOSMElements = useMapStore((s) => s.setSearchElements);
const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s);
const { data: keywords } = useQuery({
queryKey: ["keywords"],
@@ -120,6 +125,13 @@ export const MissionForm = () => {
}
}, [session.data?.user.id, form]);
useEffect(() => {
form.setValue(
"addressOSMways",
searchElements.filter((e) => e.isSelected) as unknown as JsonValueType[],
);
}, [searchElements, form]);
useEffect(() => {
if (missionFormValues) {
if (Object.keys(missionFormValues).length === 0) {
@@ -157,6 +169,8 @@ export const MissionForm = () => {
}).catch((error) => {
toast.error(`Fehler beim Starten der HPG-Validierung: ${error.message}`);
});
} else if (alertWhenValid) {
await sendAlertMutation.mutateAsync(newMission.id);
}
return newMission;
} else {
@@ -247,10 +261,23 @@ export const MissionForm = () => {
placeholder="Wähle ein oder mehrere Rettungsmittel aus"
isMulti
form={form}
options={stations?.map((s) => ({
label: s.bosCallsign,
value: s.id,
}))}
options={stations
?.sort((a, b) => {
const aHasAircraft = aircrafts?.some((ac) => ac.stationId === a.id) ? 0 : 1;
const bHasAircraft = aircrafts?.some((ac) => ac.stationId === b.id) ? 0 : 1;
return aHasAircraft - bHasAircraft;
})
.map((s) => ({
label: (
<span className="flex items-center gap-2">
{aircrafts?.some((a) => a.stationId === s.id) && (
<Radio className="text-success" size={15} />
)}
{s.bosCallsign}
</span>
),
value: s.id,
}))}
/>
</div>
@@ -263,7 +290,7 @@ export const MissionForm = () => {
onChange={(e) => {
form.setValue("type", e.target.value as missionType);
form.setValue("missionKeywordName", KEYWORD_CATEGORY.AB_ATMUNG);
form.setValue("missionKeywordAbbreviation", "");
form.setValue("missionKeywordAbbreviation", null as any);
form.setValue("hpgMissionString", null);
}}
>
@@ -274,22 +301,16 @@ export const MissionForm = () => {
<>
<select
{...form.register("missionKeywordCategory")}
className="select select-primary select-bordered w-full"
className="select select-primary select-bordered w-full mb-4"
onChange={(e) => {
const firstKeyword = keywords?.find(
(k) => k.category === form.watch("missionKeywordCategory"),
);
form.setValue("missionKeywordCategory", e.target.value as string);
form.setValue("missionKeywordName", firstKeyword?.name || (null as any));
form.setValue(
"missionKeywordAbbreviation",
firstKeyword?.abreviation || (null as any),
);
form.setValue("missionKeywordName", null as any);
form.setValue("missionKeywordAbbreviation", null as any);
form.setValue("hpgMissionString", "");
}}
defaultValue=""
value={form.watch("missionKeywordCategory") || "please_select"}
>
<option disabled value="">
<option disabled value="please_select">
Einsatz Kategorie auswählen...
</option>
{Object.keys(KEYWORD_CATEGORY).map((use) => (
@@ -303,16 +324,16 @@ export const MissionForm = () => {
)}
<select
{...form.register("missionKeywordAbbreviation")}
className="select select-primary select-bordered w-full"
className="select select-primary select-bordered w-full mb-4"
onChange={(e) => {
const keyword = keywords?.find((k) => k.abreviation === e.target.value);
form.setValue("missionKeywordName", keyword?.name || (null as any));
form.setValue("missionKeywordAbbreviation", keyword?.abreviation || (null as any));
form.setValue("hpgMissionString", "default");
form.setValue("hpgMissionString", null);
}}
defaultValue="default"
value={form.watch("missionKeywordAbbreviation") || "please_select"}
>
<option disabled value={""}>
<option disabled value={"please_select"}>
Einsatzstichwort auswählen...
</option>
{keywords &&
@@ -330,10 +351,20 @@ export const MissionForm = () => {
<div className="mb-4">
<select
{...form.register("hpgMissionString")}
className="select select-primary select-bordered w-full"
defaultValue="default"
onChange={(e) => {
form.setValue("hpgMissionString", e.target.value);
const [name] = e.target.value.split(":");
if (
!form.watch("missionAdditionalInfo") ||
form.watch("missionAdditionalInfo") === name
) {
form.setValue("missionAdditionalInfo", name || "");
}
}}
className="select select-primary select-bordered w-full mb-2"
value={form.watch("hpgMissionString") || "please_select"}
>
<option disabled value="">
<option disabled value="please_select">
Einsatz Szenario auswählen...
</option>
{keywords &&
@@ -377,11 +408,11 @@ export const MissionForm = () => {
/>
</div>
{missionFormValues?.addressOSMways?.length && (
<p className="text-sm text-info">
In diesem Einsatz gibt es {missionFormValues?.addressOSMways?.length} Gebäude
</p>
)}
<p
className={cn("text-sm text-gray-500", form.watch("addressOSMways").length && "text-info")}
>
In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude
</p>
<div className="form-control min-h-[140px]">
<div className="flex gap-2">
@@ -393,7 +424,7 @@ export const MissionForm = () => {
try {
const newMission = await saveMission(mission);
toast.success(`Einsatz ${newMission.id} erfolgreich aktualisiert`);
setSeachOSMElements([]); // Reset search elements
setSearchElements([]); // Reset search elements
setEditingMission(false, null); // Reset editing state
form.reset(); // Reset the form
setOpen(false);
@@ -418,12 +449,14 @@ export const MissionForm = () => {
onClick={form.handleSubmit(async (mission: MissionOptionalDefaults) => {
try {
const newMission = await saveMission(mission, {
createNewMission: true,
alertWhenValid: true,
});
if (!validationRequired) {
await sendAlertMutation.mutateAsync(newMission.id);
}
setSeachOSMElements([]); // Reset search elements
setSearchElements([]); // Reset search elements
setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`);
form.reset();
setOpen(false);
} catch (error) {
if (error instanceof AxiosError) {
@@ -449,7 +482,8 @@ export const MissionForm = () => {
createNewMission: true,
});
setSeachOSMElements([]); // Reset search elements
setSearchElements([]); // Reset search elements
setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`);
form.reset();
setOpen(false);

View File

@@ -16,7 +16,7 @@ export default async function RootLayout({
}>) {
const session = await getServerSession();
if (!session) {
if (!session || !session.user) {
redirect("/login");
}

View File

@@ -7,10 +7,12 @@ import dynamic from "next/dynamic";
import { Chat } from "../_components/left/Chat";
import { Report } from "../_components/left/Report";
import { SituationBoard } from "_components/left/SituationBoard";
const Map = dynamic(() => import("../_components/map/Map"), { ssr: false });
const DispatchPage = () => {
const { isOpen } = usePannelStore();
/* return null; */
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full">
{/* <MapToastCard2 /> */}

View File

@@ -56,3 +56,8 @@
.leaflet-popup-close-button {
display: none;
}
.missionListItem {
background-color: color-mix(in oklab, var(--color-info) 8%, var(--color-base-100));
color: var(--color-info, var(--color-base-content));
}

View File

@@ -43,6 +43,7 @@ export default async function RootLayout({
background: "var(--color-base-100)",
color: "var(--color-base-content)",
},
duration: 4000,
}}
position="top-left"
reverseOrder={false}

View File

@@ -8,7 +8,11 @@ export default () => {
const session = useSession();
useEffect(() => {
if (session.status === "authenticated" && session.data?.user) {
if (session.status !== "authenticated") {
router.replace("/login");
return;
}
if (session.data?.user) {
const hasDispoPermission = session.data.user.permissions?.includes("DISPO");
if (hasDispoPermission) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

View File

@@ -1,5 +1,6 @@
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";
@@ -18,6 +19,7 @@ const MRT_DISPLAYLINE_STYLES: CSSProperties = {
};
export interface DisplayLineProps {
lineStyle?: CSSProperties;
style?: CSSProperties;
textLeft?: string;
textMid?: string;
@@ -31,12 +33,14 @@ const DisplayLine = ({
textMid,
textRight,
textSize,
lineStyle,
}: DisplayLineProps) => {
const INNER_TEXT_PARTS: CSSProperties = {
fontFamily: "Melder",
flex: "1",
flexBasis: "auto",
overflowWrap: "break-word",
...lineStyle,
};
return (
@@ -46,13 +50,12 @@ const DisplayLine = ({
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: "center", ...INNER_TEXT_PARTS }}>{textMid}</span>
<span style={{ textAlign: "end", ...INNER_TEXT_PARTS }}>{textRight}</span>
</div>
);
@@ -61,7 +64,7 @@ const DisplayLine = ({
export const Mrt = () => {
useSounds();
const { handleButton } = useButtons();
const lines = useMrtStore((state) => state.lines);
const { lines, page } = useMrtStore((state) => state);
return (
<div
@@ -75,21 +78,38 @@ 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: "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%",
}}
>
<Image
src={MrtImage}
alt="MrtImage"
style={{
zIndex: 0,
height: "100%",
width: "100%",
gridArea: "1 / 1 / 13 / 13",
}}
{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")}
@@ -135,25 +155,50 @@ export const Mrt = () => {
{lines[0] && (
<DisplayLine
{...lines[0]}
style={{
gridArea: "4 / 3 / 5 / 4",
marginLeft: "9px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[0]?.style,
}}
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
{...lines[1]}
style={{
gridArea: "5 / 3 / 7 / 4",
marginLeft: "3px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[1].style,
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] && (

View File

@@ -1,19 +1,30 @@
import { ConnectedAircraft } from "@repo/db";
import { ConnectedAircraft, Prisma } from "@repo/db";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import { pilotSocket } from "pilot/socket";
import { editConnectedAircraftAPI } from "_querys/aircrafts";
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
export const useButtons = () => {
const station = usePilotConnectionStore((state) => state.selectedStation);
const connectedAircraft = usePilotConnectionStore((state) => state.connectedAircraft);
const connectionStatus = usePilotConnectionStore((state) => state.status);
const updateAircraftMutation = useMutation({
mutationKey: ["edit-pilot-connected-aircraft"],
mutationFn: ({
aircraftId,
data,
}: {
aircraftId: number;
data: Prisma.ConnectedAircraftUpdateInput;
}) => editConnectedAircraftAPI(aircraftId, data),
});
const { page, setPage } = useMrtStore((state) => state);
const handleButton =
(button: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0") => () => {
(button: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0" | "home") => () => {
if (connectionStatus !== "connected") return;
if (!station) return;
if (!connectedAircraft?.id) return;
@@ -29,12 +40,14 @@ export const useButtons = () => {
button === "9" ||
button === "0"
) {
if (page !== "home") return;
setPage({ page: "sending-status", station });
setTimeout(async () => {
await editConnectedAircraftAPI(connectedAircraft!.id, {
fmsStatus: button,
await updateAircraftMutation.mutateAsync({
aircraftId: connectedAircraft.id,
data: {
fmsStatus: button,
},
});
setPage({
page: "home",
@@ -42,6 +55,8 @@ export const useButtons = () => {
fmsStatus: button,
});
}, 1000);
} else {
setPage({ page: "home", fmsStatus: connectedAircraft.fmsStatus || "6", station });
}
};

View File

@@ -5,9 +5,7 @@ import { useEffect, useRef } from "react";
export const useSounds = () => {
const mrtState = useMrtStore((state) => state);
const { connectedAircraft, selectedStation } = usePilotConnectionStore(
(state) => state,
);
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
const setPage = useMrtStore((state) => state.setPage);
const MRTstatusSoundRef = useRef<HTMLAudioElement>(null);
@@ -16,9 +14,7 @@ export const useSounds = () => {
useEffect(() => {
if (typeof window !== "undefined") {
MRTstatusSoundRef.current = new Audio("/sounds/MRT-status.mp3");
MrtMessageReceivedSoundRef.current = new Audio(
"/sounds/MRT-message-received.mp3",
);
MrtMessageReceivedSoundRef.current = new Audio("/sounds/MRT-message-received.mp3");
MRTstatusSoundRef.current.onended = () => {
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
setPage({
@@ -29,6 +25,7 @@ export const useSounds = () => {
};
MrtMessageReceivedSoundRef.current.onended = () => {
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
if (mrtState.page === "sds") return;
setPage({
page: "home",
station: selectedStation,
@@ -36,7 +33,7 @@ export const useSounds = () => {
});
};
}
}, [connectedAircraft?.fmsStatus, selectedStation, setPage]);
}, [connectedAircraft?.fmsStatus, selectedStation, setPage, mrtState.page]);
const fmsStatus = connectedAircraft?.fmsStatus || "NaN";
@@ -48,6 +45,8 @@ export const useSounds = () => {
} else {
MRTstatusSoundRef.current?.play();
}
} else if (mrtState.page === "sds") {
MrtMessageReceivedSoundRef.current?.play();
}
}, [mrtState, fmsStatus, connectedAircraft, selectedStation]);
};

View File

@@ -16,7 +16,7 @@ export default async function RootLayout({
}>) {
const session = await getServerSession();
if (!session) {
if (!session || !session.user.firstname) {
redirect("/login");
}
if (!session.user.emailVerified) {

View File

@@ -5,15 +5,17 @@ import { Chat } from "../_components/left/Chat";
import { Report } from "../_components/left/Report";
import { Dme } from "pilot/_components/dme/Dme";
import dynamic from "next/dynamic";
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher";
const Map = dynamic(() => import("../_components/map/Map"), {
ssr: false,
});
const DispatchPage = () => {
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full">
<div className="relative flex-1 flex transition-all duration-500 ease w-full h-screen overflow-hidden">
{/* <MapToastCard2 /> */}
<div className="flex flex-1 relative w-full">
<div className="flex flex-1 relative w-full h-full">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
<Chat />
<div className="mt-2">
@@ -21,7 +23,12 @@ const DispatchPage = () => {
</div>
</div>
<div className="flex w-2/3 h-full">
<Map />
<div className="relative flex flex-1 h-full">
<Map />
<div className="absolute top-5 right-10 z-99999">
<ConnectedDispatcher />
</div>
</div>
</div>
<div className="flex w-1/3 h-full">
<div className="flex flex-col w-full h-full p-4 bg-base-300">

View File

@@ -1,8 +1,8 @@
import { BADGES, PublicUser } from "@repo/db";
import { asPublicUser, BADGES, PublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "_components/Badge/Badge";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getConnectedDispatcherAPI } from "_querys/connected-user";
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { Plane, Workflow } from "lucide-react";
export const ConnectedDispatcher = () => {
@@ -20,13 +20,13 @@ export const ConnectedDispatcher = () => {
const connections = (aircrafts?.length || 0) + (dispatcher?.length || 0);
return (
<div className="absolute top-5 right-10 min-w-120 z-99999">
<div className="min-w-120">
<div className="collapse collapse-arrow bg-base-100 border-base-300 border">
<input type="checkbox" />
{/* <div className="collapse-title font-semibold">Kein Disponent Online</div> */}
<div className="collapse-title font-semibold flex items-center justify-between">
<span>
{connections} {connections == 1 ? "Verbundene Mitglieder" : "Verbundenes Mitglied"}
{connections} {connections == 1 ? "Verbundenes Mitglied" : "Verbundene Mitglieder"}
</span>
<div className="gap-2 flex items-center">
<div
@@ -70,7 +70,7 @@ export const ConnectedDispatcher = () => {
)}
</div>
<div>
<div>{(d.publicUser as unknown as PublicUser)?.firstname}</div>
<div>{asPublicUser(d.publicUser).fullName}</div>
<div className="text-xs uppercase font-semibold opacity-60">{d.zone}</div>
</div>
<div>

View File

@@ -1,4 +1,5 @@
"use client";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import dynamic from "next/dynamic";
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher";
@@ -10,7 +11,10 @@ const Page = () => {
return (
<>
<Map />
<ConnectedDispatcher />
<div className="flex gap-3 absolute top-5 right-10 z-99999">
<ConnectedDispatcher />
<ModeSwitchDropdown className="dropdown-end" />
</div>
</>
);
};

View File

@@ -23,13 +23,20 @@
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.8",
"@tanstack/react-query": "^5.79.0",
"@turf/turf": "^7.2.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/leaflet": "^1.9.18",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"daisyui": "^5.0.43",
"geojson": "^0.5.0",
"i": "^0.3.7",
"jsonwebtoken": "^9.0.2",
"leaflet": "^1.9.4",
"leaflet.polylinemeasure": "^3.0.0",
"livekit-client": "^2.13.3",
"livekit-server-sdk": "^2.13.0",
"lucide-react": "^0.511.0",
@@ -47,16 +54,9 @@
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"zod": "^3.25.46",
"zustand": "^5.0.5",
"zustand-sync-tabs": "^0.2.2"
},
"devDependencies": {
"@types/leaflet": "^1.9.18",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"daisyui": "^5.0.43",
"typescript": "^5.8.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

18
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
/coverage
/src/client/shared.ts
/src/node/shared.ts
*.log
*.tgz
.DS_Store
.idea
.temp
.vite_opt_cache
.vscode
dist
cache
temp
examples-temp
node_modules
pnpm-global
TODOs.md
*.timestamp-*.mjs

View File

@@ -0,0 +1,193 @@
import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "VAR Knowledgebase",
description: "How To's und mehr zu Virtual Air Rescue",
srcDir: "src",
themeConfig: {
logo: "/var_logo.png",
search: {
provider: "local",
},
lastUpdated: {
text: "Letzte Änderung",
formatOptions: {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
},
},
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: "Startseite", link: "/" },
{
text: "How-To's",
items: [
{ text: "Wie werde ich Pilot?", link: "/pilotenbereich/how-to-pilot" },
{ text: "Wie werde ich Disponent?", link: "/disponentenbereich/how-to-disponent" },
{
text: "Wie verbinde ich meinen Discord Account?",
link: "/allgemein/var-systeme/hub/how-to-discord",
},
],
},
{ text: "FAQ", link: "/faq" },
],
footer: {
message: "<a href=''>Impressum</a> | <a href=''>Datenschutzerklärung</a>",
},
sidebar: [
{
text: "Pilotenbereich",
items: [
{ text: "How-To Pilot", link: "/pilotenbereich/how-to-pilot" },
{
text: "HPG H145",
collapsed: false,
items: [
{ text: "Allgemeine Informationen", link: "/pilotenbereich/hpg-h145/info" },
{ text: "Start-Up", link: "/pilotenbereich/hpg-h145/Start-Up" },
{ text: "Powering Down", link: "/pilotenbereich/hpg-h145/Powering-Down" },
{
text: "R&E Integration",
collapsed: true,
items: [
{
text: "Voraussetzungen",
link: "/pilotenbereich/hpg-h145/r-e-integration/Voraussetzungen",
},
{
text: "Einrichtung",
link: "/pilotenbereich/hpg-h145/r-e-integration/Einrichtung",
},
{
text: "Fehlerbehebung",
link: "/pilotenbereich/hpg-h145/r-e-integration/Fehlerbehebung",
},
],
},
],
},
{ text: "EC135 Bedienung", link: "/pilotenbereich/ec-135" },
{ text: "Hubschrauber Steuerorgane", link: "/pilotenbereich/Steuerorgane" },
{ text: "Luftraumstruktur", link: "/pilotenbereich/Luftraumstruktur" },
{ text: "Meteorologie", link: "/pilotenbereich/Meteorologie" },
{ text: "Navigation", link: "/pilotenbereich/Navigation" },
{ text: "Standardplatzrunde", link: "/pilotenbereich/Standardplatzrunde" },
{ text: "Reichweite / Endurance", link: "/pilotenbereich/Endurance" },
{ text: "Hubschraubertypen", link: "/pilotenbereich/Hubschraubertypen" },
{
text: "Luftrettung",
collapsed: true,
items: [
{ text: "Außenlandung", link: "/pilotenbereich/luftrettung/aussenlandung" },
{ text: "Landeplätze- und Stellen", link: "/pilotenbereich/luftrettung/landeplatz" },
{
text: "Crew",
collapsed: true,
items: [
{ text: "HEMS-TC", link: "/pilotenbereich/luftrettung/crew/hems-tc" },
{ text: "Notarzt", link: "/pilotenbereich/luftrettung/crew/notarzt" },
],
},
{
text: "Militärfliegerei (SAR)",
collapsed: true,
items: [
{ text: "Einführung", link: "/pilotenbereich/luftrettung/military/Einführung" },
{ text: "SOP", link: "/pilotenbereich/luftrettung/military/SOP" },
],
},
],
},
],
},
{
text: "Disponentenbereich",
items: [
{ text: "How-To Disponent", link: "/disponentenbereich/how-to-disponent" },
{ text: "Disposition", link: "/disponentenbereich/disposition" },
{ text: "Stichworte", link: "/disponentenbereich/Stichworte" },
],
},
{
text: "Allgemein",
items: [
{
text: "VAR Systeme",
collapsed: false,
items: [
{ text: "Änderungen in der V2", link: "/allgemein/var-systeme/v2-changes" },
{
text: "HUB",
collapsed: true,
items: [
{ text: "How-To Discord", link: "/allgemein/var-systeme/hub/how-to-discord" },
],
},
{
text: "Leitstelle",
collapsed: true,
items: [{ text: "Leitstelle", link: "/allgemein/var-systeme/leitstelle/" }],
},
],
},
{
text: "BOS Funk",
collapsed: true,
items: [
{ text: "Grundlagen", link: "/allgemein/bos-funk/Grundlagen" },
{ text: "Funkverkehr", link: "/allgemein/bos-funk/Funkverkehr" },
{ text: "OPTA", link: "/allgemein/bos-funk/OPTA" },
{ text: "Status", link: "/allgemein/bos-funk/Status" },
{ text: "Funkbeispiel", link: "/allgemein/bos-funk/Funkbeispiel" },
],
},
{
text: "VATSIM",
collapsed: true,
items: [
{ text: "Registrierung", link: "/allgemein/vatsim/registrierung" },
{ text: "Prefile", link: "/allgemein/vatsim/prefile" },
],
},
],
},
{
text: "",
items: [
{ text: "Impressum", link: "/" },
{ text: "Datenschutzerklärung", link: "/" },
{ text: "Mitwirken", link: "/" },
],
},
],
socialLinks: [{ icon: "github", link: "https://github.com/VAR-Virtual-Air-Rescue/docs" }],
docFooter: {
prev: "Vorherige Seite",
next: "Nächste Seite",
},
outline: {
label: "Inhalt",
},
},
markdown: {
theme: {
light: "catppuccin-latte",
dark: "catppuccin-mocha",
},
image: {
lazyLoading: true,
},
},
});

View File

@@ -0,0 +1,3 @@
.VPHero .image-src {
max-width: 50%;
}

View File

@@ -0,0 +1,5 @@
import DefaultTheme from "vitepress/theme";
import "@catppuccin/vitepress/theme/mocha/lavender.css";
import "./custom.css";
export default DefaultTheme;

21
apps/docs/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "docs",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "vitepress dev --port 3006",
"docs:dev": "vitepress dev --port 3006",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.11.1",
"devDependencies": {
"vitepress": "^1.6.3"
},
"dependencies": {
"@catppuccin/vitepress": "^0.1.2"
}
}

View File

@@ -0,0 +1,106 @@
# Funkbeispiel
## Primäreinsatz
Zur Orientierung haben wir hier ein Funkbeispiel für einen Primäreinsatz erstellt, welches alle wichtigen Einsatzabschnitte abdeckt und einen Realeinsatz so gut wie möglich abbilden soll.
::: warning 10:00 Uhr ➜ Die Leistelle alarmiert das Rettungsmittel
Innerhalb der nächsten drei Minuten sendet das Luftrettungsmittel Status 3 zur Leitstelle, um <b>wortlos</b> die Einsatzübernahme zu quittieren.
:::
::: warning CHX 69 ➜ 10:02 Uhr ➜ Status 3
:::
Der kommende Einsatzabschnitt kann theoretisch komplett ohne Kommunikation verlaufen, hier hat die Leitstelle aber noch einige Informationen für den Hubschrauber.
::: info Leitstelle ➜ 10:05 Uhr
<strong>"Christoph 69 von Leitstelle VAR, kommen."</strong>
<p>Die Leitstelle baut das Gespräch mit dem Luftrettungsmittel auf.</p>
:::
::: warning CHX69 ➜ 10:05 Uhr
<strong>"Hier Christoph 69, kommen."</strong>
:::
::: info Leitstelle ➜ 10:05 Uhr
<strong>
"Einsatz in der VAR-Straße 187 ist eine Nachforderung vom RTW, Patient
bewusstlos, kommen."
</strong>
:::
::: warning CHX69 ➜ 10:06 Uhr
<strong>"Einsatz als Nachforderung vom RTW, verstanden, kommen."</strong>
:::
::: info Leitstelle ➜ 10:06 Uhr
<strong>"Richtig verstanden, Ende."</strong>
:::
::: warning CHX69 ➜ 10:11 Uhr ➜ Status 4
:::
Der Hubschrauber ist am Einsatzort eingetroffen und das ärztliche Personal versorgt den Patienten.
Nach der Versorgung verständigt sich der Notarzt mit dem aufnehmenden Klinikum und meldet den Patienten an.
Das Luftrettungsmittel verlegt den Patienten im kommenden Einsatzabschnitt. Um der Leitstelle diese Information in Hinblick auf dessen Verfügbarkeit mitzuteilen, versucht die Besatzung ein Gespräch mittels Status 5 - dem klassischen Sprechwunsch - aufzubauen.
::: warning CHX69 ➜ 10:35 Uhr ➜ Status 5
:::
Entweder, die Leistelle schickt den FMS-Status J, die Sprechaufforderung:
:::info Leitstelle ➜ 10:36 Uhr ➜ Status J
:::
::: warning CHX69 ➜ 10:36 Uhr
<strong>
"Hier Christoph 69, Patient aufgenommen, wir fliegen ins Capitol-Klinikum
Finsdorf."
</strong>
:::
oder die Leitstelle baut den Ruf verbal auf:
:::info Leitstelle ➜ 10:36 Uhr
<strong>"Christoph 69 von Leitstelle VAR, kommen."</strong>
:::
::: warning CHX69 ➜ 10:36 Uhr
<strong>
"Hier Christoph 69, Patient aufgenommen, wir fliegen ins Capitol-Klinikum
Finsdorf, kommen."
</strong>
:::
:::info Leitstelle ➜ 10:36 Uhr
<strong>"Verstanden, Ende."</strong>
<p>
Die Leistelle kann als übergeordneter Gesprächsteilnehmer den Ruf - wie in
diesem Fall - auch vorzeitig beenden.
</p>
:::
::: warning CHX69 ➜ 10:37 Uhr ➜ Status 7
:::
Der Hubschrauber macht sich auf den Weg nach Finsdorf. Dort angekommen setzt er den Status 8.
**Eine Kommunikation mit der Leitstelle in der Zwischenzeit ist in der Regel nicht erforderlich.**
Am Zielort angekommen teilt die Besatzung der Leitstelle mittels Status 8 mit, dass der Patient zum einen in das Klinikum verbracht wurde und das Luftrettungsmittel zum anderen auf Nachfrage bedingt für einen kommenden Einsatz abkömmlich ist.
::: warning CHX69 ➜ 10:49 Uhr ➜ Status 8
:::
::: warning CHX69 ➜ 11:05 Uhr ➜ Status 1
Der Hubschrauber ist wieder einsatzbereit und fliegt zurück zum
Luftrettungszentrum.
:::
::: warning CHX69 ➜ 11:18 Uhr ➜ Status 2
:::
Ab hier geht es dann wieder [von vorne los.](#primareinsatz)

View File

@@ -0,0 +1,168 @@
# Funkverkehr
Damit im Einsatzfunk keine Misverständnisse entstehen, gibt es im BOS-Funk eine gewissen "Funkdisziplin" . Neben bestimmten Betriebswörtern ist die korrekte und deutliche Aussprache, das Vermeiden von Floskeln oder ungeläufigen Abkürzungen und vieles mehr sehr wichtig.
Im folgenden Artikel ist alles dazu zusammengefasst.
Achtet unbedingt auf unsere [Dos und Don'ts](#dos-and-donts) am Ende dieses Artikels.
## Die Basics
Ein Funkspruch sollte immer so kurz wie möglich und nur so lang wie nötig sein.
Um lange Denkpausen wärhend des Funkspruchs zu verhindern, kann man sich an den einfachen Merkspruch **Denken, Drücken, Sprechen** halten. In jeder anderen Reihenfolge entstehen keine guten Funksprüche.
:::danger Zu vermeiden ist das Nutzen von:
- Eigennamen (sofern nicht wichtig)
- Höflichkeitsformen ("Danke", "Bitte", etc.)
- ungeläufigen Abkürzungen
:::
:::tip Unbedingt genutzt werden sollte:
- die Anrede mit "Sie"
- die unverwechselbare Aussprache von Zahlen (einzeln und "zwo" statt "zwei")
- ggf. die [deutsche postalische Buchstabiertafel](https://de.wikipedia.org/wiki/Buchstabiertafel#Deutscher_Sprachraum) ("A wie Anton", "B wie Berta" etc.)
:::
Natürlich ist im alltäglichen Gebrauch eine starke Abweichung zu erkennen - aber nur wer weiß, wie's richtig geht, kann sich eine Abweichung erlauben. Gerade in [DMO](grundlagen)-Rufgruppen ist ein "Standardfunkverkehr" nur selten gewährleistet. Bei größeren Einsatzlagen wird eine korrekte und unmisverständliche Kommunikation jedoch wichtig.
## Gesprächsaufbau
Der **Gesprächspartner** (dessen OPTA) wird zuerst gerufen, dann folgen das Bindewort "<b>von</b>" (nicht "für"!), die <b>eigene OPTA</b> und die Sprechaufforderung "<b>kommen</b>".
::: tip CHX69 ➜
<strong>"Leitstelle VAR von Christoph 69 - kommen"</strong>
:::
Auch kann ein kommender Gesprächsinhalt als Vorbereitung angefügt werden.
::: tip CHX69 ➜
<strong>"Leitstelle VAR von Christoph 69 - mit Nachforderung - kommen"</strong>
:::
Das Drücken des [Status](status) 5 kommt einem wortlosen Gesprächsaufbau gleich.
## Antwort auf einen Gesprächsaufbau
Die Antwort auf einen Gesprächsaufbau beginnt immer mit dem Wort "**Hier**", gefolgt von der **eigenen OPTA** und der Sprechaufforderung "**kommen**".
::: info Leitstelle ➜
<strong>"Hier Leitstelle VAR - kommen"</strong>
:::
Die Formulierungen "Hört", "Hört Sie" und ähnliche sind in der Realität nicht erwünscht.
Auf einen Sprechwunsch antwortet man folgendermaßen
::: info Leitstelle ➜
<strong>"Chistoph 69 - hier Leitstelle VAR - kommen."</strong>
:::
Hier ist es auch nicht unüblich, dass von der Leistelle nur die OPTA des rufenden Teilnehmers genannt wird.
:::info
Einige Leitstellen führen als Namen nicht das Wort "Leitstelle" sondern "Florian". "Florian Berlin" als Rettungsleitstelle in Berlin wird also nicht "Leitstelle Florian Berlin", sondern "Florian Berlin" gerufen.
:::
## Führen eines Gesprächs
Nach den vorherigen beiden Schritten beginnt das eigentliche Funkgespräch. Jede Nachricht wird mit dem Betriebswort "**kommen**" beendet, beide OPTA (also die eigene und die des Gesprächspartners) werden nicht mehr genannt.
::: tip CHX 69 ➜
<strong>"Starten in 2</strong>
<i> ("zwo") </i>
<strong>Minuten - kommen"</strong>
:::
Sofern die Information aufgenommen wurde, ist diese vom Empfänger mit "**Verstanden**" und der Sprechaufforderung zu bestätigen.
::: info Leitstelle ➜
<strong>"Verstanden - kommen"</strong>
:::
Komplexe Informationen sollten immer wiederholt werden.
Beispiele hierfür sind:
- Koordinaten
- Anzahlen
- Zeiten
## Beenden eines Gesprächs
Ist der Informationsaustausch beendet, muss auch das Gespräch beendet werden.
::: tip CHX 69 ➜
<strong>"Vertanden - Ende"</strong>
:::
Standardgemäß beendet **immer** der eröffnende Gesprächspartner das Gespräch.
Wird das Gespräch über einen [Sprechwunsch](status) eröffnet, gilt dieser als Gesprächsaufbau.
Außerhalb dieser Regel kann die Leitstelle ein Gespräch jederzeit beenden.
## Betriebswörter
| **Verwendung** | **Betriebsworte** |
| -------------------------------------------------------------------------- | ------------------------------------ |
| Berichtigung eines Sprech- oder Textfehlers | Ich berichtige |
| Ankündigung einer Wiederholung | Ich wiederhole |
| Aufforderung, eine Meldung zu wiederholen | Wiederholen Sie |
| Aufforderung, eine Meldung eingegrenzt zu wiederholen | Wiederholen Sie ab / bis / von...bis |
| Ankündigung, dass ein Wort buchstabiert wird | Ich buchstabiere |
| Aufforderung, ein Wort zu buchstabieren | Buchstabieren Sie |
| Ankündigung einer Frage | Frage |
| Aufforderung zum Warten | Warten |
| Nicht aufnahmebereiter Gesprächspartner ruft zurück, sofern aufnahmebereit | Ich rufe wieder |
## Dos and Don'ts
#### Unklarer Rufaufbau
> **Don't:** "Christoph 69 für Leitstelle VAR, kommen"
Abgesehen davon, dass "für" ein kompliziertes Wort in diesem Zusammenhang ist, ist vielen nicht bewusst, welche Station in diesem Anruf zuerst und welche danach gerufen wird.
In diesem Beispiel würde die Leistelle also den Christoph 69 rufen.
Besser in dieser Situation: **"von"**. Ein unmissverständliches Betriebswort, welches eindeutig beschreibt, welche Station **von** welcher gerufen wird.
> **Do:** "Christoph 69 von Leistelle VAR, kommen"
#### Erst Gedrückt, dann Gesprochen
> **Don't:** "Ähm, Christoph Ähm 69, wir ähm, fliegen jetzt in - Moment - die Uniklinik in - Moment - Erfurt mit Ankunft in ähm 5 Minuten, kommen"
Beachtet die ricthige Reihenfolge von "Denken, Drücken, Sprechen"!
> **Do:** "Christoph 69, Transportziel Uniklinik mit Ankunft in 5 Minuten, kommen"
#### Falscher Status/Notrufmissbrauch
> **Don't:** Status 0 senden, um mitzuteilen, dass sich der Start um eine Minute verzögert.
Der dringende Sprechwunsch ist dazu da, um den eigenen Sprechwunsch vor anderen Sprechwünschen zu priorisieren.
Missbraucht weder den priorisierten Sprechwunsch, noch den Notruf.
> **Do:** Status sinnig verwenden und vorher klären, ob die Information wirklich wichtig genug ist, um sie in einem Sprechwunsch mitzuteilen.
#### Flugfunk mit BOS-Funk verwechseln
> **Don't:** "Copy", "Understood", "Wilco"
Bedarf wohl keiner weiteren Erklärung.
> **Do:** "Verstanden"
#### Den Sprechwunsch meiden
> **Don't:** "Leitstelle von Christoph 69, kommen."
Der Sprechwunsch-Status 5 ist _in der Regel_ vor einem verbalen Gesprächsaufbau zu verwenden.
> **Do:** Status 5 drücken und auf ein "J" oder einen Gesprächsaufbau durch die Leistelle warten.
#### Landemeldungen
> **Don't:** "Christoph 69 zur Landung an der Einsatzstelle."
Eine Landemeldung interessiert die Leitstelle in den seltensten Fällen.
> **Do:** Nach der Landung Status 4 drücken.

View File

@@ -0,0 +1,63 @@
# BOS-Funk Grundlagen
Die Kommunikation zwischen Pilot und Flugverkehrskontrolle beschränkt sich auf die fliegerischen Informationen; die Koordination der Einsätze erflogt jedoch über eine weitere Instanz: Die Leitstelle.
Die ist die Koordinatorin aller Einsätze in einem definierten Gebiet und sorgt dort für die effektive Bereitstellung von Rettungsmitteln.
Deren Kommunikation läuft größtenteils über Funk ab, der sich jedoch maßgeblich vom Pilotenfunk unterscheidet - dem BOS-Funk.
**BOS** steht für "Behörden und Organistationen mit Sicherheitsaufgaben", dazu zählen Feuerwehr, Rettungsdienst (inkl. Luftrettung), Polizei, aber auch das Technische Hilfswerk THW und viele andere.
Während in Deutschland lange der analoge UKW BOS-Funk verbreitet war, befasst sich dieser Artikel vorerst mit dem nach und nach einheitlichen digitalen TETRA-BOS Funk.
Für den BOS-Funk in Deutschland verantwortlich ist die in 2007 gegründete [**Bundesanstalt für den Digitalfunk der Behörden und Organisationen mit Sicherheitsaufgaben**](https://www.bdbos.bund.de/DE/Home/home_node.html) - kurz BDBOS.
Sie gibt an, dass bereits 99,2 % der Fläche Deutschlands einsatzbereit für den Digitalfunk sind.
## Funktion
Digitalfunkgeräte der BOS in Deutschland werden mit einer sogenannten BSI-Sicherheitskarte ausgestattet. Sie ist ähnlich einer SIM-Karte und berechtigt das Funkgerät zum Zugriff auf das bereitgestellte Digitalfunknetz.
Diese Karte wird nur an berechtigte Teilnehmer ausgegeben und schützt somit vor einem (im Analogfunk verbreiteten) Abhören von Funkgesprächen.
Der Funkverkehr findet im Kontrast zu "Frequenzen" im Luftverkehr oder "Kanälen" im Analogfunk in sogenannten "Rufgruppen" statt und wird digital abhörsicher verschlüsselt.
## Betriebsarten
Im TETRA-BOS Funk gibt es zwei sogenannte Betriebsarten - sie geben im groben an, wie das Empfänger-Endgerät erreicht wird.
### DMO Betrieb
**DMO** steht für **D**irect **M**ode **O**peration, also den _Direktbetrieb_. Vereinfacht gesehen kommuniziert ein Funkgerät mit anderen Endgeräten in der Umgebung, wobei sich Sender und Empfänger in einem bestimmten Radius befinden müssen - die Verbindung wird direkt (**direct**) zwischen den Funkgeräten aufgebaut.
Dadurch ist der DMO-Betrieb anfällig für bestimmte Störfaktoren wie abschirmende Metalle, Gebäude, Berge und Täler etc.
Er ist außerdem **reichweitenbegrenzt**.
So kann es passieren, dass man Teilnehmer in der aktuellen Rufgruppe auf Funksprüche antworten hören kann, welche man aufgrund der eigenen Reichweite selbst nicht hören konnte.
![DMO](assets/DMO.png)
Im Beispiel kann Funkgerät 2 an beide anderen Funkgeräte senden und Nachrichten von ihnen Empfangen. 1 und 3 können zwar auf beide Wege mit Funkgerät 2 kommunizieren, jedoch nicht miteinander, da die Entfernung zwischen ihnen zu groß ist.
:::tip Eselsbrücke
**DMO** ist der **D**orf**mo**dus - kurze Reichweite, aber für eine lokale Einsatzkoordination komplett ausreichend.
:::
### TMO Betrieb
**TMO** steht für **T**runked **M**ode **O**peration, den sogenannten _Netzbetrieb_.
Hier wird eine Verbindung zwischen dem Funkgerät und einer der deutschlandweit verteilten TETRA-Antennen hergestellt, welche den Funkspruch innerhalb der Rufgruppe an weitere Antennen und final an die entsprechenden Empfänger "zustellt". So ist der TMO Betrieb weitesgehend reichweitenunbegrenzt, aber immer noch Abhängig von bekannten Störfaktoren und der TETRA-Netzabdeckung.
Ein Beispiel hier ist der klassische Leistellenfunk. Vor allem in großen Funkverkehrsbereichen ist die DMO-Reichweite selbst bei optimalen Bedingungen nicht ausreichend, um alle Teilnehmer zuverlässig zu erreichen.
In einer TMO Leitstellen-Rufgruppe wird die Reichweite erhöht und alle Teilnehmer im Funkverkehrsbereich können Gespräche mithöhren und an ihnen teilnehmen.
![TMO](assets/TMO.png)
Im Beispiel können alle drei Funkgeräte untereinander über eine erhöhte Reichweite kommunizieren. Dabei können auch mehrere TETRA-Masten zwischengeschaltet oder ein Direktruf zwischen zwei einzelnen Funkgeräten aufgebaut werden.
## Sonstiges
Mit Handfunkgeräten können auch sogenannte "Repeater" realisiert werden. Gesonderte Geräte werden taktisch platziert und wiederholen (engl. "to repeat") das Signal innerhalb einer DMO-Rufgruppe, um die Reichweite zu erhöhen oder innerhalb von Objekten eine bessere Abdeckung zu gewähleisten.
Auch ist ein sogenannter "Gateway-Betrieb" möglich. Ein Handfunkgerät kommuniziert im DMO mit einem Fahrzeugfunkgerät, welches auf eine TMO-Rufgruppe eingestellt ist und den DMO- zu einem TMO-Ruf macht.
Notrufe, die von einem Funkgerät ausgelöst wurden, haben in der Rufgruppe immer eine Sprechpriorität und unterbrechen bis zur Auflösung des Notrufs und nach einer bestimmten Zeit jeglichen anderen Funkverkehr.
## Besondere Rufgruppen
Um einen geordneten Einsatz- und Leitstellenfunk gewährleisten zu können, gibt es diverse Rufgruppen im DMO- und TMO-Betrieb. Diese sind meist spezifisch auf eine BOS ausgelegt, so gibt es etwa TMO- und DMO-Rufgruppen für den Rettungsdienst, die Feuerwehr oder die Polizei. Da die BSI-Sicherheitskarte bzw. der zuständige Administrator den Zugriff auf die jeweiligen Rufgruppen ggf. untersagt, existieren sogenannte **TBZ**-Rufgruppen. Sie dienen der **t**echnisch-**b**etrieblichen-**Z**usammenarbeit und kommen zum Einsatz, wenn bspw. ein Hubschrauber in Absprache mit der örtlichen Feuerwehr einen Landeplatz ausfindig macht, oder z.B. mit DLRG, Polizei, Feuerwehr vermisste Personen in Gewässern sucht.
Außerdem verfügen die meisten Leitstellen über gesonderte und standardisierte Fremdrufgruppen, über die Fahrzeuge aus fremden Funkverkehrsbereichen Erstkontakt mit der jeweiligen Leistelle aufnehmen kann.

View File

@@ -0,0 +1,88 @@
# OPTA
Die OPTA, oder lang: Die **Op**erativ-**T**ktische **A**dresse sorgt bei korrekter Nutzung für eine verwechslungsfreie Zuordnung von Funksprüchen zu genau einem Fahrzeug. Sie wird oft auch als "Funkkenner" bezeichnet, ist für jedes BOS-Fahrzeug einzigartig und setzt sich aus sechs Bestandteilen zusammen.
:::danger Achtung
Wie so vieles im föderalistisch organisiertem Rettungsdienst unterscheiden sich OPTA von Bundesland zu Bundesland oder sogar von Landkreis zu Landkreis; hier wird eins der geläufigsten OPTA-Schemen erläutert. Gegebenenfalls wird der Artikel erweitert.
:::
OPTA tangieren die Regelluftrettung direkt nur marginal, da Rettungshubschrauber die bundeseinheitliche OPTA **Christoph XX** tragen, wobei **XX** meist die standortspezifische Kennzahl des Hubschraubers ist.
Um ein gewisses Grundverständnis von den Vorgängen im BOS-Funk zu erlangen, kann es dennoch hilfreich sein, sich mit dem Konzept OPTA vertraut zu machen.
## Aufgbau einer OPTA
Ein gutes Beispiel hierfür ist der [NEH Kessin](https://www.rth.info/stationen.db/station.php?id=90).
Dessen OPTA ist
| **Kennwort** | **Funkverkehrskreis** | **Gemeindekennzahl** | **Teilkennzahl 1** | **Teilkennzahl 2** | **Teilkennzahl 3** |
| :----------: | :-------------------: | :------------------: | :----------------: | :----------------: | :----------------: |
| Rettung | Landkreis Rostock | 029 | 01 | 82 | 01 |
#### Organisationskennwort
Das vorangestellte Kennwort lässt auf die zugeordnete Hilfsorganisation schließen.
**Rettung** steht immer für private Hilfsorganisationen; hier die Ambulanz Millich.
#### Funkverkehrskreis
Das folgende Kennwort wird zur Identifikation eines Fahrzeugs außerhalb des eigenen Funkverkehrsbereichs genutzt.
Innerhalb dieses Bereichs wird es nicht mit genannt.
#### Gemeindekennzahl
Sie gibt den Herkunftsort des Fahrzeugs an. Im Beispiel steht "029" im Landkreis Rostock für die Gemeinde Dummerstorf.
#### TKZ 1 - Standortkennzahl
Aufsteigende Zahl zur Unterscheidung mehrerer Standorte derselben Gemeindekennzahl.
#### TKZ 2 - Typkennzahl
Gibt die Art des Fahrzeugs an. 82 steht im Beispiel für NEF bzw. NEH
#### TKZ 3 - Fahrzeugkennzahl
Unterscheidet mehrere Fahrzeuge desselben Typs.
:::info
Eine "0" wird in der OPTA nicht mitgesprochen.
:::
## Übersichten wichtiger Teile einer OPTA
### Organisationskennworte
| **Organistaion** | **Kennwort** |
| :-------------------------------: | :----------: |
| Feuerwehr | Florian |
| Deutsches Rotes Kreuz | Rotkreuz |
| Johanniter-Unfall-Hilfe | Akkon |
| Malteser Hilfsdienst | Johannes |
| Arbeiter-Samariter-Bund | Sama |
| DLRG | Pelikan |
| DGzRS | Triton |
| Katastrophenschutz | Kater |
| Kommunale/Private Rettungsdienste | Rettung |
| THW | Heros |
| Rettungshubschrauber | Christoph |
### Typenkennzahlen (des Rettungsdienstes)
| **Fahrzeug** | **TKZ** |
| :------------------------------: | :-----: |
| NAW oder ITW | 81 |
| NEF | 82 |
| RTW oder MZF | 83 |
| NKTW, teilweise auch RTH | 84 |
| KTW | 85 |
| KatS RTW (nicht ständig besetzt) | 86 |
| KatS KTW | 87 |
:::info
Diese TKZ unterscheiden sich und sind in Hessen und Bayern teilweise anders zugeordnet.
:::
## ISSI
Die **I**ndividual **S**hort **S**ubscriber **I**dentity ist eine einzigartige siebenstellige Nummer, die ein TETRA-Endgerät eindeutig kennzeichnet. Einige BOS-Fahrzeuge schreiben teilweise die ISSI des Fahrzeugfunkgerätes auf ihr Dach, da durch sie ein direktes Gespräch mit dem jeweiligen Funkgerät aufgebaut werden kann.

View File

@@ -0,0 +1,53 @@
# Status
Der _Status_ eines Rettungsmittels wurde lange per FMS ("Funkmeldesystem") übertragen. Seit der Einführung des Digitalfunks weicht das sogenannte "tonfrequente Übertragungssystem" zum Senden von Statusmeldungen dem SDS (**S**hort **D**ata **S**ervice). Dieses System im Digitalfunk kann ähnliche Funktionen wie das alte FMS im Analogfunk abdecken. Es dient dem Austausch von Kurznachrichten, ähnlich einer SMS.
SDS ermöglicht die Übertragung von End-To-End verschlüsselten Alarmierungen, GPS-Positionsdaten oder Textnachrichten. Grundlegend besteht eine Statusnachricht aus einer fünfstelligen Zahl, für die in den TETRA-Endgeräten ein Statustext hinterlegt ist.
Darüber kann auch das Senden von den bekannten zahlengebundenen Statusmeldungen realisiert werden.
Der Vorteil dieser Statusnummern ist die deutliche Reduktion des Funkverkehrs innerhalb einer Leitstellenrufgruppe.
## Statusmeldungen
| **Status** | **Bedeutung** | **Details** |
| :--------: | :------------------------: | :------------------------------------------------------------------------------------------------ |
| 0 | Priorisierter Sprechwunsch | Das Einsatzmittel möchte vor allen anderen Kontakt mit der Leitstelle aufnehmen. |
| 1 | Einsatzbereit über Funk | Das Einsatzmittel kann nach Rücksprache und abhängig vom Standort alarmiert werden. |
| 2 | Einsatzbereit am Standort | Das Einsatzmittel kann am Heimatstandort alarmiert werden. |
| 3 | Einsatz übernommen | Das Einsatzmittel hat den Auftrag angenommen und befindet sich auf Anfahrt. |
| 4 | Ankunft am Einsatzort | Das Einsatzmittel ist mit der Abarbeitung vor Ort beschäftigt und nur bedingt erreichbar. |
| 5 | Sprechwunsch | Das Einsatzmittel möchte Kontakt mit der Leitstelle aufnehmen. |
| 6 | Nicht einsatzbereit | Das Einsatzmittel kann nicht alarmiert werden. |
| 7 | Patient aufgenommen | Das Einsatzmittel hat einen Patienten aufgenommen und kann nicht alarmiert werden. |
| 8 | Ankunft am Zielort | Das Einsatzmittel kann mit einer längeren Reaktionszeit nach Rücksprache alarmiert werden. |
| 9 | Fahrzeuganmeldung | Das Einsatzmittel meldet sich im Funkverkehrsbereich an. Bei der VAR: Meldung nach dem Einloggen. |
## Statusanweisungen
| **Status** | **Bedeutung** | **Details** |
| :--------: | :-------------------------------: | :---------------------------------------------------------------------------------------------- |
| E | Einsatzabbruch | Das Einsatzmittel wird vom Einsatzabgezogen und quittiert mit `2` oder `1`. |
| C | Einsatzübernahme melden | Dem Disponenten muss mit `3` die Übernahme des Einsatzes quittiert werden. |
| F | Kommen Sie über Draht | Das Einsatzmittel muss sich telefonisch (per Discord) beim Disponenten melden. |
| H | Fahren Sie Wache an | Keine Nutzung in der VAR |
| J | Sprechaufforderung (nach `5`/`0`) | Nicht-mündliche Aufforderung, mit dem Sprechen zu beginnen. |
| L | Geben Sie Lagemeldung | Die Leitstelle fordert eine Lagemeldung vom Rettungsmittel an. |
| P | Einsatz mit Polizei/Pause nehmen | Keine Nutzung in der VAR |
| U | Unerlaubte Statusfolge | Keine Nutzung in der VAR |
| c | Status korrigieren | Das Einsatzmittel hat einen offensichtlich falschen Status gesetzt und muss diesen korrigieren. |
| d | Transportziel durchgeben | Die Leitstelle erfragt das Transportziel des Rettungsmittels. |
| h | Zielklinik verständigt | Die Leitstelle hat die Zielklinik verständigt und positive Rückmeldung erhalten. |
| o | Warten, alle Abfrageplätze belegt | Das Rettungsmittel muss auf die Sprechaufforderung warten. |
| u | Verstanden | Die Leitstelle hat die Informationen aufgenommen und verstanden. |
:::info Status 5
In vielen Leitstellen kann ein Gespräch nur über das Senden des Status 5 initiiert werden, da der Disponent den Funk innerhalb seiner Rufgruppe nicht ständig mithört. Auch bei uns wird der Sprechwunsch mittels Status 5 von vielen Disponenten erwartet und immer vor mündlichen Sprechwünschen priorisiert.
:::
:::info Status 9
Die vergangenen Monate haben gezeigt, dass die Implementierung eines Status 9 sinnvoll ist. In Realität ist das in vielen Leitstellenbereichen die Anmeldung in einer fremden Rufgruppe (analog zu Status 5).
VAR-intern kann der Status 9 dann genutzt werden, wenn man sich eingeloggt hat und dem Disponenten diesen Umstand mitteilen möchte. Die Reaktion darauf wird dann `u` sein. Somit wird die Rufgruppe frei von
"Funksprechproben" oder versteckten Hinweisen auf die Einsatzbereitschaft eines Einsatzmittels gehalten. Solltet ihr eure Audio-Einstellungen überprüfen wollen, tut das bitte in den Einstellungen selbst. Das indirekte Betteln nach schnellen Einsätzen mit wiederholtem Drücken von Status 9
ist nicht erwünscht und wird entsprechend geahndet. Sollte das Rettungsmittel durch Witterung und Zeit nur beschränkt bzw. unter Auflagen alarmierbar sein, ist das nach wie vor mit Status 5 mitzuteilen.
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

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