116 Commits

Author SHA1 Message Date
PxlLoewe
7175f6571e Revert "PR v2.0.7" 2026-01-15 23:35:14 +01:00
PxlLoewe
614b92325e Merge pull request #147 from VAR-Virtual-Air-Rescue/staging
@everyone | Wir haben eine kurze Downtime überwunden und stellen euch heute v2.0.7 vor.

In der Vergangenheit haben wir viel an der Dispositionsseite gearbeitet. Das ändert sich heute. Wir aktualisieren die Bedieneinheit für Piloten auf eine neue Softwareversion, die dem Sepura SCG22 nachempfunden ist und so tatsächlich in vielen Hubschraubern verbaut ist.

### Neue Features:
- Ladescreen beim Einschalten
- Wechseln der Rufgruppe direkt im Gerät
- Status senden/empfangen
- SDS-Text bzw. senden/empfangen
- Nachtmodus ab 22:00 Uhr
- Popup bei Funkverkehr auf der Rufgruppe

Eine entsprechende Dokumentation findet ihr[ in den Docs](https://docs.virtualairrescue.com/allgemein/var-systeme/leitstelle/pilot.html).

### Weiteres:
- kleinere Bugfixes
- Performanceupgrades durch verbessertes Backup-Handling
2026-01-15 22:59:21 +01:00
PxlLoewe
b5d67e55b4 night mode nur wenn das mrt an ist 2026-01-15 22:51:10 +01:00
PxlLoewe
ea9c2c0f38 added night iamge 2026-01-15 22:37:29 +01:00
PxlLoewe
72c214a189 fixed Sound nach Verbinden auf RG 2026-01-15 22:22:36 +01:00
PxlLoewe
022d20356c Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2026-01-15 22:08:07 +01:00
PxlLoewe
228b0617e6 Mrt Button bug 2026-01-15 22:06:39 +01:00
PxlLoewe
3413f74fcd Merge pull request #145 from VAR-Virtual-Air-Rescue/mrt-rework
repaired nextJS dockerfiles
2026-01-15 21:38:06 +01:00
PxlLoewe
90fcaf259e repaired nextJS dockerfiles 2026-01-15 21:35:28 +01:00
PxlLoewe
bfe4d56cf7 Merge pull request #144 from VAR-Virtual-Air-Rescue/mrt-rework
Mrt rework
2026-01-15 21:14:59 +01:00
PxlLoewe
48d36af382 MRT: Rufgruppenauswahl, Herunterfahren, Hilfe 2026-01-15 21:12:15 +01:00
PxlLoewe
a65af7f011 Ealisitische Sequenz im HEader vom call-Bildschirm im MRT 2026-01-15 14:09:47 +01:00
PxlLoewe
0b30936f73 Neues MRt eingefügt. Status 059 sind nun keine FMS status mehr 2026-01-15 00:18:50 +01:00
PxlLoewe
edfaf7a228 Fehlender EventID Filter zu Teilnehmer tabelle hinzugefügt. Adatar-alternative in Nutzer übersicht 2026-01-13 13:11:54 +01:00
PxlLoewe
b1d1e7f2bf reduce image size of hub and disptach container 2026-01-13 12:35:44 +01:00
PxlLoewe
b1e508ef36 release v2.0.6
v2.0.6
2026-01-06 13:58:24 +01:00
PxlLoewe
c5c3bc0775 Changelog-Seite, option zum verstecken von Einträgen auf dieser 2026-01-06 12:19:10 +01:00
PxlLoewe
dd39331c1a cron performance improved 2026-01-06 03:08:16 +01:00
PxlLoewe
0ac943c63f Discord account Linkage, penalty update 2026-01-06 03:07:09 +01:00
PxlLoewe
6e8884f3fb Merge pull request #141 from VAR-Virtual-Air-Rescue/staging
v2.0.5
2025-12-27 16:23:33 +01:00
PxlLoewe
b16b719c74 Redesigned Search, removed Unused Admin Route 2025-12-27 15:33:00 +01:00
PxlLoewe
e9a4c50a12 fixed admin search 2025-12-26 01:25:17 +01:00
PxlLoewe
17208eded9 Added Account Dublicate fucntion, improved default sorts 2025-12-26 01:23:32 +01:00
PxlLoewe
51ef9cd90c use fetch to get Aircraft Marker 2025-12-15 21:19:39 +01:00
PxlLoewe
434154e26d Security Fixes 2025-12-15 02:55:44 +01:00
PxlLoewe
dde52bde39 Release v2.0.4
Release v2.0.4
2025-12-08 19:48:53 +01:00
PxlLoewe
483b5eba46 Merge branch 'release' into staging 2025-12-08 19:40:07 +01:00
PxlLoewe
bc61144258 Fixed Buchungssystem 2025-12-08 19:30:08 +01:00
PxlLoewe
1e36622289 update nextJS 2025-12-08 18:48:28 +01:00
PxlLoewe
b9e871ae01 Dispo-Option, die HPG validierung nicht zu nutzen 2025-11-27 22:21:27 +01:00
PxlLoewe
6081c1e38d vm network 2025-11-08 12:03:00 +01:00
PxlLoewe
d6bfcd3061 merge prometheus 2025-11-08 11:45:08 +01:00
PxlLoewe
59357a2ae6 VM volume 2025-11-08 11:41:50 +01:00
PxlLoewe
e639ba6704 added victoriametrics 2025-11-08 11:41:50 +01:00
PxlLoewe
6a739f4871 VM volume 2025-11-08 11:31:27 +01:00
PxlLoewe
cce2c246f6 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-11-08 11:27:44 +01:00
PxlLoewe
238fae694c added victoriametrics 2025-11-08 11:27:39 +01:00
PxlLoewe
60e60ea069 Merge pull request #139 from VAR-Virtual-Air-Rescue/release
Maerge Commits from release to staging
2025-11-08 10:40:39 +01:00
PxlLoewe
f0d133d827 rename Map-Aircraft cache key 2025-11-08 10:38:57 +01:00
PxlLoewe
cda2f272cc rename Map-Aircraft cache key 2025-11-08 09:28:23 +01:00
PxlLoewe
33c33b4de1 Doppeltes "Einsatz" bei benachrichtigungen entfernt 2025-10-28 02:35:12 +01:00
PxlLoewe
4d43e2a36d Einsatz geschlossen event wird richtig an piloten gesendet 2025-10-28 02:19:55 +01:00
PxlLoewe
da9b957fcf XPlane objecte können wegebt und per rechts-click gelöscht werden 2025-10-28 01:52:55 +01:00
PxlLoewe
5af68b8a70 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-10-24 22:41:25 +02:00
PxlLoewe
192ad7dedd .env example anbepasst 2025-10-24 22:41:21 +02:00
PxlLoewe
4d93ceaf1c Kein Szenerie als Standart + keine Validierung für dieses Szenario 2025-10-16 18:05:15 +02:00
PxlLoewe
3d77ab3b90 mission closed socket event 2025-10-16 14:30:01 +02:00
PxlLoewe
c4e0213a5f Plazierung von X Plane Objekten 2025-10-16 14:23:50 +02:00
PxlLoewe
b5f07071a5 dev 2025-10-16 11:17:52 +02:00
PxlLoewe
1919227cd4 redis not req for local dev 2025-10-04 21:58:34 +02:00
PxlLoewe
a2c320ddbe XPlane Plugin anzeige auf tracker 2025-10-04 21:16:24 +02:00
PxlLoewe
859b8519db closes #136 2025-10-04 19:53:56 +02:00
PxlLoewe
f691eb5f7c Keine Simmulator-Verbundung kommt erst 30 sek nach Verbinden für piloten 2025-10-01 23:20:41 +02:00
PxlLoewe
cd6885c5f2 closes #102 2025-10-01 23:03:03 +02:00
PxlLoewe
eddb3317d5 closes #135 2025-10-01 22:43:30 +02:00
PxlLoewe
ada041bd4a Falsche Zeitzone bei Buchungen und HTTP status codes als Fehlermeldung behoben 2025-10-01 22:23:27 +02:00
PxlLoewe
dc4a3ab4d8 Buchungen werden in Connect Modal angezeigt (Pilot), new Booking form improvement 2025-09-28 12:19:14 +02:00
PxlLoewe
ebeb2cf93a Booking Panel auf Dashboard 2025-09-27 22:25:31 +02:00
PxlLoewe
cf199150fe futher booking stuff 2025-09-20 22:16:23 +02:00
PxlLoewe
ba027957ce Buchungssystem erste überarbeitungen 2025-09-20 00:28:53 +02:00
nocnico
a612cf9951 Add Booking System 2025-09-18 21:49:03 +02:00
PxlLoewe
13ce99da96 Merge pull request #137 from VAR-Virtual-Air-Rescue/staging
Datenschutzerklärung im Registrierungsformular
2025-09-10 23:42:33 +02:00
PxlLoewe
715cb9ef53 Datenschutzerklärung im Registrierungsformular 2025-09-10 15:58:36 +02:00
PxlLoewe
9a26920d7d Merge pull request #133 from VAR-Virtual-Air-Rescue/staging
v2.0.3
2025-07-29 16:33:11 -07:00
PxlLoewe
266ff87fd8 Penalty Nachricht im Admin form, abgelaufen für Timebans in tabelle werden Farblich dargestellt 2025-07-29 15:54:09 -07:00
PxlLoewe
99c3024d85 fixes #132 2025-07-29 15:52:34 -07:00
PxlLoewe
627060e32e Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-29 13:21:51 -07:00
PxlLoewe
23f7671d42 Name des Chatpartners wird nun mit rolle angezeigt 2025-07-29 13:21:47 -07:00
PxlLoewe
e134c9b2fa Huschrauber logging für alle 2025-07-29 12:37:36 -07:00
PxlLoewe
1bdccc46fe Zeitstreafen werden nun beim Verbinden überprüft 2025-07-29 12:34:55 -07:00
nocnico
d01ba24243 Fix changelog text dark on lightmode browser 2025-07-29 11:51:17 +02:00
PxlLoewe
fd50e9c4d5 logging entfernt in NewReport 2025-07-27 20:43:30 -07:00
PxlLoewe
2a859b3415 Merge pull request #128 from VAR-Virtual-Air-Rescue/staging
remove old User File from repo
2025-07-27 20:34:45 -07:00
PxlLoewe
157d2f02e1 fixed NewReport form 2025-07-27 20:23:30 -07:00
PxlLoewe
a3e143145f mit var.User.json in der gitignore kopiert git die auch nicht... 2025-07-27 19:49:57 -07:00
PxlLoewe
1b447edb11 add user migration file to gitignore 2025-07-27 17:15:28 -07:00
PxlLoewe
6b4cc0b58b remove user from repo. Local only 2025-07-27 17:15:09 -07:00
PxlLoewe
6aa6329d83 Merge pull request #127 from VAR-Virtual-Air-Rescue/staging
Bugfixes + Manuelle reports
2025-07-27 17:00:57 -07:00
PxlLoewe
f88b0bb56c Improved Station Notification 2025-07-27 16:27:58 -07:00
PxlLoewe
25f56026fc Fixed type bugs in Reports form 2025-07-27 15:02:14 -07:00
PxlLoewe
7fc8749676 staging ci timeout 2025-07-27 14:22:56 -07:00
PxlLoewe
575438e974 link zu neuem Report auf Admin-seite 2025-07-27 14:17:35 -07:00
PxlLoewe
89a0eb7135 Manuelle reports 2025-07-27 13:45:13 -07:00
PxlLoewe
2c6913eeb9 Merge pull request #126 from VAR-Virtual-Air-Rescue/staging
Prometheus rückgänging
2025-07-26 14:00:34 -07:00
PxlLoewe
453ad28538 Prometheus zückgänging 2025-07-26 13:59:55 -07:00
PxlLoewe
15f9512d8e Merge pull request #125 from VAR-Virtual-Air-Rescue/staging
vlt jetzt?
2025-07-26 13:58:22 -07:00
PxlLoewe
5a8bd1abe3 vlt jetzt? 2025-07-26 13:43:50 -07:00
PxlLoewe
daf5759778 Merge pull request #124 from VAR-Virtual-Air-Rescue/staging
prometheus config
2025-07-26 13:32:05 -07:00
PxlLoewe
1736bc79c0 prometheus config 2025-07-26 13:25:33 -07:00
PxlLoewe
de54103e6e Merge pull request #123 from VAR-Virtual-Air-Rescue/staging
Bugfux und Node Exporter für Prometheus
2025-07-26 12:34:57 -07:00
PxlLoewe
efa2ca8412 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-26 12:33:44 -07:00
PxlLoewe
b9a4f5e8d3 added http service to prometheus, fixes sound Bug 2025-07-26 12:33:41 -07:00
PxlLoewe
a09671036d entferne ADMIN_HELIPORT und ADMIN_EVENT berechtigung für CGs 2025-07-26 11:24:45 -07:00
Nicolas
f0dfe91a00 Merge pull request #120 from VAR-Virtual-Air-Rescue/staging
Fix Chats können nur mit Disponenten eröffnet werden
2025-07-26 12:40:38 +02:00
nocnico
f32ed62a76 Fix Chats können nur mit Disponenten eröffnet werden 2025-07-26 12:21:23 +02:00
PxlLoewe
53de66e811 Merge pull request #119 from VAR-Virtual-Air-Rescue/staging
v2.0.2
2025-07-25 22:55:41 -07:00
PxlLoewe
5b57b31706 fixes #118 2025-07-25 22:37:46 -07:00
PxlLoewe
2abdbf168f fixes #117 2025-07-25 21:30:03 -07:00
PxlLoewe
1da23c3412 resolves #116 2025-07-25 21:02:18 -07:00
PxlLoewe
f8e9ad84b9 resolves #103 2025-07-25 20:56:21 -07:00
PxlLoewe
f534bbc902 implemented #112 2025-07-25 16:53:22 -07:00
PxlLoewe
14ea5fcf55 fixed User argument is missing on Discord-connect 2025-07-25 16:49:50 -07:00
Nicolas
1bcb2dbff7 Merge pull request #101 from VAR-Virtual-Air-Rescue/staging
fixed #100 // fix Cluster naming, fix Marker Popup settings not used
2025-07-25 16:45:24 +02:00
Nicolas
4f22d48e83 Merge pull request #99 from VAR-Virtual-Air-Rescue/staging
Release v2.0.1
2025-07-25 02:25:07 +02:00
Nicolas
e9c1cf0c94 Merge pull request #84 from VAR-Virtual-Air-Rescue/staging
Namenseinstellungen und Markdown Bug Fix
2025-07-23 12:14:33 +02:00
Nicolas
940d62fdd5 Merge pull request #83 from VAR-Virtual-Air-Rescue/staging
make V1 login not case sensitive
2025-07-23 10:33:21 +02:00
PxlLoewe
644fee3e29 Merge pull request #81 from VAR-Virtual-Air-Rescue/staging
fixed condition for discord role assignment
2025-07-22 21:40:07 -07:00
PxlLoewe
d2a865c955 Merge pull request #80 from VAR-Virtual-Air-Rescue/staging
typos
2025-07-22 19:38:40 -07:00
PxlLoewe
33ec5574f2 Merge pull request #79 from VAR-Virtual-Air-Rescue/staging
added more delay to moodle ID lookup
2025-07-22 13:19:17 -07:00
PxlLoewe
8c6057fe6a Merge pull request #78 from VAR-Virtual-Air-Rescue/staging
Bug Fixes
2025-07-22 12:16:10 -07:00
PxlLoewe
25769f551a Merge pull request #77 from VAR-Virtual-Air-Rescue/staging
Final Bugfixes
2025-07-22 11:16:44 -07:00
PxlLoewe
a5998fbe0f Merge pull request #75 from VAR-Virtual-Air-Rescue/staging
Nutzerliste aus V1 übernommen
2025-07-22 09:12:47 -07:00
PxlLoewe
92e550736b Merge pull request #74 from VAR-Virtual-Air-Rescue/staging
release V2.0
2025-07-22 09:05:14 -07:00
PxlLoewe
616d3d3a61 Merge pull request #70 from VAR-Virtual-Air-Rescue/staging
Fix prod-workflow
2025-07-18 13:40:07 -07:00
PxlLoewe
df7f1b8cd1 Merge pull request #69 from VAR-Virtual-Air-Rescue/staging
Datentypen in Prod DB vorbereiten für release
2025-07-18 13:05:13 -07:00
PxlLoewe
eb98971e8a Merge pull request #47 from VAR-Virtual-Air-Rescue/staging
CD Deployment
2025-07-08 23:23:24 -07:00
148 changed files with 4305 additions and 884 deletions

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

@@ -1,6 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { getNextDateWithTime, getUserPenaltys } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
@@ -28,8 +28,17 @@ export const handleConnectDispatch =
return;
}
if (!user.permissions?.includes("DISPO")) {
socket.emit("error", "You do not have permission to connect to the dispatch server.");
const userPenaltys = await getUserPenaltys(user.id);
if (
userPenaltys.openTimeban.length > 0 ||
user.isBanned ||
userPenaltys.openBans.length > 0
) {
socket.emit("connect-message", {
message: "Du hast eine aktive Strafe und kannst dich deshalb nicht verbinden.",
});
socket.disconnect();
return;
}

View File

@@ -1,8 +1,8 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
import { getUserPenaltys } from "@repo/shared-components";
export const handleConnectPilot =
(socket: Socket, io: Server) =>
@@ -34,6 +34,19 @@ export const handleConnectPilot =
socket.disconnect();
return;
}
const userPenaltys = await getUserPenaltys(userId);
if (
userPenaltys.openTimeban.length > 0 ||
user.isBanned ||
userPenaltys.openBans.length > 0
) {
socket.emit("connect-message", {
message: "Du hast eine aktive Strafe und kannst dich deshalb nicht verbinden.",
});
socket.disconnect();
return;
}
if (!user) return Error("User not found");
@@ -83,6 +96,8 @@ export const handleConnectPilot =
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat,
posLng: randomPos?.lng,
posXplanePluginActive: debug ? true : undefined,
posH145active: debug ? true : undefined,
},
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { Prisma } from "@repo/db";
import { Button, getNextDateWithTime } from "@repo/shared-components";
import { Button, cn, getNextDateWithTime } from "@repo/shared-components";
import { Select } from "_components/Select";
import { Radio } from "lucide-react";
import { Calendar, Radio } from "lucide-react";
import { getBookingsAPI } from "_querys/bookings";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
@@ -27,6 +28,19 @@ export const ConnectionBtn = () => {
queryKey: ["stations"],
queryFn: () => getStationsAPI(),
});
const { data: bookings } = useQuery({
queryKey: ["bookings"],
queryFn: () =>
getBookingsAPI({
startTime: {
lte: new Date(Date.now() + 8 * 60 * 60 * 1000),
},
endTime: {
gte: new Date(),
},
}),
});
const aircraftMutation = useMutation({
mutationFn: ({
change,
@@ -62,6 +76,7 @@ export const ConnectionBtn = () => {
const session = useSession();
const uid = session.data?.user?.id;
if (!uid) return null;
console.log(bookings);
return (
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && (
@@ -117,20 +132,39 @@ export const ConnectionBtn = () => {
(option as { component: React.ReactNode }).component
}
options={
stations?.map((station) => ({
value: station.id.toString(),
label: station.bosCallsign,
component: (
<div>
<span className="flex items-center gap-2">
{connectedAircrafts?.find((a) => a.stationId == station.id) && (
<Radio className="text-warning" size={15} />
)}
{station.bosCallsign}
</span>
</div>
),
})) ?? []
stations?.map((station) => {
const booking = bookings?.find((b) => b.stationId == station.id);
return {
value: station.id.toString(),
label: station.bosCallsign,
component: (
<div>
<span className="flex items-center gap-2">
{connectedAircrafts?.find((a) => a.stationId == station.id) && (
<Radio className="text-warning" size={15} />
)}
{booking && (
<div
className="tooltip tooltip-right"
data-tip={`${new Date(booking.startTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${new Date(booking.endTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} Uhr gebucht von ${booking.userId == session.data?.user?.id ? "dir" : booking.User.fullName}`}
>
<Calendar
className={
cn(
"text-warning",
booking?.userId === session.data?.user?.id,
) && "text-success"
}
size={15}
/>
</div>
)}
{station.bosCallsign}
</span>
</div>
),
};
}) ?? []
}
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export const useSounds = ({
useEffect(() => {
if (!window) return;
connectionStart.current = new Audio("/sounds/connection_started_sepura.mp3");
connectionEnd.current = new Audio("/sounds/connection_stoped_sepura.mp3");
connectionEnd.current = new Audio("/sounds/connection_stopped_sepura.mp3");
ownCallStarted.current = new Audio("/sounds/call_end_sepura.wav");
foreignCallStop.current = new Audio("/sounds/call_end_sepura.wav");
foreignCallBlocked.current = new Audio("/sounds/call_blocked_sepura.wav");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

View File

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

View File

@@ -8,24 +8,22 @@ export const Badges: () => Promise<JSX.Element> = async () => {
if (!session) return <div />;
return (
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<Award className="w-4 h-4" /> Verdiente Abzeichen
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<Award className="h-4 w-4" /> Verdiente Abzeichen
</span>
</h2>
<div className="flex flex-wrap gap-2">
{session.user.badges.length === 0 && (
<span className="text-sm text-gray-500">
Noch ziemlich leer hier. Du kannst dir Abzeichen erarbeiten indem du an Events
teilnimmst.
</span>
</h2>
<div className="flex flex-wrap gap-2">
{session.user.badges.length === 0 && (
<span className="text-sm text-gray-500">
Noch ziemlich leer hier. Du kannst dir Abzeichen erarbeiten indem du an Events
teilnimmst.
</span>
)}
{session.user.badges.map((badge, i) => {
return <Badge badge={badge} key={`${badge} - ${i}`} />;
})}
</div>
)}
{session.user.badges.map((badge, i) => {
return <Badge badge={badge} key={`${badge} - ${i}`} />;
})}
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
"use client";
import { PaginatedTable } from "_components/PaginatedTable";
import { reportColumns } from "(app)/admin/report/columns";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
export default function ReportPage() {
return (
@@ -12,6 +14,18 @@ export default function ReportPage() {
Sender: true,
Reported: true,
}}
leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<TriangleAlert className="h-5 w-5" /> Reports
</p>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/report/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>
</p>
}
columns={reportColumns}
/>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -235,6 +235,20 @@ export const Register = () => {
? form.formState.errors.passwordConfirm.message
: ""}
</p>
<div className="mt-2">
<p className="text-xs opacity-70">
Indem du dich registrierst, stimmst du unseren{" "}
<a
href="https://virtualairrescue.com/datenschutz/"
target="_blank"
rel="noopener noreferrer"
className="link link-accent link-hover"
>
Datenschutzbestimmungen
</a>{" "}
zu.
</p>
</div>
<div className="card-actions mt-6">
<Button disabled={isLoading} isLoading={isLoading} className="btn btn-primary btn-block">
Registrieren

View File

@@ -1,7 +1,7 @@
"use server";
import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs";
import OLD_USER from "../../api/auth/[...nextauth]/var.User.json";
import v1User from "../../api/auth/[...nextauth]/var.User.json";
import { OldUser } from "../../../types/oldUser";
export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInput, "publicId">) => {
@@ -29,7 +29,7 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
},
});
const existingOldUser = (OLD_USER as OldUser[]).find(
const existingOldUser = (v1User as OldUser[]).find(
(u) => u.email.toLocaleLowerCase() === user.email,
);

View File

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

View File

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

View File

@@ -0,0 +1,361 @@
"use client";
import { useState } from "react";
import { CalendarIcon, Plus, X, ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
import { Booking, PublicUser, Station, User } from "@repo/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { deleteBookingAPI, getBookingsAPI } from "(app)/_querys/bookings";
import { Button } from "@repo/shared-components";
import { formatTimeRange } from "../../helper/timerange";
import toast from "react-hot-toast";
import Link from "next/link";
interface BookingTimelineModalProps {
isOpen: boolean;
onClose: () => void;
onOpenNewBooking: () => void;
currentUser: User;
}
type ViewMode = "day" | "week" | "month";
export const BookingTimelineModal = ({
isOpen,
onClose,
onOpenNewBooking,
currentUser,
}: BookingTimelineModalProps) => {
const queryClient = useQueryClient();
const [currentDate, setCurrentDate] = useState(new Date());
const [viewMode, setViewMode] = useState<ViewMode>("day");
const getTimeRange = () => {
const start = new Date(currentDate);
const end = new Date(currentDate);
switch (viewMode) {
case "day":
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
break;
case "week": {
const dayOfWeek = start.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
start.setDate(start.getDate() + mondayOffset);
start.setHours(0, 0, 0, 0);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
break;
}
case "month":
start.setDate(1);
start.setHours(0, 0, 0, 0);
end.setMonth(end.getMonth() + 1);
end.setDate(0);
end.setHours(23, 59, 59, 999);
break;
}
return { start, end };
};
const { data: bookings, isLoading: isBookingsLoading } = useQuery({
queryKey: ["bookings", getTimeRange().start, getTimeRange().end],
queryFn: () =>
getBookingsAPI({
startTime: {
gte: getTimeRange().start,
},
endTime: {
lte: getTimeRange().end,
},
}),
});
const { mutate: deleteBooking } = useMutation({
mutationKey: ["deleteBooking"],
mutationFn: async (bookingId: string) => {
await deleteBookingAPI(bookingId);
queryClient.invalidateQueries({ queryKey: ["bookings"] });
},
onSuccess: () => {
toast.success("Buchung erfolgreich gelöscht");
},
});
// Check if user can create bookings
const canCreateBookings =
currentUser &&
currentUser.emailVerified &&
!currentUser.isBanned &&
(currentUser.permissions.includes("PILOT") || currentUser.permissions.includes("DISPO"));
// Check if user can delete a booking
const canDeleteBooking = (bookingUserPublicId: string) => {
if (!currentUser) return false;
if (currentUser.permissions.includes("ADMIN_BOOKING")) return true;
// User can delete their own bookings if they meet basic requirements
if (bookingUserPublicId === currentUser.publicId) {
return true;
}
// Admins can delete any booking
return false;
};
const navigate = (direction: "prev" | "next") => {
const newDate = new Date(currentDate);
switch (viewMode) {
case "day":
newDate.setDate(newDate.getDate() + (direction === "next" ? 1 : -1));
break;
case "week":
newDate.setDate(newDate.getDate() + (direction === "next" ? 7 : -7));
break;
case "month":
newDate.setMonth(newDate.getMonth() + (direction === "next" ? 1 : -1));
break;
}
setCurrentDate(newDate);
};
const formatDateRange = () => {
const { start, end } = getTimeRange();
const options: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "2-digit",
year: "numeric",
};
switch (viewMode) {
case "day":
return start.toLocaleDateString("de-DE", options);
case "week":
return `${start.toLocaleDateString("de-DE", options)} - ${end.toLocaleDateString("de-DE", options)}`;
case "month":
return start.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
}
};
const groupBookingsByResource = () => {
if (!bookings) return {};
// For day view, group by resource (station/type)
if (viewMode === "day") {
const groups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
bookings.forEach((booking) => {
let key: string = booking.type;
if (booking.Station) {
key = `${booking.Station.bosCallsign}`;
}
if (!groups[key]) {
groups[key] = [];
}
groups[key]!.push(booking);
});
// Sort bookings within each group, LST_ types first, then alphanumerical
Object.keys(groups).forEach((key) => {
groups[key] = groups[key]!.sort((a, b) => {
const aIsLST = a.type.startsWith("LST_");
const bIsLST = b.type.startsWith("LST_");
if (aIsLST && !bIsLST) return -1;
if (!aIsLST && bIsLST) return 1;
// Within same category (both LST_ or both non-LST_), sort alphanumerically by type
return a.type.localeCompare(b.type);
});
});
// Sort the groups themselves - LST_ types first, then alphabetical
const sortedGroups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
Object.keys(groups)
.sort((a, b) => {
// Check if groups contain LST_ types
const aHasLST = groups[a]?.some((booking) => booking.type.startsWith("LST_"));
const bHasLST = groups[b]?.some((booking) => booking.type.startsWith("LST_"));
if (aHasLST && !bHasLST) return -1;
if (!aHasLST && bHasLST) return 1;
// Within same category, sort alphabetically by group name
return a.localeCompare(b);
})
.forEach((key) => {
sortedGroups[key] = groups[key]!;
});
return sortedGroups;
}
// For week and month views, group by date
const groups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
bookings.forEach((booking) => {
const dateKey = new Date(booking.startTime).toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey]!.push(booking);
});
// Sort groups by date for week/month view and sort bookings within each group
const sortedGroups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
Object.keys(groups)
.sort((a, b) => {
// Extract date from the formatted string and compare
const dateA = groups[a]?.[0]?.startTime;
const dateB = groups[b]?.[0]?.startTime;
if (!dateA || !dateB) return 0;
return new Date(dateA).getTime() - new Date(dateB).getTime();
})
.forEach((key) => {
const bookingsForKey = groups[key];
if (bookingsForKey) {
// Sort bookings within each date group, LST_ types first, then alphanumerical
sortedGroups[key] = bookingsForKey.sort((a, b) => {
const aIsLST = a.type.startsWith("LST_");
const bIsLST = b.type.startsWith("LST_");
if (aIsLST && !bIsLST) return -1;
if (!aIsLST && bIsLST) return 1;
// Within same category (both LST_ or both non-LST_), sort alphanumerically by type
return a.type.localeCompare(b.type);
});
}
});
return sortedGroups;
};
if (!isOpen) return null;
const groupedBookings = groupBookingsByResource();
return (
<div className="modal modal-open">
<div className="modal-box flex max-h-[83vh] w-11/12 max-w-7xl flex-col">
<div className="mb-4 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold">
<CalendarIcon size={24} />
Slot Buchung
</h3>
<button className="btn btn-sm btn-circle btn-ghost" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Controls */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<button className="btn btn-sm btn-ghost" onClick={() => navigate("prev")}>
<ChevronLeft size={16} />
</button>
<span className="min-w-[200px] text-center font-medium">{formatDateRange()}</span>
<button className="btn btn-sm btn-ghost" onClick={() => navigate("next")}>
<ChevronRight size={16} />
</button>
</div>
<div className="join">
{(["day", "week", "month"] as ViewMode[]).map((mode) => (
<button
key={mode}
className={`btn btn-sm join-item ${viewMode === mode ? "btn-active" : ""}`}
onClick={() => setViewMode(mode)}
>
{mode === "day" ? "Tag" : mode === "week" ? "Woche" : "Monat"}
</button>
))}
</div>
</div>
{isBookingsLoading ? (
<div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : (
<div className="grid max-h-[calc(83vh-200px)] grid-cols-1 gap-3 overflow-y-auto md:grid-cols-2">
{Object.entries(groupedBookings).map(([groupName, resourceBookings]) => (
<div key={groupName} className="card bg-base-200 shadow-sm">
<div className="card-body p-3">
<h4 className="mb-2 text-sm font-medium opacity-70">
{viewMode === "day" ? groupName : groupName}
</h4>
<div className="space-y-1">
{resourceBookings.map((booking) => (
<div
key={booking.id}
className={`alert alert-horizontal ${booking.type.startsWith("LST_") ? "alert-success" : "alert-info"} alert-soft px-3 py-2`}
>
<div className="flex items-center gap-3">
<span className="badge badge-outline text-xs">
{booking.type.startsWith("LST_")
? "LST"
: booking.Station.bosCallsignShort || booking.Station.bosCallsign}
</span>
{currentUser?.permissions.includes("ADMIN_USER") ? (
<Link
href={`/admin/user/${booking.User.publicId}`}
className="link link-hover text-xs opacity-70"
>
{booking.User.fullName}
</Link>
) : (
<span className="text-sm font-medium">{booking.User.fullName}</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-xs font-medium">{formatTimeRange(booking)}</p>
</div>
{canDeleteBooking(booking.User.publicId) && (
<Button
onClick={() => deleteBooking(booking.id)}
className={`btn btn-xs ${
currentUser?.permissions.includes("ADMIN_EVENT") &&
booking.User.publicId !== currentUser.publicId
? "btn-error"
: "btn-neutral"
}`}
title="Buchung löschen"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
))}
</div>
</div>
</div>
))}
{Object.keys(groupedBookings).length === 0 && !isBookingsLoading && (
<div className="col-span-full py-8 text-center opacity-70">
Keine Buchungen im aktuellen Zeitraum gefunden
</div>
)}
</div>
)}
<div className="modal-action">
{canCreateBookings && (
<button className="btn btn-primary" onClick={onOpenNewBooking}>
<Plus size={20} />
Neue Buchung
</button>
)}
<button className="btn" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
);
};

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