Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f22d48e83 | ||
|
|
26e7966e19 | ||
|
|
580f480ec8 | ||
|
|
145b9edfe7 | ||
|
|
26430c00ea | ||
|
|
b3070ac290 | ||
|
|
b6759e0b6c | ||
|
|
a5c4a1dc7c | ||
|
|
08c4cfe082 | ||
|
|
3c5b86eb41 | ||
|
|
792c4d5cb5 | ||
|
|
6bad77ec39 | ||
|
|
ee34876c09 | ||
|
|
0db96a0e2a | ||
|
|
ca299f52e5 | ||
|
|
41e38f0f72 | ||
|
|
4ce00c2daa | ||
|
|
1174c95348 | ||
|
|
4c6a009764 | ||
|
|
0c80c046e1 | ||
|
|
0b0f4fac2f | ||
|
|
115296d7f7 | ||
|
|
445dc13829 | ||
|
|
2ef98363b9 | ||
|
|
ae4bd6c2ff | ||
|
|
2af5325c6b | ||
|
|
b1dcaee565 | ||
|
|
e9c1cf0c94 | ||
|
|
640fca6fdd | ||
|
|
7cbada7e04 | ||
|
|
940d62fdd5 | ||
|
|
34a232024e | ||
|
|
d003b2cf12 | ||
|
|
644fee3e29 | ||
|
|
4372c8efd1 | ||
|
|
d2a865c955 | ||
|
|
57cde6ff97 | ||
|
|
ce51fa6c23 | ||
|
|
f12db76f48 | ||
|
|
d122136e74 | ||
|
|
dcb162bf1c | ||
|
|
33ec5574f2 | ||
|
|
414e238216 | ||
|
|
8c6057fe6a | ||
|
|
1d1bb713d2 | ||
|
|
70736b847b | ||
|
|
25769f551a | ||
|
|
64c895d229 | ||
|
|
e833ae0090 | ||
|
|
a905872f25 | ||
|
|
228feb0512 | ||
|
|
a5998fbe0f | ||
|
|
55f570b648 | ||
|
|
92e550736b | ||
|
|
ad1dfe9802 | ||
|
|
6fbb8c49a8 | ||
|
|
9eaf3a06ed | ||
|
|
68e26b18b2 | ||
|
|
c10d7ef91a | ||
|
|
41a3086d82 | ||
|
|
adc11ec647 | ||
|
|
15012820ab | ||
|
|
23c0d601eb | ||
|
|
3e67e2ed27 | ||
|
|
7c9ba86110 | ||
|
|
b0facf0941 | ||
|
|
93e8eaba73 | ||
|
|
a755e45697 | ||
|
|
85fdfb3bb1 | ||
|
|
616d3d3a61 | ||
|
|
97012f1b6f | ||
|
|
4961822599 | ||
|
|
df7f1b8cd1 | ||
|
|
c75803a87d | ||
|
|
11b1d8745d | ||
|
|
44427a1b4b | ||
|
|
409d5c79e6 | ||
|
|
df2b7791a6 | ||
|
|
7be0c701a4 | ||
|
|
66c32530a7 | ||
|
|
ffd8bd2e31 | ||
|
|
cfff712f8b | ||
|
|
ca5e8a87df | ||
|
|
741f42956a | ||
|
|
65976f0072 | ||
|
|
611aa4d053 | ||
|
|
9c41e2f6b9 | ||
|
|
f69fa37b2a | ||
|
|
959ae37213 | ||
|
|
844cfa4b56 | ||
|
|
fc698b22d7 | ||
|
|
46fdd2e0c2 | ||
|
|
515ff6d6c3 | ||
|
|
7be21a738a | ||
|
|
d7ca0eb166 | ||
|
|
1b7fedb0c8 | ||
|
|
127ae8720e | ||
|
|
a8ca0c0cc9 | ||
|
|
5c334cde63 | ||
|
|
4c7503781a | ||
|
|
983633652f | ||
|
|
9f8ebd0dbb | ||
|
|
7ff7ca556f | ||
|
|
a3525f81a6 | ||
|
|
1cb2ddc5bb | ||
|
|
76622a9ea1 | ||
|
|
879e9d1e89 | ||
|
|
d1c304c4de | ||
|
|
90e05dc478 | ||
|
|
a144b9efcd | ||
|
|
f721c27964 | ||
|
|
1191bb4c27 | ||
|
|
768c84f171 | ||
|
|
0730737bbe | ||
|
|
cea632c47a | ||
|
|
01bef65218 | ||
|
|
a11c8683c4 | ||
|
|
280393b307 | ||
|
|
d2b287abdc | ||
|
|
bd40c9f817 | ||
|
|
446391679c | ||
|
|
879c422366 | ||
|
|
8e71571da9 | ||
|
|
b9eef5252e | ||
|
|
3b1ceb8f8c | ||
|
|
6279732423 | ||
|
|
e3b475240d | ||
|
|
eb98971e8a |
10
.github/workflows/deploy-production.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Pull latest code
|
- name: Pull latest code
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ vars.STAGING_HOST }}
|
host: ${{ vars.PRODUCTION_HOST }}
|
||||||
username: ${{ secrets.SSH_USERNAME }}
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
password: ${{ secrets.SSH_PASSWORD }}
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
port: 22
|
port: 22
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Deploy migration to Database
|
- name: Deploy migration to Database
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ vars.STAGING_HOST }}
|
host: ${{ vars.PRODUCTION_HOST }}
|
||||||
username: ${{ secrets.SSH_USERNAME }}
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
password: ${{ secrets.SSH_PASSWORD }}
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
port: 22
|
port: 22
|
||||||
@@ -34,11 +34,11 @@ jobs:
|
|||||||
export NVM_DIR="$HOME/.nvm"
|
export NVM_DIR="$HOME/.nvm"
|
||||||
source "$NVM_DIR/nvm.sh"
|
source "$NVM_DIR/nvm.sh"
|
||||||
cd ~/docker/var-monorepo/packages/database
|
cd ~/docker/var-monorepo/packages/database
|
||||||
pnpm exec prisma migrate deploy
|
pnpm run deploy
|
||||||
- name: Build and start containers
|
- name: Build and start containers
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ vars.STAGING_HOST }}
|
host: ${{ vars.PRODUCTION_HOST }}
|
||||||
username: ${{ secrets.SSH_USERNAME }}
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
password: ${{ secrets.SSH_PASSWORD }}
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
port: 22
|
port: 22
|
||||||
@@ -46,4 +46,4 @@ jobs:
|
|||||||
export NVM_DIR="$HOME/.nvm"
|
export NVM_DIR="$HOME/.nvm"
|
||||||
source "$NVM_DIR/nvm.sh"
|
source "$NVM_DIR/nvm.sh"
|
||||||
cd ~/docker/var-monorepo
|
cd ~/docker/var-monorepo
|
||||||
pnpm staging-start
|
pnpm prod-start
|
||||||
|
|||||||
1
.github/workflows/deploy-staging.yml
vendored
@@ -40,6 +40,7 @@ jobs:
|
|||||||
username: ${{ secrets.SSH_USERNAME }}
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
password: ${{ secrets.SSH_PASSWORD }}
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
port: 22
|
port: 22
|
||||||
|
command_timeout: 30m
|
||||||
script: |
|
script: |
|
||||||
export NVM_DIR="$HOME/.nvm"
|
export NVM_DIR="$HOME/.nvm"
|
||||||
source "$NVM_DIR/nvm.sh"
|
source "$NVM_DIR/nvm.sh"
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"singleQuote": false
|
"singleQuote": false,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
DISCORD_SERVER_PORT=3005
|
DISCORD_SERVER_PORT=3005
|
||||||
DISCORD_GUILD_ID=1077269395019141140
|
DISCORD_GUILD_ID=1077269395019141140
|
||||||
DISCORD_OAUTH_CLIENT_ID=930384053344034846
|
DISCORD_OAUTH_CLIENT_ID=
|
||||||
DISCORD_OAUTH_SECRET=96aSvmIePqFTbGc54mad0QsZfDnYwhl1
|
DISCORD_OAUTH_SECRET=
|
||||||
DISCORD_BOT_TOKEN=OTMwMzg0MDUzMzQ0MDM0ODQ2.G7zIy-._hE3dTbtUv6sd7nIP2PUn3d8s-2MFk0x3nYMg8
|
DISCORD_BOT_TOKEN=
|
||||||
DISCORD_REDIRECT_URL=https://hub.premiumag.de/api/discord-redirect
|
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
|
NEXT_PUBLIC_DISCORD_URL=
|
||||||
@@ -3,10 +3,19 @@ import express from "express";
|
|||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import router from "routes/router";
|
import router from "routes/router";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import { createAdapter } from "@socket.io/redis-adapter";
|
||||||
|
import { pubClient, subClient } from "modules/redis";
|
||||||
|
import "modules/chron";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
|
export const io = new Server(server, {
|
||||||
|
adapter: createAdapter(pubClient, subClient),
|
||||||
|
cors: {},
|
||||||
|
});
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|||||||
139
apps/core-server/modules/chron.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { MissionLog, NotificationPayload, prisma } from "@repo/db";
|
||||||
|
import { io } from "index";
|
||||||
|
import cron from "node-cron";
|
||||||
|
|
||||||
|
const removeMission = async (id: number, reason: string) => {
|
||||||
|
const log: MissionLog = {
|
||||||
|
type: "completed-log",
|
||||||
|
auto: true,
|
||||||
|
timeStamp: new Date().toISOString(),
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedMission = await prisma.mission.update({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
state: "finished",
|
||||||
|
missionLog: {
|
||||||
|
push: log as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
io.to("dispatchers").emit("new-mission", { updatedMission });
|
||||||
|
io.to("dispatchers").emit("notification", {
|
||||||
|
type: "mission-auto-close",
|
||||||
|
status: "chron",
|
||||||
|
message: `Einsatz ${updatedMission.publicId} wurde aufgrund ${reason} geschlossen.`,
|
||||||
|
data: {
|
||||||
|
missionId: updatedMission.id,
|
||||||
|
publicMissionId: updatedMission.publicId,
|
||||||
|
},
|
||||||
|
} as NotificationPayload);
|
||||||
|
|
||||||
|
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeClosedMissions = async () => {
|
||||||
|
const oldMissions = await prisma.mission.findMany({
|
||||||
|
where: {
|
||||||
|
state: "running",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
oldMissions.forEach(async (mission) => {
|
||||||
|
const lastAlert = (mission.missionLog as unknown as MissionLog[]).find((l) => {
|
||||||
|
return l.type === "alert-log";
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastAlertTime = lastAlert ? new Date(lastAlert.timeStamp) : null;
|
||||||
|
|
||||||
|
const allStationsInMissionChangedFromStatus4to1Or8to1 = mission.missionStationIds.every(
|
||||||
|
(stationId) => {
|
||||||
|
const status4Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
|
||||||
|
return (
|
||||||
|
l.type === "station-log" &&
|
||||||
|
l.data?.stationId === stationId &&
|
||||||
|
l.data?.newFMSstatus === "4"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const status8Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
|
||||||
|
return (
|
||||||
|
l.type === "station-log" &&
|
||||||
|
l.data?.stationId === stationId &&
|
||||||
|
l.data?.newFMSstatus === "8"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const status1Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
|
||||||
|
return (
|
||||||
|
l.type === "station-log" &&
|
||||||
|
l.data?.stationId === stationId &&
|
||||||
|
l.data?.newFMSstatus === "1"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const status6Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
|
||||||
|
return (
|
||||||
|
l.type === "station-log" &&
|
||||||
|
l.data?.stationId === stationId &&
|
||||||
|
l.data?.newFMSstatus === "6"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
(status4Log !== -1 || status8Log !== -1) &&
|
||||||
|
(status1Log !== -1 || status6Log !== -1) &&
|
||||||
|
(status4Log < status1Log ||
|
||||||
|
status8Log < status1Log ||
|
||||||
|
status8Log < status6Log ||
|
||||||
|
status1Log < status6Log)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const missionHasManualReactivation = (mission.missionLog as unknown as MissionLog[]).some(
|
||||||
|
(l) => l.type === "reopened-log",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missionHasManualReactivation) return;
|
||||||
|
|
||||||
|
if (!lastAlertTime) return;
|
||||||
|
|
||||||
|
// Case 1: Forgotten Mission, last alert more than 3 Hours ago
|
||||||
|
const now = new Date();
|
||||||
|
if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180)
|
||||||
|
return removeMission(mission.id, "inaktivität");
|
||||||
|
|
||||||
|
// Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6
|
||||||
|
if (allStationsInMissionChangedFromStatus4to1Or8to1)
|
||||||
|
return removeMission(mission.id, "dem freimelden aller Stationen");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeConnectedAircrafts = async () => {
|
||||||
|
const connectedAircrafts = await prisma.connectedAircraft.findMany({
|
||||||
|
where: {
|
||||||
|
logoutTime: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
connectedAircrafts.forEach(async (aircraft) => {
|
||||||
|
const lastUpdate = new Date(aircraft.lastHeartbeat);
|
||||||
|
const now = new Date();
|
||||||
|
if (now.getTime() - lastUpdate.getTime() > 12 * 60 * 60 * 1000) {
|
||||||
|
await prisma.connectedAircraft.update({
|
||||||
|
where: { id: aircraft.id },
|
||||||
|
data: { logoutTime: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Aircraft ${aircraft.id} disconnected due to inactivity.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
cron.schedule("*/1 * * * *", async () => {
|
||||||
|
try {
|
||||||
|
await removeClosedMissions();
|
||||||
|
await removeConnectedAircrafts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error on cron job:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
13
apps/core-server/modules/redis.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createClient, RedisClientType } from "redis";
|
||||||
|
|
||||||
|
export const pubClient: RedisClientType = createClient({
|
||||||
|
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
|
||||||
|
});
|
||||||
|
export const subClient: RedisClientType = pubClient.duplicate();
|
||||||
|
|
||||||
|
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
|
||||||
|
console.log("Redis connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
pubClient.on("error", (err: unknown) => console.log("Redis Client Error", err));
|
||||||
|
subClient.on("error", (err: unknown) => console.log("Redis Client Error", err));
|
||||||
@@ -8,28 +8,30 @@
|
|||||||
"start": "tsx index.ts --transpile-only",
|
"start": "tsx index.ts --transpile-only",
|
||||||
"build": "tsc"
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0",
|
"packageManager": "pnpm@10.13.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/db": "workspace:*",
|
"@repo/db": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/cors": "^2.8.18",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.2",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.2.0",
|
||||||
"typescript": "latest"
|
"typescript": "latest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.9.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cron": "^4.3.1",
|
"cron": "^4.3.2",
|
||||||
"discord.js": "^14.19.3",
|
"discord.js": "^14.21.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^17.2.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"node-cron": "^4.1.0",
|
"node-cron": "^4.2.1",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"tsx": "^4.19.4"
|
"redis": "^5.6.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"tsx": "^4.20.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ router.post("/set-standard-name", async (req, res) => {
|
|||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`Setting standard name for user ${userId} (${user?.publicId}) to member ${memberId}`);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ error: "User not found" });
|
res.status(404).json({ error: "User not found" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { handleConnectDesktop } from "socket-events/connect-desktop";
|
|||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import { authMiddleware } from "modules/expressMiddleware";
|
import { authMiddleware } from "modules/expressMiddleware";
|
||||||
import "modules/chron";
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { MissionLog, prisma } from "@repo/db";
|
|
||||||
import cron from "node-cron";
|
|
||||||
|
|
||||||
const removeClosedMissions = async () => {
|
|
||||||
const oldMissions = await prisma.mission.findMany({
|
|
||||||
where: {
|
|
||||||
state: "running",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
oldMissions.forEach(async (mission) => {
|
|
||||||
const lastAlert = (mission.missionLog as unknown as MissionLog[]).find((l) => {
|
|
||||||
return l.type === "alert-log";
|
|
||||||
});
|
|
||||||
const lastAlertTime = lastAlert ? new Date(lastAlert.timeStamp) : null;
|
|
||||||
|
|
||||||
const aircraftsInMission = await prisma.connectedAircraft.findMany({
|
|
||||||
where: {
|
|
||||||
stationId: {
|
|
||||||
in: mission.missionStationIds,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!aircraftsInMission ||
|
|
||||||
!aircraftsInMission.some((a) => ["1", "2", "6"].includes(a.fmsStatus))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
if (!lastAlertTime) return;
|
|
||||||
// change State to closed if last alert was more than 180 minutes ago
|
|
||||||
if (now.getTime() - lastAlertTime.getTime() < 30 * 60 * 1000) return;
|
|
||||||
const log: MissionLog = {
|
|
||||||
type: "completed-log",
|
|
||||||
auto: true,
|
|
||||||
timeStamp: new Date().toISOString(),
|
|
||||||
data: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
await prisma.mission.update({
|
|
||||||
where: {
|
|
||||||
id: mission.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
state: "finished",
|
|
||||||
missionLog: {
|
|
||||||
push: log as any,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`Mission ${mission.id} closed due to inactivity.`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const removeConnectedAircrafts = async () => {
|
|
||||||
const connectedAircrafts = await prisma.connectedAircraft.findMany({
|
|
||||||
where: {
|
|
||||||
logoutTime: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
connectedAircrafts.forEach(async (aircraft) => {
|
|
||||||
const lastUpdate = new Date(aircraft.lastHeartbeat);
|
|
||||||
const now = new Date();
|
|
||||||
if (now.getTime() - lastUpdate.getTime() > 12 * 60 * 60 * 1000) {
|
|
||||||
await prisma.connectedAircraft.update({
|
|
||||||
where: { id: aircraft.id },
|
|
||||||
data: { logoutTime: now },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Aircraft ${aircraft.id} disconnected due to inactivity.`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
cron.schedule("*/5 * * * *", async () => {
|
|
||||||
try {
|
|
||||||
await removeClosedMissions();
|
|
||||||
await removeConnectedAircrafts();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error removing closed missions:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -7,12 +7,6 @@ export const subClient: RedisClientType = pubClient.duplicate();
|
|||||||
|
|
||||||
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
|
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
|
||||||
console.log("Redis connected");
|
console.log("Redis connected");
|
||||||
pubClient.keys("dispatchers*").then((keys) => {
|
|
||||||
if (!keys) return;
|
|
||||||
keys.forEach(async (key) => {
|
|
||||||
await pubClient.json.del(key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pubClient.on("error", (err) => console.log("Redis Client Error", err));
|
pubClient.on("error", (err) => console.log("Redis Client Error", err));
|
||||||
|
|||||||
@@ -8,39 +8,39 @@
|
|||||||
"start": "tsx index.ts --transpile-only",
|
"start": "tsx index.ts --transpile-only",
|
||||||
"build": "tsc"
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0",
|
"packageManager": "pnpm@10.13.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/db": "workspace:*",
|
"@repo/db": "workspace:*",
|
||||||
"@repo/shared-components": "workspace:*",
|
"@repo/shared-components": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/cookie-parser": "^1.4.8",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/cors": "^2.8.18",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.2",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.2.0",
|
||||||
"typescript": "latest"
|
"typescript": "latest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.0.41",
|
"@react-email/components": "^0.3.2",
|
||||||
"@redis/json": "^5.1.1",
|
"@redis/json": "^5.6.0",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.10.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cron": "^4.3.1",
|
"cron": "^4.3.2",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^17.2.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"livekit-server-sdk": "^2.13.0",
|
"livekit-server-sdk": "^2.13.1",
|
||||||
"node-cron": "^4.1.0",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.5",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"redis": "^5.1.1",
|
"redis": "^5.6.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"tsx": "^4.19.4"
|
"tsx": "^4.20.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ router.patch("/:id", async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
stationId: updatedConnectedAircraft.stationId,
|
stationId: updatedConnectedAircraft.stationId,
|
||||||
aircraftId: updatedConnectedAircraft.id,
|
aircraftId: updatedConnectedAircraft.id,
|
||||||
|
userId: updatedConnectedAircraft.userId,
|
||||||
},
|
},
|
||||||
} as NotificationPayload);
|
} as NotificationPayload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ router.patch("/:id", async (req, res) => {
|
|||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: req.body,
|
data: req.body,
|
||||||
});
|
});
|
||||||
io.to("dispatchers").emit("update-mission", updatedMission);
|
io.to("dispatchers").emit("update-mission", { updatedMission });
|
||||||
res.json(updatedMission);
|
res.json(updatedMission);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -241,9 +241,6 @@ router.post("/:id/hpg-validation-result", async (req, res) => {
|
|||||||
const newMission = await prisma.mission.update({
|
const newMission = await prisma.mission.update({
|
||||||
where: { id: Number(missionId) },
|
where: { id: Number(missionId) },
|
||||||
data: {
|
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,
|
hpgLocationLat: result.lat,
|
||||||
hpgLocationLng: result.lng,
|
hpgLocationLng: result.lng,
|
||||||
hpgValidationState: result.state,
|
hpgValidationState: result.state,
|
||||||
@@ -261,9 +258,7 @@ router.post("/:id/hpg-validation-result", async (req, res) => {
|
|||||||
},
|
},
|
||||||
} as NotificationPayload);
|
} as NotificationPayload);
|
||||||
|
|
||||||
console.log("Got positiv validation Result", result.alertWhenValid);
|
|
||||||
if (result.alertWhenValid) {
|
if (result.alertWhenValid) {
|
||||||
console.log(req.user);
|
|
||||||
sendAlert(Number(missionId), {}, "HPG");
|
sendAlert(Number(missionId), {}, "HPG");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import { Server, Socket } from "socket.io";
|
|||||||
|
|
||||||
export const handleConnectDispatch =
|
export const handleConnectDispatch =
|
||||||
(socket: Socket, io: Server) =>
|
(socket: Socket, io: Server) =>
|
||||||
async ({ logoffTime, selectedZone }: { logoffTime: string; selectedZone: string }) => {
|
async ({
|
||||||
|
logoffTime,
|
||||||
|
selectedZone,
|
||||||
|
ghostMode,
|
||||||
|
}: {
|
||||||
|
logoffTime: string;
|
||||||
|
selectedZone: string;
|
||||||
|
ghostMode: boolean;
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
const user: User = socket.data.user; // User ID aus dem JWT-Token
|
const user: User = socket.data.user; // User ID aus dem JWT-Token
|
||||||
|
|
||||||
@@ -45,17 +53,15 @@ export const handleConnectDispatch =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
|
|
||||||
|
|
||||||
const connectedDispatcherEntry = await prisma.connectedDispatcher.create({
|
const connectedDispatcherEntry = await prisma.connectedDispatcher.create({
|
||||||
data: {
|
data: {
|
||||||
publicUser: getPublicUser(user) as any,
|
publicUser: getPublicUser(user) as any,
|
||||||
esimatedLogoutTime:
|
esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
|
||||||
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
|
|
||||||
lastHeartbeat: new Date().toISOString(),
|
lastHeartbeat: new Date().toISOString(),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
zone: selectedZone,
|
zone: selectedZone,
|
||||||
loginTime: new Date().toISOString(),
|
loginTime: new Date().toISOString(),
|
||||||
|
ghostMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,13 +73,11 @@ export const handleConnectPilot =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const randomPos = debug ? getRandomGermanPosition() : undefined;
|
const randomPos = debug ? getRandomGermanPosition() : undefined;
|
||||||
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
|
|
||||||
|
|
||||||
const connectedAircraftEntry = await prisma.connectedAircraft.create({
|
const connectedAircraftEntry = await prisma.connectedAircraft.create({
|
||||||
data: {
|
data: {
|
||||||
publicUser: getPublicUser(user) as any,
|
publicUser: getPublicUser(user) as any,
|
||||||
esimatedLogoutTime:
|
esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
|
||||||
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
|
|
||||||
userId: userId,
|
userId: userId,
|
||||||
stationId: parseInt(stationId),
|
stationId: parseInt(stationId),
|
||||||
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
|
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const handleSendMessage =
|
|||||||
{ userId, message }: { userId: string; message: string },
|
{ userId, message }: { userId: string; message: string },
|
||||||
cb: (err: { error?: string }) => void,
|
cb: (err: { error?: string }) => void,
|
||||||
) => {
|
) => {
|
||||||
console.log("send-message", userId, message);
|
|
||||||
const senderId = socket.data.user.id;
|
const senderId = socket.data.user.id;
|
||||||
|
|
||||||
const senderUser = await prisma.user.findUnique({
|
const senderUser = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -45,16 +45,9 @@ export function StationsSelect({
|
|||||||
queryFn: () => getStationsAPI(),
|
queryFn: () => getStationsAPI(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [value, setValue] = useState<string[]>(selectedStations?.map((id) => String(id)) || []);
|
const [value, setValue] = useState<string[] | string | null>(
|
||||||
|
selectedStations?.map((id) => String(id)) || [],
|
||||||
useEffect(() => {
|
);
|
||||||
setValue([
|
|
||||||
...(selectedStations || []).map((id) => String(id)),
|
|
||||||
...(vehicleStates.hpgAmbulanceState !== HpgState.NOT_REQUESTED || undefined ? ["RTW"] : []),
|
|
||||||
...(vehicleStates.hpgFireEngineState !== HpgState.NOT_REQUESTED || undefined ? ["FW"] : []),
|
|
||||||
...(vehicleStates.hpgPoliceState !== HpgState.NOT_REQUESTED || undefined ? ["POL"] : []),
|
|
||||||
]);
|
|
||||||
}, [selectedStations, vehicleStates]);
|
|
||||||
|
|
||||||
// Helper to check if a station is a vehicle and its state is NOT_REQUESTED
|
// Helper to check if a station is a vehicle and its state is NOT_REQUESTED
|
||||||
const stationsOptions = [
|
const stationsOptions = [
|
||||||
@@ -101,6 +94,20 @@ export function StationsSelect({
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMulti) {
|
||||||
|
setValue([
|
||||||
|
...(selectedStations || []).map((id) => String(id)),
|
||||||
|
...(vehicleStates.hpgAmbulanceState !== HpgState.NOT_REQUESTED || undefined ? ["RTW"] : []),
|
||||||
|
...(vehicleStates.hpgFireEngineState !== HpgState.NOT_REQUESTED || undefined ? ["FW"] : []),
|
||||||
|
...(vehicleStates.hpgPoliceState !== HpgState.NOT_REQUESTED || undefined ? ["POL"] : []),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
console.log("clear selected stations");
|
||||||
|
setValue(null);
|
||||||
|
}
|
||||||
|
}, [selectedStations, vehicleStates, isMulti]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
className={className}
|
className={className}
|
||||||
@@ -108,7 +115,25 @@ export function StationsSelect({
|
|||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setValue(v);
|
setValue(v);
|
||||||
if (!isMulti) return onChange?.(v);
|
if (!isMulti) {
|
||||||
|
const singleValue = v as string;
|
||||||
|
const isVehicle = ["RTW", "FW", "POL"].includes(singleValue);
|
||||||
|
|
||||||
|
const hpgAmbulanceState =
|
||||||
|
singleValue === "RTW" ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
||||||
|
const hpgFireEngineState =
|
||||||
|
singleValue === "FW" ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
||||||
|
const hpgPoliceState =
|
||||||
|
singleValue === "POL" ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
||||||
|
|
||||||
|
onChange?.({
|
||||||
|
selectedStationIds: isVehicle ? [] : [Number(singleValue)],
|
||||||
|
hpgAmbulanceState,
|
||||||
|
hpgFireEngineState,
|
||||||
|
hpgPoliceState,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const hpgAmbulanceState = v.includes("RTW") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
const hpgAmbulanceState = v.includes("RTW") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
||||||
const hpgFireEngineState = v.includes("FW") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
const hpgFireEngineState = v.includes("FW") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
||||||
const hpgPoliceState = v.includes("POL") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
const hpgPoliceState = v.includes("POL") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
|
||||||
|
|||||||
@@ -2,19 +2,29 @@ import { Connection } from "./_components/Connection";
|
|||||||
import { Audio } from "../../../../_components/Audio/Audio";
|
import { Audio } from "../../../../_components/Audio/Audio";
|
||||||
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Settings } from "_components/navbar/Settings";
|
import { Settings } from "./_components/Settings";
|
||||||
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
|
|
||||||
import AdminPanel from "_components/navbar/AdminPanel";
|
import AdminPanel from "_components/navbar/AdminPanel";
|
||||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||||
import { WarningAlert } from "_components/navbar/PageAlert";
|
import { WarningAlert } from "_components/navbar/PageAlert";
|
||||||
|
import { Radar } from "lucide-react";
|
||||||
|
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
|
||||||
|
import { prisma } from "@repo/db";
|
||||||
|
|
||||||
export default async function Navbar() {
|
export default async function Navbar() {
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
|
const latestChangelog = await prisma.changelog.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
|
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ModeSwitchDropdown />
|
<div>
|
||||||
|
<p className="text-xl font-semibold normal-case">VAR Leitstelle</p>
|
||||||
|
<ChangelogWrapper latestChangelog={latestChangelog} />
|
||||||
|
</div>
|
||||||
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
|
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
|
||||||
</div>
|
</div>
|
||||||
<WarningAlert />
|
<WarningAlert />
|
||||||
@@ -27,18 +37,23 @@ export default async function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Settings />
|
<Settings />
|
||||||
|
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
|
||||||
|
<button className="btn btn-ghost">
|
||||||
|
<Radar size={19} /> Tracker
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
|
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExternalLinkIcon className="w-4 h-4" /> HUB
|
<ExternalLinkIcon className="h-4 w-4" /> HUB
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={"/logout"}>
|
<Link href={"/logout"}>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExitIcon className="w-4 h-4" />
|
<ExitIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useDispatchConnectionStore } from "../../../../../_store/dispatch/connectionStore";
|
import { useDispatchConnectionStore } from "../../../../../_store/dispatch/connectionStore";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Prisma } from "@repo/db";
|
import { Prisma } from "@repo/db";
|
||||||
import { changeDispatcherAPI } from "_querys/dispatcher";
|
import { changeDispatcherAPI } from "_querys/dispatcher";
|
||||||
import { getNextDateWithTime } from "@repo/shared-components";
|
import { Button, getNextDateWithTime } from "@repo/shared-components";
|
||||||
|
import { Ghost } from "lucide-react";
|
||||||
|
|
||||||
export const ConnectionBtn = () => {
|
export const ConnectionBtn = () => {
|
||||||
const modalRef = useRef<HTMLDialogElement>(null);
|
const modalRef = useRef<HTMLDialogElement>(null);
|
||||||
@@ -15,54 +15,27 @@ export const ConnectionBtn = () => {
|
|||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
logoffTime: "",
|
logoffTime: "",
|
||||||
selectedZone: "LST_01",
|
selectedZone: "LST_01",
|
||||||
|
ghostMode: false,
|
||||||
});
|
});
|
||||||
const changeDispatcherMutation = useMutation({
|
const changeDispatcherMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Prisma.ConnectedDispatcherUpdateInput }) =>
|
mutationFn: ({ id, data }: { id: number; data: Prisma.ConnectedDispatcherUpdateInput }) =>
|
||||||
changeDispatcherAPI(id, data),
|
changeDispatcherAPI(id, data),
|
||||||
});
|
});
|
||||||
const [logoffDebounce, setLogoffDebounce] = useState<NodeJS.Timeout | null>(null);
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const uid = session.data?.user?.id;
|
const uid = session.data?.user?.id;
|
||||||
|
|
||||||
// useEffect für die Logoff-Zeit
|
|
||||||
const [logoffHours, logoffMinutes] = form.logoffTime?.split(":").map(Number) || [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!logoffHours || !logoffMinutes) return;
|
|
||||||
if (logoffDebounce) clearTimeout(logoffDebounce);
|
|
||||||
|
|
||||||
const timeout = setTimeout(async () => {
|
|
||||||
if (!logoffHours || !logoffMinutes || !connection.connectedDispatcher) return;
|
|
||||||
await changeDispatcherMutation.mutateAsync({
|
|
||||||
id: connection.connectedDispatcher?.id,
|
|
||||||
data: {
|
|
||||||
esimatedLogoutTime:
|
|
||||||
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.success("Änderung gespeichert!");
|
|
||||||
modalRef.current?.close();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
setLogoffDebounce(timeout);
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
|
||||||
if (logoffDebounce) clearTimeout(logoffDebounce);
|
|
||||||
};
|
|
||||||
}, [form.logoffTime, connection.connectedDispatcher]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Disconnect the socket when the component unmounts
|
// Disconnect the socket when the component unmounts
|
||||||
return () => {
|
return () => {
|
||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
};
|
};
|
||||||
}, [connection.disconnect]);
|
}, [connection.disconnect]);
|
||||||
|
|
||||||
if (!uid) return null;
|
if (!uid) return null;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
|
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
|
||||||
{connection.message.length > 0 && (
|
{connection.message.length > 0 && (
|
||||||
<span className="mx-2 text-error">{connection.message}</span>
|
<span className="text-error mx-2">{connection.message}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{connection.status == "connected" ? (
|
{connection.status == "connected" ? (
|
||||||
@@ -74,7 +47,7 @@ export const ConnectionBtn = () => {
|
|||||||
modalRef.current?.showModal();
|
modalRef.current?.showModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Verbunden
|
Verbunden {connection.ghostMode && <Ghost />}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -92,11 +65,11 @@ export const ConnectionBtn = () => {
|
|||||||
<dialog ref={modalRef} className="modal">
|
<dialog ref={modalRef} className="modal">
|
||||||
<div className="modal-box flex flex-col items-center justify-center">
|
<div className="modal-box flex flex-col items-center justify-center">
|
||||||
{connection.status == "connected" ? (
|
{connection.status == "connected" ? (
|
||||||
<h3 className="text-lg font-bold mb-5">
|
<h3 className="mb-5 text-lg font-bold">
|
||||||
Verbunden als <span className="text-info"><{connection.selectedZone}></span>
|
Verbunden als <span className="text-info"><{connection.selectedZone}></span>
|
||||||
</h3>
|
</h3>
|
||||||
) : (
|
) : (
|
||||||
<h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3>
|
<h3 className="mb-5 text-lg font-bold">Als Disponent anmelden</h3>
|
||||||
)}
|
)}
|
||||||
<fieldset className="fieldset w-full">
|
<fieldset className="fieldset w-full">
|
||||||
<label className="floating-label w-full text-base">
|
<label className="floating-label w-full text-base">
|
||||||
@@ -118,26 +91,72 @@ export const ConnectionBtn = () => {
|
|||||||
<p className="fieldset-label">Du kannst diese Zeit später noch anpassen.</p>
|
<p className="fieldset-label">Du kannst diese Zeit später noch anpassen.</p>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="modal-action flex justify-between w-full">
|
{session.data?.user.permissions.includes("ADMIN_KICK") &&
|
||||||
<form method="dialog" className="w-full flex justify-between">
|
connection.status === "disconnected" && (
|
||||||
|
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-full border p-4">
|
||||||
|
<legend className="fieldset-legend">Ghost-Mode</legend>
|
||||||
|
<label className="label">
|
||||||
|
<input
|
||||||
|
checked={form.ghostMode}
|
||||||
|
onChange={(e) => setForm({ ...form, ghostMode: e.target.checked })}
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
/>
|
||||||
|
Vesteckt deine Verbindung auf dem Tracker
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
<div className="modal-action flex w-full justify-between">
|
||||||
|
<form method="dialog" className="flex w-full justify-between">
|
||||||
<button className="btn btn-soft">Zurück</button>
|
<button className="btn btn-soft">Zurück</button>
|
||||||
{connection.status == "connected" ? (
|
{connection.status == "connected" ? (
|
||||||
<button
|
<>
|
||||||
className="btn btn-soft btn-error"
|
<Button
|
||||||
type="submit"
|
className="btn"
|
||||||
onSubmit={() => false}
|
onClick={async () => {
|
||||||
onClick={() => {
|
if (!connection.connectedDispatcher?.id) return;
|
||||||
connection.disconnect();
|
const [logoffHours, logoffMinutes] =
|
||||||
}}
|
form.logoffTime?.split(":").map(Number) || [];
|
||||||
>
|
await changeDispatcherMutation.mutateAsync({
|
||||||
Verbindung Trennen
|
id: connection.connectedDispatcher?.id,
|
||||||
</button>
|
data: {
|
||||||
|
esimatedLogoutTime:
|
||||||
|
logoffHours !== undefined && logoffMinutes !== undefined
|
||||||
|
? getNextDateWithTime(logoffHours, logoffMinutes)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
modalRef.current?.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logoff-Zeit speichern
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
className="btn btn-soft btn-error"
|
||||||
|
type="submit"
|
||||||
|
onSubmit={() => false}
|
||||||
|
onClick={() => {
|
||||||
|
connection.disconnect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verbindung Trennen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
onSubmit={() => false}
|
onSubmit={() => false}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
connection.connect(uid, form.selectedZone, form.logoffTime);
|
const [logoffHours, logoffMinutes] =
|
||||||
|
form.logoffTime?.split(":").map(Number) || [];
|
||||||
|
connection.connect(
|
||||||
|
uid,
|
||||||
|
form.selectedZone,
|
||||||
|
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
|
||||||
|
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
|
||||||
|
: "",
|
||||||
|
form.ghostMode,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="btn btn-soft btn-info"
|
className="btn btn-soft btn-info"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import { editUserAPI, getUserAPI } from "_querys/user";
|
|||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useAudioStore } from "_store/audioStore";
|
import { useAudioStore } from "_store/audioStore";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { useMapStore } from "_store/mapStore";
|
||||||
|
import { set } from "date-fns";
|
||||||
|
|
||||||
export const SettingsBtn = () => {
|
export const SettingsBtn = () => {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|
||||||
|
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
const { data: user } = useQuery({
|
const { data: user } = useQuery({
|
||||||
queryKey: ["user", session.data?.user.id],
|
queryKey: ["user", session.data?.user.id],
|
||||||
queryFn: () => getUserAPI(session.data!.user.id),
|
queryFn: () => getUserAPI(session.data!.user.id),
|
||||||
@@ -23,39 +27,62 @@ export const SettingsBtn = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
testSoundRef.current = new Audio("/sounds/Melder3.wav");
|
testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modalRef = useRef<HTMLDialogElement>(null);
|
const modalRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
|
|
||||||
const [selectedDevice, setSelectedDevice] = useState<string | null>(
|
|
||||||
user?.settingsMicDevice || null,
|
|
||||||
);
|
|
||||||
const [showIndication, setShowIndication] = useState<boolean>(false);
|
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);
|
const [settings, setSettings] = useState({
|
||||||
|
micDeviceId: user?.settingsMicDevice || null,
|
||||||
|
micVolume: user?.settingsMicVolume || 1,
|
||||||
|
radioVolume: user?.settingsRadioVolume || 0.8,
|
||||||
|
autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
|
||||||
|
const { setUserSettings: setUserSettings } = useMapStore((state) => state);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setSelectedDevice(user.settingsMicDevice);
|
setAudioSettings({
|
||||||
setMic(user.settingsMicDevice, user.settingsMicVolume || 1);
|
micDeviceId: user.settingsMicDevice,
|
||||||
setMicVol(user.settingsMicVolume || 1);
|
micVolume: user.settingsMicVolume || 1,
|
||||||
setFunkVol(user.settingsRadioVolume || 0.8);
|
radioVolume: user.settingsRadioVolume || 0.8,
|
||||||
setDmeVol(user.settingsDmeVolume || 0.8);
|
dmeVolume: user.settingsDmeVolume || 0.8,
|
||||||
}
|
});
|
||||||
}, [user, setMic]);
|
setSettings({
|
||||||
|
micDeviceId: user.settingsMicDevice,
|
||||||
useEffect(() => {
|
micVolume: user.settingsMicVolume || 1,
|
||||||
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
|
radioVolume: user.settingsRadioVolume || 0.8,
|
||||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
autoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
|
||||||
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
|
});
|
||||||
|
setUserSettings({
|
||||||
|
settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}, [user, setSettings, setAudioSettings, setUserSettings]);
|
||||||
|
|
||||||
|
const setSettingsPartial = (newSettings: Partial<typeof settings>) => {
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newSettings,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const setDevices = async () => {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setDevices();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,23 +94,23 @@ export const SettingsBtn = () => {
|
|||||||
modalRef.current?.showModal();
|
modalRef.current?.showModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GearIcon className="w-5 h-5" />
|
<GearIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<dialog ref={modalRef} className="modal">
|
<dialog ref={modalRef} className="modal">
|
||||||
<div className="modal-box">
|
<div className="modal-box">
|
||||||
<h3 className="flex items-center gap-2 text-lg font-bold mb-5">
|
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
|
||||||
<SettingsIcon size={20} /> Einstellungen
|
<SettingsIcon size={20} /> Einstellungen
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<fieldset className="fieldset w-full mb-2">
|
<fieldset className="fieldset mb-2 w-full">
|
||||||
<label className="floating-label w-full text-base">
|
<label className="floating-label w-full text-base">
|
||||||
<span>Eingabegerät</span>
|
<span>Eingabegerät</span>
|
||||||
<select
|
<select
|
||||||
className="input w-full"
|
className="input w-full"
|
||||||
value={selectedDevice ? selectedDevice : ""}
|
value={settings.micDeviceId ? settings.micDeviceId : ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedDevice(e.target.value);
|
setSettingsPartial({ micDeviceId: e.target.value });
|
||||||
setShowIndication(true);
|
setShowIndication(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -98,7 +125,7 @@ export const SettingsBtn = () => {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p className="flex items-center gap-2 text-base mb-2 justify-start w-full">
|
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
|
||||||
<Volume2 size={20} /> Eingabelautstärke
|
<Volume2 size={20} /> Eingabelautstärke
|
||||||
</p>
|
</p>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -109,13 +136,13 @@ export const SettingsBtn = () => {
|
|||||||
step={0.01}
|
step={0.01}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = parseFloat(e.target.value);
|
const value = parseFloat(e.target.value);
|
||||||
setMicVol(value);
|
setSettingsPartial({ micVolume: value });
|
||||||
setShowIndication(true);
|
setShowIndication(true);
|
||||||
}}
|
}}
|
||||||
value={micVol}
|
value={settings.micVolume}
|
||||||
className="range range-xs range-accent w-full"
|
className="range range-xs range-accent w-full"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between px-2.5 mt-2 text-xs">
|
<div className="mt-2 flex justify-between px-2.5 text-xs">
|
||||||
<span>0%</span>
|
<span>0%</span>
|
||||||
<span>25%</span>
|
<span>25%</span>
|
||||||
<span>50%</span>
|
<span>50%</span>
|
||||||
@@ -124,14 +151,17 @@ export const SettingsBtn = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showIndication && (
|
{showIndication && (
|
||||||
<MicVolumeBar deviceId={selectedDevice ? selectedDevice : ""} volumeInput={micVol} />
|
<MicVolumeBar
|
||||||
|
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
|
||||||
|
volumeInput={settings.micVolume}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="divider w-full" />
|
<div className="divider w-full" />
|
||||||
</div>
|
</div>
|
||||||
<p className="flex items-center gap-2 text-base mb-2">
|
<p className="mb-2 flex items-center gap-2 text-base">
|
||||||
<Volume2 size={20} /> Funk Lautstärke
|
<Volume2 size={20} /> Funk Lautstärke
|
||||||
</p>
|
</p>
|
||||||
<div className="w-full mb-2">
|
<div className="mb-2 w-full">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -139,42 +169,12 @@ export const SettingsBtn = () => {
|
|||||||
step={0.01}
|
step={0.01}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = parseFloat(e.target.value);
|
const value = parseFloat(e.target.value);
|
||||||
setFunkVol(value);
|
setSettingsPartial({ radioVolume: value });
|
||||||
}}
|
}}
|
||||||
value={funkVolume}
|
value={settings.radioVolume}
|
||||||
className="range range-xs range-primary w-full"
|
className="range range-xs range-primary w-full"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between px-2.5 mt-2 text-xs">
|
<div className="mt-2 flex justify-between px-2.5 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>
|
|
||||||
<div className="w-full">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseFloat(e.target.value);
|
|
||||||
setDmeVol(value);
|
|
||||||
if (!testSoundRef.current) return;
|
|
||||||
testSoundRef.current.volume = value;
|
|
||||||
testSoundRef.current.play();
|
|
||||||
}}
|
|
||||||
value={dmeVolume}
|
|
||||||
className="range range-xs range-primary w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between px-2.5 mt-2 text-xs">
|
|
||||||
<span>0%</span>
|
<span>0%</span>
|
||||||
<span>25%</span>
|
<span>25%</span>
|
||||||
<span>50%</span>
|
<span>50%</span>
|
||||||
@@ -183,7 +183,23 @@ export const SettingsBtn = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between modal-action">
|
<div className="flex w-full justify-center">
|
||||||
|
<div className="divider w-full">Disponenten Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle"
|
||||||
|
checked={settings.autoCloseMapPopup}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettingsPartial({ autoCloseMapPopup: e.target.checked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Popups automatisch schließen
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action flex justify-between">
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft"
|
className="btn btn-soft"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -204,13 +220,20 @@ export const SettingsBtn = () => {
|
|||||||
await editUserMutation.mutateAsync({
|
await editUserMutation.mutateAsync({
|
||||||
id: session.data!.user.id,
|
id: session.data!.user.id,
|
||||||
user: {
|
user: {
|
||||||
settingsMicDevice: selectedDevice,
|
settingsMicDevice: settings.micDeviceId,
|
||||||
settingsMicVolume: micVol,
|
settingsMicVolume: settings.micVolume,
|
||||||
settingsRadioVolume: funkVolume,
|
settingsRadioVolume: settings.radioVolume,
|
||||||
settingsDmeVolume: dmeVolume,
|
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setMic(selectedDevice, micVol);
|
setAudioSettings({
|
||||||
|
micDeviceId: settings.micDeviceId,
|
||||||
|
micVolume: settings.micVolume,
|
||||||
|
radioVolume: settings.radioVolume,
|
||||||
|
});
|
||||||
|
setUserSettings({
|
||||||
|
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
|
||||||
|
});
|
||||||
modalRef.current?.close();
|
modalRef.current?.close();
|
||||||
toast.success("Einstellungen gespeichert");
|
toast.success("Einstellungen gespeichert");
|
||||||
}}
|
}}
|
||||||
@@ -223,7 +246,6 @@ export const SettingsBtn = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -195,7 +195,7 @@ export const MissionForm = () => {
|
|||||||
<form className="space-y-4">
|
<form className="space-y-4">
|
||||||
{/* Koorinaten Section */}
|
{/* Koorinaten Section */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<h2 className="text-lg font-bold mb-2">Koordinaten</h2>
|
<h2 className="mb-2 text-lg font-bold">Koordinaten</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -219,12 +219,12 @@ export const MissionForm = () => {
|
|||||||
|
|
||||||
{/* Adresse Section */}
|
{/* Adresse Section */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<h2 className="text-lg font-bold mb-2">Adresse</h2>
|
<h2 className="mb-2 text-lg font-bold">Adresse</h2>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
{...form.register("addressStreet")}
|
{...form.register("addressStreet")}
|
||||||
placeholder="Straße"
|
placeholder="Straße"
|
||||||
className="input input-primary input-bordered w-full mb-4"
|
className="input input-primary input-bordered mb-4 w-full"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -244,17 +244,16 @@ export const MissionForm = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
{...form.register("addressAdditionalInfo")}
|
{...form.register("addressAdditionalInfo")}
|
||||||
placeholder="Zusätzliche Adressinformationen"
|
placeholder="Zusätzliche Adressinformationen"
|
||||||
className="input input-primary input-bordered w-full mt-4"
|
className="input input-primary input-bordered mt-4 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Rettungsmittel Section */}
|
{/* Rettungsmittel Section */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<h2 className="text-lg font-bold mb-2">Rettungsmittel</h2>
|
<h2 className="mb-2 text-lg font-bold">Rettungsmittel</h2>
|
||||||
<StationsSelect
|
<StationsSelect
|
||||||
isMulti
|
isMulti
|
||||||
selectedStations={form.watch("missionStationIds")}
|
selectedStations={form.watch("missionStationIds")}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
console.log("Selected stations:", v);
|
|
||||||
form.setValue("missionStationIds", v.selectedStationIds);
|
form.setValue("missionStationIds", v.selectedStationIds);
|
||||||
form.setValue("hpgAmbulanceState", v.hpgAmbulanceState);
|
form.setValue("hpgAmbulanceState", v.hpgAmbulanceState);
|
||||||
form.setValue("hpgFireEngineState", v.hpgFireEngineState);
|
form.setValue("hpgFireEngineState", v.hpgFireEngineState);
|
||||||
@@ -270,10 +269,10 @@ export const MissionForm = () => {
|
|||||||
|
|
||||||
{/* Einsatzdaten Section */}
|
{/* Einsatzdaten Section */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<h2 className="text-lg font-bold mb-2">Einsatzdaten</h2>
|
<h2 className="mb-2 text-lg font-bold">Einsatzdaten</h2>
|
||||||
<select
|
<select
|
||||||
{...form.register("type")}
|
{...form.register("type")}
|
||||||
className="select select-primary select-bordered w-full mb-4"
|
className="select select-primary select-bordered mb-4 w-full"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
form.setValue("type", e.target.value as missionType);
|
form.setValue("type", e.target.value as missionType);
|
||||||
if (e.target.value === "primary") {
|
if (e.target.value === "primary") {
|
||||||
@@ -295,7 +294,7 @@ export const MissionForm = () => {
|
|||||||
<>
|
<>
|
||||||
<select
|
<select
|
||||||
{...form.register("missionKeywordCategory")}
|
{...form.register("missionKeywordCategory")}
|
||||||
className="select select-primary select-bordered w-full mb-4"
|
className="select select-primary select-bordered mb-4 w-full"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
form.setValue("missionKeywordCategory", e.target.value as string);
|
form.setValue("missionKeywordCategory", e.target.value as string);
|
||||||
form.setValue("missionKeywordName", null as any);
|
form.setValue("missionKeywordName", null as any);
|
||||||
@@ -307,20 +306,22 @@ export const MissionForm = () => {
|
|||||||
<option disabled value="please_select">
|
<option disabled value="please_select">
|
||||||
Einsatz Kategorie auswählen...
|
Einsatz Kategorie auswählen...
|
||||||
</option>
|
</option>
|
||||||
{Object.keys(KEYWORD_CATEGORY).map((use) => (
|
{Object.keys(KEYWORD_CATEGORY)
|
||||||
<option key={use} value={use}>
|
.filter((k) => !k.startsWith("V_"))
|
||||||
{use}
|
.map((use) => (
|
||||||
</option>
|
<option key={use} value={use}>
|
||||||
))}
|
{use}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
{form.formState.errors.missionKeywordCategory && (
|
{form.formState.errors.missionKeywordCategory && (
|
||||||
<p className="text-error text-sm mb-4">Bitte wähle eine Kategorie aus.</p>
|
<p className="text-error mb-4 text-sm">Bitte wähle eine Kategorie aus.</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<select
|
<select
|
||||||
{...form.register("missionKeywordAbbreviation")}
|
{...form.register("missionKeywordAbbreviation")}
|
||||||
className="select select-primary select-bordered w-full mb-4"
|
className="select select-primary select-bordered mb-4 w-full"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const keyword = keywords?.find((k) => k.abreviation === e.target.value);
|
const keyword = keywords?.find((k) => k.abreviation === e.target.value);
|
||||||
form.setValue("missionKeywordName", keyword?.name || (null as any));
|
form.setValue("missionKeywordName", keyword?.name || (null as any));
|
||||||
@@ -342,7 +343,7 @@ export const MissionForm = () => {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{form.formState.errors.missionKeywordAbbreviation && (
|
{form.formState.errors.missionKeywordAbbreviation && (
|
||||||
<p className="text-error text-sm mb-4">Bitte wähle ein Stichwort aus.</p>
|
<p className="text-error mb-4 text-sm">Bitte wähle ein Stichwort aus.</p>
|
||||||
)}
|
)}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<select
|
<select
|
||||||
@@ -362,7 +363,7 @@ export const MissionForm = () => {
|
|||||||
form.setValue("missionAdditionalInfo", name || "");
|
form.setValue("missionAdditionalInfo", name || "");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="select select-primary select-bordered w-full mb-2"
|
className="select select-primary select-bordered mb-2 w-full"
|
||||||
value={form.watch("hpgMissionString") || "please_select"}
|
value={form.watch("hpgMissionString") || "please_select"}
|
||||||
>
|
>
|
||||||
<option disabled value="please_select">
|
<option disabled value="please_select">
|
||||||
@@ -381,14 +382,14 @@ export const MissionForm = () => {
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
{validationRequired && (
|
{validationRequired && (
|
||||||
<p className="text-sm text-warning">Szenario wird vor Alarmierung HPG-Validiert.</p>
|
<p className="text-warning text-sm">Szenario wird vor Alarmierung HPG-Validiert.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
{...form.register("missionAdditionalInfo")}
|
{...form.register("missionAdditionalInfo")}
|
||||||
placeholder="Einsatzinformationen"
|
placeholder="Einsatzinformationen"
|
||||||
className="textarea textarea-primary textarea-bordered w-full mb-4"
|
className="textarea textarea-primary textarea-bordered mb-4 w-full"
|
||||||
/>
|
/>
|
||||||
{form.watch("type") === "sekundär" && (
|
{form.watch("type") === "sekundär" && (
|
||||||
<input
|
<input
|
||||||
@@ -400,7 +401,7 @@ export const MissionForm = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<h2 className="text-lg font-bold mb-2">Patienteninformationen</h2>
|
<h2 className="mb-2 text-lg font-bold">Patienteninformationen</h2>
|
||||||
<textarea
|
<textarea
|
||||||
{...form.register("missionPatientInfo")}
|
{...form.register("missionPatientInfo")}
|
||||||
placeholder="Patienteninformationen"
|
placeholder="Patienteninformationen"
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ export default async function RootLayout({
|
|||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("DISPO"))
|
if (!session?.user.permissions.includes("DISPO"))
|
||||||
return <Error title="Zugriff verweigert" statusCode={403} />;
|
return (
|
||||||
|
<Error
|
||||||
|
title=" Fehlende Berechtigung"
|
||||||
|
description="Du hast nicht die erforderlichen Berechtigungen, dich als Disponent anzumelden. Du kannst im HUB Kurse abschließen um die Berechtigung zu erhalten."
|
||||||
|
statusCode={403}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dynamic from "next/dynamic";
|
|||||||
import { Chat } from "../../_components/left/Chat";
|
import { Chat } from "../../_components/left/Chat";
|
||||||
import { Report } from "../../_components/left/Report";
|
import { Report } from "../../_components/left/Report";
|
||||||
import { SituationBoard } from "_components/left/SituationBoard";
|
import { SituationBoard } from "_components/left/SituationBoard";
|
||||||
|
import { BugReport } from "_components/left/BugReport";
|
||||||
|
|
||||||
const Map = dynamic(() => import("../../_components/map/Map"), { ssr: false });
|
const Map = dynamic(() => import("../../_components/map/Map"), { ssr: false });
|
||||||
|
|
||||||
@@ -14,16 +15,15 @@ const DispatchPage = () => {
|
|||||||
const { isOpen } = usePannelStore();
|
const { isOpen } = usePannelStore();
|
||||||
/* return null; */
|
/* return null; */
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex transition-all duration-500 ease w-full">
|
<div className="ease relative flex w-full flex-1 transition-all duration-500">
|
||||||
{/* <MapToastCard2 /> */}
|
{/* <MapToastCard2 /> */}
|
||||||
<div className="flex flex-1 relative">
|
<div className="relative flex flex-1">
|
||||||
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
|
<div className="z-999999 absolute left-0 top-1/2 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
|
||||||
<Chat />
|
<Chat />
|
||||||
<div className="mt-2">
|
<Report />
|
||||||
<Report />
|
<BugReport />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-0 top-19/20 transform -translate-y-1/2 pl-4 z-999999">
|
<div className="top-19/20 z-999999 absolute left-0 -translate-y-1/2 transform pl-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<SituationBoard />
|
<SituationBoard />
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +32,7 @@ const DispatchPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 w-[500px] z-999 transition-transform",
|
"z-999 absolute right-0 w-[500px] transition-transform",
|
||||||
isOpen ? "translate-x-0" : "translate-x-full",
|
isOpen ? "translate-x-0" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export default async function RootLayout({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.log(session);
|
|
||||||
return redirect("/logout");
|
return redirect("/logout");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useAudioStore } from "_store/audioStore";
|
||||||
import { getUserAPI } from "_querys/user";
|
|
||||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
import { useDmeStore } from "_store/pilot/dmeStore";
|
import { useDmeStore } from "_store/pilot/dmeStore";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export const useSounds = () => {
|
export const useSounds = () => {
|
||||||
const session = useSession();
|
const dmeVolume = useAudioStore((state) => state.settings.dmeVolume);
|
||||||
const { data: user } = useQuery({
|
|
||||||
queryKey: ["user", session.data?.user.id],
|
|
||||||
queryFn: () => getUserAPI(session.data!.user.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { page, setPage } = useDmeStore((state) => state);
|
const { page, setPage } = useDmeStore((state) => state);
|
||||||
const mission = usePilotConnectionStore((state) => state.activeMission);
|
const mission = usePilotConnectionStore((state) => state.activeMission);
|
||||||
|
|
||||||
@@ -20,27 +13,25 @@ export const useSounds = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
newMissionSound.current = new Audio("/sounds/Melder3.wav");
|
newMissionSound.current = new Audio("/sounds/DME-new-mission.wav");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.settingsDmeVolume) {
|
if (dmeVolume) {
|
||||||
if (newMissionSound.current) {
|
if (newMissionSound.current) {
|
||||||
newMissionSound.current.volume = user.settingsDmeVolume;
|
newMissionSound.current.volume = dmeVolume;
|
||||||
}
|
}
|
||||||
} else if (newMissionSound.current) {
|
} else if (newMissionSound.current) {
|
||||||
newMissionSound.current.volume = 0.8; // Default volume
|
newMissionSound.current.volume = 0.8; // Default volume
|
||||||
}
|
}
|
||||||
}, [user?.settingsDmeVolume]);
|
}, [dmeVolume]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeouts: NodeJS.Timeout[] = [];
|
const timeouts: NodeJS.Timeout[] = [];
|
||||||
|
|
||||||
if (page === "new-mission" && newMissionSound.current) {
|
if (page === "new-mission" && newMissionSound.current) {
|
||||||
console.log("new-mission", mission);
|
|
||||||
newMissionSound.current.currentTime = 0;
|
newMissionSound.current.currentTime = 0;
|
||||||
newMissionSound.current.volume = 0.3;
|
|
||||||
newMissionSound.current.play();
|
newMissionSound.current.play();
|
||||||
if (mission) {
|
if (mission) {
|
||||||
timeouts.push(setTimeout(() => setPage({ page: "mission", mission }), 500));
|
timeouts.push(setTimeout(() => setPage({ page: "mission", mission }), 500));
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 366 KiB |
@@ -2,14 +2,26 @@ import { Connection } from "./_components/Connection";
|
|||||||
import { Audio } from "_components/Audio/Audio";
|
import { Audio } from "_components/Audio/Audio";
|
||||||
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Settings } from "_components/navbar/Settings";
|
import { Settings } from "./_components/Settings";
|
||||||
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
|
|
||||||
import { WarningAlert } from "_components/navbar/PageAlert";
|
import { WarningAlert } from "_components/navbar/PageAlert";
|
||||||
|
import { Radar } from "lucide-react";
|
||||||
|
import { prisma } from "@repo/db";
|
||||||
|
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default async function Navbar() {
|
||||||
|
const latestChangelog = await prisma.changelog.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
|
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
|
||||||
<ModeSwitchDropdown />
|
<div className="flex items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
|
||||||
|
<ChangelogWrapper latestChangelog={latestChangelog} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<WarningAlert />
|
<WarningAlert />
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -20,18 +32,23 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Settings />
|
<Settings />
|
||||||
|
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
|
||||||
|
<button className="btn btn-ghost">
|
||||||
|
<Radar size={19} /> Tracker
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
|
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExternalLinkIcon className="w-4 h-4" /> HUB
|
<ExternalLinkIcon className="h-4 w-4" /> HUB
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={"/logout"}>
|
<Link href={"/logout"}>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExitIcon className="w-4 h-4" />
|
<ExitIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { getStationsAPI } from "_querys/stations";
|
import { getStationsAPI } from "_querys/stations";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
import { Prisma } from "@repo/db";
|
import { Prisma } from "@repo/db";
|
||||||
import { getNextDateWithTime } from "@repo/shared-components";
|
import { Button, getNextDateWithTime } from "@repo/shared-components";
|
||||||
import { Select } from "_components/Select";
|
import { Select } from "_components/Select";
|
||||||
import { Radio } from "lucide-react";
|
import { Radio } from "lucide-react";
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ export const ConnectionBtn = () => {
|
|||||||
selectedStationId: null,
|
selectedStationId: null,
|
||||||
debugPosition: false,
|
debugPosition: false,
|
||||||
});
|
});
|
||||||
const [logoffDebounce, setLogoffDebounce] = useState<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const { data: stations } = useQuery({
|
const { data: stations } = useQuery({
|
||||||
queryKey: ["stations"],
|
queryKey: ["stations"],
|
||||||
@@ -53,48 +51,21 @@ export const ConnectionBtn = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
};
|
};
|
||||||
}, [connection, connection.disconnect]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [connection.disconnect]);
|
||||||
const [logoffHours, logoffMinutes] = form.logoffTime?.split(":").map(Number) || [];
|
|
||||||
|
|
||||||
const { data: connectedAircrafts } = useQuery({
|
const { data: connectedAircrafts } = useQuery({
|
||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
queryFn: () => getConnectedAircraftsAPI(),
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!logoffHours || !logoffMinutes || !connection.connectedAircraft) return;
|
|
||||||
|
|
||||||
if (logoffDebounce) clearTimeout(logoffDebounce);
|
|
||||||
|
|
||||||
const timeout = setTimeout(async () => {
|
|
||||||
if (!connection.connectedAircraft?.id) return;
|
|
||||||
await aircraftMutation.mutateAsync({
|
|
||||||
sessionId: connection.connectedAircraft.id,
|
|
||||||
change: {
|
|
||||||
esimatedLogoutTime:
|
|
||||||
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
modalRef.current?.close();
|
|
||||||
toast.success("Änderung gespeichert!");
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
setLogoffDebounce(timeout);
|
|
||||||
|
|
||||||
// Cleanup function to clear timeout
|
|
||||||
return () => {
|
|
||||||
if (logoffDebounce) clearTimeout(logoffDebounce);
|
|
||||||
};
|
|
||||||
}, [logoffHours, logoffMinutes, connection.connectedAircraft, aircraftMutation, logoffDebounce]);
|
|
||||||
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const uid = session.data?.user?.id;
|
const uid = session.data?.user?.id;
|
||||||
if (!uid) return null;
|
if (!uid) return null;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
|
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
|
||||||
{connection.message.length > 0 && (
|
{connection.message.length > 0 && (
|
||||||
<span className="mx-2 text-error">{connection.message}</span>
|
<span className="text-error mx-2">{connection.message}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{connection.status == "connected" ? (
|
{connection.status == "connected" ? (
|
||||||
@@ -124,12 +95,12 @@ export const ConnectionBtn = () => {
|
|||||||
<dialog ref={modalRef} className="modal">
|
<dialog ref={modalRef} className="modal">
|
||||||
<div className="modal-box flex flex-col items-center justify-center">
|
<div className="modal-box flex flex-col items-center justify-center">
|
||||||
{connection.status == "connected" ? (
|
{connection.status == "connected" ? (
|
||||||
<h3 className="text-lg font-bold mb-5">
|
<h3 className="mb-5 text-lg font-bold">
|
||||||
Verbunden als{" "}
|
Verbunden als{" "}
|
||||||
<span className="text-info"><{connection.selectedStation?.bosCallsign}></span>
|
<span className="text-info"><{connection.selectedStation?.bosCallsign}></span>
|
||||||
</h3>
|
</h3>
|
||||||
) : (
|
) : (
|
||||||
<h3 className="text-lg font-bold mb-5">Als Pilot anmelden</h3>
|
<h3 className="mb-5 text-lg font-bold">Als Pilot anmelden</h3>
|
||||||
)}
|
)}
|
||||||
{connection.status !== "connected" && (
|
{connection.status !== "connected" && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -164,7 +135,7 @@ export const ConnectionBtn = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<fieldset className="fieldset w-full mt-2">
|
<fieldset className="fieldset mt-4 w-full">
|
||||||
<label className="floating-label w-full text-base">
|
<label className="floating-label w-full text-base">
|
||||||
<span>Logoff Zeit (LCL)</span>
|
<span>Logoff Zeit (LCL)</span>
|
||||||
<input
|
<input
|
||||||
@@ -185,58 +156,89 @@ export const ConnectionBtn = () => {
|
|||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{session.data?.user.permissions.includes("ADMIN_STATION") && (
|
{session.data?.user.permissions.includes("ADMIN_STATION") &&
|
||||||
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-full border p-4">
|
connection.status === "disconnected" && (
|
||||||
<legend className="fieldset-legend">Debug-optionen</legend>
|
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-full border p-4">
|
||||||
<label className="label">
|
<legend className="fieldset-legend">Debug-optionen</legend>
|
||||||
<input
|
<label className="label">
|
||||||
checked={form.debugPosition}
|
<input
|
||||||
onChange={(e) => setForm({ ...form, debugPosition: e.target.checked })}
|
checked={form.debugPosition}
|
||||||
type="checkbox"
|
onChange={(e) => setForm({ ...form, debugPosition: e.target.checked })}
|
||||||
className="checkbox"
|
type="checkbox"
|
||||||
/>
|
className="checkbox"
|
||||||
Zufalls Position für 2h anzeigen
|
/>
|
||||||
</label>
|
Zufalls Position für 2h anzeigen
|
||||||
</fieldset>
|
</label>
|
||||||
)}
|
</fieldset>
|
||||||
<div className="modal-action flex justify-between w-full">
|
)}
|
||||||
<form method="dialog" className="w-full flex justify-between">
|
<div className="modal-action flex w-full justify-between">
|
||||||
|
<form method="dialog" className="flex w-full justify-between">
|
||||||
<button className="btn btn-soft">Zurück</button>
|
<button className="btn btn-soft">Zurück</button>
|
||||||
{connection.status == "connected" ? (
|
{connection.status == "connected" ? (
|
||||||
<button
|
<>
|
||||||
className="btn btn-soft btn-error"
|
<Button
|
||||||
type="submit"
|
className="btn"
|
||||||
onSubmit={() => false}
|
onClick={async () => {
|
||||||
onClick={() => {
|
if (!connection.connectedAircraft) return;
|
||||||
connection.disconnect();
|
const [logoffHours, logoffMinutes] =
|
||||||
}}
|
form.logoffTime?.split(":").map(Number) || [];
|
||||||
>
|
|
||||||
Verbindung Trennen
|
await aircraftMutation.mutateAsync({
|
||||||
</button>
|
sessionId: connection.connectedAircraft.id,
|
||||||
|
change: {
|
||||||
|
esimatedLogoutTime:
|
||||||
|
logoffHours !== undefined && logoffMinutes !== undefined
|
||||||
|
? getNextDateWithTime(logoffHours, logoffMinutes)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
modalRef.current?.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logoff-Zeit speichern
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
className="btn btn-soft btn-error"
|
||||||
|
type="submit"
|
||||||
|
onSubmit={() => false}
|
||||||
|
onClick={() => {
|
||||||
|
connection.disconnect();
|
||||||
|
modalRef.current?.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verbindung Trennen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
onSubmit={() => false}
|
onSubmit={() => false}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const selectedStation = stations?.find(
|
const selectedStation = stations?.find(
|
||||||
(station) =>
|
(station) =>
|
||||||
station.id === parseInt(form.selectedStationId?.toString() || ""),
|
station.id === parseInt(form.selectedStationId?.toString() || ""),
|
||||||
);
|
);
|
||||||
if (selectedStation) {
|
if (selectedStation) {
|
||||||
connection.connect(
|
const [logoffHours, logoffMinutes] =
|
||||||
|
form.logoffTime?.split(":").map(Number) || [];
|
||||||
|
|
||||||
|
await connection.connect(
|
||||||
uid,
|
uid,
|
||||||
form.selectedStationId?.toString() || "",
|
form.selectedStationId?.toString() || "",
|
||||||
form.logoffTime || "",
|
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
|
||||||
|
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
|
||||||
|
: "",
|
||||||
selectedStation,
|
selectedStation,
|
||||||
session.data!.user,
|
session.data!.user,
|
||||||
form.debugPosition,
|
form.debugPosition,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
modalRef.current?.close();
|
||||||
}}
|
}}
|
||||||
className="btn btn-soft btn-info"
|
className="btn btn-soft btn-info"
|
||||||
>
|
>
|
||||||
{connection.status == "disconnected" ? "Verbinden" : connection.status}
|
{connection.status == "disconnected" ? "Verbinden" : connection.status}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { GearIcon } from "@radix-ui/react-icons";
|
||||||
|
import { Bell, SettingsIcon, Volume2 } from "lucide-react";
|
||||||
|
import MicVolumeBar from "_components/MicVolumeIndication";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { editUserAPI, getUserAPI } from "_querys/user";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useAudioStore } from "_store/audioStore";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const SettingsBtn = () => {
|
||||||
|
const session = useSession();
|
||||||
|
|
||||||
|
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ["user", session.data?.user.id],
|
||||||
|
queryFn: () => getUserAPI(session.data!.user.id),
|
||||||
|
});
|
||||||
|
const testSoundRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const editUserMutation = useMutation({
|
||||||
|
mutationFn: editUserAPI,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const modalRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
const [showIndication, setShowIndication] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
micDeviceId: user?.settingsMicDevice || null,
|
||||||
|
micVolume: user?.settingsMicVolume || 1,
|
||||||
|
radioVolume: user?.settingsRadioVolume || 0.8,
|
||||||
|
dmeVolume: user?.settingsDmeVolume || 0.8,
|
||||||
|
pilotNtfyRoom: user?.settingsNtfyRoom || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setAudioSettings({
|
||||||
|
micDeviceId: user.settingsMicDevice,
|
||||||
|
micVolume: user.settingsMicVolume || 1,
|
||||||
|
radioVolume: user.settingsRadioVolume || 0.8,
|
||||||
|
dmeVolume: user.settingsDmeVolume || 0.8,
|
||||||
|
});
|
||||||
|
setSettings({
|
||||||
|
micDeviceId: user.settingsMicDevice,
|
||||||
|
micVolume: user.settingsMicVolume || 1,
|
||||||
|
radioVolume: user.settingsRadioVolume || 0.8,
|
||||||
|
dmeVolume: user.settingsDmeVolume || 0.8,
|
||||||
|
pilotNtfyRoom: user.settingsNtfyRoom || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, setSettings, setAudioSettings]);
|
||||||
|
|
||||||
|
const setSettingsPartial = (newSettings: Partial<typeof settings>) => {
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newSettings,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const setDevices = async () => {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setDevices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onSubmit={() => false}
|
||||||
|
onClick={() => {
|
||||||
|
modalRef.current?.showModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GearIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<dialog ref={modalRef} className="modal">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
|
||||||
|
<SettingsIcon size={20} /> Einstellungen
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<fieldset className="fieldset mb-2 w-full">
|
||||||
|
<label className="floating-label w-full text-base">
|
||||||
|
<span>Eingabegerät</span>
|
||||||
|
<select
|
||||||
|
className="input w-full"
|
||||||
|
value={settings.micDeviceId ? settings.micDeviceId : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSettingsPartial({ micDeviceId: e.target.value });
|
||||||
|
setShowIndication(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option key={0} value={0} disabled>
|
||||||
|
Bitte wähle ein Eingabegerät...
|
||||||
|
</option>
|
||||||
|
{inputDevices.map((device, index) => (
|
||||||
|
<option key={index} value={device.deviceId}>
|
||||||
|
{device.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
|
||||||
|
<Volume2 size={20} /> Eingabelautstärke
|
||||||
|
</p>
|
||||||
|
<div className="w-full">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
setSettingsPartial({ micVolume: value });
|
||||||
|
setShowIndication(true);
|
||||||
|
}}
|
||||||
|
value={settings.micVolume}
|
||||||
|
className="range range-xs range-accent w-full"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex justify-between px-2.5 text-xs">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>25%</span>
|
||||||
|
<span>50%</span>
|
||||||
|
<span>75%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showIndication && (
|
||||||
|
<MicVolumeBar
|
||||||
|
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
|
||||||
|
volumeInput={settings.micVolume}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="divider w-full" />
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 flex items-center gap-2 text-base">
|
||||||
|
<Volume2 size={20} /> Funk Lautstärke
|
||||||
|
</p>
|
||||||
|
<div className="mb-2 w-full">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
setSettingsPartial({ radioVolume: value });
|
||||||
|
}}
|
||||||
|
value={settings.radioVolume}
|
||||||
|
className="range range-xs range-primary w-full"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex justify-between px-2.5 text-xs">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>25%</span>
|
||||||
|
<span>50%</span>
|
||||||
|
<span>75%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-2 flex items-center gap-2 text-base">
|
||||||
|
<Volume2 size={20} /> Melder Lautstärke
|
||||||
|
</p>
|
||||||
|
<div className="w-full">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
setSettingsPartial({ dmeVolume: value });
|
||||||
|
if (!testSoundRef.current) return;
|
||||||
|
testSoundRef.current.volume = value;
|
||||||
|
testSoundRef.current.play();
|
||||||
|
}}
|
||||||
|
value={settings.dmeVolume}
|
||||||
|
className="range range-xs range-primary w-full"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex justify-between px-2.5 text-xs">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>25%</span>
|
||||||
|
<span>50%</span>
|
||||||
|
<span>75%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<div className="divider w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="floating-label w-full">
|
||||||
|
<span className="flex items-center gap-2 text-lg">
|
||||||
|
<Bell /> NTFY room
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
placeholder="Erhalte eine Benachrichtigung auf dein Handy über NTFY"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
value={settings.pilotNtfyRoom}
|
||||||
|
onChange={(e) => setSettingsPartial({ pilotNtfyRoom: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="label mt-2 w-full">
|
||||||
|
<Link
|
||||||
|
href="https://docs.virtualairrescue.com/pilotenbereich/app-alarmierung.html#download"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
Hier
|
||||||
|
</Link>
|
||||||
|
findest du mehr Informationen!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action flex justify-between">
|
||||||
|
<button
|
||||||
|
className="btn btn-soft"
|
||||||
|
type="submit"
|
||||||
|
onSubmit={() => false}
|
||||||
|
onClick={() => {
|
||||||
|
modalRef.current?.close();
|
||||||
|
testSoundRef.current?.pause();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-soft btn-success"
|
||||||
|
type="submit"
|
||||||
|
onSubmit={() => false}
|
||||||
|
onClick={async () => {
|
||||||
|
testSoundRef.current?.pause();
|
||||||
|
await editUserMutation.mutateAsync({
|
||||||
|
id: session.data!.user.id,
|
||||||
|
user: {
|
||||||
|
settingsMicDevice: settings.micDeviceId,
|
||||||
|
settingsMicVolume: settings.micVolume,
|
||||||
|
settingsRadioVolume: settings.radioVolume,
|
||||||
|
settingsDmeVolume: settings.dmeVolume,
|
||||||
|
settingsNtfyRoom: settings.pilotNtfyRoom,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setAudioSettings({
|
||||||
|
micDeviceId: settings.micDeviceId,
|
||||||
|
micVolume: settings.micVolume,
|
||||||
|
radioVolume: settings.radioVolume,
|
||||||
|
dmeVolume: settings.dmeVolume,
|
||||||
|
});
|
||||||
|
modalRef.current?.close();
|
||||||
|
toast.success("Einstellungen gespeichert");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const Settings = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsBtn />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,7 +16,13 @@ export default async function RootLayout({
|
|||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
|
|
||||||
if (!session?.user.permissions.includes("PILOT"))
|
if (!session?.user.permissions.includes("PILOT"))
|
||||||
return <Error title="Zugriff verweigert" statusCode={403} />;
|
return (
|
||||||
|
<Error
|
||||||
|
title=" Fehlende Berechtigung"
|
||||||
|
description="Du hast nicht die erforderlichen Berechtigungen, dich als Pilot anzumelden. Du kannst in HUB Kurse abschließen um die Berechtigung zu erhalten."
|
||||||
|
statusCode={403}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import dynamic from "next/dynamic";
|
|||||||
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher";
|
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
import { getAircraftsAPI } from "_querys/aircrafts";
|
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
import { checkSimulatorConnected } from "@repo/shared-components";
|
import { checkSimulatorConnected } from "@repo/shared-components";
|
||||||
import { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert";
|
import { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert";
|
||||||
|
import { SettingsBoard } from "_components/left/SettingsBoard";
|
||||||
|
import { BugReport } from "_components/left/BugReport";
|
||||||
|
|
||||||
const Map = dynamic(() => import("_components/map/Map"), {
|
const Map = dynamic(() => import("_components/map/Map"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -18,30 +20,33 @@ const Map = dynamic(() => import("_components/map/Map"), {
|
|||||||
|
|
||||||
const PilotPage = () => {
|
const PilotPage = () => {
|
||||||
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
|
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
|
||||||
const { data: ownAircraftArray = [] } = useQuery({
|
// Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
|
||||||
queryKey: ["own-aircraft", connectedAircraft?.id],
|
const { data: aircrafts } = useQuery({
|
||||||
queryFn: () =>
|
queryKey: ["aircrafts"],
|
||||||
getAircraftsAPI({
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
id: connectedAircraft?.id,
|
refetchInterval: 10_000,
|
||||||
}),
|
|
||||||
refetchInterval: 10000,
|
|
||||||
});
|
});
|
||||||
const ownAircraft = ownAircraftArray[0];
|
|
||||||
|
const ownAircraft = aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
|
||||||
const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
|
const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex transition-all duration-500 ease w-full h-screen overflow-hidden">
|
<div className="ease relative flex h-screen w-full flex-1 overflow-hidden transition-all duration-500">
|
||||||
{/* <MapToastCard2 /> */}
|
{/* <MapToastCard2 /> */}
|
||||||
<div className="flex flex-1 relative w-full h-full">
|
<div className="relative flex h-full w-full flex-1">
|
||||||
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
|
<div className="absolute left-0 top-1/2 z-20 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
|
||||||
<Chat />
|
<Chat />
|
||||||
<div className="mt-2">
|
<Report />
|
||||||
<Report />
|
<BugReport />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-2/3 h-full">
|
<div className="flex h-full w-2/3">
|
||||||
<div className="relative flex flex-1 h-full">
|
<div className="relative flex h-full flex-1">
|
||||||
|
<div className="top-19/20 absolute left-0 z-20 -translate-y-1/2 transform pl-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<SettingsBoard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Map />
|
<Map />
|
||||||
<div className="absolute top-5 right-10 z-99999 space-y-2">
|
<div className="absolute right-10 top-5 z-20 space-y-2">
|
||||||
{!simulatorConnected && status === "connected" && (
|
{!simulatorConnected && status === "connected" && (
|
||||||
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
|
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
|
||||||
)}
|
)}
|
||||||
@@ -49,19 +54,19 @@ const PilotPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-1/3 h-full">
|
<div className="flex h-full w-1/3">
|
||||||
<div className="flex flex-col w-full h-full p-4 bg-base-300">
|
<div className="bg-base-300 flex h-full w-full flex-col p-4">
|
||||||
<h2 className="card-title mb-2">MRT & DME</h2>
|
<h2 className="card-title mb-2">MRT & DME</h2>
|
||||||
<div className="card bg-base-200 shadow-xl mb-4">
|
<div className="card bg-base-200 mb-4 shadow-xl">
|
||||||
<div className="card-body w-full h-full flex items-center justify-center">
|
<div className="card-body flex h-full w-full items-center justify-center">
|
||||||
<div className=" max-w-150">
|
<div className="max-w-150">
|
||||||
<Mrt />
|
<Mrt />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card bg-base-200 shadow-xl h-1/2 flex">
|
<div className="card bg-base-200 flex h-1/2 shadow-xl">
|
||||||
<div className="card-body w-full h-full p-4 mb-0 flex items-center justify-center">
|
<div className="card-body mb-0 flex h-full w-full items-center justify-center p-4">
|
||||||
<div className=" max-w-140">
|
<div className="max-w-140">
|
||||||
<Dme />
|
<Dme />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,27 +47,28 @@ export default function MicrophoneLevel({ deviceId, volumeInput }: MicrophoneLev
|
|||||||
};
|
};
|
||||||
}, [deviceId, volumeInput]);
|
}, [deviceId, volumeInput]);
|
||||||
|
|
||||||
const barWidth = Math.max((volumeLevel / 70) * 100 - 35, 0);
|
const barWidth = Math.min((volumeLevel / 140) * 100, 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative w-full bg-base-300 h-5 rounded">
|
<div className="relative w-full bg-base-300 h-5 rounded">
|
||||||
<div
|
<div
|
||||||
className={cn("bg-primary h-full rounded", barWidth > 100 && "bg-red-400")}
|
className={cn("bg-primary h-full rounded", barWidth == 100 && "bg-red-400")}
|
||||||
style={{
|
style={{
|
||||||
width: `${barWidth > 100 ? 100 : barWidth}%`,
|
width: `${barWidth}%`,
|
||||||
transition: "width 0.2s",
|
transition: "width 0.2s",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-[60%] w-[20%] h-full bg-green-500 opacity-40 rounded"
|
className="absolute top-0 left-[60%] w-[30%] h-full bg-green-500 opacity-40 rounded"
|
||||||
style={{
|
style={{
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
Lautstärke sollte beim Sprechen in dem Grünen bereich bleiben
|
Lautstärke sollte beim Sprechen in dem Grünen bereich bleiben. Beachte das scharfe Laute
|
||||||
|
(z.B. "S" oder "Z") die Anzeige verfälschen können.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useMapStore } from "_store/mapStore";
|
|||||||
import { AdminMessageToast } from "_components/customToasts/AdminMessage";
|
import { AdminMessageToast } from "_components/customToasts/AdminMessage";
|
||||||
import { pilotSocket } from "(app)/pilot/socket";
|
import { pilotSocket } from "(app)/pilot/socket";
|
||||||
import { QUICK_RESPONSE, StatusToast } from "_components/customToasts/StationStatusToast";
|
import { QUICK_RESPONSE, StatusToast } from "_components/customToasts/StationStatusToast";
|
||||||
|
import { MissionAutoCloseToast } from "_components/customToasts/MissionAutoClose";
|
||||||
|
|
||||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
const mapStore = useMapStore((s) => s);
|
const mapStore = useMapStore((s) => s);
|
||||||
@@ -79,6 +80,14 @@ export function QueryProvider({ children }: { children: ReactNode }) {
|
|||||||
duration: 60000,
|
duration: 60000,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "mission-auto-close":
|
||||||
|
toast.custom(
|
||||||
|
(t) => <MissionAutoCloseToast event={notification} t={t} mapStore={mapStore} />,
|
||||||
|
{
|
||||||
|
duration: 60000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
toast("unbekanntes Notification-Event");
|
toast("unbekanntes Notification-Event");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -139,10 +139,10 @@ export const SmartPopup = (
|
|||||||
<Popup {...props} className={cn("relative", wrapperClassName)}>
|
<Popup {...props} className={cn("relative", wrapperClassName)}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto bg-base-100 relative",
|
"bg-base-100 pointer-events-auto relative",
|
||||||
anchor.includes("right") && "-translate-x-full",
|
anchor.includes("right") && "-translate-x-full",
|
||||||
anchor.includes("bottom") && "-translate-y-full",
|
anchor.includes("bottom") && "-translate-y-full",
|
||||||
!showContent && "opacity-0 pointer-events-none",
|
!showContent && "pointer-events-none opacity-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -150,7 +150,7 @@ export const SmartPopup = (
|
|||||||
data-id={id}
|
data-id={id}
|
||||||
id={`popup-domain-${id}`}
|
id={`popup-domain-${id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"map-collision absolute w-[200%] h-[200%] top-0 left-0 transform pointer-events-none",
|
"map-collision pointer-events-none absolute left-0 top-0 h-[200%] w-[200%] transform",
|
||||||
anchor.includes("left") && "-translate-x-1/2",
|
anchor.includes("left") && "-translate-x-1/2",
|
||||||
anchor.includes("top") && "-translate-y-1/2",
|
anchor.includes("top") && "-translate-y-1/2",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,10 +15,19 @@ export const HPGnotificationToast = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
toast.dismiss(t.id);
|
toast.dismiss(t.id);
|
||||||
mapStore.setOpenMissionMarker({
|
|
||||||
open: [{ id: event.data.mission.id, tab: "home" }],
|
if (mapStore.userSettings.settingsAutoCloseMapPopup) {
|
||||||
close: [],
|
mapStore.setOpenMissionMarker({
|
||||||
});
|
open: [{ id: event.data.mission.id, tab: "home" }],
|
||||||
|
close: mapStore.openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mapStore.setOpenMissionMarker({
|
||||||
|
open: [{ id: event.data.mission.id, tab: "home" }],
|
||||||
|
close: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mapStore.setMap({
|
mapStore.setMap({
|
||||||
center: [event.data.mission.addressLat, event.data.mission.addressLng],
|
center: [event.data.mission.addressLat, event.data.mission.addressLng],
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
@@ -29,7 +38,7 @@ export const HPGnotificationToast = ({
|
|||||||
return (
|
return (
|
||||||
<BaseNotification icon={<Cross />} className="flex flex-row">
|
<BaseNotification icon={<Cross />} className="flex flex-row">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-red-500 font-bold">HPG validierung fehlgeschlagen</h1>
|
<h1 className="font-bold text-red-500">HPG validierung fehlgeschlagen</h1>
|
||||||
<p>{event.message}</p>
|
<p>{event.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-11">
|
<div className="ml-11">
|
||||||
@@ -43,7 +52,7 @@ export const HPGnotificationToast = ({
|
|||||||
return (
|
return (
|
||||||
<BaseNotification icon={<Check />} className="flex flex-row">
|
<BaseNotification icon={<Check />} className="flex flex-row">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-green-600 font-bold">HPG validierung erfolgreich</h1>
|
<h1 className="font-bold text-green-600">HPG validierung erfolgreich</h1>
|
||||||
<p className="text-sm">{event.message}</p>
|
<p className="text-sm">{event.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-11">
|
<div className="ml-11">
|
||||||
|
|||||||
100
apps/dispatch/app/_components/customToasts/MissionAutoClose.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { getPublicUser, MissionAutoClose, Prisma } from "@repo/db";
|
||||||
|
import { JsonValueType } from "@repo/db/zod";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { BaseNotification } from "_components/customToasts/BaseNotification";
|
||||||
|
import { editMissionAPI } from "_querys/missions";
|
||||||
|
import { MapStore } from "_store/mapStore";
|
||||||
|
import { Clock, X } from "lucide-react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import toast, { Toast } from "react-hot-toast";
|
||||||
|
|
||||||
|
export const MissionAutoCloseToast = ({
|
||||||
|
event,
|
||||||
|
t,
|
||||||
|
mapStore,
|
||||||
|
}: {
|
||||||
|
event: MissionAutoClose;
|
||||||
|
t: Toast;
|
||||||
|
mapStore: MapStore;
|
||||||
|
}) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const editMissionMutation = useMutation({
|
||||||
|
mutationFn: ({ id, mission }: { id: number; mission: Partial<Prisma.MissionUpdateInput> }) =>
|
||||||
|
editMissionAPI(id, mission),
|
||||||
|
mutationKey: ["missions"],
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["missions"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseNotification icon={<Clock />} className="flex flex-row">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-warning font-bold">Inaktiver Einsatz wurde automatisch geschlossen</h1>
|
||||||
|
<p>{event.message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-11">
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!session?.user) return;
|
||||||
|
const mission = await editMissionMutation.mutateAsync({
|
||||||
|
id: event.data.missionId,
|
||||||
|
mission: {
|
||||||
|
state: "running",
|
||||||
|
missionLog: {
|
||||||
|
push: {
|
||||||
|
type: "reopened-log",
|
||||||
|
timeStamp: new Date().toISOString(),
|
||||||
|
data: {
|
||||||
|
user: getPublicUser(session?.user, {
|
||||||
|
ignorePrivacy: true,
|
||||||
|
}) as unknown as JsonValueType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mapStore.setMap({
|
||||||
|
zoom: 14,
|
||||||
|
center: {
|
||||||
|
lat: mission.addressLat,
|
||||||
|
lng: mission.addressLng,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (mapStore.userSettings.settingsAutoCloseMapPopup) {
|
||||||
|
mapStore.setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: mapStore.openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mapStore.setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.dismiss(t.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
schließen widerrufen
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => toast.remove(t.id)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BaseNotification>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { BaseNotification } from "_components/customToasts/BaseNotification";
|
import { BaseNotification } from "_components/customToasts/BaseNotification";
|
||||||
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
|
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
|
||||||
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
|
import { getLivekitRooms } from "_querys/livekit";
|
||||||
import { getStationsAPI } from "_querys/stations";
|
import { getStationsAPI } from "_querys/stations";
|
||||||
|
import { useAudioStore } from "_store/audioStore";
|
||||||
import { useMapStore } from "_store/mapStore";
|
import { useMapStore } from "_store/mapStore";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -20,6 +22,23 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
const status5Sounds = useRef<HTMLAudioElement | null>(null);
|
const status5Sounds = useRef<HTMLAudioElement | null>(null);
|
||||||
const status9Sounds = useRef<HTMLAudioElement | null>(null);
|
const status9Sounds = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const { data: livekitRooms } = useQuery({
|
||||||
|
queryKey: ["livekit-rooms"],
|
||||||
|
queryFn: () => getLivekitRooms(),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
const audioRoom = useAudioStore((s) => s.room?.name);
|
||||||
|
|
||||||
|
const participants =
|
||||||
|
livekitRooms?.flatMap((room) =>
|
||||||
|
room.participants.map((p) => ({
|
||||||
|
...p,
|
||||||
|
roomName: room.room.name,
|
||||||
|
})),
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
const livekitUser = participants.find((p) => p.attributes.userId === event.data?.userId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
status0Sounds.current = new Audio("/sounds/status-0.mp3");
|
status0Sounds.current = new Audio("/sounds/status-0.mp3");
|
||||||
@@ -28,7 +47,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
|
const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
|
||||||
const mapStore = useMapStore((s) => s);
|
//const mapStore = useMapStore((s) => s);
|
||||||
|
const { setOpenAircraftMarker, setMap } = useMapStore((store) => store);
|
||||||
|
|
||||||
const { data: connectedAircrafts } = useQuery({
|
const { data: connectedAircrafts } = useQuery({
|
||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
@@ -83,6 +103,11 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
default:
|
default:
|
||||||
soundRef = null;
|
soundRef = null;
|
||||||
}
|
}
|
||||||
|
if (audioRoom !== livekitUser?.roomName) {
|
||||||
|
toast.remove(t.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (soundRef?.current) {
|
if (soundRef?.current) {
|
||||||
soundRef.current.currentTime = 0;
|
soundRef.current.currentTime = 0;
|
||||||
soundRef.current.volume = 0.7;
|
soundRef.current.volume = 0.7;
|
||||||
@@ -94,22 +119,23 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
soundRef.current.currentTime = 0;
|
soundRef.current.currentTime = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [event.status]);
|
}, [event.status, livekitUser?.roomName, audioRoom, t.id]);
|
||||||
|
|
||||||
if (!connectedAircraft || !station) return null;
|
if (!connectedAircraft || !station) return null;
|
||||||
return (
|
return (
|
||||||
<BaseNotification>
|
<BaseNotification>
|
||||||
<div className="flex flex-row gap-14 items-center">
|
<div className="flex flex-row items-center gap-14">
|
||||||
<p>
|
<p>
|
||||||
<span
|
<span
|
||||||
className="underline mr-1 cursor-pointer font-bold"
|
className="mr-1 cursor-pointer font-bold underline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!connectedAircraft.posLat || !connectedAircraft.posLng) return;
|
if (!connectedAircraft.posLat || !connectedAircraft.posLng) return;
|
||||||
mapStore.setOpenAircraftMarker({
|
|
||||||
|
setOpenAircraftMarker({
|
||||||
open: [{ id: connectedAircraft.id, tab: "fms" }],
|
open: [{ id: connectedAircraft.id, tab: "fms" }],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
mapStore.setMap({
|
setMap({
|
||||||
center: [connectedAircraft.posLat, connectedAircraft.posLng],
|
center: [connectedAircraft.posLat, connectedAircraft.posLng],
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
});
|
});
|
||||||
@@ -119,12 +145,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
</span>
|
</span>
|
||||||
sendet Status {event.status}
|
sendet Status {event.status}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex items-center gap-2">
|
||||||
{QUICK_RESPONSE[String(event.status)]?.map((status) => (
|
{QUICK_RESPONSE[String(event.status)]?.map((status) => (
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
className={
|
className={
|
||||||
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold"
|
"flex min-h-10 min-w-10 cursor-pointer items-center justify-center text-lg font-bold"
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: FMS_STATUS_COLORS[status],
|
backgroundColor: FMS_STATUS_COLORS[status],
|
||||||
|
|||||||
16
apps/dispatch/app/_components/left/BugReport.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Bug } from "lucide-react";
|
||||||
|
|
||||||
|
export const BugReport = () => {
|
||||||
|
return (
|
||||||
|
<div className="indicator">
|
||||||
|
<a
|
||||||
|
className="btn btn-soft btn-sm btn-warning tooltip tooltip-right"
|
||||||
|
data-tip="Fehler melden"
|
||||||
|
href="https://discord.com/channels/1077269395019141140/1395892524404576367"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Bug className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
|
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
|
||||||
import { useLeftMenuStore } from "_store/leftMenuStore";
|
import { useLeftMenuStore } from "_store/leftMenuStore";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useEffect, useState, useRef } from "react";
|
||||||
import { cn } from "@repo/shared-components";
|
import { cn } from "@repo/shared-components";
|
||||||
import { asPublicUser } from "@repo/db";
|
import { asPublicUser } from "@repo/db";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
|
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
|
||||||
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||||
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
|
||||||
export const Chat = () => {
|
export const Chat = () => {
|
||||||
const {
|
const {
|
||||||
@@ -26,6 +28,10 @@ export const Chat = () => {
|
|||||||
const session = useSession();
|
const session = useSession();
|
||||||
const [addTabValue, setAddTabValue] = useState<string>("default");
|
const [addTabValue, setAddTabValue] = useState<string>("default");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
|
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
|
||||||
|
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
|
||||||
|
const [someChat, setSomeChat] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
const { data: dispatcher } = useQuery({
|
const { data: dispatcher } = useQuery({
|
||||||
queryKey: ["dispatcher"],
|
queryKey: ["dispatcher"],
|
||||||
@@ -36,6 +42,7 @@ export const Chat = () => {
|
|||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
queryFn: () => getConnectedAircraftsAPI(),
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
|
enabled: dispatcherConnected,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,17 +51,42 @@ export const Chat = () => {
|
|||||||
}, [session.data?.user.id, setOwnId]);
|
}, [session.data?.user.id, setOwnId]);
|
||||||
|
|
||||||
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
|
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
|
||||||
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
|
const filteredAircrafts = aircrafts?.filter(
|
||||||
|
(a) => a.userId !== session.data?.user.id && dispatcherConnected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const btnActive = pilotConnected || dispatcherConnected;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!btnActive) {
|
||||||
|
setChatOpen(false);
|
||||||
|
}
|
||||||
|
}, [btnActive, setChatOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.values(chats).some((c) => c.notification)) {
|
||||||
|
setSomeChat(true);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = 0.5;
|
||||||
|
audioRef.current.play().catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSomeChat(false);
|
||||||
|
}
|
||||||
|
}, [chats]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
|
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
|
||||||
|
<audio ref={audioRef} src="/sounds/newChat.mp3" preload="auto" />
|
||||||
<div className="indicator">
|
<div className="indicator">
|
||||||
{Object.values(chats).some((c) => c.notification) && (
|
|
||||||
<span className="indicator-item status status-info"></span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft btn-sm btn-primary"
|
className={cn(
|
||||||
|
"btn btn-soft btn-sm cursor-default",
|
||||||
|
btnActive && "btn-primary cursor-pointer",
|
||||||
|
someChat && "border-warning animate-pulse",
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!btnActive) return;
|
||||||
setReportTabOpen(false);
|
setReportTabOpen(false);
|
||||||
setChatOpen(!chatOpen);
|
setChatOpen(!chatOpen);
|
||||||
if (selectedChat) {
|
if (selectedChat) {
|
||||||
@@ -62,23 +94,23 @@ export const Chat = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChatBubbleIcon className="w-4 h-4" />
|
<ChatBubbleIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{chatOpen && (
|
{chatOpen && (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100] max-h-[480px] ml-2 border-1 border-primary"
|
className="dropdown-content card bg-base-200 w-150 border-1 border-primary z-[1100] ml-2 max-h-[480px] shadow-md"
|
||||||
>
|
>
|
||||||
<div className="card-body relative">
|
<div className="card-body relative">
|
||||||
<button
|
<button
|
||||||
className="absolute top-2 right-2 btn btn-xs btn-circle btn-ghost"
|
className="btn btn-xs btn-circle btn-ghost absolute right-2 top-2"
|
||||||
onClick={() => setChatOpen(false)}
|
onClick={() => setChatOpen(false)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="text-xl leading-none">×</span>
|
<span className="text-xl leading-none">×</span>
|
||||||
</button>
|
</button>
|
||||||
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
|
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
|
||||||
<ChatBubbleIcon /> Chat
|
<ChatBubbleIcon /> Chat
|
||||||
</h2>
|
</h2>
|
||||||
<div className="join">
|
<div className="join">
|
||||||
@@ -134,12 +166,18 @@ export const Chat = () => {
|
|||||||
<Fragment key={userId}>
|
<Fragment key={userId}>
|
||||||
<a
|
<a
|
||||||
className={cn("indicator tab", selectedChat === userId && "tab-active")}
|
className={cn("indicator tab", selectedChat === userId && "tab-active")}
|
||||||
onClick={() => setSelectedChat(userId)}
|
onClick={() => {
|
||||||
|
if (selectedChat === userId) {
|
||||||
|
setSelectedChat(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedChat(userId);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{chat.name}
|
{chat.name}
|
||||||
{chat.notification && <span className="indicator-item status status-info" />}
|
{chat.notification && <span className="indicator-item status status-info" />}
|
||||||
</a>
|
</a>
|
||||||
<div className="tab-content bg-base-100 border-base-300 p-6 overflow-y-auto max-h-[250px]">
|
<div className="tab-content bg-base-100 border-base-300 max-h-[250px] overflow-y-auto p-6">
|
||||||
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
|
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
|
||||||
{chat.messages.map((chatMessage) => {
|
{chat.messages.map((chatMessage) => {
|
||||||
const isSender = chatMessage.senderId === session.data?.user.id;
|
const isSender = chatMessage.senderId === session.data?.user.id;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
|
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
|
||||||
import { sendReportAPI } from "_querys/report";
|
import { sendReportAPI } from "_querys/report";
|
||||||
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||||
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
|
||||||
export const Report = () => {
|
export const Report = () => {
|
||||||
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore();
|
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore();
|
||||||
@@ -18,6 +20,9 @@ export const Report = () => {
|
|||||||
const [selectedPlayer, setSelectedPlayer] = useState<string>("default");
|
const [selectedPlayer, setSelectedPlayer] = useState<string>("default");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
|
|
||||||
|
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
|
||||||
|
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session.data?.user.id) return;
|
if (!session.data?.user.id) return;
|
||||||
setOwnId(session.data.user.id);
|
setOwnId(session.data.user.id);
|
||||||
@@ -36,6 +41,13 @@ export const Report = () => {
|
|||||||
|
|
||||||
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== 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);
|
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
|
||||||
|
const btnActive = pilotConnected || dispatcherConnected;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!btnActive) {
|
||||||
|
setReportTabOpen(false);
|
||||||
|
}
|
||||||
|
}, [btnActive, setReportTabOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -43,8 +55,12 @@ export const Report = () => {
|
|||||||
>
|
>
|
||||||
<div className="indicator">
|
<div className="indicator">
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft btn-sm btn-error"
|
className={cn(
|
||||||
|
"btn btn-soft btn-sm cursor-default",
|
||||||
|
btnActive && "cursor-pointer btn-error",
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!btnActive) return;
|
||||||
setChatOpen(false);
|
setChatOpen(false);
|
||||||
setReportTabOpen(!reportTabOpen);
|
setReportTabOpen(!reportTabOpen);
|
||||||
}}
|
}}
|
||||||
|
|||||||
104
apps/dispatch/app/_components/left/SettingsBoard.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
import { useLeftMenuStore } from "_store/leftMenuStore";
|
||||||
|
import { cn } from "@repo/shared-components";
|
||||||
|
import { SettingsIcon } from "lucide-react";
|
||||||
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
|
||||||
|
export const SettingsBoard = () => {
|
||||||
|
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
|
||||||
|
const { followOwnAircraft, showOtherAircrafts, showOtherMissions, setMapOptions } =
|
||||||
|
usePilotConnectionStore();
|
||||||
|
const cross = (
|
||||||
|
<svg
|
||||||
|
aria-label="disabled"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const check = (
|
||||||
|
<svg aria-label="enabled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
|
||||||
|
<div className="indicator">
|
||||||
|
<button
|
||||||
|
className="btn btn-soft btn-sm btn-info"
|
||||||
|
onClick={() => {
|
||||||
|
setSituationTabOpen(!situationTabOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{situationTabOpen && (
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
className="dropdown-content card bg-base-200 shadow-md z-[1100] ml-2 border-1 border-info min-w-[300px] max-h-[300px]"
|
||||||
|
>
|
||||||
|
<div className="card-body flex flex-row gap-4">
|
||||||
|
<div className="flex flex-col w-full h-full gap-2">
|
||||||
|
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
|
||||||
|
<SettingsIcon size={18} /> Map Einstellungen
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="toggle text-base-content">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={followOwnAircraft}
|
||||||
|
onChange={(e) => setMapOptions({ followOwnAircraft: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{cross}
|
||||||
|
{check}
|
||||||
|
</label>
|
||||||
|
Folge mir selbst
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="toggle text-base-content">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showOtherAircrafts}
|
||||||
|
onChange={(e) => setMapOptions({ showOtherAircrafts: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{cross}
|
||||||
|
{check}
|
||||||
|
</label>
|
||||||
|
Zeige andere Piloten
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="toggle text-base-content">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showOtherMissions}
|
||||||
|
onChange={(e) => setMapOptions({ showOtherMissions: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{cross}
|
||||||
|
{check}
|
||||||
|
</label>
|
||||||
|
Zeige andere Einsätze
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
|||||||
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
||||||
import { useMapStore } from "_store/mapStore";
|
import { useMapStore } from "_store/mapStore";
|
||||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||||
|
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
|
||||||
|
|
||||||
export const SituationBoard = () => {
|
export const SituationBoard = () => {
|
||||||
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
|
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
|
||||||
@@ -53,7 +54,14 @@ export const SituationBoard = () => {
|
|||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
queryFn: () => getConnectedAircraftsAPI(),
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
});
|
});
|
||||||
const { setOpenAircraftMarker, setOpenMissionMarker, setMap } = useMapStore((state) => state);
|
const {
|
||||||
|
setOpenAircraftMarker,
|
||||||
|
setOpenMissionMarker,
|
||||||
|
setMap,
|
||||||
|
userSettings,
|
||||||
|
openAircraftMarker,
|
||||||
|
openMissionMarker,
|
||||||
|
} = useMapStore((state) => state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
|
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
|
||||||
@@ -64,17 +72,17 @@ export const SituationBoard = () => {
|
|||||||
setSituationTabOpen(!situationTabOpen);
|
setSituationTabOpen(!situationTabOpen);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListCollapse className="w-4 h-4" />
|
<ListCollapse className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{situationTabOpen && (
|
{situationTabOpen && (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="dropdown-content card bg-base-200 shadow-md z-[1100] ml-2 border-1 border-info"
|
className="dropdown-content card bg-base-200 border-1 border-info z-[1100] ml-2 max-h-[300px] min-w-[900px] shadow-md"
|
||||||
>
|
>
|
||||||
<div className="card-body flex flex-row gap-4">
|
<div className="card-body flex flex-row gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
|
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
|
||||||
<ListCollapse /> Einsatzliste{" "}
|
<ListCollapse /> Einsatzliste{" "}
|
||||||
</h2>
|
</h2>
|
||||||
<div>
|
<div>
|
||||||
@@ -90,8 +98,8 @@ export const SituationBoard = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="max-h-[170px] select-none overflow-x-auto overflow-y-auto">
|
||||||
<table className="table table-xs">
|
<table className="table-xs table">
|
||||||
{/* head */}
|
{/* head */}
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -106,16 +114,32 @@ export const SituationBoard = () => {
|
|||||||
(mission) =>
|
(mission) =>
|
||||||
(dispatcherConnected || mission.state !== "draft") && (
|
(dispatcherConnected || mission.state !== "draft") && (
|
||||||
<tr
|
<tr
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
mission.state === "draft" && "missionListItem",
|
||||||
|
)}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
setOpenMissionMarker({
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
open: [
|
setOpenMissionMarker({
|
||||||
{
|
open: [
|
||||||
id: mission.id,
|
{
|
||||||
tab: "home",
|
id: mission.id,
|
||||||
},
|
tab: "home",
|
||||||
],
|
},
|
||||||
close: [],
|
],
|
||||||
});
|
close: openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
setMap({
|
setMap({
|
||||||
center: {
|
center: {
|
||||||
lat: mission.addressLat,
|
lat: mission.addressLat,
|
||||||
@@ -125,9 +149,8 @@ export const SituationBoard = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
key={mission.id}
|
key={mission.id}
|
||||||
className={cn(mission.state === "draft" && "missionListItem")}
|
|
||||||
>
|
>
|
||||||
<td>{mission.publicId}</td>
|
<td>{mission.publicId.replace("ENr.: ", "")}</td>
|
||||||
<td>{mission.missionKeywordAbbreviation}</td>
|
<td>{mission.missionKeywordAbbreviation}</td>
|
||||||
<td>{mission.addressCity}</td>
|
<td>{mission.addressCity}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -142,13 +165,13 @@ export const SituationBoard = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px bg-gray-400 mx-2" />
|
<div className="mx-2 w-px bg-gray-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
|
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
|
||||||
<Plane /> Stationen
|
<Plane /> Stationen
|
||||||
</h2>
|
</h2>
|
||||||
<div className="overflow-x-auto">
|
<div className="max-h-[200px] select-none overflow-x-auto overflow-y-auto">
|
||||||
<table className="table table-xs">
|
<table className="table-xs table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>BOS Name</th>
|
<th>BOS Name</th>
|
||||||
@@ -157,40 +180,59 @@ export const SituationBoard = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{connectedAircrafts?.map((station) => (
|
{connectedAircrafts?.map((aircraft) => (
|
||||||
<tr
|
<tr
|
||||||
key={station.id}
|
className="cursor-pointer"
|
||||||
|
key={aircraft.id}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
setOpenAircraftMarker({
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
open: [
|
setOpenAircraftMarker({
|
||||||
{
|
open: [
|
||||||
id: station.id,
|
{
|
||||||
tab: "home",
|
id: aircraft.id,
|
||||||
},
|
tab: "home",
|
||||||
],
|
},
|
||||||
close: [],
|
],
|
||||||
});
|
close: openAircraftMarker?.map((m) => m.id) || [],
|
||||||
if (station.posLat === null || station.posLng === null) return;
|
});
|
||||||
|
} else {
|
||||||
|
setOpenAircraftMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: aircraft.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (aircraft.posLat === null || aircraft.posLng === null) return;
|
||||||
setMap({
|
setMap({
|
||||||
center: {
|
center: {
|
||||||
lat: station.posLat,
|
lat: aircraft.posLat,
|
||||||
lng: station.posLng,
|
lng: aircraft.posLng,
|
||||||
},
|
},
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td>{station.Station.bosCallsignShort}</td>
|
<td>{aircraft.Station.bosCallsignShort}</td>
|
||||||
<td
|
<td
|
||||||
className="text-center font-lg font-semibold"
|
className="font-lg text-center font-semibold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[station.fmsStatus],
|
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
|
||||||
backgroundColor: FMS_STATUS_COLORS[station.fmsStatus],
|
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{station.fmsStatus}
|
{aircraft.fmsStatus}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap">
|
||||||
|
{aircraft.posLng || !aircraft.posLat ? (
|
||||||
|
<>{findLeitstelleForPosition(aircraft.posLng!, aircraft.posLat!)}</>
|
||||||
|
) : (
|
||||||
|
aircraft.Station.bosRadioArea
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap">{station.Station.bosRadioArea}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
import { getMissionsAPI } from "_querys/missions";
|
import { getMissionsAPI } from "_querys/missions";
|
||||||
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
||||||
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
const AircraftPopupContent = ({
|
const AircraftPopupContent = ({
|
||||||
aircraft,
|
aircraft,
|
||||||
@@ -36,7 +38,7 @@ const AircraftPopupContent = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: missions } = useQuery({
|
const { data: missions } = useQuery({
|
||||||
queryKey: ["missions", "missions-map"],
|
queryKey: ["missions", "missions-aircraft-marker", aircraft.id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getMissionsAPI({
|
getMissionsAPI({
|
||||||
state: "running",
|
state: "running",
|
||||||
@@ -60,8 +62,8 @@ const AircraftPopupContent = ({
|
|||||||
return mission ? (
|
return mission ? (
|
||||||
<MissionTab mission={mission} />
|
<MissionTab mission={mission} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center min-h-full">
|
<div className="flex min-h-full flex-col items-center justify-center">
|
||||||
<span className="text-gray-500 my-10 font-semibold">Kein aktiver Einsatz</span>
|
<span className="my-10 font-semibold text-gray-500">Kein aktiver Einsatz</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "chat":
|
case "chat":
|
||||||
@@ -71,12 +73,12 @@ const AircraftPopupContent = ({
|
|||||||
}
|
}
|
||||||
}, [currentTab, aircraft, mission]);
|
}, [currentTab, aircraft, mission]);
|
||||||
|
|
||||||
const { setOpenAircraftMarker, setMap } = useMapStore((state) => state);
|
const { setOpenAircraftMarker, setMap, openAircraftMarker } = useMapStore((state) => state);
|
||||||
const { anchor } = useSmartPopup();
|
const { anchor } = useSmartPopup();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="absolute p-1 z-99 top-0 right-0 transform -translate-y-full bg-base-100 cursor-pointer"
|
className="z-99 bg-base-100 absolute right-0 top-0 -translate-y-full transform cursor-pointer p-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenAircraftMarker({
|
setOpenAircraftMarker({
|
||||||
open: [],
|
open: [],
|
||||||
@@ -89,7 +91,7 @@ const AircraftPopupContent = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute w-[calc(100%+2px)] h-4 z-99", // As offset is 2px, we need to add 2px to the width
|
"z-99 absolute h-4 w-[calc(100%+2px)]", // As offset is 2px, we need to add 2px to the width
|
||||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||||
)}
|
)}
|
||||||
@@ -110,13 +112,13 @@ const AircraftPopupContent = ({
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="flex gap-[2px] text-white pb-0.5 overflow-auto"
|
className="flex gap-[2px] pb-0.5 text-white"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="px-3 flex justify-center items-center cursor-pointer"
|
className="flex cursor-pointer items-center justify-center px-3"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -129,7 +131,7 @@ const AircraftPopupContent = ({
|
|||||||
<House className="text-sm" />
|
<House className="text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="px-4 flex justify-center items-center cursor-pointer"
|
className="flex cursor-pointer items-center justify-center px-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
}}
|
}}
|
||||||
@@ -144,7 +146,7 @@ const AircraftPopupContent = ({
|
|||||||
<ChevronsRightLeft className="text-sm" />
|
<ChevronsRightLeft className="text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex justify-center items-center text-5xl font-bold px-6 cursor-pointer"
|
className="flex cursor-pointer items-center justify-center px-6 text-5xl font-bold"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -158,7 +160,7 @@ const AircraftPopupContent = ({
|
|||||||
{aircraft.fmsStatus}
|
{aircraft.fmsStatus}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer px-2 min-w-[130px]"
|
className="cursor-pointer px-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -168,8 +170,8 @@ const AircraftPopupContent = ({
|
|||||||
}}
|
}}
|
||||||
onClick={() => handleTabChange("aircraft")}
|
onClick={() => handleTabChange("aircraft")}
|
||||||
>
|
>
|
||||||
<span className="text-white text-base font-medium truncate">
|
<span className="truncate text-base font-medium text-white">
|
||||||
{aircraft.Station.bosCallsign.length > 20
|
{aircraft.Station.bosCallsign.length > 15
|
||||||
? aircraft.Station.bosCallsignShort
|
? aircraft.Station.bosCallsignShort
|
||||||
: aircraft.Station.bosCallsign}
|
: aircraft.Station.bosCallsign}
|
||||||
</span>
|
</span>
|
||||||
@@ -178,10 +180,11 @@ const AircraftPopupContent = ({
|
|||||||
{aircraft.Station.bosUse === "DUAL_USE" && "(dual use)"}
|
{aircraft.Station.bosUse === "DUAL_USE" && "(dual use)"}
|
||||||
{aircraft.Station.bosUse === "PRIMARY" && "(primär)"}
|
{aircraft.Station.bosUse === "PRIMARY" && "(primär)"}
|
||||||
{aircraft.Station.bosUse === "SECONDARY" && "(sekundär)"}
|
{aircraft.Station.bosUse === "SECONDARY" && "(sekundär)"}
|
||||||
|
{aircraft.Station.bosUse === "SPECIAL_OPS" && "(Special OPS)"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="w-150 cursor-pointer px-2"
|
className="flex-1 cursor-pointer overflow-x-hidden px-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -192,14 +195,15 @@ const AircraftPopupContent = ({
|
|||||||
}}
|
}}
|
||||||
onClick={() => handleTabChange("mission")}
|
onClick={() => handleTabChange("mission")}
|
||||||
>
|
>
|
||||||
<span className="text-white text-base font-medium">Einsatz</span>
|
<span className="text-base font-medium text-white">Einsatz</span>
|
||||||
<br />
|
<br />
|
||||||
<span className="text-white text-sm font-medium">
|
{!mission?.publicId && <span className="text-sm text-gray-400">Kein Einsatz</span>}
|
||||||
{mission?.publicId || "kein Einsatz"}
|
{mission?.publicId && (
|
||||||
</span>
|
<span className="text-sm font-medium text-white">{mission.publicId}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="px-4 flex justify-center items-center cursor-pointer"
|
className="flex cursor-pointer items-center justify-center px-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -226,7 +230,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
|
|||||||
const markerRef = useRef<LMarker>(null);
|
const markerRef = useRef<LMarker>(null);
|
||||||
const popupRef = useRef<LPopup>(null);
|
const popupRef = useRef<LPopup>(null);
|
||||||
|
|
||||||
const { openAircraftMarker, setOpenAircraftMarker } = useMapStore((store) => store);
|
const { openAircraftMarker, setOpenAircraftMarker, userSettings } = useMapStore((store) => store);
|
||||||
const { data: positionLog } = useQuery({
|
const { data: positionLog } = useQuery({
|
||||||
queryKey: ["positionlog", aircraft.id],
|
queryKey: ["positionlog", aircraft.id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -262,14 +266,16 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
|
|||||||
return () => {
|
return () => {
|
||||||
marker?.off("click", handleClick);
|
marker?.off("click", handleClick);
|
||||||
};
|
};
|
||||||
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker]);
|
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker, userSettings]);
|
||||||
|
|
||||||
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
||||||
"topleft",
|
"topleft",
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConflict = useCallback(() => {
|
const handleConflict = useCallback(() => {
|
||||||
const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker");
|
const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker", {
|
||||||
|
ignoreCluster: true,
|
||||||
|
});
|
||||||
setAnchor(newAnchor);
|
setAnchor(newAnchor);
|
||||||
}, [aircraft.id]);
|
}, [aircraft.id]);
|
||||||
|
|
||||||
@@ -371,7 +377,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
|
|||||||
closeOnClick={false}
|
closeOnClick={false}
|
||||||
autoPan={false}
|
autoPan={false}
|
||||||
wrapperClassName="relative"
|
wrapperClassName="relative"
|
||||||
className="w-[502px]"
|
className="w-[512px]"
|
||||||
>
|
>
|
||||||
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
|
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
|
||||||
<AircraftPopupContent aircraft={aircraft} />
|
<AircraftPopupContent aircraft={aircraft} />
|
||||||
@@ -396,10 +402,42 @@ export const AircraftLayer = () => {
|
|||||||
queryFn: () => getConnectedAircraftsAPI(),
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
|
const { setMap } = useMapStore((state) => state);
|
||||||
|
const map = useMap();
|
||||||
|
const {
|
||||||
|
connectedAircraft,
|
||||||
|
status: pilotConnectionStatus,
|
||||||
|
showOtherAircrafts,
|
||||||
|
followOwnAircraft,
|
||||||
|
} = usePilotConnectionStore((state) => state);
|
||||||
|
|
||||||
|
const filteredAircrafts = useMemo(() => {
|
||||||
|
if (!aircrafts) return [];
|
||||||
|
return aircrafts.filter((aircraft) => {
|
||||||
|
if (pilotConnectionStatus === "connected" && !showOtherAircrafts) {
|
||||||
|
return connectedAircraft?.stationId === aircraft.stationId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [aircrafts, pilotConnectionStatus, connectedAircraft, showOtherAircrafts]);
|
||||||
|
|
||||||
|
const ownAircraft = useMemo(() => {
|
||||||
|
return aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
|
||||||
|
}, [aircrafts, connectedAircraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pilotConnectionStatus === "connected" && followOwnAircraft && ownAircraft) {
|
||||||
|
if (!ownAircraft.posLat || !ownAircraft.posLng) return;
|
||||||
|
setMap({
|
||||||
|
center: [ownAircraft.posLat, ownAircraft.posLng],
|
||||||
|
zoom: map.getZoom(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{aircrafts?.map((aircraft) => {
|
{filteredAircrafts?.map((aircraft) => {
|
||||||
return <AircraftMarker key={aircraft.id} aircraft={aircraft} />;
|
return <AircraftMarker key={aircraft.id} aircraft={aircraft} />;
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Control, Icon, LatLngExpression } from "leaflet";
|
import { Control, divIcon, Icon, LatLngExpression } from "leaflet";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
LayerGroup,
|
LayerGroup,
|
||||||
@@ -20,10 +20,11 @@ import L from "leaflet";
|
|||||||
import LEITSTELLENBERECHE from "./_geojson/Leitstellen.json";
|
import LEITSTELLENBERECHE from "./_geojson/Leitstellen.json";
|
||||||
import WINDFARMS from "./_geojson/Windfarms.json";
|
import WINDFARMS from "./_geojson/Windfarms.json";
|
||||||
import { createCustomMarker } from "_components/map/_components/createCustomMarker";
|
import { createCustomMarker } from "_components/map/_components/createCustomMarker";
|
||||||
import { Station } from "@repo/db";
|
import { Heliport, Station } from "@repo/db";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getStationsAPI } from "_querys/stations";
|
import { getStationsAPI } from "_querys/stations";
|
||||||
import "./darkMapStyles.css";
|
import "./darkMapStyles.css";
|
||||||
|
import { getHeliportsAPI } from "_querys/heliports";
|
||||||
|
|
||||||
const RadioAreaLayer = () => {
|
const RadioAreaLayer = () => {
|
||||||
const getColor = (randint: number) => {
|
const getColor = (randint: number) => {
|
||||||
@@ -67,6 +68,199 @@ const RadioAreaLayer = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HeliportsLayer = () => {
|
||||||
|
const { data: heliports } = useQuery({
|
||||||
|
queryKey: ["heliports"],
|
||||||
|
queryFn: () => getHeliportsAPI(),
|
||||||
|
});
|
||||||
|
const [heliportsWithIcon, setHeliportsWithIcon] = useState<(Heliport & { icon?: string })[]>([]);
|
||||||
|
const map = useMap();
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const [boxContent, setBoxContent] = useState<React.ReactNode>(null);
|
||||||
|
// Übergangslösung
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
const year = date.getFullYear().toString().slice(-2); // Letzte 2 Stellen des Jahres
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, "0"); // Monat (mit führender Null, falls notwendig)
|
||||||
|
const day = date.getDate().toString().padStart(2, "0"); // Tag (mit führender Null, falls notwendig)
|
||||||
|
return `${year}${month}${day}`;
|
||||||
|
};
|
||||||
|
const replaceWithYesterdayDate = (url: string): string => {
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 2); // Einen Tag zurücksetzen
|
||||||
|
const formattedDate = formatDate(yesterday);
|
||||||
|
|
||||||
|
return url.replace(/\.at\/lo\/\d{6}/, `.at/lo/${formattedDate}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSelection = () => {
|
||||||
|
setBoxContent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useMapEvent("click", () => {
|
||||||
|
resetSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleZoom = () => {
|
||||||
|
setIsVisible(map.getZoom() > 8);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleZoom();
|
||||||
|
|
||||||
|
map.on("zoomend", handleZoom);
|
||||||
|
|
||||||
|
const fetchIcons = async () => {
|
||||||
|
if (!heliports) return;
|
||||||
|
const urls = await Promise.all(
|
||||||
|
heliports.map(async (heliport) => {
|
||||||
|
return createCustomMarker(heliport.type);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setHeliportsWithIcon(
|
||||||
|
heliports.map((heliport, index) => ({ ...heliport, icon: urls[index] })),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterVisibleHeliports = () => {
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
if (!heliports?.length) return;
|
||||||
|
// Filtere die Heliports, die innerhalb der Kartenansicht liegen
|
||||||
|
const visibleHeliports = heliports.filter((heliport) => {
|
||||||
|
const coordinates: LatLngExpression = [heliport.lat, heliport.lng];
|
||||||
|
return bounds.contains(coordinates); // Überprüft, ob der Heliport innerhalb der aktuellen Bounds liegt
|
||||||
|
});
|
||||||
|
|
||||||
|
setHeliportsWithIcon(visibleHeliports);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (heliports?.length) {
|
||||||
|
fetchIcons();
|
||||||
|
filterVisibleHeliports();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleZoom();
|
||||||
|
|
||||||
|
map.on("zoomend", handleZoom);
|
||||||
|
map.on("moveend", filterVisibleHeliports);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("zoomend", handleZoom);
|
||||||
|
map.off("moveend", filterVisibleHeliports);
|
||||||
|
};
|
||||||
|
}, [map, heliports]);
|
||||||
|
|
||||||
|
const createCustomIcon = (heliportType: string) => {
|
||||||
|
if (heliportType === "POI") {
|
||||||
|
return divIcon({
|
||||||
|
className: "custom-marker no-pointer", // CSS-Klasse für Styling
|
||||||
|
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">H</span></div>',
|
||||||
|
iconSize: [15, 15], // Größe des Icons
|
||||||
|
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heliport Typ: H-Icon
|
||||||
|
if (heliportType === "HELIPAD") {
|
||||||
|
return divIcon({
|
||||||
|
className: "custom-marker no-pointer", // CSS-Klasse für Styling
|
||||||
|
html: '<div style="width: 15px; height: 15px; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">H</span></div>',
|
||||||
|
iconSize: [15, 15], // Größe des Icons (15x15 px Viereck)
|
||||||
|
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mountain Typ: Kreis mit "M"
|
||||||
|
if (heliportType === "MOUNTAIN") {
|
||||||
|
return divIcon({
|
||||||
|
className: "custom-marker no-pointer",
|
||||||
|
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">M</span></div>',
|
||||||
|
iconSize: [15, 15], // Größe des Icons
|
||||||
|
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls kein Typ übereinstimmt, standardmäßig das POI-Icon mit Fragezeichen verwenden
|
||||||
|
return divIcon({
|
||||||
|
className: "custom-marker no-pointer",
|
||||||
|
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">?</span></div>',
|
||||||
|
iconSize: [15, 15],
|
||||||
|
iconAnchor: [7.5, 15],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FeatureGroup attribution="">
|
||||||
|
{isVisible &&
|
||||||
|
heliportsWithIcon.map((heliport) => {
|
||||||
|
const coordinates: LatLngExpression = [heliport.lat, heliport.lng];
|
||||||
|
const designatorLabel = heliport.designator.charAt(0).toUpperCase();
|
||||||
|
const heliportType = heliport.type;
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={heliport.id}
|
||||||
|
position={coordinates}
|
||||||
|
icon={createCustomIcon(heliportType)}
|
||||||
|
eventHandlers={{
|
||||||
|
mouseover: (e) => {
|
||||||
|
const tooltipContent = `${heliport.siteNameSub26} (${heliport.designator})`;
|
||||||
|
e.target
|
||||||
|
.bindTooltip(tooltipContent, {
|
||||||
|
direction: "top",
|
||||||
|
offset: [4, -15],
|
||||||
|
})
|
||||||
|
.openTooltip();
|
||||||
|
},
|
||||||
|
mouseout: (e) => {
|
||||||
|
e.target.closeTooltip();
|
||||||
|
},
|
||||||
|
click: () => {
|
||||||
|
setBoxContent(
|
||||||
|
<div>
|
||||||
|
<h4>{heliport.siteNameSub26}</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Designator:</strong> {heliport.designator}
|
||||||
|
</p>
|
||||||
|
{heliport.info?.startsWith("http") ? (
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
href={replaceWithYesterdayDate(heliport.info)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{heliport.info}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>{heliport.info}</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
{heliport.lat} °N, {heliport.lng} °E
|
||||||
|
</p>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" sticky>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<strong>{heliport.designator}</strong>
|
||||||
|
<small style={{ fontWeight: "bold", fontSize: "0.7em" }}>
|
||||||
|
{` (${designatorLabel})`}
|
||||||
|
</small>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FeatureGroup>
|
||||||
|
|
||||||
|
{boxContent && <div className="modal-box">{boxContent}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const StationsLayer = ({ attribution }: { attribution: Control.Attribution }) => {
|
const StationsLayer = ({ attribution }: { attribution: Control.Attribution }) => {
|
||||||
const { data: stations } = useQuery({
|
const { data: stations } = useQuery({
|
||||||
queryKey: ["stations"],
|
queryKey: ["stations"],
|
||||||
@@ -260,6 +454,25 @@ const NiederschlagOverlay = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SlopesOverlay = () => {
|
||||||
|
const tileLayerRef = useRef<L.TileLayer.WMS | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WMSTileLayer
|
||||||
|
ref={tileLayerRef}
|
||||||
|
eventHandlers={{
|
||||||
|
add: () => {
|
||||||
|
tileLayerRef.current?.bringToFront();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
attribution="Opensnowmap.org (CC-BY-SA)"
|
||||||
|
url="http://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png?"
|
||||||
|
transparent
|
||||||
|
zIndex={1000}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const WindfarmOutlineLayer = () => {
|
const WindfarmOutlineLayer = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -304,7 +517,7 @@ const WindfarmOutlineLayer = () => {
|
|||||||
export const BaseMaps = () => {
|
export const BaseMaps = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
return (
|
return (
|
||||||
<LayersControl position="topleft">
|
<LayersControl position="bottomright">
|
||||||
<LayersControl.Overlay name={"Leitstellenbereiche"}>
|
<LayersControl.Overlay name={"Leitstellenbereiche"}>
|
||||||
<RadioAreaLayer />
|
<RadioAreaLayer />
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
@@ -319,9 +532,15 @@ export const BaseMaps = () => {
|
|||||||
<LayersControl.Overlay name={"LRZs"}>
|
<LayersControl.Overlay name={"LRZs"}>
|
||||||
<StationsLayer attribution={map.attributionControl} />
|
<StationsLayer attribution={map.attributionControl} />
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
<LayersControl.Overlay name={"Heliports"}>
|
||||||
|
<HeliportsLayer />
|
||||||
|
</LayersControl.Overlay>
|
||||||
<LayersControl.Overlay name={"OpenAIP"}>
|
<LayersControl.Overlay name={"OpenAIP"}>
|
||||||
<OpenAIP />
|
<OpenAIP />
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
<LayersControl.Overlay name={"Skigebiete"}>
|
||||||
|
<SlopesOverlay />
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
<LayersControl.BaseLayer name="OpenStreetMap Dark" checked>
|
<LayersControl.BaseLayer name="OpenStreetMap Dark" checked>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ export const ContextMenu = () => {
|
|||||||
setSearchPopup,
|
setSearchPopup,
|
||||||
toggleSearchElementSelection,
|
toggleSearchElementSelection,
|
||||||
} = useMapStore();
|
} = useMapStore();
|
||||||
const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore(
|
const {
|
||||||
(state) => state,
|
missionFormValues,
|
||||||
);
|
setMissionFormValues,
|
||||||
|
setOpen,
|
||||||
|
isOpen: isPannelOpen,
|
||||||
|
} = usePannelStore((state) => state);
|
||||||
const [showRulerOptions, setShowRulerOptions] = useState(false);
|
const [showRulerOptions, setShowRulerOptions] = useState(false);
|
||||||
const [rulerHover, setRulerHover] = useState(false);
|
const [rulerHover, setRulerHover] = useState(false);
|
||||||
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
|
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
|
||||||
@@ -53,7 +56,8 @@ export const ContextMenu = () => {
|
|||||||
|
|
||||||
if (!contextMenu || !dispatcherConnected) return null;
|
if (!contextMenu || !dispatcherConnected) return null;
|
||||||
|
|
||||||
const einsatzBtnText = missionFormValues && isOpen ? "Position übernehmen" : "Einsatz erstellen";
|
const missionBtnText =
|
||||||
|
missionFormValues && isPannelOpen ? "Position übernehmen" : "Einsatz erstellen";
|
||||||
|
|
||||||
const addOSMobjects = async (ignorePreviosSelected?: boolean) => {
|
const addOSMobjects = async (ignorePreviosSelected?: boolean) => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -101,14 +105,14 @@ export const ContextMenu = () => {
|
|||||||
autoPan={false}
|
autoPan={false}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute opacity-100 pointer-events-none p-3 flex items-center justify-center"
|
className="pointer-events-none absolute flex items-center justify-center p-3 opacity-100"
|
||||||
style={{ left: "-13px", top: "-13px" }}
|
style={{ left: "-13px", top: "-13px" }}
|
||||||
>
|
>
|
||||||
<div className="relative w-38 h-38 flex items-center justify-center -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
<div className="w-38 h-38 pointer-events-none relative flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||||
{/* Top Button */}
|
{/* Top Button */}
|
||||||
<button
|
<button
|
||||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 top-0 pointer-events-auto opacity-80 tooltip tooltip-top tooltip-accent"
|
className="btn btn-circle bg-rescuetrack tooltip tooltip-top tooltip-accent pointer-events-auto absolute left-1/2 top-0 h-10 w-10 opacity-80"
|
||||||
data-tip={einsatzBtnText}
|
data-tip={missionBtnText}
|
||||||
style={{ transform: "translateX(-50%)" }}
|
style={{ transform: "translateX(-50%)" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const { parsed } = await getOsmAddress(contextMenu.lat, contextMenu.lng);
|
const { parsed } = await getOsmAddress(contextMenu.lat, contextMenu.lng);
|
||||||
@@ -131,17 +135,18 @@ export const ContextMenu = () => {
|
|||||||
if (closestObject) {
|
if (closestObject) {
|
||||||
toggleSearchElementSelection(closestObject.wayID, true);
|
toggleSearchElementSelection(closestObject.wayID, true);
|
||||||
}
|
}
|
||||||
|
if (isPannelOpen) {
|
||||||
map.setView([contextMenu.lat, contextMenu.lng], 18, {
|
map.setView([contextMenu.lat, contextMenu.lng], 18, {
|
||||||
animate: true,
|
animate: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapPinned size={20} />
|
<MapPinned size={20} />
|
||||||
</button>
|
</button>
|
||||||
{/* Left Button */}
|
{/* Left Button */}
|
||||||
<button
|
<button
|
||||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 left-0 pointer-events-auto opacity-80"
|
className="btn btn-circle bg-rescuetrack pointer-events-auto absolute left-0 top-1/2 h-10 w-10 opacity-80"
|
||||||
style={{ transform: "translateY(-50%)" }}
|
style={{ transform: "translateY(-50%)" }}
|
||||||
onMouseEnter={() => setRulerHover(true)}
|
onMouseEnter={() => setRulerHover(true)}
|
||||||
onMouseLeave={() => setRulerHover(false)}
|
onMouseLeave={() => setRulerHover(false)}
|
||||||
@@ -151,7 +156,7 @@ export const ContextMenu = () => {
|
|||||||
</button>
|
</button>
|
||||||
{/* Bottom Button */}
|
{/* Bottom Button */}
|
||||||
<button
|
<button
|
||||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 bottom-0 pointer-events-auto opacity-80 tooltip tooltip-bottom tooltip-accent"
|
className="btn btn-circle bg-rescuetrack tooltip tooltip-bottom tooltip-accent pointer-events-auto absolute bottom-0 left-1/2 h-10 w-10 opacity-80"
|
||||||
data-tip="Koordinaten kopieren"
|
data-tip="Koordinaten kopieren"
|
||||||
style={{ transform: "translateX(-50%)" }}
|
style={{ transform: "translateX(-50%)" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -164,7 +169,7 @@ export const ContextMenu = () => {
|
|||||||
</button>
|
</button>
|
||||||
{/* Right Button (original Search button) */}
|
{/* Right Button (original Search button) */}
|
||||||
<button
|
<button
|
||||||
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 right-0 pointer-events-auto opacity-80 tooltip tooltip-right tooltip-accent"
|
className="btn btn-circle bg-rescuetrack tooltip tooltip-right tooltip-accent pointer-events-auto absolute right-0 top-1/2 h-10 w-10 opacity-80"
|
||||||
data-tip="Gebäude suchen"
|
data-tip="Gebäude suchen"
|
||||||
style={{ transform: "translateY(-50%)" }}
|
style={{ transform: "translateY(-50%)" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -176,7 +181,7 @@ export const ContextMenu = () => {
|
|||||||
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
|
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
|
||||||
{showRulerOptions && (
|
{showRulerOptions && (
|
||||||
<div
|
<div
|
||||||
className="absolute flex flex-col items-center pointer-events-auto"
|
className="pointer-events-auto absolute flex flex-col items-center"
|
||||||
style={{
|
style={{
|
||||||
left: "-100px", // position to the right of the left button
|
left: "-100px", // position to the right of the left button
|
||||||
top: "50%",
|
top: "50%",
|
||||||
@@ -200,7 +205,7 @@ export const ContextMenu = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
|
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
|
||||||
data-tip="Strecke Messen"
|
data-tip="Strecke Messen"
|
||||||
style={{
|
style={{
|
||||||
transform: "translateX(100%)",
|
transform: "translateX(100%)",
|
||||||
@@ -212,7 +217,7 @@ export const ContextMenu = () => {
|
|||||||
<RulerDimensionLine size={20} />
|
<RulerDimensionLine size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
|
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
|
||||||
data-tip="Radius Messen"
|
data-tip="Radius Messen"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
/* ... */
|
/* ... */
|
||||||
@@ -221,7 +226,7 @@ export const ContextMenu = () => {
|
|||||||
<Radius size={20} />
|
<Radius size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-circle bg-rescuetrack w-10 h-10 opacity-80 tooltip tooltip-left tooltip-accent"
|
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent h-10 w-10 opacity-80"
|
||||||
data-tip="Fläche Messen"
|
data-tip="Fläche Messen"
|
||||||
style={{
|
style={{
|
||||||
transform: "translateX(100%)",
|
transform: "translateX(100%)",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MarkerCluster } from "_components/map/_components/MarkerCluster";
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Map as TMap } from "leaflet";
|
import { Map as TMap } from "leaflet";
|
||||||
import { DistanceLayer } from "_components/map/Measurement";
|
import { DistanceLayer } from "_components/map/Measurement";
|
||||||
|
import { MapAdditionals } from "_components/map/MapAdditionals";
|
||||||
|
|
||||||
const Map = () => {
|
const Map = () => {
|
||||||
const ref = useRef<TMap | null>(null);
|
const ref = useRef<TMap | null>(null);
|
||||||
@@ -36,7 +37,7 @@ const Map = () => {
|
|||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="flex-1 bg-base-200"
|
className="bg-base-200 z-10 flex-1"
|
||||||
center={map.center}
|
center={map.center}
|
||||||
zoom={map.zoom}
|
zoom={map.zoom}
|
||||||
fadeAnimation={false}
|
fadeAnimation={false}
|
||||||
@@ -48,6 +49,7 @@ const Map = () => {
|
|||||||
<MissionLayer />
|
<MissionLayer />
|
||||||
<AircraftLayer />
|
<AircraftLayer />
|
||||||
<DistanceLayer />
|
<DistanceLayer />
|
||||||
|
<MapAdditionals />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
79
apps/dispatch/app/_components/map/MapAdditionals.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
import { usePannelStore } from "_store/pannelStore";
|
||||||
|
import { Marker } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getMissionsAPI } from "_querys/missions";
|
||||||
|
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
|
||||||
|
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
|
import { useMapStore } from "_store/mapStore";
|
||||||
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||||
|
|
||||||
|
export const MapAdditionals = () => {
|
||||||
|
const { isOpen, missionFormValues } = usePannelStore((state) => state);
|
||||||
|
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
|
||||||
|
const { data: missions = [] } = useQuery({
|
||||||
|
queryKey: ["missions"],
|
||||||
|
queryFn: () =>
|
||||||
|
getMissionsAPI({
|
||||||
|
OR: [{ state: "draft" }, { state: "running" }],
|
||||||
|
}),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const mapStore = useMapStore((state) => state);
|
||||||
|
|
||||||
|
const { data: aircrafts } = useQuery({
|
||||||
|
queryKey: ["aircrafts"],
|
||||||
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markersNeedingAttention = missions.filter(
|
||||||
|
(m) =>
|
||||||
|
HPGValidationRequired(m.missionStationIds, aircrafts, m.hpgMissionString) &&
|
||||||
|
m.hpgValidationState === "POSITION_AMANDED" &&
|
||||||
|
m.state === "draft" &&
|
||||||
|
m.hpgLocationLat &&
|
||||||
|
dispatcherConnectionState === "connected" &&
|
||||||
|
m.hpgLocationLng,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{missionFormValues?.addressLat && missionFormValues?.addressLng && isOpen && (
|
||||||
|
<Marker
|
||||||
|
position={[missionFormValues.addressLat, missionFormValues.addressLng]}
|
||||||
|
icon={L.icon({
|
||||||
|
iconUrl: "/icons/mapMarker.png",
|
||||||
|
iconSize: [40, 40],
|
||||||
|
iconAnchor: [20, 35],
|
||||||
|
})}
|
||||||
|
interactive={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{markersNeedingAttention.map((mission) => (
|
||||||
|
<Marker
|
||||||
|
key={mission.id}
|
||||||
|
position={[mission.hpgLocationLat!, mission.hpgLocationLng!]}
|
||||||
|
icon={L.icon({
|
||||||
|
iconUrl: "/icons/mapMarker.png",
|
||||||
|
iconSize: [40, 40],
|
||||||
|
iconAnchor: [20, 35],
|
||||||
|
})}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () =>
|
||||||
|
mapStore.setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: [],
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ import { getMissionsAPI } from "_querys/missions";
|
|||||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||||
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
|
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
|
||||||
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
|
||||||
export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> = {
|
export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> = {
|
||||||
draft: "#0092b8",
|
draft: "#0092b8",
|
||||||
@@ -81,7 +82,7 @@ const MissionPopupContent = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="absolute p-1 z-99 top-0 right-0 transform -translate-y-full bg-base-100 cursor-pointer"
|
className="z-99 bg-base-100 absolute right-0 top-0 -translate-y-full transform cursor-pointer p-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenMissionMarker({
|
setOpenMissionMarker({
|
||||||
open: [],
|
open: [],
|
||||||
@@ -94,7 +95,7 @@ const MissionPopupContent = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute w-[calc(100%+2px)] h-4 z-99",
|
"z-99 absolute h-4 w-[calc(100%+2px)]",
|
||||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||||
)}
|
)}
|
||||||
@@ -115,13 +116,13 @@ const MissionPopupContent = ({
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="flex gap-[2px] text-white pb-0.5"
|
className="flex gap-[2px] pb-0.5 text-white"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`,
|
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="p-2 px-3 flex justify-center items-center cursor-pointer"
|
className="flex cursor-pointer items-center justify-center p-2 px-3"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -134,7 +135,7 @@ const MissionPopupContent = ({
|
|||||||
<House className="text-sm" />
|
<House className="text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="p-2 px-4 flex justify-center items-center cursor-pointer"
|
className="flex cursor-pointer items-center justify-center p-2 px-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -147,7 +148,7 @@ const MissionPopupContent = ({
|
|||||||
<Cross className="text-sm" />
|
<Cross className="text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="p-2 px-4 flex justify-center items-center cursor-pointer"
|
className="flex cursor-pointer items-center justify-center p-2 px-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -161,7 +162,7 @@ const MissionPopupContent = ({
|
|||||||
</div>
|
</div>
|
||||||
{mission.state === "draft" && (
|
{mission.state === "draft" && (
|
||||||
<div
|
<div
|
||||||
className="p-2 px-4 flex justify-center items-center cursor-pointer ml-auto"
|
className="ml-auto flex cursor-pointer items-center justify-center p-2 px-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${MISSION_STATUS_COLORS["attention"]}`,
|
backgroundColor: `${MISSION_STATUS_COLORS["attention"]}`,
|
||||||
borderBottom: "5px solid transparent",
|
borderBottom: "5px solid transparent",
|
||||||
@@ -172,9 +173,9 @@ const MissionPopupContent = ({
|
|||||||
addressMissionDestination: mission.addressMissionDestination ?? undefined,
|
addressMissionDestination: mission.addressMissionDestination ?? undefined,
|
||||||
addressAdditionalInfo: mission.addressAdditionalInfo ?? undefined,
|
addressAdditionalInfo: mission.addressAdditionalInfo ?? undefined,
|
||||||
state: "draft",
|
state: "draft",
|
||||||
hpgAmbulanceState: "NOT_REQUESTED",
|
hpgAmbulanceState: mission.hpgAmbulanceState ?? "NOT_REQUESTED",
|
||||||
hpgFireEngineState: "NOT_REQUESTED",
|
hpgFireEngineState: mission.hpgFireEngineState ?? "NOT_REQUESTED",
|
||||||
hpgPoliceState: "NOT_REQUESTED",
|
hpgPoliceState: mission.hpgPoliceState ?? "NOT_REQUESTED",
|
||||||
hpgLocationLat: mission.hpgLocationLat ?? undefined,
|
hpgLocationLat: mission.hpgLocationLat ?? undefined,
|
||||||
hpgLocationLng: mission.hpgLocationLng ?? undefined,
|
hpgLocationLng: mission.hpgLocationLng ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -187,7 +188,7 @@ const MissionPopupContent = ({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 px-4 flex justify-center items-center cursor-pointer",
|
"flex cursor-pointer items-center justify-center p-2 px-4",
|
||||||
mission.state !== "draft" && "ml-auto",
|
mission.state !== "draft" && "ml-auto",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -208,7 +209,15 @@ const MissionPopupContent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MissionMarker = ({ mission }: { mission: Mission }) => {
|
const MissionMarker = ({
|
||||||
|
mission,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
mission: Mission;
|
||||||
|
options: {
|
||||||
|
hideDetailedKeyword?: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [hideMarker, setHideMarker] = useState(false);
|
const [hideMarker, setHideMarker] = useState(false);
|
||||||
const { editingMissionId, missionFormValues } = usePannelStore((state) => state);
|
const { editingMissionId, missionFormValues } = usePannelStore((state) => state);
|
||||||
@@ -221,7 +230,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { openMissionMarker, setOpenMissionMarker } = useMapStore((store) => store);
|
const { openMissionMarker, setOpenMissionMarker, userSettings } = useMapStore((store) => store);
|
||||||
|
|
||||||
const needsAction =
|
const needsAction =
|
||||||
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString) &&
|
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString) &&
|
||||||
@@ -244,7 +253,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
tab: "home",
|
tab: "home",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
close: [],
|
close: openMissionMarker?.map((m) => m.id) || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -253,14 +262,16 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
markerCopy?.off("click", handleClick);
|
markerCopy?.off("click", handleClick);
|
||||||
};
|
};
|
||||||
}, [mission.id, openMissionMarker, setOpenMissionMarker]);
|
}, [mission.id, openMissionMarker, setOpenMissionMarker, userSettings]);
|
||||||
|
|
||||||
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
||||||
"topleft",
|
"topleft",
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConflict = useCallback(() => {
|
const handleConflict = useCallback(() => {
|
||||||
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker");
|
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker", {
|
||||||
|
ignoreCluster: true,
|
||||||
|
});
|
||||||
setAnchor(newAnchor);
|
setAnchor(newAnchor);
|
||||||
}, [mission.id]);
|
}, [mission.id]);
|
||||||
|
|
||||||
@@ -317,7 +328,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
"
|
"
|
||||||
></div>
|
></div>
|
||||||
<span class="text-white text-[15px] text-nowrap">
|
<span class="text-white text-[15px] text-nowrap">
|
||||||
${mission.missionKeywordAbbreviation} ${mission.missionKeywordName}
|
${mission.missionKeywordAbbreviation} ${options.hideDetailedKeyword ? "" : mission.missionKeywordName}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
data-anchor-lat="${mission.addressLat}"
|
data-anchor-lat="${mission.addressLat}"
|
||||||
@@ -337,22 +348,15 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
return [
|
return [
|
||||||
editingMissionId === mission.id && missionFormValues?.addressLat
|
editingMissionId === mission.id && missionFormValues?.addressLat
|
||||||
? missionFormValues.addressLat
|
? missionFormValues.addressLat
|
||||||
: mission.hpgValidationState !== "POSITION_AMANDED" && mission.hpgLocationLat
|
: mission.addressLat,
|
||||||
? mission.hpgLocationLat
|
|
||||||
: mission.addressLat,
|
|
||||||
editingMissionId === mission.id && missionFormValues?.addressLng
|
editingMissionId === mission.id && missionFormValues?.addressLng
|
||||||
? missionFormValues.addressLng
|
? missionFormValues.addressLng
|
||||||
: mission.hpgValidationState !== "POSITION_AMANDED" && mission.hpgLocationLng
|
: mission.addressLng,
|
||||||
? mission.hpgLocationLng
|
|
||||||
: mission.addressLng,
|
|
||||||
];
|
];
|
||||||
}, [
|
}, [
|
||||||
editingMissionId,
|
editingMissionId,
|
||||||
mission.addressLat,
|
mission.addressLat,
|
||||||
mission.addressLng,
|
mission.addressLng,
|
||||||
mission.hpgLocationLat,
|
|
||||||
mission.hpgLocationLng,
|
|
||||||
mission.hpgValidationState,
|
|
||||||
mission.id,
|
mission.id,
|
||||||
missionFormValues?.addressLat,
|
missionFormValues?.addressLat,
|
||||||
missionFormValues?.addressLng,
|
missionFormValues?.addressLng,
|
||||||
@@ -396,7 +400,17 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
export const MissionLayer = () => {
|
export const MissionLayer = () => {
|
||||||
const dispatchState = useDispatchConnectionStore((s) => s);
|
const dispatchState = useDispatchConnectionStore((s) => s);
|
||||||
const dispatcherConnected = dispatchState.status === "connected";
|
const dispatcherConnected = dispatchState.status === "connected";
|
||||||
|
const {
|
||||||
|
status: pilotConnectionStatus,
|
||||||
|
showOtherMissions,
|
||||||
|
selectedStation,
|
||||||
|
} = usePilotConnectionStore((state) => state);
|
||||||
|
|
||||||
|
const { data: aircrafts = [] } = useQuery({
|
||||||
|
queryKey: ["aircrafts"],
|
||||||
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
const { data: missions = [] } = useQuery({
|
const { data: missions = [] } = useQuery({
|
||||||
queryKey: ["missions"],
|
queryKey: ["missions"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -410,15 +424,32 @@ export const MissionLayer = () => {
|
|||||||
return missions.filter((m: Mission) => {
|
return missions.filter((m: Mission) => {
|
||||||
if (m.state === "draft" && !dispatcherConnected) return false;
|
if (m.state === "draft" && !dispatcherConnected) return false;
|
||||||
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
|
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
|
||||||
|
if (pilotConnectionStatus === "connected" && !showOtherMissions)
|
||||||
|
return m.missionStationIds.includes(selectedStation!.id);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [missions, dispatcherConnected, dispatchState.hideDraftMissions]);
|
}, [
|
||||||
|
missions,
|
||||||
|
dispatcherConnected,
|
||||||
|
dispatchState.hideDraftMissions,
|
||||||
|
pilotConnectionStatus,
|
||||||
|
showOtherMissions,
|
||||||
|
selectedStation,
|
||||||
|
]);
|
||||||
|
|
||||||
// IDEA: Add Marker to Map Layer / LayerGroup
|
// IDEA: Add Marker to Map Layer / LayerGroup
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filteredMissions.map((mission) => {
|
{filteredMissions.map((mission) => {
|
||||||
return <MissionMarker key={mission.id} mission={mission as Mission} />;
|
return (
|
||||||
|
<MissionMarker
|
||||||
|
key={mission.id}
|
||||||
|
mission={mission as Mission}
|
||||||
|
options={{
|
||||||
|
hideDetailedKeyword: missions.length + aircrafts.length > 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ const FMSStatusHistory = ({
|
|||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ul className="text-base-content font-semibold">
|
<ul className="text-base-content font-semibold">
|
||||||
<li className="flex items-center gap-2 mb-1">
|
<li className="mb-1 flex items-center gap-2">
|
||||||
<p className="flex items-center gap-2 flex-1">
|
<p className="flex flex-1 items-center gap-2">
|
||||||
<PersonIcon className="w-5 h-5" /> {aircraftUser.fullName} ({aircraftUser.publicId}){" "}
|
<PersonIcon className="h-5 w-5" /> {aircraftUser.fullName} ({aircraftUser.publicId}){" "}
|
||||||
{(() => {
|
{(() => {
|
||||||
const badges = aircraftUser.badges
|
const badges = aircraftUser.badges
|
||||||
.filter((b) => b.startsWith("P") && b.length == 2)
|
.filter((b) => b.startsWith("P") && b.length == 2)
|
||||||
@@ -96,12 +96,12 @@ const FMSStatusHistory = ({
|
|||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{log.map((entry, index) => (
|
{log.map((entry, index) => (
|
||||||
<li key={index} className="flex items-center gap-2">
|
<li key={index} className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="font-bold text-base"
|
className="text-base font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
|
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
|
||||||
}}
|
}}
|
||||||
@@ -145,8 +145,8 @@ const FMSStatusSelector = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 mt-2 p-4 text-base-content">
|
<div className="text-base-content mt-2 flex flex-col gap-2 p-4">
|
||||||
<div className="flex gap-2 justify-center items-center h-full">
|
<div className="flex h-full items-center justify-center gap-2">
|
||||||
{Array.from({ length: 9 }, (_, i) => (i + 1).toString())
|
{Array.from({ length: 9 }, (_, i) => (i + 1).toString())
|
||||||
.filter((status) => status !== "5") // Exclude status 5
|
.filter((status) => status !== "5") // Exclude status 5
|
||||||
.map((status) => (
|
.map((status) => (
|
||||||
@@ -154,7 +154,7 @@ const FMSStatusSelector = ({
|
|||||||
disabled={!dispatcherConnected}
|
disabled={!dispatcherConnected}
|
||||||
key={status}
|
key={status}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex justify-center items-center min-w-13 min-h-13 cursor-pointer text-4xl font-bold",
|
"min-w-13 min-h-13 flex cursor-pointer items-center justify-center text-4xl font-bold",
|
||||||
!dispatcherConnected && "cursor-not-allowed",
|
!dispatcherConnected && "cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -187,13 +187,13 @@ const FMSStatusSelector = ({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 p-2 justify-center items-center">
|
<div className="flex items-center justify-center gap-1 p-2">
|
||||||
{["E", "C", "F", "J", "L", "c", "d", "h", "o", "u"].map((status) => (
|
{["E", "C", "F", "J", "L", "c", "d", "h", "o", "u"].map((status) => (
|
||||||
<button
|
<button
|
||||||
disabled={!dispatcherConnected}
|
disabled={!dispatcherConnected}
|
||||||
key={status}
|
key={status}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold",
|
"flex min-h-10 min-w-10 cursor-pointer items-center justify-center text-lg font-bold",
|
||||||
!dispatcherConnected && "cursor-not-allowed",
|
!dispatcherConnected && "cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -245,7 +245,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
|
|||||||
})),
|
})),
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
const livekitUser = participants.find((p) => (p.attributes.userId = aircraft.userId));
|
const livekitUser = participants.find((p) => p.attributes.userId === aircraft.userId);
|
||||||
|
|
||||||
const lstName = useMemo(() => {
|
const lstName = useMemo(() => {
|
||||||
if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea;
|
if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea;
|
||||||
@@ -253,17 +253,17 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
|
|||||||
}, [aircraft.posLng, aircraft.posLat, station.bosRadioArea]);
|
}, [aircraft.posLng, aircraft.posLat, station.bosRadioArea]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-base-content">
|
<div className="text-base-content p-4">
|
||||||
<ul className="text-base-content font-semibold">
|
<ul className="text-base-content font-semibold">
|
||||||
<li className="flex items-center gap-2 mb-1">
|
<li className="mb-1 flex items-center gap-2">
|
||||||
<Component size={16} /> Aktuelle Rufgruppe: {livekitUser?.roomName || "Nicht verbunden"}
|
<Component size={16} /> Aktuelle Rufgruppe: {livekitUser?.roomName || "Nicht verbunden"}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2 mb-1">
|
<li className="mb-1 flex items-center gap-2">
|
||||||
<RadioTower size={16} /> Leitstellenbereich: {lstName || station.bosRadioArea}
|
<RadioTower size={16} /> Leitstellenbereich: {lstName || station.bosRadioArea}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2 mb-2">
|
<div className="mb-2 mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Clock size={16} /> {station.is24h ? "24h Betrieb" : "Tagbetrieb"}
|
<Clock size={16} /> {station.is24h ? "24h Betrieb" : "Tagbetrieb"}
|
||||||
</span>
|
</span>
|
||||||
@@ -277,8 +277,8 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
|
|||||||
<TextSearch size={16} /> {station.aircraftRegistration}
|
<TextSearch size={16} /> {station.aircraftRegistration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
|
<div className="mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<CompassIcon size={16} /> HDG: {aircraft.posHeading}°
|
<CompassIcon size={16} /> HDG: {aircraft.posHeading}°
|
||||||
</span>
|
</span>
|
||||||
@@ -289,7 +289,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
|
|||||||
<CircleGaugeIcon size={16} /> ALT: {aircraft.posAlt} ft
|
<CircleGaugeIcon size={16} /> ALT: {aircraft.posAlt} ft
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
|
<div className="mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Lollipop size={16} />{" "}
|
<Lollipop size={16} />{" "}
|
||||||
<span className={cn(aircraft.posH145active && "text-green-500")}>
|
<span className={cn(aircraft.posH145active && "text-green-500")}>
|
||||||
@@ -303,22 +303,22 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
|
|||||||
|
|
||||||
const MissionTab = ({ mission }: { mission: Mission }) => {
|
const MissionTab = ({ mission }: { mission: Mission }) => {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-base-content">
|
<div className="text-base-content p-4">
|
||||||
<ul className="text-base-content font-semibold">
|
<ul className="text-base-content font-semibold">
|
||||||
<li className="flex items-center gap-2 mb-1">
|
<li className="mb-1 flex items-center gap-2">
|
||||||
<BellRing size={16} /> {mission.missionKeywordCategory}
|
<BellRing size={16} /> {mission.missionKeywordCategory}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2 mb-1">
|
<li className="mb-1 flex items-center gap-2">
|
||||||
<ListCollapse size={16} />
|
<ListCollapse size={16} />
|
||||||
{mission.missionKeywordName}
|
{mission.missionKeywordName}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2 mt-3">
|
<li className="mt-3 flex items-center gap-2">
|
||||||
<Hash size={16} />
|
<Hash size={16} />
|
||||||
__{new Date().toISOString().slice(0, 10).replace(/-/g, "")}
|
__{new Date().toISOString().slice(0, 10).replace(/-/g, "")}
|
||||||
{mission.id}
|
{mission.id}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
<div className="text-sm font-semibold">
|
<div className="text-sm font-semibold">
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
|
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
|
||||||
@@ -416,7 +416,7 @@ const SDSTab = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isChatOpen ? (
|
{!isChatOpen ? (
|
||||||
<button
|
<button
|
||||||
className="text-base-content text-base cursor-pointer"
|
className="text-base-content cursor-pointer text-base"
|
||||||
onClick={() => setIsChatOpen(true)}
|
onClick={() => setIsChatOpen(true)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -424,7 +424,7 @@ const SDSTab = ({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex w-full items-center gap-2">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
@@ -463,7 +463,7 @@ const SDSTab = ({
|
|||||||
<div className="divider m-0" />
|
<div className="divider m-0" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto">
|
<ul className="max-h-[300px] space-y-2 overflow-x-auto overflow-y-auto">
|
||||||
{log.map((entry, index) => {
|
{log.map((entry, index) => {
|
||||||
const sdsEntry = entry as MissionSdsLog;
|
const sdsEntry = entry as MissionSdsLog;
|
||||||
return (
|
return (
|
||||||
@@ -475,7 +475,7 @@ const SDSTab = ({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-bold text-base"
|
className="text-base font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[6],
|
color: FMS_STATUS_TEXT_COLORS[6],
|
||||||
}}
|
}}
|
||||||
@@ -488,7 +488,7 @@ const SDSTab = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!log.length && (
|
{!log.length && (
|
||||||
<p className="text-gray-500 w-full text-center my-10 font-semibold">
|
<p className="my-10 w-full text-center font-semibold text-gray-500">
|
||||||
Kein SDS-Verlauf verfügbar
|
Kein SDS-Verlauf verfügbar
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getMissionsAPI } from "_querys/missions";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMap } from "react-leaflet";
|
import { useMap } from "react-leaflet";
|
||||||
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
|
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
|
||||||
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
|
||||||
const PopupContent = ({
|
const PopupContent = ({
|
||||||
aircrafts,
|
aircrafts,
|
||||||
@@ -20,7 +21,13 @@ const PopupContent = ({
|
|||||||
missions: Mission[];
|
missions: Mission[];
|
||||||
}) => {
|
}) => {
|
||||||
const { anchor } = useSmartPopup();
|
const { anchor } = useSmartPopup();
|
||||||
const { setOpenAircraftMarker, setOpenMissionMarker } = useMapStore((state) => state);
|
const {
|
||||||
|
setOpenAircraftMarker,
|
||||||
|
setOpenMissionMarker,
|
||||||
|
openAircraftMarker,
|
||||||
|
openMissionMarker,
|
||||||
|
userSettings,
|
||||||
|
} = useMapStore((state) => state);
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
let borderColor = "";
|
let borderColor = "";
|
||||||
@@ -41,10 +48,10 @@ const PopupContent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex flex-col text-white min-w-[200px]">
|
<div className="relative flex min-w-fit flex-col text-white">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute w-[calc(100%+2px)] h-4 z-99 pointer-events-none",
|
"z-99 pointer-events-none absolute h-4 w-[calc(100%+2px)]",
|
||||||
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
|
||||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||||
)}
|
)}
|
||||||
@@ -67,7 +74,7 @@ const PopupContent = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mission.id}
|
key={mission.id}
|
||||||
className={cn("relative inline-flex items-center gap-2 text-nowrap w-full")}
|
className={cn("relative inline-flex w-full items-center gap-2 text-nowrap")}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: markerColor,
|
backgroundColor: markerColor,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -76,15 +83,27 @@ const PopupContent = ({
|
|||||||
<span
|
<span
|
||||||
className="mx-2 my-0.5 flex-1 cursor-pointer"
|
className="mx-2 my-0.5 flex-1 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenMissionMarker({
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
open: [
|
setOpenMissionMarker({
|
||||||
{
|
open: [
|
||||||
id: mission.id,
|
{
|
||||||
tab: "home",
|
id: mission.id,
|
||||||
},
|
tab: "home",
|
||||||
],
|
},
|
||||||
close: [],
|
],
|
||||||
});
|
close: openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
map.setView([mission.addressLat, mission.addressLng], 12, {
|
map.setView([mission.addressLat, mission.addressLng], 12, {
|
||||||
animate: true,
|
animate: true,
|
||||||
});
|
});
|
||||||
@@ -98,34 +117,50 @@ const PopupContent = ({
|
|||||||
{aircrafts.map((aircraft) => (
|
{aircrafts.map((aircraft) => (
|
||||||
<div
|
<div
|
||||||
key={aircraft.id}
|
key={aircraft.id}
|
||||||
className="relative w-auto inline-flex items-center gap-2 text-nowrap cursor-pointer"
|
className="relative inline-flex w-auto cursor-pointer items-center gap-2 text-nowrap px-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
|
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenAircraftMarker({
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
open: [
|
setOpenAircraftMarker({
|
||||||
{
|
open: [
|
||||||
id: aircraft.id,
|
{
|
||||||
tab: "aircraft",
|
id: aircraft.id,
|
||||||
},
|
tab: "home",
|
||||||
],
|
},
|
||||||
close: [],
|
],
|
||||||
});
|
close: openAircraftMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOpenAircraftMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: aircraft.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
|
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
|
||||||
animate: true,
|
animate: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="mx-2 my-0.5 text-gt font-bold"
|
className="text-gt my-0.5 font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
|
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{aircraft.fmsStatus}
|
{aircraft.fmsStatus}
|
||||||
</span>
|
</span>
|
||||||
<span>{aircraft.Station.bosCallsign}</span>
|
<span>
|
||||||
|
{aircraft.Station.bosCallsign.length > 15
|
||||||
|
? aircraft.Station.locationStateShort
|
||||||
|
: aircraft.Station.bosCallsign}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -136,6 +171,7 @@ const PopupContent = ({
|
|||||||
export const MarkerCluster = () => {
|
export const MarkerCluster = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const dispatchState = useDispatchConnectionStore((s) => s);
|
const dispatchState = useDispatchConnectionStore((s) => s);
|
||||||
|
const pilotState = usePilotConnectionStore((s) => s);
|
||||||
const dispatcherConnected = dispatchState.status === "connected";
|
const dispatcherConnected = dispatchState.status === "connected";
|
||||||
const { data: aircrafts } = useQuery({
|
const { data: aircrafts } = useQuery({
|
||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
@@ -155,9 +191,36 @@ export const MarkerCluster = () => {
|
|||||||
return missions.filter((m: Mission) => {
|
return missions.filter((m: Mission) => {
|
||||||
if (m.state === "draft" && !dispatcherConnected) return false;
|
if (m.state === "draft" && !dispatcherConnected) return false;
|
||||||
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
|
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
|
||||||
|
if (
|
||||||
|
pilotState.status === "connected" &&
|
||||||
|
!pilotState.showOtherMissions &&
|
||||||
|
pilotState.selectedStation
|
||||||
|
)
|
||||||
|
return m.missionStationIds.includes(pilotState.selectedStation.id);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [missions, dispatcherConnected, dispatchState.hideDraftMissions]);
|
}, [
|
||||||
|
missions,
|
||||||
|
dispatcherConnected,
|
||||||
|
dispatchState.hideDraftMissions,
|
||||||
|
pilotState.selectedStation,
|
||||||
|
pilotState.showOtherMissions,
|
||||||
|
pilotState.status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filteredAircrafts = useMemo(() => {
|
||||||
|
return aircrafts?.filter((a: ConnectedAircraft) => {
|
||||||
|
if (pilotState.status === "connected" && !pilotState.showOtherAircrafts) {
|
||||||
|
return a.stationId === pilotState.connectedAircraft?.stationId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
aircrafts,
|
||||||
|
pilotState.status,
|
||||||
|
pilotState.showOtherAircrafts,
|
||||||
|
pilotState.connectedAircraft?.stationId,
|
||||||
|
]);
|
||||||
|
|
||||||
// Track zoom level in state
|
// Track zoom level in state
|
||||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||||
@@ -178,12 +241,12 @@ export const MarkerCluster = () => {
|
|||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
aircrafts?.forEach((aircraft) => {
|
filteredAircrafts?.forEach((aircraft) => {
|
||||||
const lat = aircraft.posLat!;
|
const lat = aircraft.posLat!;
|
||||||
const lng = aircraft.posLng!;
|
const lng = aircraft.posLng!;
|
||||||
|
|
||||||
const existingClusterIndex = newCluster.findIndex(
|
const existingClusterIndex = newCluster.findIndex(
|
||||||
(c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1,
|
(c) => Math.abs(c.lat - lat) < 1.55 && Math.abs(c.lng - lng) < 1,
|
||||||
);
|
);
|
||||||
const existingCluster = newCluster[existingClusterIndex];
|
const existingCluster = newCluster[existingClusterIndex];
|
||||||
if (existingCluster) {
|
if (existingCluster) {
|
||||||
@@ -255,7 +318,7 @@ export const MarkerCluster = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return clusterWithAvgPos;
|
return clusterWithAvgPos;
|
||||||
}, [aircrafts, filteredMissions, zoom]);
|
}, [filteredAircrafts, filteredMissions, zoom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -270,7 +333,7 @@ export const MarkerCluster = () => {
|
|||||||
position={[c.lat, c.lng]}
|
position={[c.lat, c.lng]}
|
||||||
autoPan={false}
|
autoPan={false}
|
||||||
autoClose={false}
|
autoClose={false}
|
||||||
className="w-[202px]"
|
className="min-w-fit"
|
||||||
>
|
>
|
||||||
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
|
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
|
||||||
</SmartPopup>
|
</SmartPopup>
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ const Einsatzdetails = ({
|
|||||||
const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state);
|
const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state);
|
||||||
const [ignoreHpg, setIgnoreHpg] = useState(false);
|
const [ignoreHpg, setIgnoreHpg] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-base-content">
|
<div className="text-base-content p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="flex items-center gap-2 text-lg font-bold">
|
<h2 className="flex items-center gap-2 text-lg font-bold">
|
||||||
<Flag /> Einsatzdetails
|
<Flag /> Einsatzdetails
|
||||||
</h2>
|
</h2>
|
||||||
@@ -126,7 +126,7 @@ const Einsatzdetails = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="tooltip tooltip-warning tooltip-left font-semibold z-[9999]"
|
className="tooltip tooltip-warning tooltip-left z-[9999] font-semibold"
|
||||||
data-tip="Einsatz abschließen"
|
data-tip="Einsatz abschließen"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -161,19 +161,19 @@ const Einsatzdetails = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-base-content font-semibold">
|
<ul className="text-base-content font-semibold">
|
||||||
<li className="flex items-center gap-2 mb-1">
|
<li className="mb-1 flex items-center gap-2">
|
||||||
<BellRing size={16} /> {mission.missionKeywordCategory}
|
<BellRing size={16} /> {mission.missionKeywordCategory}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2 mb-1">
|
<li className="mb-1 flex items-center gap-2">
|
||||||
<ListCollapse size={16} />
|
<ListCollapse size={16} />
|
||||||
{mission.missionKeywordName}
|
{mission.missionKeywordName}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2 mt-3">
|
<li className="mt-3 flex items-center gap-2">
|
||||||
<Hash size={16} />
|
<Hash size={16} />
|
||||||
{mission.publicId}
|
{mission.publicId}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
<div className="text-sm font-semibold">
|
<div className="text-sm font-semibold">
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
|
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
|
||||||
@@ -192,7 +192,7 @@ const Einsatzdetails = ({
|
|||||||
</div>
|
</div>
|
||||||
{mission.type == "sekundär" && (
|
{mission.type == "sekundär" && (
|
||||||
<>
|
<>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
<div className="text-sm font-semibold">
|
<div className="text-sm font-semibold">
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
<Route size={16} /> {mission.addressMissionDestination}
|
<Route size={16} /> {mission.addressMissionDestination}
|
||||||
@@ -202,11 +202,11 @@ const Einsatzdetails = ({
|
|||||||
)}
|
)}
|
||||||
{mission.state === "draft" && (
|
{mission.state === "draft" && (
|
||||||
<div>
|
<div>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
|
|
||||||
{hpgNeedsAttention && mission.hpgValidationState !== "POSITION_AMANDED" && (
|
{hpgNeedsAttention && mission.hpgValidationState !== "POSITION_AMANDED" && (
|
||||||
<div className="form-control mb-2 flex justify-between items-center">
|
<div className="form-control mb-2 flex items-center justify-between">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-sm checkbox-primary"
|
className="checkbox checkbox-sm checkbox-primary"
|
||||||
@@ -214,7 +214,7 @@ const Einsatzdetails = ({
|
|||||||
onChange={(e) => setIgnoreHpg(e.target.checked)}
|
onChange={(e) => setIgnoreHpg(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="label-text font-semibold leading-6 tooltip"
|
className="label-text tooltip font-semibold leading-6"
|
||||||
data-tip="Die HPG-Alarmierung wird trotzdem ausgeführt. Die Position des HPG-Einsatzes kann gravierend von der Einsatzposition abweichen"
|
data-tip="Die HPG-Alarmierung wird trotzdem ausgeführt. Die Position des HPG-Einsatzes kann gravierend von der Einsatzposition abweichen"
|
||||||
>
|
>
|
||||||
HPG-Fehler ignorieren
|
HPG-Fehler ignorieren
|
||||||
@@ -235,7 +235,7 @@ const Einsatzdetails = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex w-full items-center gap-2">
|
||||||
{(!hpgNeedsAttention || ignoreHpg) &&
|
{(!hpgNeedsAttention || ignoreHpg) &&
|
||||||
mission.hpgValidationState !== HpgValidationState.POSITION_AMANDED && (
|
mission.hpgValidationState !== HpgValidationState.POSITION_AMANDED && (
|
||||||
<button
|
<button
|
||||||
@@ -354,13 +354,13 @@ const Einsatzdetails = ({
|
|||||||
|
|
||||||
const Patientdetails = ({ mission }: { mission: Mission }) => {
|
const Patientdetails = ({ mission }: { mission: Mission }) => {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-base-content">
|
<div className="text-base-content p-4">
|
||||||
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-bold">
|
||||||
<User /> Patientendetails
|
<User /> Patientendetails
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base-content font-semibold">{mission.missionPatientInfo}</p>
|
<p className="text-base-content font-semibold">{mission.missionPatientInfo}</p>
|
||||||
<div className="divider my-2" />
|
<div className="divider my-2" />
|
||||||
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
|
<h2 className="mb-3 flex items-center gap-2 text-lg font-bold">
|
||||||
<Cross /> Einsatzinformationen
|
<Cross /> Einsatzinformationen
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base-content font-semibold">{mission.missionAdditionalInfo}</p>
|
<p className="text-base-content font-semibold">{mission.missionAdditionalInfo}</p>
|
||||||
@@ -370,9 +370,17 @@ const Patientdetails = ({ mission }: { mission: Mission }) => {
|
|||||||
|
|
||||||
const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedStation, setSelectedStation] = useState<number | "RTW" | "POL" | "FW" | null>(
|
const [selectedStation, setSelectedStation] = useState<{
|
||||||
null,
|
selectedStationId: number | undefined;
|
||||||
);
|
hpgAmbulanceState: HpgState;
|
||||||
|
hpgFireEngineState: HpgState;
|
||||||
|
hpgPoliceState: HpgState;
|
||||||
|
}>({
|
||||||
|
selectedStationId: undefined,
|
||||||
|
hpgAmbulanceState: HpgState.NOT_REQUESTED,
|
||||||
|
hpgFireEngineState: HpgState.NOT_REQUESTED,
|
||||||
|
hpgPoliceState: HpgState.NOT_REQUESTED,
|
||||||
|
});
|
||||||
const { data: connectedAircrafts } = useQuery({
|
const { data: connectedAircrafts } = useQuery({
|
||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
queryFn: () => getConnectedAircraftsAPI(),
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
@@ -432,7 +440,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
|||||||
const HPGVehicle = ({ state, name }: { state: HpgState; name: string }) => (
|
const HPGVehicle = ({ state, name }: { state: HpgState; name: string }) => (
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="font-bold text-base"
|
className="text-base font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[hpgStateToFMSStatus(state)],
|
color: FMS_STATUS_TEXT_COLORS[hpgStateToFMSStatus(state)],
|
||||||
}}
|
}}
|
||||||
@@ -449,8 +457,8 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-base-content">
|
<div className="text-base-content p-4">
|
||||||
<div className="flex items-center w-full justify-between mb-2">
|
<div className="mb-2 flex w-full items-center justify-between">
|
||||||
<h2 className="flex items-center gap-2 text-lg font-bold">
|
<h2 className="flex items-center gap-2 text-lg font-bold">
|
||||||
<SmartphoneNfc /> Rettungsmittel
|
<SmartphoneNfc /> Rettungsmittel
|
||||||
</h2>
|
</h2>
|
||||||
@@ -472,9 +480,9 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 h-[130px] overflow-y-auto overflow-x-auto flex-1">
|
<ul className="h-[130px] flex-1 space-y-2 overflow-x-auto overflow-y-auto">
|
||||||
{mission.missionStationIds.length === 0 && (
|
{mission.missionStationIds.length === 0 && (
|
||||||
<p className="text-gray-500 w-full text-center my-10 font-semibold">
|
<p className="my-10 w-full text-center font-semibold text-gray-500">
|
||||||
Keine Rettungsmittel zugewiesen
|
Keine Rettungsmittel zugewiesen
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -486,17 +494,17 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
|||||||
return (
|
return (
|
||||||
<li key={index} className="flex items-center gap-2">
|
<li key={index} className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="font-bold text-base"
|
className="text-base font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[connectedAircraft?.fmsStatus || "6"],
|
color: FMS_STATUS_TEXT_COLORS[connectedAircraft?.fmsStatus || "6"],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{connectedAircraft?.fmsStatus || "6"}
|
{connectedAircraft?.fmsStatus || "6"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base-content flex flex-col ">
|
<span className="text-base-content flex flex-col">
|
||||||
<span className="font-bold">{station.bosCallsign}</span>
|
<span className="font-bold">{station.bosCallsign}</span>
|
||||||
{!connectedAircraft && (
|
{!connectedAircraft && (
|
||||||
<span className="text-gray-400 text-xs">Kein Benutzer verbunden</span>
|
<span className="text-xs text-gray-400">Kein Benutzer verbunden</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -514,16 +522,24 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
{dispatcherConnected && (
|
{dispatcherConnected && (
|
||||||
<div>
|
<div>
|
||||||
<div className="divider mt-0 mb-0" />
|
<div className="divider mb-0 mt-0" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* TODO: make it a small multiselect */}
|
{/* TODO: make it a small multiselect */}
|
||||||
<StationsSelect
|
<StationsSelect
|
||||||
menuPlacement="top"
|
menuPlacement="top"
|
||||||
className="min-w-[320px] flex-1"
|
className="min-w-[320px] flex-1"
|
||||||
isMulti={false}
|
isMulti={false}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
onChange={(v) => {
|
||||||
onChange={(v: any) => {
|
console.log("Selected Station:", v);
|
||||||
setSelectedStation(v);
|
setSelectedStation({
|
||||||
|
selectedStationId: v?.selectedStationIds[0],
|
||||||
|
hpgAmbulanceState:
|
||||||
|
v.hpgAmbulanceState || mission.hpgAmbulanceState || HpgState.NOT_REQUESTED,
|
||||||
|
hpgFireEngineState:
|
||||||
|
v.hpgFireEngineState || mission.hpgFireEngineState || HpgState.NOT_REQUESTED,
|
||||||
|
hpgPoliceState:
|
||||||
|
v.hpgPoliceState || mission.hpgPoliceState || HpgState.NOT_REQUESTED,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
selectedStations={mission.missionStationIds}
|
selectedStations={mission.missionStationIds}
|
||||||
filterSelected
|
filterSelected
|
||||||
@@ -536,24 +552,40 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary btn-outline"
|
className="btn btn-sm btn-primary btn-outline"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (typeof selectedStation === "string") {
|
console.log("Selected Station:", selectedStation);
|
||||||
|
if (
|
||||||
|
selectedStation.hpgAmbulanceState !== "NOT_REQUESTED" ||
|
||||||
|
selectedStation.hpgFireEngineState !== "NOT_REQUESTED" ||
|
||||||
|
selectedStation.hpgPoliceState !== "NOT_REQUESTED"
|
||||||
|
) {
|
||||||
|
// Determine which vehicle type is selected
|
||||||
|
let vehicleName: "RTW" | "POL" | "FW" | undefined;
|
||||||
|
if (selectedStation.hpgAmbulanceState !== "NOT_REQUESTED") {
|
||||||
|
vehicleName = "RTW";
|
||||||
|
} else if (selectedStation.hpgPoliceState !== "NOT_REQUESTED") {
|
||||||
|
vehicleName = "POL";
|
||||||
|
} else if (selectedStation.hpgFireEngineState !== "NOT_REQUESTED") {
|
||||||
|
vehicleName = "FW";
|
||||||
|
}
|
||||||
|
|
||||||
await sendAlertMutation.mutate({
|
await sendAlertMutation.mutate({
|
||||||
id: mission.id,
|
id: mission.id,
|
||||||
vehicleName: selectedStation,
|
vehicleName: vehicleName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!selectedStation) return;
|
if (typeof selectedStation.selectedStationId !== "number") return;
|
||||||
|
|
||||||
await updateMissionMutation.mutateAsync({
|
await updateMissionMutation.mutateAsync({
|
||||||
id: mission.id,
|
id: mission.id,
|
||||||
missionEdit: {
|
missionEdit: {
|
||||||
missionStationIds: {
|
missionStationIds: {
|
||||||
push: selectedStation,
|
push: selectedStation.selectedStationId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await sendAlertMutation.mutate({
|
await sendAlertMutation.mutate({
|
||||||
id: mission.id,
|
id: mission.id,
|
||||||
stationId: selectedStation,
|
stationId: selectedStation.selectedStationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -629,7 +661,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isAddingNote ? (
|
{!isAddingNote ? (
|
||||||
<button
|
<button
|
||||||
className="text-base-content text-base cursor-pointer"
|
className="text-base-content cursor-pointer text-base"
|
||||||
onClick={() => setIsAddingNote(true)}
|
onClick={() => setIsAddingNote(true)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -637,7 +669,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex w-full items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
@@ -669,7 +701,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
<div className="divider m-0" />
|
<div className="divider m-0" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-1 max-h-[300px] overflow-y-auto overflow-x-auto">
|
<ul className="max-h-[300px] space-y-1 overflow-x-auto overflow-y-auto">
|
||||||
{(mission.missionLog as unknown as MissionLog[])
|
{(mission.missionLog as unknown as MissionLog[])
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -684,7 +716,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-bold text-base"
|
className="text-base font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
|
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
|
||||||
}}
|
}}
|
||||||
@@ -704,7 +736,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-bold text-base flex items-center gap-0.5"
|
className="flex items-center gap-0.5 text-base font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[6],
|
color: FMS_STATUS_TEXT_COLORS[6],
|
||||||
}}
|
}}
|
||||||
@@ -735,10 +767,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
<span className="text-base-content">{entry.data.message}</span>
|
<span className="text-base-content">{entry.data.message}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
if (entry.type === "alert-log") {
|
if (
|
||||||
const alertReceiver = entry.auto
|
entry.type === "alert-log" ||
|
||||||
? null
|
entry.type === "completed-log" ||
|
||||||
: entry.data.station?.bosCallsignShort || entry.data.vehicle;
|
entry.type === "reopened-log"
|
||||||
|
) {
|
||||||
|
const alertReceiver =
|
||||||
|
entry.auto || entry.type !== "alert-log"
|
||||||
|
? null
|
||||||
|
: entry.data.station?.bosCallsignShort || entry.data.vehicle;
|
||||||
return (
|
return (
|
||||||
<li key={index} className="flex items-center gap-2">
|
<li key={index} className="flex items-center gap-2">
|
||||||
<span className="text-base-content">
|
<span className="text-base-content">
|
||||||
@@ -748,15 +785,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-bold text-base flex items-center gap-0.5"
|
className="flex items-center gap-0.5 text-base font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[6],
|
color: FMS_STATUS_TEXT_COLORS[6],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!entry.auto && (
|
{!entry.auto && (
|
||||||
<>
|
<>
|
||||||
{entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
|
{entry.data.user?.firstname?.[0]?.toUpperCase() ?? "?"}
|
||||||
{entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
|
{entry.data.user?.lastname?.[0]?.toUpperCase() ?? "?"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{entry.auto && "AUTO"}
|
{entry.auto && "AUTO"}
|
||||||
@@ -781,7 +818,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base-content">Einsatz alarmiert</span>
|
{entry.type === "alert-log" && (
|
||||||
|
<span className="text-base-content">Einsatz alarmiert</span>
|
||||||
|
)}
|
||||||
|
{entry.type === "completed-log" && (
|
||||||
|
<span className="text-base-content">Einsatz abgeschlossen</span>
|
||||||
|
)}
|
||||||
|
{entry.type === "reopened-log" && (
|
||||||
|
<span className="text-base-content">Einsatz wiedereröffnet</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -789,7 +834,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{!mission.missionLog.length && (
|
{!mission.missionLog.length && (
|
||||||
<p className="text-gray-500 w-full text-center my-10 font-semibold">
|
<p className="my-10 w-full text-center font-semibold text-gray-500">
|
||||||
Keine Notizen verfügbar
|
Keine Notizen verfügbar
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
const modalRef = useRef<HTMLDialogElement>(null);
|
const modalRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
console.debug("piloten von API", {
|
||||||
|
anzahl: pilots?.length,
|
||||||
|
pilots,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -108,11 +113,11 @@ export default function AdminPanel() {
|
|||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
</form>
|
</form>
|
||||||
<h3 className="font-bold text-lg flex items-center gap-2">
|
<h3 className="flex items-center gap-2 text-lg font-bold">
|
||||||
<Shield size={22} /> Admin Panel
|
<Shield size={22} /> Admin Panel
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2 mt-4 w-full">
|
<div className="mt-4 flex w-full gap-2">
|
||||||
<div className="card bg-base-300 shadow-md w-full h-96 overflow-y-auto">
|
<div className="card bg-base-300 h-96 w-full overflow-y-auto shadow-md">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="card-title flex items-center gap-2">
|
<div className="card-title flex items-center gap-2">
|
||||||
<UserCheck size={20} /> Verbundene Clients
|
<UserCheck size={20} /> Verbundene Clients
|
||||||
|
|||||||
34
apps/dispatch/app/_components/navbar/ChangelogWrapper.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
import { Changelog } from "@repo/db";
|
||||||
|
import { ChangelogModalBtn } from "@repo/shared-components";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { editUserAPI } from "_querys/user";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
export const ChangelogWrapper = ({ latestChangelog }: { latestChangelog: Changelog | null }) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const editUserMutation = useMutation({
|
||||||
|
mutationFn: editUserAPI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const autoOpen = !session?.user.changelogAck && !!latestChangelog;
|
||||||
|
|
||||||
|
if (!latestChangelog) return null;
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChangelogModalBtn
|
||||||
|
hideIcon
|
||||||
|
className="text-sm text-gray-500"
|
||||||
|
latestChangelog={latestChangelog}
|
||||||
|
autoOpen={autoOpen}
|
||||||
|
onClose={async () => {
|
||||||
|
await editUserMutation.mutateAsync({ id: session?.user.id, user: { changelogAck: true } });
|
||||||
|
if (!session?.user.changelogAck) {
|
||||||
|
toast.success("Changelog als gelesen markiert");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -30,7 +30,7 @@ export default function ModeSwitchDropdown({ className }: { className?: string }
|
|||||||
{session.data?.user.permissions?.includes("PILOT") && (
|
{session.data?.user.permissions?.includes("PILOT") && (
|
||||||
<li>
|
<li>
|
||||||
<Link href={"/pilot"}>
|
<Link href={"/pilot"}>
|
||||||
<Plane size={22} /> Pilot
|
<Plane size={22} /> Operations Center
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
export const fmsStatusDescription: { [key: string]: string } = {
|
export const fmsStatusDescription: { [key: string]: string } = {
|
||||||
NaN: "Keine Daten",
|
NaN: "Keine Daten",
|
||||||
"0": "Prio. Sprechwunsch",
|
"0": "Prio. Sprechwunsch",
|
||||||
"1": "Frei auf Funk",
|
"1": "Einsatzbereit über Funk",
|
||||||
"2": "Einsatzbereit am LRZ",
|
"2": "Einsatzbereit auf Wache",
|
||||||
"3": "Auf dem Weg",
|
"3": "Einsatzübernahme",
|
||||||
"4": "Am Einsatzort",
|
"4": "Einsatzort an",
|
||||||
"5": "Sprechwunsch",
|
"5": "Sprechwunsch",
|
||||||
"6": "Nicht einsatzbereit",
|
"6": "Nicht einsatzbereit",
|
||||||
"7": "Patient aufgenommen",
|
"7": "Einsatzgebunden",
|
||||||
"8": "Am Transportziel",
|
"8": "Bedingt verfügbar",
|
||||||
"9": "Fremdanmeldung",
|
"9": "Fremdanmeldung",
|
||||||
E: "Indent/Abbruch/Einsatzbefehl abgebrochen",
|
E: "Einsatzabbruch",
|
||||||
C: "Anmelden zur Übernahme des Einsatzes",
|
C: "Einsatzübernahme melden",
|
||||||
F: "Kommen über Draht",
|
F: "Kommen Sie über Draht",
|
||||||
H: "Fahren auf Wache",
|
H: "Fahren auf Wache",
|
||||||
J: "Sprechaufforderung",
|
J: "Sprechaufforderung",
|
||||||
L: "Lagebericht abgeben",
|
L: "Geben Sie Lagemeldung",
|
||||||
P: "Einsatz mit Polizei/Pause machen",
|
P: "Einsatz mit Polizei/Pause machen",
|
||||||
U: "Ungültiger Status",
|
U: "Ungültige Statusfolge",
|
||||||
c: "Status korrigieren",
|
c: "Status korrigieren",
|
||||||
d: "Transportziel angeben",
|
d: "Transportziel angeben",
|
||||||
h: "Zielklinik verständigt",
|
h: "Zielklinik verständigt",
|
||||||
|
|||||||
@@ -4,28 +4,40 @@ import {
|
|||||||
RemoteParticipant,
|
RemoteParticipant,
|
||||||
RemoteTrack,
|
RemoteTrack,
|
||||||
RemoteTrackPublication,
|
RemoteTrackPublication,
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
|
|
||||||
|
const initialTrackTimeouts = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
export const handleTrackSubscribed = (
|
export const handleTrackSubscribed = (
|
||||||
track: RemoteTrack,
|
track: RemoteTrack,
|
||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
) => {
|
) => {
|
||||||
|
const element = track.attach();
|
||||||
|
element.pause();
|
||||||
|
|
||||||
if (!track.isMuted) {
|
if (!track.isMuted) {
|
||||||
useAudioStore.getState().addSpeakingParticipant(participant);
|
initialTrackTimeouts.set(
|
||||||
|
participant.sid,
|
||||||
|
setTimeout(() => {
|
||||||
|
useAudioStore.getState().addSpeakingParticipant(participant);
|
||||||
|
}, 1000),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
element.play();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
track.on("unmuted", () => {
|
track.on("unmuted", () => {
|
||||||
useAudioStore.getState().addSpeakingParticipant(participant);
|
useAudioStore.getState().addSpeakingParticipant(participant);
|
||||||
|
element.volume = useAudioStore.getState().settings.radioVolume;
|
||||||
});
|
});
|
||||||
|
|
||||||
track.on("muted", () => {
|
track.on("muted", () => {
|
||||||
|
clearTimeout(initialTrackTimeouts.get(participant.sid));
|
||||||
|
initialTrackTimeouts.get(participant.sid);
|
||||||
useAudioStore.getState().removeSpeakingParticipant(participant);
|
useAudioStore.getState().removeSpeakingParticipant(participant);
|
||||||
});
|
});
|
||||||
if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) {
|
|
||||||
// attach it to a new HTMLVideoElement or HTMLAudioElement
|
|
||||||
const element = track.attach();
|
|
||||||
element.play();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleTrackUnsubscribed = (track: RemoteTrack) => {
|
export const handleTrackUnsubscribed = (track: RemoteTrack) => {
|
||||||
|
|||||||
14
apps/dispatch/app/_querys/heliports.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Heliport, Prisma } from "@repo/db";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const getHeliportsAPI = async (filter?: Prisma.HeliportWhereInput) => {
|
||||||
|
const res = await axios.get<Heliport[]>("/api/heliports", {
|
||||||
|
params: {
|
||||||
|
filter: JSON.stringify(filter),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error("Failed to fetch heliports");
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -5,18 +5,32 @@ import {
|
|||||||
handleTrackSubscribed,
|
handleTrackSubscribed,
|
||||||
handleTrackUnsubscribed,
|
handleTrackUnsubscribed,
|
||||||
} from "_helpers/liveKitEventHandler";
|
} from "_helpers/liveKitEventHandler";
|
||||||
import { ConnectionQuality, Participant, Room, RoomEvent, RpcInvocationData } from "livekit-client";
|
import {
|
||||||
|
ConnectionQuality,
|
||||||
|
LocalTrackPublication,
|
||||||
|
Participant,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RpcInvocationData,
|
||||||
|
Track,
|
||||||
|
} from "livekit-client";
|
||||||
import { pilotSocket } from "(app)/pilot/socket";
|
import { pilotSocket } from "(app)/pilot/socket";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||||
import { changeDispatcherAPI } from "_querys/dispatcher";
|
import { changeDispatcherAPI } from "_querys/dispatcher";
|
||||||
|
import { getRadioStream } from "_helpers/radioEffect";
|
||||||
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
|
||||||
let interval: NodeJS.Timeout;
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
type TalkState = {
|
type TalkState = {
|
||||||
micDeviceId: string | null;
|
settings: {
|
||||||
micVolume: number;
|
micDeviceId: string | null;
|
||||||
|
micVolume: number;
|
||||||
|
radioVolume: number;
|
||||||
|
dmeVolume: number;
|
||||||
|
};
|
||||||
isTalking: boolean;
|
isTalking: boolean;
|
||||||
transmitBlocked: boolean;
|
transmitBlocked: boolean;
|
||||||
removeMessage: () => void;
|
removeMessage: () => void;
|
||||||
@@ -25,13 +39,14 @@ type TalkState = {
|
|||||||
connectionQuality: ConnectionQuality;
|
connectionQuality: ConnectionQuality;
|
||||||
remoteParticipants: number;
|
remoteParticipants: number;
|
||||||
toggleTalking: () => void;
|
toggleTalking: () => void;
|
||||||
setMic: (micDeviceId: string | null, volume: number) => void;
|
setSettings: (settings: Partial<TalkState["settings"]>) => void;
|
||||||
connect: (roomName: string, role: string) => void;
|
connect: (roomName: string, role: string) => void;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
speakingParticipants: Participant[];
|
speakingParticipants: Participant[];
|
||||||
addSpeakingParticipant: (participant: Participant) => void;
|
addSpeakingParticipant: (participant: Participant) => void;
|
||||||
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
|
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
|
||||||
room: Room | null;
|
room: Room | null;
|
||||||
|
localRadioTrack: LocalTrackPublication | undefined;
|
||||||
};
|
};
|
||||||
const getToken = async (roomName: string) => {
|
const getToken = async (roomName: string) => {
|
||||||
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
|
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
|
||||||
@@ -41,11 +56,17 @@ const getToken = async (roomName: string) => {
|
|||||||
|
|
||||||
export const useAudioStore = create<TalkState>((set, get) => ({
|
export const useAudioStore = create<TalkState>((set, get) => ({
|
||||||
isTalking: false,
|
isTalking: false,
|
||||||
|
localRadioTrack: undefined,
|
||||||
transmitBlocked: false,
|
transmitBlocked: false,
|
||||||
message: null,
|
message: null,
|
||||||
micDeviceId: null,
|
|
||||||
speakingParticipants: [],
|
speakingParticipants: [],
|
||||||
micVolume: 1,
|
micVolume: 1,
|
||||||
|
settings: {
|
||||||
|
micDeviceId: null,
|
||||||
|
micVolume: 1,
|
||||||
|
radioVolume: 0.8,
|
||||||
|
dmeVolume: 0.8,
|
||||||
|
},
|
||||||
state: "disconnected" as const,
|
state: "disconnected" as const,
|
||||||
remoteParticipants: 0,
|
remoteParticipants: 0,
|
||||||
connectionQuality: ConnectionQuality.Unknown,
|
connectionQuality: ConnectionQuality.Unknown,
|
||||||
@@ -73,11 +94,29 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
|||||||
set({ transmitBlocked: false, message: null, isTalking: true });
|
set({ transmitBlocked: false, message: null, isTalking: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setMic: (micDeviceId, micVolume) => {
|
setSettings: (newSettings) => {
|
||||||
set({ micDeviceId, micVolume });
|
const oldSettings = get().settings;
|
||||||
|
set((s) => ({
|
||||||
|
settings: {
|
||||||
|
...s.settings,
|
||||||
|
...newSettings,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (
|
||||||
|
get().state === "connected" &&
|
||||||
|
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
|
||||||
|
oldSettings.micVolume !== newSettings.micVolume)
|
||||||
|
) {
|
||||||
|
const { room, disconnect, connect } = get();
|
||||||
|
const role = room?.localParticipant.attributes.role;
|
||||||
|
if (room?.name || role) {
|
||||||
|
disconnect();
|
||||||
|
connect(room?.name || "", role || "user");
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
toggleTalking: () => {
|
toggleTalking: () => {
|
||||||
const { room, isTalking, micDeviceId, speakingParticipants, transmitBlocked } = get();
|
const { room, isTalking, speakingParticipants, transmitBlocked } = get();
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
if (speakingParticipants.length > 0 && !isTalking && !transmitBlocked) {
|
if (speakingParticipants.length > 0 && !isTalking && !transmitBlocked) {
|
||||||
@@ -94,10 +133,20 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Todo: use micVolume
|
|
||||||
room.localParticipant.setMicrophoneEnabled(!isTalking, {
|
const { status: DispatcherConnectionStatus } = useDispatchConnectionStore.getState();
|
||||||
deviceId: micDeviceId ?? undefined,
|
const { status: PilotConnectionStatus } = usePilotConnectionStore.getState();
|
||||||
});
|
if (
|
||||||
|
!isTalking &&
|
||||||
|
!(DispatcherConnectionStatus === "connected" || PilotConnectionStatus === "connected")
|
||||||
|
) {
|
||||||
|
useAudioStore.setState({
|
||||||
|
message: "Keine Verbindung",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
room.localParticipant.setMicrophoneEnabled(!isTalking);
|
||||||
|
|
||||||
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
|
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
|
||||||
},
|
},
|
||||||
@@ -131,10 +180,30 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
deviceId: get().settings.micDeviceId ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Funk-Effekt anwenden
|
||||||
|
const radioStream = getRadioStream(inputStream, get().settings.micVolume);
|
||||||
|
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
|
||||||
|
|
||||||
|
const [track] = radioStream.getAudioTracks();
|
||||||
|
if (!track) throw new Error("Konnte Audio-Track nicht erzeugen");
|
||||||
|
|
||||||
|
const publishedTrack = await room.localParticipant.publishTrack(track, {
|
||||||
|
name: "radio-audio",
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
});
|
||||||
|
await publishedTrack.mute();
|
||||||
|
|
||||||
|
set({ localRadioTrack: publishedTrack });
|
||||||
|
|
||||||
set({ state: "connected", room, message: null });
|
set({ state: "connected", room, message: null });
|
||||||
})
|
})
|
||||||
.on(RoomEvent.Disconnected, () => {
|
.on(RoomEvent.Disconnected, () => {
|
||||||
set({ state: "disconnected" });
|
set({ state: "disconnected", speakingParticipants: [], transmitBlocked: false });
|
||||||
|
|
||||||
handleDisconnect();
|
handleDisconnect();
|
||||||
})
|
})
|
||||||
@@ -198,6 +267,17 @@ const handlePTT = (data: PTTData) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { status: DispatcherConnectionStatus } = useDispatchConnectionStore.getState();
|
||||||
|
const { status: PilotConnectionStatus } = usePilotConnectionStore.getState();
|
||||||
|
if (
|
||||||
|
shouldTransmit &&
|
||||||
|
!(DispatcherConnectionStatus === "connected" || PilotConnectionStatus === "connected")
|
||||||
|
) {
|
||||||
|
useAudioStore.setState({
|
||||||
|
message: "Keine Verbindung",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
useAudioStore.setState({
|
useAudioStore.setState({
|
||||||
isTalking: shouldTransmit,
|
isTalking: shouldTransmit,
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ interface ConnectionStore {
|
|||||||
message: string;
|
message: string;
|
||||||
selectedZone: string;
|
selectedZone: string;
|
||||||
logoffTime: string;
|
logoffTime: string;
|
||||||
connect: (uid: string, selectedZone: string, logoffTime: string) => Promise<void>;
|
ghostMode: boolean;
|
||||||
|
connect: (
|
||||||
|
uid: string,
|
||||||
|
selectedZone: string,
|
||||||
|
logoffTime: string,
|
||||||
|
ghostMode: boolean,
|
||||||
|
) => Promise<void>;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,11 +29,12 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
|
|||||||
message: "",
|
message: "",
|
||||||
selectedZone: "LST_01",
|
selectedZone: "LST_01",
|
||||||
logoffTime: "",
|
logoffTime: "",
|
||||||
connect: async (uid, selectedZone, logoffTime) =>
|
ghostMode: false,
|
||||||
|
connect: async (uid, selectedZone, logoffTime, ghostMode) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
set({ status: "connecting", message: "" });
|
set({ status: "connecting", message: "" });
|
||||||
dispatchSocket.auth = { uid };
|
dispatchSocket.auth = { uid };
|
||||||
set({ selectedZone, logoffTime });
|
set({ selectedZone, logoffTime, ghostMode });
|
||||||
dispatchSocket.connect();
|
dispatchSocket.connect();
|
||||||
|
|
||||||
dispatchSocket.once("connect", () => {
|
dispatchSocket.once("connect", () => {
|
||||||
@@ -40,11 +47,12 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
dispatchSocket.on("connect", () => {
|
dispatchSocket.on("connect", () => {
|
||||||
const { logoffTime, selectedZone } = useDispatchConnectionStore.getState();
|
const { logoffTime, selectedZone, ghostMode } = useDispatchConnectionStore.getState();
|
||||||
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
|
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
|
||||||
dispatchSocket.emit("connect-dispatch", {
|
dispatchSocket.emit("connect-dispatch", {
|
||||||
logoffTime,
|
logoffTime,
|
||||||
selectedZone,
|
selectedZone,
|
||||||
|
ghostMode,
|
||||||
});
|
});
|
||||||
useDispatchConnectionStore.setState({ status: "connected", message: "" });
|
useDispatchConnectionStore.setState({ status: "connected", message: "" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,14 +116,12 @@ dispatchSocket.on(
|
|||||||
"chat-message",
|
"chat-message",
|
||||||
({ userId, message }: { userId: string; message: ChatMessage }) => {
|
({ userId, message }: { userId: string; message: ChatMessage }) => {
|
||||||
const store = useLeftMenuStore.getState();
|
const store = useLeftMenuStore.getState();
|
||||||
console.log("chat-message", userId, message);
|
|
||||||
// Update the chat store with the new message
|
// Update the chat store with the new message
|
||||||
store.addMessage(userId, message);
|
store.addMessage(userId, message);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
pilotSocket.on("chat-message", ({ userId, message }: { userId: string; message: ChatMessage }) => {
|
pilotSocket.on("chat-message", ({ userId, message }: { userId: string; message: ChatMessage }) => {
|
||||||
const store = useLeftMenuStore.getState();
|
const store = useLeftMenuStore.getState();
|
||||||
console.log("chat-message", userId, message);
|
|
||||||
// Update the chat store with the new message
|
// Update the chat store with the new message
|
||||||
store.addMessage(userId, message);
|
store.addMessage(userId, message);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface MapStore {
|
|||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
map: {
|
map: {
|
||||||
center: L.LatLngExpression;
|
center: L.LatLngExpression;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
@@ -38,23 +39,37 @@ export interface MapStore {
|
|||||||
[aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat";
|
[aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat";
|
||||||
};
|
};
|
||||||
setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void;
|
setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void;
|
||||||
|
userSettings: {
|
||||||
|
settingsAutoCloseMapPopup: boolean;
|
||||||
|
};
|
||||||
|
setUserSettings: (settings: Partial<MapStore["userSettings"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMapStore = create<MapStore>((set, get) => ({
|
export const useMapStore = create<MapStore>((set, get) => ({
|
||||||
openMissionMarker: [],
|
openMissionMarker: [],
|
||||||
setOpenMissionMarker: ({ open, close }) => {
|
setOpenMissionMarker: ({ open, close }) => {
|
||||||
const oldMarkers = get().openMissionMarker.filter(
|
const { settingsAutoCloseMapPopup } = get().userSettings;
|
||||||
(m) => !close.includes(m.id) && !open.find((o) => o.id === m.id),
|
|
||||||
);
|
const oldMarkers =
|
||||||
|
settingsAutoCloseMapPopup && open.length > 0
|
||||||
|
? [] // If auto-close is enabled and opening a new popup, close all others
|
||||||
|
: get().openMissionMarker.filter(
|
||||||
|
(m) => !close.includes(m.id) && !open.find((o) => o.id === m.id),
|
||||||
|
);
|
||||||
set(() => ({
|
set(() => ({
|
||||||
openMissionMarker: [...oldMarkers, ...open],
|
openMissionMarker: [...oldMarkers, ...open],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
openAircraftMarker: [],
|
openAircraftMarker: [],
|
||||||
setOpenAircraftMarker: ({ open, close }) => {
|
setOpenAircraftMarker: ({ open, close }) => {
|
||||||
const oldMarkers = get().openAircraftMarker.filter(
|
const { settingsAutoCloseMapPopup } = get().userSettings;
|
||||||
(m) => !close.includes(m.id) && !open.find((o) => o.id === m.id),
|
|
||||||
);
|
const oldMarkers =
|
||||||
|
settingsAutoCloseMapPopup && open.length > 0
|
||||||
|
? [] // If auto-close is enabled and opening a new popup, close all others
|
||||||
|
: get().openAircraftMarker.filter(
|
||||||
|
(m) => !close.includes(m.id) && !open.find((o) => o.id === m.id),
|
||||||
|
);
|
||||||
set(() => ({
|
set(() => ({
|
||||||
openAircraftMarker: [...oldMarkers, ...open],
|
openAircraftMarker: [...oldMarkers, ...open],
|
||||||
}));
|
}));
|
||||||
@@ -101,4 +116,14 @@ export const useMapStore = create<MapStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
missionTabs: {},
|
missionTabs: {},
|
||||||
|
userSettings: {
|
||||||
|
settingsAutoCloseMapPopup: false,
|
||||||
|
},
|
||||||
|
setUserSettings: (settings) =>
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
...settings,
|
||||||
|
},
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ interface ConnectionStore {
|
|||||||
debug?: boolean,
|
debug?: boolean,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
|
followOwnAircraft: boolean;
|
||||||
|
showOtherAircrafts: boolean;
|
||||||
|
showOtherMissions: boolean;
|
||||||
|
setMapOptions: (options: {
|
||||||
|
followOwnAircraft?: boolean;
|
||||||
|
showOtherAircrafts?: boolean;
|
||||||
|
showOtherMissions?: boolean;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePilotConnectionStore = create<ConnectionStore>((set) => ({
|
export const usePilotConnectionStore = create<ConnectionStore>((set) => ({
|
||||||
@@ -37,7 +45,15 @@ export const usePilotConnectionStore = create<ConnectionStore>((set) => ({
|
|||||||
connectedAircraft: null,
|
connectedAircraft: null,
|
||||||
activeMission: null,
|
activeMission: null,
|
||||||
debug: false,
|
debug: false,
|
||||||
|
followOwnAircraft: false,
|
||||||
|
showOtherAircrafts: false,
|
||||||
|
showOtherMissions: false,
|
||||||
|
setMapOptions(options) {
|
||||||
|
set((state) => ({
|
||||||
|
...state,
|
||||||
|
...options,
|
||||||
|
}));
|
||||||
|
},
|
||||||
connect: async (uid, stationId, logoffTime, station, user, debug) =>
|
connect: async (uid, stationId, logoffTime, station, user, debug) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -149,8 +149,7 @@ export const useDmeStore = create<MrtStore>(
|
|||||||
{
|
{
|
||||||
textLeft: pageData.mission.addressAdditionalInfo || "keine Daten",
|
textLeft: pageData.mission.addressAdditionalInfo || "keine Daten",
|
||||||
},
|
},
|
||||||
...(pageData.mission.addressMissionDestination &&
|
...(pageData.mission.type === "sekundär"
|
||||||
pageData.mission.addressMissionDestination.length > 0
|
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
textMid: "Zielort:",
|
textMid: "Zielort:",
|
||||||
@@ -161,20 +160,30 @@ export const useDmeStore = create<MrtStore>(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
...(pageData.mission.missionPatientInfo &&
|
||||||
textMid: "Patienteninfos:",
|
pageData.mission.missionPatientInfo.length > 0
|
||||||
style: { fontWeight: "bold" },
|
? [
|
||||||
},
|
{
|
||||||
{
|
textMid: "Patienteninfos:",
|
||||||
textLeft: pageData.mission.missionPatientInfo || "keine Daten",
|
style: { fontWeight: "bold" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
textMid: "Weitere Infos:",
|
textLeft: pageData.mission.missionPatientInfo,
|
||||||
style: { fontWeight: "bold" },
|
},
|
||||||
},
|
]
|
||||||
{
|
: []),
|
||||||
textLeft: pageData.mission.missionAdditionalInfo || "keine Daten",
|
...(pageData.mission.missionAdditionalInfo &&
|
||||||
},
|
pageData.mission.missionAdditionalInfo.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
textMid: "Weitere Infos:",
|
||||||
|
style: { fontWeight: "bold" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
textLeft: pageData.mission.missionAdditionalInfo,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|||||||
const connectedDispatcher = await prisma.connectedDispatcher.findMany({
|
const connectedDispatcher = await prisma.connectedDispatcher.findMany({
|
||||||
where: {
|
where: {
|
||||||
logoutTime: null,
|
logoutTime: null,
|
||||||
|
ghostMode: false, // Ensure we only get non-ghost mode connections
|
||||||
...filter, // Ensure filter is parsed correctly
|
...filter, // Ensure filter is parsed correctly
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
22
apps/dispatch/app/api/heliports/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@repo/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
const filter = searchParams.get("filter");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await prisma.heliport.findMany({
|
||||||
|
where: {
|
||||||
|
id: id ? Number(id) : undefined,
|
||||||
|
...(filter ? JSON.parse(filter) : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(data, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch heliport" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ export const PUT = async (req: Request) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: Position Runden
|
||||||
if (activeAircraft.posLat === position.lat && activeAircraft.posLng === position.lng) {
|
if (activeAircraft.posLat === position.lat && activeAircraft.posLng === position.lng) {
|
||||||
return Response.json({ message: "Position has not changed" }, { status: 200 });
|
return Response.json({ message: "Position has not changed" }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ export const ConnectedDispatcher = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-120">
|
<div className="min-w-120">
|
||||||
<div className="collapse collapse-arrow bg-base-100 border-base-300 border">
|
<div className="collapse-arrow bg-base-100 border-base-300 collapse border">
|
||||||
<input type="checkbox" />
|
<input type="checkbox" />
|
||||||
{/* <div className="collapse-title font-semibold">Kein Disponent Online</div> */}
|
{/* <div className="collapse-title font-semibold">Kein Disponent Online</div> */}
|
||||||
<div className="collapse-title font-semibold flex items-center justify-between">
|
<div className="collapse-title flex items-center justify-between font-semibold">
|
||||||
<span>
|
<span>
|
||||||
{connections} {connections == 1 ? "Verbundenes Mitglied" : "Verbundene Mitglieder"}
|
{connections} {connections == 1 ? "Verbundenes Mitglied" : "Verbundene Mitglieder"}
|
||||||
</span>
|
</span>
|
||||||
<div className="gap-2 flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`badge badge-outline ${
|
className={`badge badge-outline ${
|
||||||
(dispatcher?.length || 0) > 0 ? "badge-success" : "badge-error"
|
(dispatcher?.length || 0) > 0 ? "badge-success" : "badge-error"
|
||||||
@@ -65,7 +65,7 @@ export const ConnectedDispatcher = () => {
|
|||||||
className="tooltip tooltip-right"
|
className="tooltip tooltip-right"
|
||||||
data-tip={`vorraussichtliche Abmeldung in ${formatDistance(new Date(), new Date(d.esimatedLogoutTime), { locale: de })}`}
|
data-tip={`vorraussichtliche Abmeldung in ${formatDistance(new Date(), new Date(d.esimatedLogoutTime), { locale: de })}`}
|
||||||
>
|
>
|
||||||
<p className="text-gray-500 font-thin ">
|
<p className="font-thin text-gray-500">
|
||||||
{new Date(d.esimatedLogoutTime).toLocaleTimeString([], {
|
{new Date(d.esimatedLogoutTime).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -76,7 +76,7 @@ export const ConnectedDispatcher = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>{asPublicUser(d.publicUser).fullName}</div>
|
<div>{asPublicUser(d.publicUser).fullName}</div>
|
||||||
<div className="text-xs uppercase font-semibold opacity-60">{d.zone}</div>
|
<div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.11.0",
|
"packageManager": "pnpm@10.13.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 3001",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint --max-warnings 0",
|
"lint": "next lint --max-warnings 0",
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@livekit/components-react": "^2.9.12",
|
"@livekit/components-react": "^2.9.14",
|
||||||
"@livekit/components-styles": "^1.1.6",
|
"@livekit/components-styles": "^1.1.6",
|
||||||
"@livekit/track-processors": "^0.5.7",
|
"@livekit/track-processors": "^0.5.8",
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@repo/db": "workspace:*",
|
"@repo/db": "workspace:*",
|
||||||
@@ -24,37 +24,37 @@
|
|||||||
"@repo/shared-components": "workspace:*",
|
"@repo/shared-components": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@tanstack/react-query": "^5.81.5",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/leaflet": "^1.9.19",
|
"@types/leaflet": "^1.9.20",
|
||||||
"@types/node": "^22.15.34",
|
"@types/node": "^22.15.34",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"daisyui": "^5.0.43",
|
"daisyui": "^5.0.46",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"eslint-config-next": "^15.3.4",
|
"eslint-config-next": "^15.4.2",
|
||||||
"geojson": "^0.5.0",
|
"geojson": "^0.5.0",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.polylinemeasure": "^3.0.0",
|
"leaflet.polylinemeasure": "^3.0.0",
|
||||||
"livekit-client": "^2.14.0",
|
"livekit-client": "^2.15.3",
|
||||||
"livekit-server-sdk": "^2.13.1",
|
"livekit-server-sdk": "^2.13.1",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^15.3.4",
|
"next": "^15.4.2",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"npm": "^11.4.2",
|
"npm": "^11.4.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.59.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-select": "^5.10.1",
|
"react-select": "^5.10.2",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
|
|||||||
BIN
apps/dispatch/public/icons/mapMarker.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/dispatch/public/sounds/connection_stoped_sepura_old.mp3
Normal file
BIN
apps/dispatch/public/sounds/newChat.mp3
Normal file
@@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
18
apps/docs/.gitignore
vendored
@@ -1,18 +0,0 @@
|
|||||||
/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
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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='https://virtualairrescue.com/impressum/'>Impressum</a> | <a href='https://virtualairrescue.com/datenschutz/'>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: "Mobile App-Alarmierung", link: "/pilotenbereich/app-alarmierung" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: "Piloten", link: "/allgemein/var-systeme/leitstelle/pilot" },
|
|
||||||
{ text: "Disponenten", link: "/allgemein/var-systeme/leitstelle/disponent" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
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: "https://virtualairrescue.com/impressum/" },
|
|
||||||
{ text: "Datenschutzerklärung", link: "https://virtualairrescue.com/datenschutz/" },
|
|
||||||
{ 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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.VPHero .image-src {
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import DefaultTheme from "vitepress/theme";
|
|
||||||
import "@catppuccin/vitepress/theme/mocha/lavender.css";
|
|
||||||
import "./custom.css";
|
|
||||||
|
|
||||||
export default DefaultTheme;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# --- Build stage ---
|
|
||||||
FROM node:24-alpine3.21 AS builder
|
|
||||||
# Consider using the latest patch version for security updates
|
|
||||||
RUN apk update && apk upgrade
|
|
||||||
|
|
||||||
# Install pnpm
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Set workdir
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy project files
|
|
||||||
COPY ./apps/docs .
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN pnpm install
|
|
||||||
|
|
||||||
# Build VitePress site
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# --- Serve stage ---
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy built site to nginx public folder
|
|
||||||
COPY --from=builder /app/.vitepress/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "docs",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vitepress dev --port 3006",
|
|
||||||
"docs:dev": "vitepress dev --port 3006",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# 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](/allgemein/bos-funk/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](/allgemein/bos-funk/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.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||

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

|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 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.
|
|
||||||
:::
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 82 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M80.3 44C69.8 69.9 64 98.2 64 128s5.8 58.1 16.3 84c6.6 16.4-1.3 35-17.7 41.7s-35-1.3-41.7-17.7C7.4 202.6 0 166.1 0 128S7.4 53.4 20.9 20C27.6 3.6 46.2-4.3 62.6 2.3S86.9 27.6 80.3 44zM555.1 20C568.6 53.4 576 89.9 576 128s-7.4 74.6-20.9 108c-6.6 16.4-25.3 24.3-41.7 17.7S489.1 228.4 495.7 212c10.5-25.9 16.3-54.2 16.3-84s-5.8-58.1-16.3-84C489.1 27.6 497 9 513.4 2.3s35 1.3 41.7 17.7zM352 128c0 23.7-12.9 44.4-32 55.4V480c0 17.7-14.3 32-32 32s-32-14.3-32-32V183.4c-19.1-11.1-32-31.7-32-55.4c0-35.3 28.7-64 64-64s64 28.7 64 64zM170.6 76.8C163.8 92.4 160 109.7 160 128s3.8 35.6 10.6 51.2c7.1 16.2-.3 35.1-16.5 42.1s-35.1-.3-42.1-16.5c-10.3-23.6-16-49.6-16-76.8s5.7-53.2 16-76.8c7.1-16.2 25.9-23.6 42.1-16.5s23.6 25.9 16.5 42.1zM464 51.2c10.3 23.6 16 49.6 16 76.8s-5.7 53.2-16 76.8c-7.1 16.2-25.9 23.6-42.1 16.5s-23.6-25.9-16.5-42.1c6.8-15.6 10.6-32.9 10.6-51.2s-3.8-35.6-10.6-51.2c-7.1-16.2 .3-35.1 16.5-42.1s35.1 .3 42.1 16.5z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M128 32c0-17.7 14.3-32 32-32H544c17.7 0 32 14.3 32 32s-14.3 32-32 32H384v64h32c88.4 0 160 71.6 160 160v64c0 17.7-14.3 32-32 32H384 320c-20.1 0-39.1-9.5-51.2-25.6l-71.4-95.2c-3.5-4.7-8.3-8.3-13.7-10.5L47.2 198.1c-9.5-3.8-16.7-12-19.2-22L5 83.9C2.4 73.8 10.1 64 20.5 64H48c10.1 0 19.6 4.7 25.6 12.8L112 128H320V64H160c-17.7 0-32-14.3-32-32zM384 320H512V288c0-53-43-96-96-96H384V320zM630.6 425.4c12.5 12.5 12.5 32.8 0 45.3l-3.9 3.9c-24 24-56.6 37.5-90.5 37.5H256c-17.7 0-32-14.3-32-32s14.3-32 32-32H536.2c17 0 33.3-6.7 45.3-18.7l3.9-3.9c12.5-12.5 32.8-12.5 45.3 0z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 809 B |
@@ -1,5 +0,0 @@
|
|||||||
# How-To Discord verbinden
|
|
||||||
|
|
||||||
Besuche hierfür das HUB und [navigiere zu den Einstellungen](https://hub.premiumag.de/settings). Dort findest du oben rechts einen Button "Mit Discord Verbinden".
|
|
||||||
|
|
||||||
Klicke diesen an und melde dich im Anschluss mit deinen Discord Nutzerdaten an.
|
|
||||||
|
Before Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 55 KiB |