270 Commits

Author SHA1 Message Date
PxlLoewe
1b425d82e2 cron zum aktuallisieren der Discord Avatare hinzugefügt 2026-02-08 22:22:25 +01:00
PxlLoewe
5d0a36f967 Report anzeige für auto-reports verbessert 2026-02-08 21:37:10 +01:00
PxlLoewe
b46ad25bde Imports entfernt 2026-02-08 20:17:44 +01:00
PxlLoewe
d5a4118025 Admin-Panel hinzugefügt 2026-02-08 20:12:45 +01:00
PxlLoewe
aded6d1492 HPG Warnung in Dispatch Settings, Status Notification 2026-02-08 13:03:11 +01:00
PxlLoewe
8340c2408c Fixed Acc deleted Warnung im Profil 2026-02-07 13:43:37 +01:00
PxlLoewe
2e9bb95d12 admin link 2026-02-01 12:49:05 +01:00
PxlLoewe
824d2e40a9 Fixed Wrong IP being loged 2026-02-01 11:45:18 +01:00
PxlLoewe
cc29ac3e14 List headers 2026-02-01 00:49:17 +01:00
PxlLoewe
195f1dc9c0 Fixed Account Log filter 2026-02-01 00:41:45 +01:00
PxlLoewe
4ae2e93249 Error handling Rename 2026-02-01 00:18:26 +01:00
PxlLoewe
a60cd67c44 Catch Blocks 2026-02-01 00:01:06 +01:00
PxlLoewe
829d6d8cde member, guild fetch improved 2026-01-31 23:39:35 +01:00
PxlLoewe
f0c138655e added getMember into catch block 2026-01-31 23:02:29 +01:00
PxlLoewe
ac441e908d Discord Permissions will be revoked, when under a penalty 2026-01-31 22:48:26 +01:00
PxlLoewe
d1c49a3208 Moved Dispatch NAvbar component, to remove code dupl.; Fixed timezone bug in hub 2026-01-31 22:11:46 +01:00
PxlLoewe
580dc32ad0 fix Type errors by ESM Module of Prisma 2026-01-30 19:42:34 +01:00
PxlLoewe
2d8a282cec add log for delet account 2026-01-30 19:00:01 +01:00
PxlLoewe
5607aacd16 Account deleted flag 2026-01-30 17:29:50 +01:00
PxlLoewe
8555b901a5 Fixe type errors 2026-01-30 17:14:16 +01:00
PxlLoewe
76d4355320 Merge pull request #154 from VAR-Virtual-Air-Rescue/Enhanced-Audit-log-for-user-Profiles
Enhanced audit log for user profiles
2026-01-30 17:01:50 +01:00
PxlLoewe
10af6bf71a Added Account log for registration 2026-01-30 16:56:22 +01:00
PxlLoewe
2154684223 completed Account Log 2026-01-30 16:19:00 +01:00
PxlLoewe
ea8d63ce0b Merge branch 'Enhanced-Audit-log-for-user-Profiles' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into Enhanced-Audit-log-for-user-Profiles 2026-01-30 00:27:09 +01:00
PxlLoewe
e4aae9804b Continue Account log 2026-01-30 00:25:51 +01:00
PxlLoewe
005509598c Include Profile log in renamed penalty model -> Audit log 2026-01-29 21:54:04 +01:00
PxlLoewe
b250fa46c2 Merge pull request #153 from VAR-Virtual-Air-Rescue/event-admin-redesign
Event admin redesign
2026-01-29 21:49:05 +01:00
PxlLoewe
e4fa011d96 upgrade pnpm, Table auf Event seite 2026-01-29 21:47:49 +01:00
PxlLoewe
bdc35ea6b3 Include Profile log in renamed penalty model -> Audit log 2026-01-21 19:38:55 +01:00
PxlLoewe
9129652912 remove appointment from events 2026-01-18 01:09:39 +01:00
PxlLoewe
606379d151 Remove Event-Appointment 2026-01-18 01:01:15 +01:00
PxlLoewe
ad15f2d942 Discord Einladungslink 2026-01-17 20:52:16 +01:00
PxlLoewe
2638ad473f Dockerfile Hub 2026-01-16 00:03:33 +01:00
PxlLoewe
da93b5e60c Update Dockerfile 2026-01-16 00:03:33 +01:00
PxlLoewe
15118cac66 Revert "Revert "PR v2.0.7"" 2026-01-16 00:03:33 +01:00
PxlLoewe
c254cd0774 Revert "PR v2.0.7" 2026-01-16 00:03:33 +01:00
PxlLoewe
062e7d44c0 dev 2026-01-16 00:03:33 +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
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
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
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
PxlLoewe
383e15f38a Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-25 10:32:45 -07:00
PxlLoewe
afe32306d3 fixed #106 2025-07-25 10:32:35 -07:00
PxlLoewe
1b23aede89 fixed #105 2025-07-25 10:04:44 -07:00
PxlLoewe
54e0bc0b12 fixed #104 2025-07-25 09:53:58 -07:00
nocnico
2671571bfb maybe ghostmode fix now? 2025-07-25 17:43:05 +02:00
nocnico
b602a5836c Ghostmode fix now? 2025-07-25 17:19:58 +02: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
nocnico
4eb46cb783 fixed #100 // fix Marker Cluster naming, fix Marker Popup settings not used 2025-07-25 16:39:21 +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
PxlLoewe
26e7966e19 cleanup openMarkerSettings 2025-07-24 16:47:02 -07:00
PxlLoewe
580f480ec8 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-24 16:13:17 -07:00
nocnico
145b9edfe7 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-25 01:12:38 +02:00
nocnico
26430c00ea resolves #96 2025-07-25 01:11:53 +02:00
PxlLoewe
b3070ac290 Version realight in Navbar 2025-07-24 16:06:32 -07:00
PxlLoewe
b6759e0b6c fixed zIndex-jungle 2025-07-24 15:51:13 -07:00
PxlLoewe
a5c4a1dc7c Improved Changelog, Changelog in Dispatch 2025-07-24 15:44:34 -07:00
PxlLoewe
08c4cfe082 dev, Dispatch + Pilot Settings 2025-07-24 14:53:40 -07:00
PxlLoewe
3c5b86eb41 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-24 14:31:38 -07:00
PxlLoewe
792c4d5cb5 resolves #91 2025-07-24 14:31:34 -07:00
nocnico
6bad77ec39 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-24 22:51:12 +02:00
nocnico
ee34876c09 Connection stopped entferne klicken 2025-07-24 22:51:10 +02:00
PxlLoewe
0db96a0e2a Event Teilnehmer Icon von Kallender in Nutzer Icon geändert 2025-07-24 13:45:17 -07:00
PxlLoewe
ca299f52e5 Special-OPS, Scroll-Bug in Aircraft Marker 2025-07-24 13:30:54 -07:00
nocnico
41e38f0f72 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-24 21:56:16 +02:00
nocnico
4ce00c2daa + migrations 2025-07-24 21:56:13 +02:00
PxlLoewe
1174c95348 debug log für verbundene Piloten 2025-07-24 12:37:29 -07:00
PxlLoewe
4c6a009764 fixed #98 2025-07-24 11:58:11 -07:00
nocnico
0c80c046e1 + Changelog Funktionalität
completed #95
2025-07-24 16:33:10 +02:00
PxlLoewe
0b0f4fac2f implemented #76 2025-07-23 19:06:35 -07:00
PxlLoewe
115296d7f7 fixed #92 2025-07-23 18:48:49 -07:00
PxlLoewe
445dc13829 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-23 18:44:12 -07:00
PxlLoewe
2ef98363b9 Status Toast wird nur für Aircraft auf der selben RG wie Disponent angezeigt 2025-07-23 18:44:07 -07:00
nocnico
ae4bd6c2ff Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-24 03:24:28 +02:00
nocnico
2af5325c6b Make new Chat message more visible #93 2025-07-24 03:24:24 +02:00
PxlLoewe
b1dcaee565 Rufgruppe anzeige gefixed 2025-07-23 18:06:14 -07: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
640fca6fdd Namenseinstellung klarifizieren 2025-07-23 12:07:29 +02:00
Nicolas
7cbada7e04 fix Markdown macht text dunkel bei lightmode Browsern 2025-07-23 12:01:44 +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
Nicolas
34a232024e make V1 Mail Case-Insensitive 2025-07-23 10:10:18 +02:00
PxlLoewe
d003b2cf12 Serch publicID 2025-07-22 21:41:31 -07: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
4372c8efd1 fixed condition for discord role assignment 2025-07-22 21:39:32 -07:00
PxlLoewe
d2a865c955 Merge pull request #80 from VAR-Virtual-Air-Rescue/staging
typos
2025-07-22 19:38:40 -07:00
PxlLoewe
57cde6ff97 merge 2025-07-22 19:33:25 -07:00
PxlLoewe
ce51fa6c23 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-22 19:33:02 -07:00
PxlLoewe
f12db76f48 typos und link zu adminuser in event tabelle 2025-07-22 19:31:21 -07:00
nocnico
d122136e74 FMS Statustexte angepasst 2025-07-23 02:00:55 +02:00
nocnico
dcb162bf1c Rechtschreib- und Grammatikfehler 2025-07-22 23:10:23 +02: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
414e238216 added more delay to moodle ID lookup 2025-07-22 13:17:36 -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
1d1bb713d2 remove scrollbar on AircraftMarker 2025-07-22 11:54:03 -07:00
PxlLoewe
70736b847b fixed marker position beeing updated on Validation 2025-07-22 11:50:15 -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
64c895d229 DME Text enhancement 2025-07-22 11:13:28 -07:00
PxlLoewe
e833ae0090 redirect von alter domain 2025-07-22 11:09:24 -07:00
PxlLoewe
a905872f25 Fix Station-Select 2025-07-22 11:06:00 -07:00
PxlLoewe
228feb0512 fix deployment flow 2025-07-22 10:07:57 -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
55f570b648 Update var.User.json 2025-07-22 09:07:40 -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
ad1dfe9802 User Spalte 2025-07-21 18:56:58 -07:00
PxlLoewe
6fbb8c49a8 Heliport Search, Station connection history table, reworked mission close functionality 2025-07-21 11:43:29 -07:00
PxlLoewe
9eaf3a06ed removed console.log 2025-07-19 16:05:01 -07:00
PxlLoewe
68e26b18b2 Added AudioLogging 2025-07-19 13:21:00 -07:00
PxlLoewe
c10d7ef91a Fix Logout time unable 2025-07-19 13:07:23 -07:00
PxlLoewe
41a3086d82 fix Livekit Audio 2025-07-18 18:21:39 -07:00
PxlLoewe
adc11ec647 fixed extimatedLoggoff time 2025-07-18 17:46:28 -07:00
PxlLoewe
15012820ab Marker für HPG Position-amanded, prettier plugin für Tailwind, #61 2025-07-18 17:00:31 -07:00
nocnico
23c0d601eb Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-19 01:11:33 +02:00
nocnico
3e67e2ed27 Fix updates 2025-07-19 01:11:30 +02:00
PxlLoewe
7c9ba86110 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-18 16:07:26 -07:00
PxlLoewe
b0facf0941 #46 Einsatz-Marker & Chat/Report disabled wenn nicht verbunden 2025-07-18 15:59:26 -07:00
nocnico
93e8eaba73 Aktualisiere alle Pakete 2025-07-19 00:50:28 +02:00
nocnico
a755e45697 Fehler Melden Knopf hinzugefügt 2025-07-19 00:29:39 +02:00
PxlLoewe
85fdfb3bb1 Event kurzbeschreibung für Listenansicht hinzugefügt 2025-07-18 14:24:29 -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
97012f1b6f Change db deploy command 2025-07-18 13:37:00 -07:00
PxlLoewe
4961822599 Footer in eigenem Component 2025-07-18 13:07:16 -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
c75803a87d props url now ops. 2025-07-18 12:27:54 -07:00
PxlLoewe
11b1d8745d added missing Settings functionality, moved ntfy setting 2025-07-17 01:08:10 -07:00
PxlLoewe
44427a1b4b chronjon log 2025-07-17 00:05:32 -07:00
PxlLoewe
409d5c79e6 chronjob logging in core-server 2025-07-16 23:55:43 -07:00
PxlLoewe
df2b7791a6 fixed core-server not being in redis network 2025-07-16 23:39:31 -07:00
PxlLoewe
7be0c701a4 Funk-effekt und Mikrifon-Einstellungen hinzugefügt 2025-07-16 23:24:55 -07:00
PxlLoewe
66c32530a7 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-16 14:08:09 -07:00
PxlLoewe
ffd8bd2e31 added registration-check for old accounts 2025-07-16 14:08:05 -07:00
lucuswolfius
cfff712f8b #46 added facebook/youtube to footer 2025-07-16 13:59:51 -07:00
lucuswolfius
ca5e8a87df #58 code cleanup
changed unnecessary loading statements into form.formState.isLoading/submitting
2025-07-16 12:32:37 -07:00
PxlLoewe
741f42956a nav menu wrap 2025-07-16 12:25:13 -07:00
PxlLoewe
65976f0072 fix menu wrap 2025-07-16 12:09:15 -07:00
PxlLoewe
611aa4d053 remove secrets from .env.example 2025-07-16 11:56:03 -07:00
PxlLoewe
9c41e2f6b9 zoom range for heliports 2025-07-16 00:50:56 -07:00
PxlLoewe
f69fa37b2a added heliport layer 2025-07-16 00:49:25 -07:00
PxlLoewe
959ae37213 Nested-Suche hinzugefügt #59 2025-07-16 00:24:54 -07:00
PxlLoewe
844cfa4b56 Dispatch-Chron Jobs in Core-Server verschoben #65 2025-07-16 00:15:38 -07:00
PxlLoewe
fc698b22d7 Added Pilot Filter functionality 2025-07-15 23:47:13 -07:00
PxlLoewe
46fdd2e0c2 Added Status 6 for MissionAuto-close 2025-07-15 23:46:59 -07:00
PxlLoewe
515ff6d6c3 Improved StationSelect 2025-07-15 23:46:22 -07:00
PxlLoewe
7be21a738a added Mission Closed Toeast, enhanced logic 2025-07-14 23:57:33 -07:00
PxlLoewe
d7ca0eb166 #62 2025-07-14 19:33:10 -07:00
PxlLoewe
1b7fedb0c8 altAgl optional 2025-07-14 14:50:51 -07:00
PxlLoewe
127ae8720e migration 2025-07-14 14:44:04 -07:00
PxlLoewe
a8ca0c0cc9 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-14 14:43:16 -07:00
PxlLoewe
5c334cde63 db migration 2025-07-14 14:43:14 -07:00
nocnico
4c7503781a Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-14 23:37:14 +02:00
nocnico
983633652f Map Einstellungen für Piloten 2025-07-14 23:37:11 +02:00
PxlLoewe
9f8ebd0dbb country is optional 2025-07-14 14:30:42 -07:00
PxlLoewe
7ff7ca556f coutry prisma model 2025-07-14 14:24:25 -07:00
PxlLoewe
a3525f81a6 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-14 14:13:44 -07:00
PxlLoewe
1cb2ddc5bb remove docs from monorepo 2025-07-14 14:13:40 -07:00
nocnico
76622a9ea1 Einsatz & Stationsliste mit Scroll Element #55 2025-07-14 23:11:41 +02:00
nocnico
879e9d1e89 Rework Dispatch Navigation 2025-07-14 20:56:15 +02:00
nocnico
d1c304c4de Add Streaming to Docs 2025-07-14 20:16:04 +02:00
PxlLoewe
90e05dc478 toast für admin-formulare 2025-07-13 01:13:13 -07:00
PxlLoewe
a144b9efcd Admin formulare 2025-07-13 01:04:51 -07:00
PxlLoewe
f721c27964 Einstellungen 2025-07-13 00:44:09 -07:00
PxlLoewe
1191bb4c27 Download text 2025-07-13 00:36:22 -07:00
PxlLoewe
768c84f171 Sticky headers fix, added Heliports 2025-07-13 00:30:46 -07:00
PxlLoewe
0730737bbe Check sim enhancement 2025-07-12 19:43:32 -07:00
PxlLoewe
cea632c47a Chat animation, Audio-Berechtigungsabfrage, Berechtigung Fehlermeldung 2025-07-12 00:48:48 -07:00
PxlLoewe
01bef65218 Skigebiete layer in Karte hinzugefügt 2025-07-11 23:56:24 -07:00
PxlLoewe
a11c8683c4 Chat tabs können durch erneutes auswählen geschlossen werden 2025-07-11 23:31:47 -07:00
PxlLoewe
280393b307 Moodle Chron wrapped in tryCatch 2025-07-11 23:26:01 -07:00
PxlLoewe
d2b287abdc Lade-anzeige für Tabellen 2025-07-11 23:23:30 -07:00
PxlLoewe
bd40c9f817 Filter Verlegung aus Primäreinsätzen 2025-07-11 21:10:26 -07:00
PxlLoewe
446391679c Fix lougout-Zeit 2025-07-10 23:41:39 -07:00
PxlLoewe
879c422366 event page 2025-07-10 11:57:13 -07:00
PxlLoewe
8e71571da9 Fix Admin Links, Piloten können nur mit Dispos schreiben 2025-07-10 10:40:19 -07:00
PxlLoewe
b9eef5252e Fix Pilot-conenction 2025-07-10 10:19:36 -07:00
PxlLoewe
3b1ceb8f8c Aktualisieren von deploy-staging.yml 2025-07-10 00:50:52 -07:00
PxlLoewe
6279732423 Merge pull request #48 from VAR-Virtual-Air-Rescue/eslint
Fix NextJS app ESlint errors
2025-07-10 00:37:12 -07:00
PxlLoewe
e3b475240d Fix nachname in user formularen 2025-07-09 16:06:47 -07:00
PxlLoewe
eb98971e8a Merge pull request #47 from VAR-Virtual-Air-Rescue/staging
CD Deployment
2025-07-08 23:23:24 -07:00
430 changed files with 56131 additions and 19795 deletions

View File

@@ -15,7 +15,7 @@ jobs:
- name: Pull latest code
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.STAGING_HOST }}
host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
port: 22
@@ -26,7 +26,7 @@ jobs:
- name: Deploy migration to Database
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.STAGING_HOST }}
host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
port: 22
@@ -34,11 +34,11 @@ jobs:
export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"
cd ~/docker/var-monorepo/packages/database
pnpm exec prisma migrate deploy
pnpm run deploy
- name: Build and start containers
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.STAGING_HOST }}
host: ${{ vars.PRODUCTION_HOST }}
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
port: 22
@@ -46,4 +46,4 @@ jobs:
export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"
cd ~/docker/var-monorepo
pnpm staging-start
pnpm prod-start

View File

@@ -40,6 +40,7 @@ jobs:
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
port: 22
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

@@ -2,5 +2,6 @@
"tabWidth": 2,
"useTabs": true,
"printWidth": 100,
"singleQuote": false
"singleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

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

View File

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

View File

@@ -3,10 +3,19 @@ import express from "express";
import { createServer } from "http";
import router from "routes/router";
import cors from "cors";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { pubClient, subClient } from "modules/redis";
import "modules/chron";
const app = express();
const server = createServer(app);
export const io = new Server(server, {
adapter: createAdapter(pubClient, subClient),
cors: {},
});
app.use(cors());
app.use(express.json());
app.use(router);

View File

@@ -0,0 +1,267 @@
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index";
import cron from "node-cron";
import { setUserStandardNamePermissions } from "routes/helper";
import { changeMemberRoles } from "routes/member";
import client from "./discord";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
type: "completed-log",
auto: true,
timeStamp: new Date().toISOString(),
data: {},
};
const updatedMission = await prisma.mission.update({
where: {
id: id,
},
data: {
state: "finished",
missionLog: {
push: log as any,
},
},
});
io.to("dispatchers").emit("new-mission", { updatedMission });
io.to("dispatchers").emit("notification", {
type: "mission-auto-close",
status: "chron",
message: `Einsatz ${updatedMission.publicId} wurde aufgrund ${reason} geschlossen.`,
data: {
missionId: updatedMission.id,
publicMissionId: updatedMission.publicId,
},
} as NotificationPayload);
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
};
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
state: "running",
},
});
oldMissions.forEach(async (mission) => {
const lastAlert = (mission.missionLog as unknown as MissionLog[]).find((l) => {
return l.type === "alert-log";
});
const lastAlertTime = lastAlert ? new Date(lastAlert.timeStamp) : null;
const allStationsInMissionChangedFromStatus4to1Or8to1 = mission.missionStationIds.every(
(stationId) => {
const status4Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
return (
l.type === "station-log" &&
l.data?.stationId === stationId &&
l.data?.newFMSstatus === "4"
);
});
const status8Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
return (
l.type === "station-log" &&
l.data?.stationId === stationId &&
l.data?.newFMSstatus === "8"
);
});
const status1Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
return (
l.type === "station-log" &&
l.data?.stationId === stationId &&
l.data?.newFMSstatus === "1"
);
});
const status6Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
return (
l.type === "station-log" &&
l.data?.stationId === stationId &&
l.data?.newFMSstatus === "6"
);
});
return (
(status4Log !== -1 || status8Log !== -1) &&
(status1Log !== -1 || status6Log !== -1) &&
(status4Log < status1Log ||
status8Log < status1Log ||
status8Log < status6Log ||
status1Log < status6Log)
);
},
);
const missionHasManualReactivation = (mission.missionLog as unknown as MissionLog[]).some(
(l) => l.type === "reopened-log",
);
if (missionHasManualReactivation) return;
if (!lastAlertTime) return;
const lastStatus1or6Log = (mission.missionLog as unknown as MissionLog[])
.filter((l) => {
return (
l.type === "station-log" && (l.data?.newFMSstatus === "1" || l.data?.newFMSstatus === "6")
);
})
.sort((a, b) => new Date(b.timeStamp).getTime() - new Date(a.timeStamp).getTime())[0];
// Case 1: Forgotten Mission, last alert more than 3 Hours ago
const now = new Date();
if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180)
return removeMission(mission.id, "inaktivität");
// Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6, Status 1/6 change less more 5 minutes ago
if (
allStationsInMissionChangedFromStatus4to1Or8to1 &&
lastStatus1or6Log &&
now.getTime() - new Date(lastStatus1or6Log.timeStamp).getTime() > 1000 * 60 * 5
)
return removeMission(mission.id, "dem freimelden aller Stationen");
});
};
const syncDiscordImgUrls = async () => {
try {
const discordAccounts = await prisma.discordAccount.findMany({
where: {
updatedAt: {
lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
User: {
isNot: null,
},
},
});
for (const account of discordAccounts) {
client.users.fetch(account.discordId).then((discordUser) => {
const nextAvatar = discordUser?.avatar ?? null;
if (typeof nextAvatar !== "string") return;
if (nextAvatar === account.avatar) return;
prisma.discordAccount.update({
where: {
id: account.id,
},
data: {
avatar: nextAvatar,
},
});
});
}
} catch (error) {}
};
const removeConnectedAircrafts = async () => {
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
logoutTime: null,
},
});
connectedAircrafts.forEach(async (aircraft) => {
const lastUpdate = new Date(aircraft.lastHeartbeat);
const now = new Date();
if (now.getTime() - lastUpdate.getTime() > 12 * 60 * 60 * 1000) {
await prisma.connectedAircraft.update({
where: { id: aircraft.id },
data: { logoutTime: now },
});
console.log(`Aircraft ${aircraft.id} disconnected due to inactivity.`);
}
});
};
const removePermissionsForBannedUsers = async () => {
try {
const removePermissionsPenaltys = await prisma.penalty.findMany({
where: {
removePermissionApplied: false,
User: {
DiscordAccount: { isNot: null },
},
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
const addPermissionsPenaltys = await prisma.penalty.findMany({
where: {
addPermissionApplied: false,
User: {
DiscordAccount: { isNot: null },
},
OR: [{ suspended: true }, { until: { lt: new Date().toISOString() } }],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of removePermissionsPenaltys) {
const user = penalty.User;
console.log(`Removing roles for user ${user.id} due to penalty ${penalty.id}`);
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",
);
}
await prisma.penalty.update({
where: { id: penalty.id },
data: { removePermissionApplied: true },
});
}
for (const penalty of addPermissionsPenaltys) {
console.log(`Restoring roles for user ${penalty.userId} due to penalty ${penalty.id}`);
await setUserStandardNamePermissions({
memberId: penalty.User.DiscordAccount!.discordId,
userId: penalty.userId,
});
await prisma.penalty.update({
where: { id: penalty.id },
data: { addPermissionApplied: true },
});
}
} catch (error) {
console.error("Error removing permissions for banned users:", error);
}
};
cron.schedule("0 0 * * *", async () => {
try {
await syncDiscordImgUrls();
} catch (error) {
console.error("Error on daily cron job:", error);
}
});
cron.schedule("*/1 * * * *", async () => {
try {
await removePermissionsForBannedUsers();
await removeClosedMissions();
await removeConnectedAircrafts();
} catch (error) {
console.error("Error on cron job:", error);
}
});

View File

@@ -0,0 +1,13 @@
import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
});
export const subClient: RedisClientType = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected");
});
pubClient.on("error", (err: unknown) => console.log("Redis Client Error", err));
subClient.on("error", (err: unknown) => console.log("Redis Client Error", err));

View File

@@ -8,28 +8,30 @@
"start": "tsx index.ts --transpile-only",
"build": "tsc"
},
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.13.1",
"devDependencies": {
"@repo/db": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2",
"concurrently": "^9.2.0",
"typescript": "latest"
},
"dependencies": {
"axios": "^1.9.0",
"@socket.io/redis-adapter": "^8.3.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"discord.js": "^14.19.3",
"dotenv": "^16.5.0",
"cron": "^4.3.2",
"discord.js": "^14.21.0",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"node-cron": "^4.1.0",
"node-cron": "^4.2.1",
"nodemon": "^3.1.10",
"prom-client": "^15.1.3",
"react": "^19.1.0",
"tsx": "^4.19.4"
"redis": "^5.6.0",
"socket.io": "^4.8.1",
"tsx": "^4.20.3"
}
}

View File

@@ -7,23 +7,25 @@ const router: Router = Router();
export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false;
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true;
};
router.post("/set-standard-name", async (req, res) => {
const { memberId, userId } = req.body;
export const setUserStandardNamePermissions = async ({
memberId,
userId,
}: {
memberId: string;
userId: string;
}) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
console.log(`Setting standard name for user ${userId} (${user?.publicId}) to member ${memberId}`);
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const participant = await prisma.participant.findMany({
where: {
userId: user.id,
@@ -33,6 +35,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)) {
@@ -45,12 +66,29 @@ router.post("/set-standard-name", async (req, res) => {
const publicUser = getPublicUser(user);
const member = await getMember(memberId);
if (!member) throw new Error("Member not found");
await member.setNickname(`${publicUser.fullName} - ${user.publicId}`);
const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO");
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");
}
};
router.post("/set-standard-name", async (req, res) => {
try {
const { memberId, userId } = req.body;
await setUserStandardNamePermissions({ memberId, userId });
res.status(200).json({ message: "Standard name and permissions set" });
} catch (error) {
res.status(500).json({ error: (error as unknown as Error).message });
}
});
export default router;

View File

@@ -9,13 +9,23 @@ if (!GUILD_ID) {
const router: Router = Router();
export const getMember = async (memberId: string) => {
const guild = client.guilds.cache.get(GUILD_ID);
let guild = client.guilds.cache.get(GUILD_ID);
if (!guild) {
guild = await client.guilds.fetch(GUILD_ID);
}
if (!guild) throw new Error("Guild not found");
try {
return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId));
let member = guild.members.cache.get(memberId);
if (!member) {
member = await guild.members.fetch(memberId).catch((e) => undefined);
}
return member;
} catch (error) {
console.error("Error fetching member:", error);
throw new Error("Member not found");
return null;
}
};
@@ -27,11 +37,15 @@ router.post("/rename", async (req: Request, res: Response) => {
}
try {
const member = await getMember(memberId);
if (!member) {
res.status(404).json({ error: "Member not found" });
return;
}
await member.setNickname(newName);
console.log(`Member ${member.id} renamed to ${newName}`);
res.status(200).json({ message: "Member renamed successfully" });
} catch (error) {
console.error("Error renaming member:", error);
console.error("Error renaming member:", (error as Error).message);
res.status(500).json({ error: "Failed to rename member" });
}
});
@@ -42,6 +56,9 @@ export const changeMemberRoles = async (
action: "add" | "remove",
) => {
const member = await getMember(memberId);
if (!member) {
throw new Error("Member not found");
}
const currentRoleIds = member.roles.cache.map((role) => role.id);
const filteredRoleIds =
@@ -67,7 +84,7 @@ const handleRoleChange = (action: "add" | "remove") => async (req: Request, res:
const result = await changeMemberRoles(memberId, roleIds, action);
res.status(200).json(result);
} catch (error) {
console.error(`Error ${action}ing roles:`, error);
console.error(`Error ${action}ing roles:`, (error as Error).message);
res.status(500).json({ error: `Failed to ${action} roles` });
}
};

View File

@@ -43,14 +43,20 @@ router.post("/admin-embed", async (req, res) => {
{ name: "angemeldet als", value: report.reportedUserRole, inline: true },
{
name: "gemeldet von",
value: `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`,
value: report.Sender
? `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`
: "System",
},
)
.setFooter({
text: "Bitte reagiere mit 🫡, wenn du den Report bearbeitet hast, oder mit ✅, wenn er abgeschlossen ist.",
})
.setTimestamp(new Date(report.timestamp))
.setColor("DarkRed");
.setTimestamp(new Date(report.timestamp));
if (report.reviewed) {
embed.setColor("DarkGreen");
} else {
embed.setColor("DarkRed");
}
const reportsChannel = await client.channels.fetch(process.env.DISCORD_REPORT_CHANNEL!);
if (!reportsChannel || !reportsChannel.isSendable()) {
@@ -59,7 +65,9 @@ router.post("/admin-embed", async (req, res) => {
}
const message = await reportsChannel.send({ embeds: [embed] });
message.react("🫡").catch(console.error);
message.react("✅").catch(console.error);
if (!report.reviewed) {
message.react("✅").catch(console.error);
}
res.json({
message: "Report embed sent to Discord channel",
});

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

@@ -13,13 +13,15 @@ import { handleConnectDesktop } from "socket-events/connect-desktop";
import cookieParser from "cookie-parser";
import cors from "cors";
import { authMiddleware } from "modules/expressMiddleware";
import "modules/chron";
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

@@ -1,83 +0,0 @@
import { MissionLog, prisma } from "@repo/db";
import cron from "node-cron";
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
state: "running",
},
});
oldMissions.forEach(async (mission) => {
const lastAlert = (mission.missionLog as unknown as MissionLog[]).find((l) => {
return l.type === "alert-log";
});
const lastAlertTime = lastAlert ? new Date(lastAlert.timeStamp) : null;
const aircraftsInMission = await prisma.connectedAircraft.findMany({
where: {
stationId: {
in: mission.missionStationIds,
},
},
});
if (
!aircraftsInMission ||
!aircraftsInMission.some((a) => ["1", "2", "6"].includes(a.fmsStatus))
)
return;
const now = new Date();
if (!lastAlertTime) return;
// change State to closed if last alert was more than 180 minutes ago
if (now.getTime() - lastAlertTime.getTime() < 30 * 60 * 1000) return;
const log: MissionLog = {
type: "completed-log",
auto: true,
timeStamp: new Date().toISOString(),
data: {},
};
await prisma.mission.update({
where: {
id: mission.id,
},
data: {
state: "finished",
missionLog: {
push: log as any,
},
},
});
console.log(`Mission ${mission.id} closed due to inactivity.`);
});
};
const removeConnectedAircrafts = async () => {
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
logoutTime: null,
},
});
connectedAircrafts.forEach(async (aircraft) => {
const lastUpdate = new Date(aircraft.lastHeartbeat);
const now = new Date();
if (now.getTime() - lastUpdate.getTime() > 12 * 60 * 60 * 1000) {
await prisma.connectedAircraft.update({
where: { id: aircraft.id },
data: { logoutTime: now },
});
console.log(`Aircraft ${aircraft.id} disconnected due to inactivity.`);
}
});
};
cron.schedule("*/5 * * * *", async () => {
try {
await removeClosedMissions();
await removeConnectedAircrafts();
} catch (error) {
console.error("Error removing closed missions:", error);
}
});

View File

@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { AxiosError } from "axios";
const discordAxiosClient = axios.create({
baseURL: process.env.CORE_SERVER_URL,
@@ -11,7 +11,10 @@ export const renameMember = async (memberId: string, newName: string) => {
newName,
})
.catch((error) => {
console.error("Error renaming member:", error);
console.error(
"Error renaming member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};
@@ -22,7 +25,10 @@ export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
roleIds,
})
.catch((error) => {
console.error("Error adding roles to member:", error);
console.error(
"Error adding roles to member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};
@@ -33,7 +39,10 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
roleIds,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};
@@ -43,6 +52,9 @@ export const sendReportEmbed = async (reportId: number) => {
reportId,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};

View File

@@ -6,121 +6,131 @@ export const sendAlert = async (
id: number,
{
stationId,
desktopOnly,
}: {
stationId?: number;
desktopOnly?: boolean;
},
user: User | "HPG",
): Promise<{
connectedAircrafts: ConnectedAircraft[];
mission: Mission;
}> => {
const mission = await prisma.mission.findUnique({
where: { id: id },
});
const Stations = await prisma.station.findMany({
where: {
id: {
in: mission?.missionStationIds,
},
},
});
if (!mission) {
throw new Error("Mission not found");
}
// connectedAircrafts the alert is sent to
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
stationId: stationId
? stationId
: {
in: mission.missionStationIds,
},
logoutTime: null,
},
include: {
Station: true,
},
});
for (const aircraft of connectedAircrafts) {
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission,
Stations,
try {
const mission = await prisma.mission.findUnique({
where: { id: id },
});
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
missionId: mission.id,
});
const user = await prisma.user.findUnique({
where: { id: aircraft.userId },
});
if (!user) continue;
if (user.settingsNtfyRoom) {
await sendNtfyMission(mission, Stations, aircraft.Station, user.settingsNtfyRoom);
}
const existingMissionOnStationUser = await prisma.missionOnStationUsers.findFirst({
const Stations = await prisma.station.findMany({
where: {
missionId: mission.id,
userId: aircraft.userId,
stationId: aircraft.stationId,
id: {
in: mission?.missionStationIds,
},
},
});
if (!existingMissionOnStationUser)
await prisma.missionOnStationUsers.create({
data: {
if (!mission) {
throw new Error("Mission not found");
}
// connectedAircrafts the alert is sent to
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
stationId: stationId
? stationId
: {
in: mission.missionStationIds,
},
logoutTime: null,
},
include: {
Station: true,
},
});
for (const aircraft of connectedAircrafts) {
if (!desktopOnly) {
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission,
Stations,
});
}
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
missionId: mission.id,
});
const user = await prisma.user.findUnique({
where: { id: aircraft.userId },
});
if (!user) continue;
if (user.settingsNtfyRoom) {
await sendNtfyMission(mission, Stations, aircraft.Station, user.settingsNtfyRoom);
}
const existingMissionOnStationUser = await prisma.missionOnStationUsers.findFirst({
where: {
missionId: mission.id,
userId: aircraft.userId,
stationId: aircraft.stationId,
},
});
}
// for statistics only
await prisma.missionsOnStations
.createMany({
data: mission.missionStationIds.map((stationId) => ({
missionId: mission.id,
stationId,
})),
})
.catch((err) => {
// Ignore if the entry already exists
});
if (user === "HPG") {
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
missionLog: {
push: {
type: "alert-log",
auto: true,
timeStamp: new Date().toISOString(),
} as any,
if (!existingMissionOnStationUser)
await prisma.missionOnStationUsers.create({
data: {
missionId: mission.id,
userId: aircraft.userId,
stationId: aircraft.stationId,
},
});
}
// for statistics only
await prisma.missionsOnStations
.createMany({
data: mission.missionStationIds.map((stationId) => ({
missionId: mission.id,
stationId,
})),
})
.catch((err) => {
// Ignore if the entry already exists
});
if (user === "HPG") {
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
missionLog: {
push: {
type: "alert-log",
auto: true,
timeStamp: new Date().toISOString(),
} as any,
},
},
},
});
} else {
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
missionLog: {
push: {
type: "alert-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
stationId: stationId,
user: getPublicUser(user, { ignorePrivacy: true }),
},
} as any,
});
} else {
await prisma.mission.update({
where: { id: Number(id) },
data: {
state: "running",
missionLog: {
push: {
type: "alert-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
stationId: stationId,
user: getPublicUser(user, { ignorePrivacy: true }),
},
} as any,
},
},
},
});
});
}
return { connectedAircrafts, mission };
} catch (error) {
console.error("Error sending mission alert:", error);
throw new Error("Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug");
}
return { connectedAircrafts, mission };
};

View File

@@ -50,10 +50,7 @@ const getRthCallsigns = (mission: Mission, stations: Station[]) => {
return `🚁 RTH${callsigns.length > 1 ? "s" : ""}: ${callsigns.join(" / ")} `;
};
const getNtfyHeader = (
mission: Mission,
clientStation: Station,
): NtfyHeader => ({
const getNtfyHeader = (mission: Mission, clientStation: Station): NtfyHeader => ({
headers: {
Title: `${clientStation.bosCallsignShort} / ${mission.missionKeywordAbbreviation} / ${mission.missionKeywordCategory}`,
Tags: "pager",
@@ -76,9 +73,13 @@ export const sendNtfyMission = async (
clientStation: Station,
ntfyRoom: string,
) => {
axios.post(
`https://ntfy.sh/${ntfyRoom}`,
getNtfyData(mission, stations),
getNtfyHeader(mission, clientStation),
);
try {
await axios.post(
`https://ntfy.sh/${ntfyRoom}`,
getNtfyData(mission, stations),
getNtfyHeader(mission, clientStation),
);
} catch (error) {
console.error("Error sending Ntfy mission:", error);
}
};

View File

@@ -1,19 +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");
pubClient.keys("dispatchers*").then((keys) => {
if (!keys) return;
keys.forEach(async (key) => {
await pubClient.json.del(key);
});
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

@@ -8,39 +8,39 @@
"start": "tsx index.ts --transpile-only",
"build": "tsc"
},
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.13.1",
"devDependencies": {
"@repo/db": "workspace:*",
"@repo/shared-components": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/cookie-parser": "^1.4.9",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2",
"concurrently": "^9.2.0",
"typescript": "latest"
},
"dependencies": {
"@react-email/components": "^0.0.41",
"@redis/json": "^5.1.1",
"@react-email/components": "^0.3.2",
"@redis/json": "^5.6.0",
"@socket.io/redis-adapter": "^8.3.0",
"@types/jsonwebtoken": "^9.0.9",
"axios": "^1.9.0",
"@types/jsonwebtoken": "^9.0.10",
"axios": "^1.10.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron": "^4.3.1",
"dotenv": "^16.5.0",
"cron": "^4.3.2",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.13.0",
"node-cron": "^4.1.0",
"nodemailer": "^7.0.3",
"livekit-server-sdk": "^2.13.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.5",
"nodemon": "^3.1.10",
"prom-client": "^15.1.3",
"react": "^19.1.0",
"redis": "^5.1.1",
"redis": "^5.6.0",
"socket.io": "^4.8.1",
"tsx": "^4.19.4"
"tsx": "^4.20.3"
}
}

View File

@@ -2,6 +2,7 @@ import {
AdminMessage,
getPublicUser,
MissionLog,
MissionSdsStatusLog,
NotificationPayload,
Prisma,
prisma,
@@ -83,6 +84,7 @@ router.patch("/:id", async (req, res) => {
data: {
stationId: updatedConnectedAircraft.stationId,
aircraftId: updatedConnectedAircraft.id,
userId: updatedConnectedAircraft.userId,
},
} as NotificationPayload);
}
@@ -129,6 +131,54 @@ router.patch("/:id", async (req, res) => {
}
});
router.post("/:id/send-sds-message", async (req, res) => {
const { id } = req.params;
const { sdsMessage } = req.body as { sdsMessage: MissionSdsStatusLog };
if (!sdsMessage.data.stationId || !id) {
res.status(400).json({ error: "Missing aircraftId or stationId" });
return;
}
await prisma.mission.updateMany({
where: {
state: "running",
missionStationIds: {
has: sdsMessage.data.stationId,
},
},
data: {
missionLog: {
push: sdsMessage as unknown as Prisma.InputJsonValue,
},
},
});
const user = await prisma.user.findFirst({
where: { publicId: sdsMessage.data.user.publicId, firstname: sdsMessage.data.user.firstname },
});
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
io.to(
sdsMessage.data.direction === "to-lst" ? "dispatchers" : `station:${sdsMessage.data.stationId}`,
).emit(sdsMessage.data.direction === "to-lst" ? "notification" : "sds-status", {
type: "station-status",
status: sdsMessage.data.status,
message: "SDS Status Message",
data: {
aircraftId: parseInt(id),
stationId: sdsMessage.data.stationId,
userId: user.id,
},
} as NotificationPayload);
res.sendStatus(204);
});
// Kick a connectedAircraft by ID
router.delete("/:id", async (req, res) => {
const { id } = req.params;

View File

@@ -34,7 +34,7 @@ router.patch("/:id", async (req, res) => {
},
});
if (discordAccount?.id) {
if (discordAccount?.id && !disaptcherUpdate.ghostMode) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(newDispatcher.user).fullName}${newDispatcher.zone}`,

View File

@@ -86,7 +86,30 @@ router.patch("/:id", async (req, res) => {
where: { id: Number(id) },
data: req.body,
});
io.to("dispatchers").emit("update-mission", updatedMission);
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,7 +217,9 @@ router.post("/:id/send-alert", async (req, res) => {
return;
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to send mission" });
res.status(500).json({
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
});
return;
}
});
@@ -241,9 +271,6 @@ router.post("/:id/hpg-validation-result", async (req, res) => {
const newMission = await prisma.mission.update({
where: { id: Number(missionId) },
data: {
// save position of new mission
addressLat: result.state === "POSITION_AMANDED" ? result.lat : undefined,
addressLng: result.state === "POSITION_AMANDED" ? result.lng : undefined,
hpgLocationLat: result.lat,
hpgLocationLng: result.lng,
hpgValidationState: result.state,
@@ -261,9 +288,7 @@ router.post("/:id/hpg-validation-result", async (req, res) => {
},
} as NotificationPayload);
console.log("Got positiv validation Result", result.alertWhenValid);
if (result.alertWhenValid) {
console.log(req.user);
sendAlert(Number(missionId), {}, "HPG");
}
} else {

View File

View File

@@ -1,12 +1,20 @@
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";
export const handleConnectDispatch =
(socket: Socket, io: Server) =>
async ({ logoffTime, selectedZone }: { logoffTime: string; selectedZone: string }) => {
async ({
logoffTime,
selectedZone,
ghostMode,
}: {
logoffTime: string;
selectedZone: string;
ghostMode: boolean;
}) => {
try {
const user: User = socket.data.user; // User ID aus dem JWT-Token
@@ -20,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;
}
@@ -45,17 +62,15 @@ export const handleConnectDispatch =
});
}
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
const connectedDispatcherEntry = await prisma.connectedDispatcher.create({
data: {
publicUser: getPublicUser(user) as any,
esimatedLogoutTime:
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
lastHeartbeat: new Date().toISOString(),
userId: user.id,
zone: selectedZone,
loginTime: new Date().toISOString(),
ghostMode,
},
});
@@ -64,7 +79,7 @@ export const handleConnectDispatch =
userId: user.id,
},
});
if (discordAccount?.id) {
if (discordAccount?.id && !ghostMode) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${selectedZone}`,

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");
@@ -73,18 +86,18 @@ export const handleConnectPilot =
}
const randomPos = debug ? getRandomGermanPosition() : undefined;
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
const connectedAircraftEntry = await prisma.connectedAircraft.create({
data: {
publicUser: getPublicUser(user) as any,
esimatedLogoutTime:
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
userId: userId,
stationId: parseInt(stationId),
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat,
posLng: randomPos?.lng,
posXplanePluginActive: debug ? true : undefined,
posH145active: debug ? true : undefined,
},
});

View File

@@ -5,10 +5,9 @@ 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,
) => {
console.log("send-message", userId, message);
const senderId = socket.data.user.id;
const senderUser = await prisma.user.findUnique({
@@ -25,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

@@ -1,26 +1,28 @@
FROM node:22-alpine AS base
ARG NEXT_PUBLIC_DISPATCH_URL
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL
ARG NEXT_PUBLIC_HUB_URL
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID
ARG NEXT_PUBLIC_LIVEKIT_URL
ARG NEXT_PUBLIC_DISCORD_URL
ARG NEXT_PUBLIC_DISPATCH_URL="http://localhost:3001"
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL="http://localhost:4001"
ARG NEXT_PUBLIC_HUB_URL="http://localhost:3002"
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID="1"
ARG NEXT_PUBLIC_LIVEKIT_URL="http://localhost:7880"
ARG NEXT_PUBLIC_DISCORD_URL="https://discord.com"
ARG NEXT_PUBLIC_OPENAIP_ACCESS=""
ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL
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
FROM base AS builder
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
@@ -29,12 +31,20 @@ WORKDIR /usr/app
RUN echo "NEXT_PUBLIC_HUB_URL is: $NEXT_PUBLIC_HUB_URL"
RUN echo "NEXT_PUBLIC_DISPATCH_SERVICE_ID is: $NEXT_PUBLIC_DISPATCH_SERVICE_ID"
RUN echo "NEXT_PUBLIC_DISPATCH_SERVER_URL is: $NEXT_PUBLIC_DISPATCH_SERVER_URL"
RUN echo "NEXT_PUBLIC_LIVEKIT_URL is: $NEXT_PUBLIC_LIVEKIT_URL"
COPY . .
RUN turbo prune dispatch --docker
FROM base AS installer
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5
RUN apk update
RUN apk add --no-cache libc6-compat
@@ -48,19 +58,23 @@ COPY --from=builder /usr/app/out/full/ .
RUN turbo run build
FROM base AS runner
FROM node:22-alpine AS runner
WORKDIR /usr/app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/static ./apps/dispatch/.next/static
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/public ./apps/dispatch/public
USER nextjs
# Expose the application port
EXPOSE 3001
EXPOSE 3000
ENV HOST=0.0.0.0
CMD ["pnpm", "--dir", "apps/dispatch", "run", "start"]
CMD ["node", "apps/dispatch/server.js"]

View File

@@ -0,0 +1,36 @@
import { ExitIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { prisma } from "@repo/db";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import AdminPanel from "_components/navbar/AdminPanel";
export default async function Navbar({ children }: { children: React.ReactNode }) {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return (
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<div className="flex items-center gap-2">
{children}
<ModeSwitchDropdown className="dropdown-center" btnClassName="btn-ghost" />
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>
</div>
);
}

View File

@@ -45,16 +45,9 @@ export function StationsSelect({
queryFn: () => getStationsAPI(),
});
const [value, setValue] = useState<string[]>(selectedStations?.map((id) => String(id)) || []);
useEffect(() => {
setValue([
...(selectedStations || []).map((id) => String(id)),
...(vehicleStates.hpgAmbulanceState !== HpgState.NOT_REQUESTED || undefined ? ["RTW"] : []),
...(vehicleStates.hpgFireEngineState !== HpgState.NOT_REQUESTED || undefined ? ["FW"] : []),
...(vehicleStates.hpgPoliceState !== HpgState.NOT_REQUESTED || undefined ? ["POL"] : []),
]);
}, [selectedStations, vehicleStates]);
const [value, setValue] = useState<string[] | string | null>(
selectedStations?.map((id) => String(id)) || [],
);
// Helper to check if a station is a vehicle and its state is NOT_REQUESTED
const stationsOptions = [
@@ -101,6 +94,20 @@ export function StationsSelect({
return true;
});
useEffect(() => {
if (isMulti) {
setValue([
...(selectedStations || []).map((id) => String(id)),
...(vehicleStates.hpgAmbulanceState !== HpgState.NOT_REQUESTED || undefined ? ["RTW"] : []),
...(vehicleStates.hpgFireEngineState !== HpgState.NOT_REQUESTED || undefined ? ["FW"] : []),
...(vehicleStates.hpgPoliceState !== HpgState.NOT_REQUESTED || undefined ? ["POL"] : []),
]);
} else {
console.log("clear selected stations");
setValue(null);
}
}, [selectedStations, vehicleStates, isMulti]);
return (
<Select
className={className}
@@ -108,7 +115,25 @@ export function StationsSelect({
isMulti={isMulti}
onChange={(v) => {
setValue(v);
if (!isMulti) return onChange?.(v);
if (!isMulti) {
const singleValue = v as string;
const isVehicle = ["RTW", "FW", "POL"].includes(singleValue);
const hpgAmbulanceState =
singleValue === "RTW" ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
const hpgFireEngineState =
singleValue === "FW" ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
const hpgPoliceState =
singleValue === "POL" ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
onChange?.({
selectedStationIds: isVehicle ? [] : [Number(singleValue)],
hpgAmbulanceState,
hpgFireEngineState,
hpgPoliceState,
});
return;
}
const hpgAmbulanceState = v.includes("RTW") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
const hpgFireEngineState = v.includes("FW") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;
const hpgPoliceState = v.includes("POL") ? HpgState.DISPATCHED : HpgState.NOT_REQUESTED;

View File

@@ -1,68 +1,41 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../../../_store/dispatch/connectionStore";
import { useDispatchConnectionStore } from "../../../../_store/dispatch/connectionStore";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useMutation } from "@tanstack/react-query";
import { Prisma } from "@repo/db";
import { changeDispatcherAPI } from "_querys/dispatcher";
import { getNextDateWithTime } from "@repo/shared-components";
import { Button, getNextDateWithTime } from "@repo/shared-components";
import { Ghost } from "lucide-react";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({
logoffTime: "",
selectedZone: "LST_01",
selectedZone: "VAR_LST_RD_01",
ghostMode: false,
});
const changeDispatcherMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Prisma.ConnectedDispatcherUpdateInput }) =>
changeDispatcherAPI(id, data),
});
const [logoffDebounce, setLogoffDebounce] = useState<NodeJS.Timeout | null>(null);
const session = useSession();
const uid = session.data?.user?.id;
// useEffect für die Logoff-Zeit
const [logoffHours, logoffMinutes] = form.logoffTime?.split(":").map(Number) || [];
useEffect(() => {
if (!logoffHours || !logoffMinutes) return;
if (logoffDebounce) clearTimeout(logoffDebounce);
const timeout = setTimeout(async () => {
if (!logoffHours || !logoffMinutes || !connection.connectedDispatcher) return;
await changeDispatcherMutation.mutateAsync({
id: connection.connectedDispatcher?.id,
data: {
esimatedLogoutTime:
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
},
});
toast.success("Änderung gespeichert!");
modalRef.current?.close();
}, 2000);
setLogoffDebounce(timeout);
// Cleanup function
return () => {
if (logoffDebounce) clearTimeout(logoffDebounce);
};
}, [form.logoffTime, connection.connectedDispatcher]);
useEffect(() => {
// Disconnect the socket when the component unmounts
return () => {
connection.disconnect();
};
}, [connection.disconnect]);
if (!uid) return null;
return (
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && (
<span className="mx-2 text-error">{connection.message}</span>
<span className="text-error mx-2">{connection.message}</span>
)}
{connection.status == "connected" ? (
@@ -74,7 +47,7 @@ export const ConnectionBtn = () => {
modalRef.current?.showModal();
}}
>
Verbunden
Verbunden {connection.ghostMode && <Ghost />}
</button>
) : (
<button
@@ -92,11 +65,11 @@ export const ConnectionBtn = () => {
<dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center">
{connection.status == "connected" ? (
<h3 className="text-lg font-bold mb-5">
<h3 className="mb-5 text-lg font-bold">
Verbunden als <span className="text-info">&lt;{connection.selectedZone}&gt;</span>
</h3>
) : (
<h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3>
<h3 className="mb-5 text-lg font-bold">Als Disponent anmelden</h3>
)}
<fieldset className="fieldset w-full">
<label className="floating-label w-full text-base">
@@ -118,26 +91,72 @@ export const ConnectionBtn = () => {
<p className="fieldset-label">Du kannst diese Zeit später noch anpassen.</p>
)}
</fieldset>
<div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between">
{session.data?.user.permissions.includes("ADMIN_KICK") &&
connection.status === "disconnected" && (
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-full border p-4">
<legend className="fieldset-legend">Ghost-Mode</legend>
<label className="label">
<input
checked={form.ghostMode}
onChange={(e) => setForm({ ...form, ghostMode: e.target.checked })}
type="checkbox"
className="checkbox"
/>
Vesteckt deine Verbindung auf dem Tracker
</label>
</fieldset>
)}
<div className="modal-action flex w-full justify-between">
<form method="dialog" className="flex w-full justify-between">
<button className="btn btn-soft">Zurück</button>
{connection.status == "connected" ? (
<button
className="btn btn-soft btn-error"
type="submit"
onSubmit={() => false}
onClick={() => {
connection.disconnect();
}}
>
Verbindung Trennen
</button>
<>
<Button
className="btn"
onClick={async () => {
if (!connection.connectedDispatcher?.id) return;
const [logoffHours, logoffMinutes] =
form.logoffTime?.split(":").map(Number) || [];
await changeDispatcherMutation.mutateAsync({
id: connection.connectedDispatcher?.id,
data: {
esimatedLogoutTime:
logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes)
: null,
},
});
modalRef.current?.close();
}}
>
Logoff-Zeit speichern
</Button>
<button
className="btn btn-soft btn-error"
type="submit"
onSubmit={() => false}
onClick={() => {
connection.disconnect();
}}
>
Verbindung Trennen
</button>
</>
) : (
<button
type="submit"
onSubmit={() => false}
onClick={() => {
connection.connect(uid, form.selectedZone, form.logoffTime);
const [logoffHours, logoffMinutes] =
form.logoffTime?.split(":").map(Number) || [];
connection.connect(
uid,
form.selectedZone,
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
: "",
form.ghostMode,
);
}}
className="btn btn-soft btn-info"
>

View File

@@ -1,48 +0,0 @@
import { Connection } from "./_components/Connection";
import { Audio } from "../../../../_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Settings } from "_components/navbar/Settings";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import AdminPanel from "_components/navbar/AdminPanel";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { WarningAlert } from "_components/navbar/PageAlert";
export default async function Navbar() {
const session = await getServerSession();
return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
<div className="flex items-center gap-2">
<ModeSwitchDropdown />
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<WarningAlert />
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />
</div>
<div className="flex items-center">
<Connection />
</div>
<div className="flex items-center">
<Settings />
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" />
</button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { Info, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
import { useMapStore } from "_store/mapStore";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => {
const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const testSoundRef = useRef<HTMLAudioElement | null>(null);
const editUserMutation = useMutation({
mutationFn: editUserAPI,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
});
useEffect(() => {
if (typeof window !== "undefined") {
testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
}
}, []);
const modalRef = useRef<HTMLDialogElement>(null);
const [showIndication, setShowIndication] = useState<boolean>(false);
const [settings, setSettings] = useState({
micDeviceId: user?.settingsMicDevice || null,
micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8,
autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false,
useHPGAsDispatcher: user?.settingsUseHPGAsDispatcher || false,
});
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
const { setUserSettings: setUserSettings } = useMapStore((state) => state);
useEffect(() => {
if (user) {
setAudioSettings({
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
dmeVolume: user.settingsDmeVolume || 0.8,
});
setSettings({
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
autoCloseMapPopup: user.settingsAutoCloseMapPopup,
useHPGAsDispatcher: user.settingsUseHPGAsDispatcher,
});
setUserSettings({
settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
});
}
}, [user, setSettings, setAudioSettings, setUserSettings]);
const setSettingsPartial = (newSettings: Partial<typeof settings>) => {
setSettings((prev) => ({
...prev,
...newSettings,
}));
};
useEffect(() => {
const setDevices = async () => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
} catch (error) {
console.error("Error accessing media devices.", error);
}
}
};
setDevices();
}, []);
return (
<div>
<button
className="btn btn-ghost"
onSubmit={() => false}
onClick={() => {
modalRef.current?.showModal();
}}
>
<GearIcon className="h-5 w-5" />
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box">
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset mb-2 w-full">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
className="input w-full"
value={settings.micDeviceId ? settings.micDeviceId : ""}
onChange={(e) => {
setSettingsPartial({ micDeviceId: e.target.value });
setShowIndication(true);
}}
>
<option key={0} value={0} disabled>
Bitte wähle ein Eingabegerät...
</option>
{inputDevices.map((device, index) => (
<option key={index} value={device.deviceId}>
{device.label}
</option>
))}
</select>
</label>
</fieldset>
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
<Volume2 size={20} /> Eingabelautstärke
</p>
<div className="w-full">
<input
type="range"
min={0}
max={3}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ micVolume: value });
setShowIndication(true);
}}
value={settings.micVolume}
className="range range-xs range-accent w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
{showIndication && (
<MicVolumeBar
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
volumeInput={settings.micVolume}
/>
)}
<div className="divider w-full" />
</div>
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Funk Lautstärke
</p>
<div className="mb-2 w-full">
<input
type="range"
min={0}
max={1}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ radioVolume: value });
}}
value={settings.radioVolume}
className="range range-xs range-primary w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
<div className="flex w-full justify-center">
<div className="divider w-full">Disponenten Einstellungen</div>
</div>
<div className="flex w-full items-center gap-2">
<input
type="checkbox"
className="toggle"
checked={settings.autoCloseMapPopup}
onChange={(e) => {
setSettingsPartial({ autoCloseMapPopup: e.target.checked });
}}
/>
Popups automatisch schließen
</div>
<div className="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 Validierung verwenden{" "}
<div
className="tooltip tooltip-warning"
data-tip="Achtung! Mit der Client Version v2.0.1.0 werden Einsätze auch ohne Validierung an die
HPG gesendet. Die Validierung über die HPG kann zu Verzögerungen bei der
Einsatzübermittlung führen, insbesondere wenn das HPG script den Einsatz nicht sofort
validieren kann. Es wird empfohlen, diese Option nur zu aktivieren, wenn es Probleme
mit der Einsatzübermittlung gibt oder wenn die HPG Validierung ausdrücklich gewünscht
wird."
>
<Info className="text-error" size={16} />
</div>
</div>
<div className="modal-action flex justify-between">
<button
className="btn btn-soft"
type="submit"
onSubmit={() => false}
onClick={() => {
modalRef.current?.close();
testSoundRef.current?.pause();
}}
>
Schließen
</button>
<Button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
onClick={async () => {
testSoundRef.current?.pause();
await editUserMutation.mutateAsync({
id: session.data!.user.id,
user: {
settingsMicDevice: settings.micDeviceId,
settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume,
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
settingsUseHPGAsDispatcher: settings.useHPGAsDispatcher,
},
});
setAudioSettings({
micDeviceId: settings.micDeviceId,
micVolume: settings.micVolume,
radioVolume: settings.radioVolume,
});
setUserSettings({
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
});
modalRef.current?.close();
toast.success("Einstellungen gespeichert");
}}
>
Speichern
</Button>
</div>
</div>
</dialog>
</div>
);
};
export const Settings = () => {
return (
<div>
<SettingsBtn />
</div>
);
};

View File

@@ -1,26 +0,0 @@
"use client";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
interface ThemeSwapProps {
isDark: boolean;
toggleTheme: () => void;
}
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
isDark,
toggleTheme,
}) => {
return (
<label className="swap swap-rotate">
<input
type="checkbox"
className="theme-controller"
checked={isDark}
onChange={toggleTheme}
/>
<MoonIcon className="swap-off h-5 w-5 fill-current" />
<SunIcon className="swap-on h-5 w-5 fill-current" />
</label>
);
};

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 } = {},
@@ -195,7 +222,7 @@ export const MissionForm = () => {
<form className="space-y-4">
{/* Koorinaten Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Koordinaten</h2>
<h2 className="mb-2 text-lg font-bold">Koordinaten</h2>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
@@ -219,12 +246,12 @@ export const MissionForm = () => {
{/* Adresse Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Adresse</h2>
<h2 className="mb-2 text-lg font-bold">Adresse</h2>
<input
type="text"
{...form.register("addressStreet")}
placeholder="Straße"
className="input input-primary input-bordered w-full mb-4"
className="input input-primary input-bordered mb-4 w-full"
/>
<div className="grid grid-cols-2 gap-4">
<input
@@ -244,17 +271,16 @@ export const MissionForm = () => {
type="text"
{...form.register("addressAdditionalInfo")}
placeholder="Zusätzliche Adressinformationen"
className="input input-primary input-bordered w-full mt-4"
className="input input-primary input-bordered mt-4 w-full"
/>
</div>
{/* Rettungsmittel Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Rettungsmittel</h2>
<h2 className="mb-2 text-lg font-bold">Rettungsmittel</h2>
<StationsSelect
isMulti
selectedStations={form.watch("missionStationIds")}
onChange={(v) => {
console.log("Selected stations:", v);
form.setValue("missionStationIds", v.selectedStationIds);
form.setValue("hpgAmbulanceState", v.hpgAmbulanceState);
form.setValue("hpgFireEngineState", v.hpgFireEngineState);
@@ -270,10 +296,10 @@ export const MissionForm = () => {
{/* Einsatzdaten Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Einsatzdaten</h2>
<h2 className="mb-2 text-lg font-bold">Einsatzdaten</h2>
<select
{...form.register("type")}
className="select select-primary select-bordered w-full mb-4"
className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => {
form.setValue("type", e.target.value as missionType);
if (e.target.value === "primary") {
@@ -295,7 +321,7 @@ export const MissionForm = () => {
<>
<select
{...form.register("missionKeywordCategory")}
className="select select-primary select-bordered w-full mb-4"
className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => {
form.setValue("missionKeywordCategory", e.target.value as string);
form.setValue("missionKeywordName", null as any);
@@ -307,20 +333,22 @@ export const MissionForm = () => {
<option disabled value="please_select">
Einsatz Kategorie auswählen...
</option>
{Object.keys(KEYWORD_CATEGORY).map((use) => (
<option key={use} value={use}>
{use}
</option>
))}
{Object.keys(KEYWORD_CATEGORY)
.filter((k) => !k.startsWith("V_"))
.map((use) => (
<option key={use} value={use}>
{use}
</option>
))}
</select>
{form.formState.errors.missionKeywordCategory && (
<p className="text-error text-sm mb-4">Bitte wähle eine Kategorie aus.</p>
<p className="text-error mb-4 text-sm">Bitte wähle eine Kategorie aus.</p>
)}
</>
)}
<select
{...form.register("missionKeywordAbbreviation")}
className="select select-primary select-bordered w-full mb-4"
className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => {
const keyword = keywords?.find((k) => k.abreviation === e.target.value);
form.setValue("missionKeywordName", keyword?.name || (null as any));
@@ -342,7 +370,7 @@ export const MissionForm = () => {
))}
</select>
{form.formState.errors.missionKeywordAbbreviation && (
<p className="text-error text-sm mb-4">Bitte wähle ein Stichwort aus.</p>
<p className="text-error mb-4 text-sm">Bitte wähle ein Stichwort aus.</p>
)}
<div className="mb-4">
<select
@@ -362,12 +390,13 @@ export const MissionForm = () => {
form.setValue("missionAdditionalInfo", name || "");
}
}}
className="select select-primary select-bordered w-full mb-2"
className="select select-primary select-bordered mb-2 w-full"
value={form.watch("hpgMissionString") || "please_select"}
>
<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"))
@@ -381,14 +410,14 @@ export const MissionForm = () => {
})}
</select>
{validationRequired && (
<p className="text-sm text-warning">Szenario wird vor Alarmierung HPG-Validiert.</p>
<p className="text-warning text-sm">Szenario wird vor Alarmierung HPG-Validiert.</p>
)}
</div>
<textarea
{...form.register("missionAdditionalInfo")}
placeholder="Einsatzinformationen"
className="textarea textarea-primary textarea-bordered w-full mb-4"
className="textarea textarea-primary textarea-bordered mb-4 w-full"
/>
{form.watch("type") === "sekundär" && (
<input
@@ -400,7 +429,7 @@ export const MissionForm = () => {
)}
</div>
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Patienteninformationen</h2>
<h2 className="mb-2 text-lg font-bold">Patienteninformationen</h2>
<textarea
{...form.register("missionPatientInfo")}
placeholder="Patienteninformationen"
@@ -414,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
@@ -429,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) {
@@ -454,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

@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "_components/Error";
import Navbar from "(app)/_components/Navbar";
import { Audio } from "_components/Audio/Audio";
import { Connection } from "./_components/navbar/Connection";
import { Settings } from "./_components/navbar/Settings";
export const metadata: Metadata = {
title: "VAR: Disponent",
@@ -16,11 +19,21 @@ export default async function RootLayout({
const session = await getServerSession();
if (!session?.user.permissions.includes("DISPO"))
return <Error title="Zugriff verweigert" statusCode={403} />;
return (
<Error
title=" Fehlende Berechtigung"
description="Du hast nicht die erforderlichen Berechtigungen, dich als Disponent anzumelden. Du kannst im HUB Kurse abschließen um die Berechtigung zu erhalten."
statusCode={403}
/>
);
return (
<>
<Navbar />
<Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
{children}
</>
);

View File

@@ -7,6 +7,7 @@ import dynamic from "next/dynamic";
import { Chat } from "../../_components/left/Chat";
import { Report } from "../../_components/left/Report";
import { SituationBoard } from "_components/left/SituationBoard";
import { BugReport } from "_components/left/BugReport";
const Map = dynamic(() => import("../../_components/map/Map"), { ssr: false });
@@ -14,16 +15,15 @@ const DispatchPage = () => {
const { isOpen } = usePannelStore();
/* return null; */
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full">
<div className="ease relative flex w-full flex-1 transition-all duration-500">
{/* <MapToastCard2 /> */}
<div className="flex flex-1 relative">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
<div className="relative flex flex-1">
<div className="z-999999 absolute left-0 top-1/2 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
<Chat />
<div className="mt-2">
<Report />
</div>
<Report />
<BugReport />
</div>
<div className="absolute left-0 top-19/20 transform -translate-y-1/2 pl-4 z-999999">
<div className="top-19/20 z-999999 absolute left-0 -translate-y-1/2 transform pl-4">
<div className="flex items-center justify-between gap-4">
<SituationBoard />
</div>
@@ -32,7 +32,7 @@ const DispatchPage = () => {
</div>
<div
className={cn(
"absolute right-0 w-[500px] z-999 transition-transform",
"z-999 absolute right-0 w-[500px] transition-transform",
isOpen ? "translate-x-0" : "translate-x-full",
)}
>

View File

@@ -27,7 +27,6 @@ export default async function RootLayout({
});
if (!session) {
console.log(session);
return redirect("/logout");
}

View File

@@ -1,18 +1,11 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { getUserAPI } from "_querys/user";
import { useAudioStore } from "_store/audioStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useDmeStore } from "_store/pilot/dmeStore";
import { useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
export const useSounds = () => {
const session = useSession();
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const dmeVolume = useAudioStore((state) => state.settings.dmeVolume);
const { page, setPage } = useDmeStore((state) => state);
const mission = usePilotConnectionStore((state) => state.activeMission);
@@ -20,27 +13,25 @@ export const useSounds = () => {
useEffect(() => {
if (typeof window !== "undefined") {
newMissionSound.current = new Audio("/sounds/Melder3.wav");
newMissionSound.current = new Audio("/sounds/DME-new-mission.wav");
}
}, []);
useEffect(() => {
if (user?.settingsDmeVolume) {
if (dmeVolume) {
if (newMissionSound.current) {
newMissionSound.current.volume = user.settingsDmeVolume;
newMissionSound.current.volume = dmeVolume;
}
} else if (newMissionSound.current) {
newMissionSound.current.volume = 0.8; // Default volume
}
}, [user?.settingsDmeVolume]);
}, [dmeVolume]);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
if (page === "new-mission" && newMissionSound.current) {
console.log("new-mission", mission);
newMissionSound.current.currentTime = 0;
newMissionSound.current.volume = 0.3;
newMissionSound.current.play();
if (mission) {
timeouts.push(setTimeout(() => setPage({ page: "mission", mission }), 500));

View File

@@ -0,0 +1,29 @@
import { useEffect } from "react"; // ...existing code...
import { useMrtStore } from "_store/pilot/MrtStore";
import Image from "next/image";
import DAY_BASE_IMG from "./images/Base_NoScreen_Day.png";
import NIGHT_BASE_IMG from "./images/Base_NoScreen_Night.png";
export const MrtBase = () => {
const { nightMode, setNightMode, page } = useMrtStore((state) => state);
useEffect(() => {
const checkNightMode = () => {
const currentHour = new Date().getHours();
setNightMode(currentHour >= 22 || currentHour < 8);
};
checkNightMode(); // Initial check
const intervalId = setInterval(checkNightMode, 60000); // Check every minute
return () => clearInterval(intervalId); // Cleanup on unmount
}, [setNightMode]); // ...existing code...
return (
<Image
src={nightMode && page !== "off" ? NIGHT_BASE_IMG : DAY_BASE_IMG}
alt=""
className="z-30 col-span-full row-span-full"
/>
);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

View File

@@ -1,22 +1,9 @@
import { CSSProperties } from "react";
import MrtImage from "./MRT.png";
import MrtMessageImage from "./MRT_MESSAGE.png";
import { useButtons } from "./useButtons";
import { useSounds } from "./useSounds";
import "./mrt.css";
import Image from "next/image";
import { useMrtStore } from "_store/pilot/MrtStore";
const MRT_BUTTON_STYLES: CSSProperties = {
cursor: "pointer",
zIndex: "9999",
backgroundColor: "transparent",
border: "none",
};
const MRT_DISPLAYLINE_STYLES: CSSProperties = {
color: "white",
zIndex: 1,
};
import { MrtBase } from "./Base";
import { MrtDisplay } from "./MrtDisplay";
import { MrtButtons } from "./MrtButtons";
import { MrtPopups } from "./MrtPopups";
export interface DisplayLineProps {
lineStyle?: CSSProperties;
@@ -27,45 +14,7 @@ export interface DisplayLineProps {
textSize: "1" | "2" | "3" | "4";
}
const DisplayLine = ({
style = {},
textLeft,
textMid,
textRight,
textSize,
lineStyle,
}: DisplayLineProps) => {
const INNER_TEXT_PARTS: CSSProperties = {
fontFamily: "Melder",
flex: "1",
flexBasis: "auto",
overflowWrap: "break-word",
...lineStyle,
};
return (
<div
className={`text-${textSize}`}
style={{
fontFamily: "Famirids",
display: "flex",
flexWrap: "wrap",
...style,
}}
>
<span style={INNER_TEXT_PARTS}>{textLeft}</span>
<span style={{ textAlign: "center", ...INNER_TEXT_PARTS }}>{textMid}</span>
<span style={{ textAlign: "end", ...INNER_TEXT_PARTS }}>{textRight}</span>
</div>
);
};
export const Mrt = () => {
useSounds();
const { handleButton } = useButtons();
const { lines, page } = useMrtStore((state) => state);
return (
<div
id="mrt-container"
@@ -78,150 +27,16 @@ export const Mrt = () => {
maxHeight: "100%",
maxWidth: "100%",
color: "white",
gridTemplateColumns: "21.83% 4.43% 24.42% 18.08% 5.93% 1.98% 6.00% 1.69% 6.00% 9.35%",
gridTemplateRows: "21.58% 11.87% 3.55% 5.00% 6.84% 0.53% 3.03% 11.84% 3.55% 11.84% 20.39%",
gridTemplateColumns:
"9.75% 4.23% 8.59% 7.30% 1.16% 7.30% 1.23% 7.16% 1.09% 7.30% 3.68% 4.23% 5.59% 6.07% 1.91% 6.07% 1.84% 6.21% 9.28%",
gridTemplateRows:
"21.55% 11.83% 3.55% 2.50% 9.46% 2.76% 0.66% 4.99% 6.83% 3.55% 1.97% 9.99% 4.20% 11.04% 5.12%",
}}
>
{page !== "sds" && (
<Image
src={MrtImage}
alt="MrtImage"
style={{
zIndex: 0,
height: "100%",
width: "100%",
gridArea: "1 / 1 / 13 / 13",
}}
/>
)}
{page === "sds" && (
<Image
src={MrtMessageImage}
alt="MrtImage-Message"
style={{
zIndex: 0,
height: "100%",
width: "100%",
gridArea: "1 / 1 / 13 / 13",
}}
/>
)}
<button
onClick={handleButton("home")}
style={{ gridArea: "2 / 4 / 3 / 5", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("1")}
style={{ gridArea: "2 / 5 / 3 / 6", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("2")}
style={{ gridArea: "2 / 7 / 3 / 7", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("3")}
style={{ gridArea: "2 / 9 / 3 / 10", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("4")}
style={{ gridArea: "4 / 5 / 6 / 6", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("5")}
style={{ gridArea: "4 / 7 / 6 / 7", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("6")}
style={{ gridArea: "4 / 9 / 6 / 10", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("7")}
style={{ gridArea: "8 / 5 / 9 / 6", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("8")}
style={{ gridArea: "8 / 7 / 9 / 7", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("9")}
style={{ gridArea: "8 / 9 / 9 / 10", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("0")}
style={{ gridArea: "10 / 7 / 11 / 8", ...MRT_BUTTON_STYLES }}
/>
{lines[0] && (
<DisplayLine
{...lines[0]}
style={
page === "sds"
? {
gridArea: "2 / 3 / 3 / 4",
marginLeft: "9px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[0]?.style,
}
: {
gridArea: "4 / 3 / 5 / 4",
marginLeft: "9px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[0]?.style,
}
}
/>
)}
{lines[1] && (
<DisplayLine
lineStyle={{
overflowX: "hidden",
maxHeight: "100%",
overflowY: "auto",
}}
{...lines[1]}
style={
page === "sds"
? {
gridArea: "4 / 2 / 10 / 4",
marginLeft: "3px",
...MRT_DISPLAYLINE_STYLES,
...lines[1].style,
}
: {
gridArea: "5 / 3 / 7 / 4",
marginLeft: "3px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[1].style,
}
}
/>
)}
{lines[2] && (
<DisplayLine
{...lines[2]}
style={{
gridArea: "8 / 2 / 9 / 4",
...MRT_DISPLAYLINE_STYLES,
...lines[2]?.style,
}}
/>
)}
{lines[3] && (
<DisplayLine
{...lines[3]}
style={{
gridArea: "9 / 2 / 10 / 4",
marginRight: "10px",
...MRT_DISPLAYLINE_STYLES,
...lines[3]?.style,
}}
/>
)}
<MrtPopups />
<MrtDisplay />
<MrtButtons />
<MrtBase />
</div>
);
};

View File

@@ -0,0 +1,150 @@
import { CSSProperties, useRef } from "react";
import { useButtons } from "./useButtons";
const MRT_BUTTON_STYLES: CSSProperties = {
cursor: "pointer",
zIndex: "9999",
backgroundColor: "transparent",
border: "none",
};
interface MrtButtonProps {
onClick: () => void;
onHold?: () => void;
style: CSSProperties;
}
const MrtButton = ({ onClick, onHold, style }: MrtButtonProps) => {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseDown = () => {
if (!onHold) return;
timeoutRef.current = setTimeout(handleTimeoutExpired, 500);
};
const handleTimeoutExpired = () => {
timeoutRef.current = null;
if (onHold) {
onHold();
}
};
const handleMouseUp = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
onClick();
}
};
return (
<button
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={() => timeoutRef.current && clearTimeout(timeoutRef.current)}
style={style}
/>
);
};
export const MrtButtons = () => {
const { handleHold, handleKlick } = useButtons();
return (
<>
{/* BELOW DISPLAY */}
<MrtButton
onClick={handleKlick("arrow-left")}
onHold={handleHold("arrow-left")}
style={{ gridArea: "14 / 4 / 15 / 5", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("arrow-down")}
onHold={handleHold("arrow-down")}
style={{ gridArea: "14 / 6 / 15 / 7", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("arrow-up")}
onHold={handleHold("arrow-up")}
style={{ gridArea: "14 / 8 / 15 / 9", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("arrow-right")}
onHold={handleHold("arrow-right")}
style={{ gridArea: "14 / 10 / 15 / 11", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("wheel-knob")}
onHold={handleHold("wheel-knob")}
style={{ gridArea: "14 / 2 / 15 / 4", ...MRT_BUTTON_STYLES }}
/>
{/* LINE SELECT KEY */}
<MrtButton
onHold={handleHold("3r")}
onClick={handleKlick("3r")}
style={{ gridArea: "9 / 12 / 11 / 13", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("3l")}
onClick={handleKlick("3l")}
style={{ gridArea: "9 / 2 / 11 / 3", ...MRT_BUTTON_STYLES }}
/>
{/* NUM PAD */}
<MrtButton
onHold={handleHold("1")}
onClick={handleKlick("1")}
style={{ gridArea: "2 / 14 / 3 / 15", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("2")}
onClick={handleKlick("2")}
style={{ gridArea: "2 / 16 / 3 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("3")}
onClick={handleKlick("3")}
style={{ gridArea: "2 / 18 / 3 / 19", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("4")}
onClick={handleKlick("4")}
style={{ gridArea: "4 / 14 / 6 / 15", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("5")}
onClick={handleKlick("5")}
style={{ gridArea: "4 / 16 / 6 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("6")}
onClick={handleKlick("6")}
style={{ gridArea: "4 / 18 / 6 / 19", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("7")}
onClick={handleKlick("7")}
style={{ gridArea: "8 / 14 / 10 / 15", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("8")}
onClick={handleKlick("8")}
style={{ gridArea: "8 / 16 / 10 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("9")}
onClick={handleKlick("9")}
style={{ gridArea: "8 / 18 / 10 / 19", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("0")}
onClick={handleKlick("0")}
style={{ gridArea: "11 / 16 / 13 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("end-call")}
onClick={handleKlick("end-call")}
style={{ gridArea: "13 / 16 / 15 / 17", ...MRT_BUTTON_STYLES }}
/>
</>
);
};

View File

@@ -0,0 +1,16 @@
.transition-image {
clip-path: inset(0 0 100% 0);
}
.transition-image.animate-reveal {
animation: revealFromTop 0.6s linear forwards;
}
@keyframes revealFromTop {
from {
clip-path: inset(0 0 100% 0);
}
to {
clip-path: inset(0 0 0 0);
}
}

View File

@@ -0,0 +1,270 @@
"use client";
import { SetPageParams, useMrtStore } from "_store/pilot/MrtStore";
import Image, { StaticImageData } from "next/image";
import PAGE_HOME from "./images/PAGE_Home.png";
import PAGE_HOME_NO_GROUP from "./images/PAGE_Home_no_group.png";
import PAGE_Call from "./images/PAGE_Call.png";
import PAGE_Off from "./images/PAGE_Off.png";
import PAGE_STARTUP from "./images/PAGE_Startup.png";
import { useEffect, useRef, useState } from "react";
import { cn, useDebounce } from "@repo/shared-components";
import "./MrtDisplay.css";
import { useSession } from "next-auth/react";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { fmsStatusDescriptionShort } from "_data/fmsStatusDescription";
import { useAudioStore } from "_store/audioStore";
import { ROOMS } from "_data/livekitRooms";
export const MrtDisplay = () => {
const { page, setPage, popup, setPopup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const callEstablishedRef = useRef(false);
const session = useSession();
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
const { room, speakingParticipants, isTalking, state } = useAudioStore((state) => state);
const [pageImage, setPageImage] = useState<{
src: StaticImageData;
name: SetPageParams["page"];
}>({
src: PAGE_Off,
name: "off",
});
const [nextImage, setNextImage] = useState<
| {
src: StaticImageData;
name: SetPageParams["page"];
}
| undefined
>(undefined);
useDebounce(
() => {
if (!nextImage) return;
setPageImage(nextImage);
setNextImage(undefined);
},
1000,
[nextImage],
);
useEffect(() => {
if ((speakingParticipants.length > 0 || isTalking) && page === "home") {
setPage({
page: "voice-call",
});
}
}, [speakingParticipants, isTalking, page, setPage]);
useDebounce(
() => {
if (page === "startup") {
setPage({
page: "home",
});
}
},
6000,
[page, setPage],
);
useDebounce(
() => {
if (page === "startup") {
setPopup({
popup: "login",
});
}
},
7500,
[page, setPage],
);
useDebounce(
() => {
if (page === "voice-call" && speakingParticipants.length === 0 && !isTalking) {
setPage({
page: "home",
});
}
},
4000,
[page, setPage, speakingParticipants, isTalking],
);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
if (page === "voice-call") {
setStringifiedData({
callTextHeader: "Wählen",
});
timeouts.push(
setTimeout(() => {
setStringifiedData({
callTextHeader: "Anruf...",
});
}, 500),
setTimeout(() => {
setStringifiedData({
callTextHeader: "Gruppenruf",
});
}, 800),
setTimeout(() => {
callEstablishedRef.current = true;
}, 1500),
);
}
return () => {
timeouts.forEach((t) => clearTimeout(t));
};
}, [page, setStringifiedData]);
useDebounce(
() => {
if (isTalking && page === "voice-call") {
setStringifiedData({
callTextHeader: "Sprechen",
});
}
},
1500,
[page, isTalking],
);
useEffect(() => {
if (isTalking && page === "voice-call" && callEstablishedRef.current) {
console.log("SET TO SPRECHEN", stringifiedData.callTextHeader);
setStringifiedData({
callTextHeader: "Sprechen",
});
} else if (
!isTalking &&
page === "voice-call" &&
stringifiedData.callTextHeader === "Sprechen"
) {
setStringifiedData({
callTextHeader: "Gruppenruf",
});
}
}, [page, stringifiedData.callTextHeader, isTalking, setStringifiedData]);
useEffect(() => {
if (page !== "voice-call") {
callEstablishedRef.current = false;
}
switch (page) {
case "home":
if (state == "connected") {
setNextImage({ src: PAGE_HOME, name: "home" });
} else {
setNextImage({ src: PAGE_HOME_NO_GROUP, name: "home" });
}
break;
case "voice-call":
setNextImage({ src: PAGE_Call, name: "voice-call" });
break;
case "off":
setNextImage({ src: PAGE_Off, name: "off" });
break;
case "startup":
setNextImage({ src: PAGE_STARTUP, name: "startup" });
break;
}
}, [page, state]);
const DisplayText = ({ pageName }: { pageName: SetPageParams["page"] }) => {
return (
<div
className={cn("font-semibold text-[#000d60]", !!popup && "filter")}
style={{
fontFamily: "Bahnschrift",
}}
>
{pageName == "startup" && (
<p className="absolute left-[17%] top-[65%] h-[10%] w-[39%] text-center">
Bediengerät #{session.data?.user?.publicId}
</p>
)}
{pageName == "home" && (
<>
<p className="absolute left-[24%] top-[21%] h-[4%] w-[1%] text-xs">
{(room?.numParticipants || 1) - 1}
</p>
<p
className={cn(
"absolute left-[17%] top-[25%] h-[8%] w-[39%] text-center",
!connectedAircraft && "text-red-600",
)}
>
{connectedAircraft && (
<>
Status {connectedAircraft.fmsStatus} -{" "}
{fmsStatusDescriptionShort[connectedAircraft?.fmsStatus || "0"]}
</>
)}
{!connectedAircraft && <>Keine Verbindung</>}
</p>
<p className="absolute left-[22.7%] top-[37.8%] flex h-[5%] w-[34%] items-center text-xs">
{state == "connected" ? room?.name : "Keine RG gewählt"}
</p>
<p className="absolute left-[28%] top-[44.5%] h-[8%] w-[34%] text-xs">
{state == "connected" && ROOMS.find((r) => r.name === room?.name)?.id}
</p>
</>
)}
{pageName == "voice-call" && (
<div>
<p className="absolute left-[18%] top-[18.8%] flex h-[10%] w-[37%] items-center">
{stringifiedData.callTextHeader}
</p>
<p className="absolute left-[18%] top-[35%] h-[8%] w-[38%]">
{isTalking && selectedStation?.bosCallsignShort}
{speakingParticipants.length > 0 &&
speakingParticipants.map((p) => p.attributes.role).join(", ")}
</p>
<p className="absolute left-[18%] top-[53.5%] h-[8%] w-[38%]">
{room?.name || "Keine RG gefunden"}
</p>
<p className="absolute left-[18%] top-[60%] h-[8%] w-[36.7%] text-right">
{ROOMS.find((r) => r.name === room?.name)?.id}
</p>
</div>
)}
</div>
);
};
return (
<>
<Image
src={pageImage.src}
alt=""
width={1000}
height={1000}
className={cn(popup && "brightness-75 filter", "z-10 col-span-full row-span-full")}
/>
{nextImage && (
<div
className={cn(
popup && "brightness-75 filter",
"transition-image animate-reveal relative z-20 col-span-full row-span-full",
)}
>
<Image src={nextImage.src} alt="" width={1000} height={1000} className="h-full w-full" />
<DisplayText pageName={nextImage.name} />
</div>
)}
<div
className={cn(
popup && "brightness-75 filter",
"relative z-10 col-span-full row-span-full overflow-hidden",
)}
>
<DisplayText pageName={pageImage.name} />
</div>
</>
);
};

View File

@@ -0,0 +1,152 @@
import { SetPopupParams, useMrtStore } from "_store/pilot/MrtStore";
import Image, { StaticImageData } from "next/image";
import { useEffect, useState } from "react";
import IAMGE_POPUP_LOGIN from "./images/POPUP_login.png";
import GROUP_SELECTION_POPUP_LOGIN from "./images/POPUP_group_selection.png";
import IAMGE_POPUP_SDS_RECEIVED from "./images/POPUP_SDS_incomming.png";
import IAMGE_POPUP_SDS_SENT from "./images/POPUP_SDS_sent.png";
import IAMGE_POPUP_STATUS_SENT from "./images/POPUP_Status_sent.png";
import { ROOMS } from "_data/livekitRooms";
import { cn, useDebounce } from "@repo/shared-components";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { fmsStatusDescription, fmsStatusDescriptionShort } from "_data/fmsStatusDescription";
import { pilotSocket } from "(app)/pilot/socket";
import { StationStatus } from "@repo/db";
import { useSounds } from "./useSounds";
import { useButtons } from "./useButtons";
import { useAudioStore } from "_store/audioStore";
export const MrtPopups = () => {
const { sdsReceivedSoundRef } = useSounds();
const { handleKlick } = useButtons();
const { selectedRoom } = useAudioStore();
const { popup, page, setPopup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const [popupImage, setPopupImage] = useState<StaticImageData | null>(null);
useEffect(() => {
switch (popup) {
case "status-sent":
setPopupImage(IAMGE_POPUP_STATUS_SENT);
break;
case "sds-sent":
setPopupImage(IAMGE_POPUP_SDS_SENT);
break;
case "sds-received":
setPopupImage(IAMGE_POPUP_SDS_RECEIVED);
break;
case "login":
setPopupImage(IAMGE_POPUP_LOGIN);
break;
case "group-selection":
setPopupImage(GROUP_SELECTION_POPUP_LOGIN);
break;
case undefined:
case null:
setPopupImage(null);
break;
}
}, [popup]);
useDebounce(
() => {
if (popup == "login") return;
if (popup == "sds-received") return;
if (popup == "group-selection") return;
setPopup(null);
},
3000,
[popup],
);
useDebounce(
() => {
if (popup == "group-selection") {
if (selectedRoom?.id === stringifiedData.groupSelectionGroupId) {
setPopup(null);
} else {
handleKlick("3l")();
}
}
},
5000,
[page, stringifiedData.groupSelectionGroupId, selectedRoom],
);
useEffect(() => {
if (status === "connecting" && page !== "off" && page !== "startup") {
setPopup({ popup: "login" });
}
}, [status, setPopup, page]);
useDebounce(
() => {
if (status === "connected") {
setPopup(null);
}
},
5000,
[status],
);
useEffect(() => {
pilotSocket.on("sds-status", (data: StationStatus) => {
setStringifiedData({ sdsText: data.status + " - " + fmsStatusDescriptionShort[data.status] });
setPopup({ popup: "sds-received" });
if (sdsReceivedSoundRef.current) {
sdsReceivedSoundRef.current.currentTime = 0;
sdsReceivedSoundRef.current.play();
}
});
}, [setPopup, setStringifiedData, sdsReceivedSoundRef]);
if (!popupImage || !popup) return null;
const DisplayText = ({ pageName }: { pageName: SetPopupParams["popup"] }) => {
const group = ROOMS.find((r) => r.id === stringifiedData.groupSelectionGroupId);
return (
<div
className={cn("font-semibold text-[#000d60]", !!popup && "filter")}
style={{
fontFamily: "Bahnschrift",
}}
>
{pageName == "status-sent" && (
<p className="absolute left-[17.5%] top-[44%] h-[10%] w-[39%] text-lg">
{fmsStatusDescription[connectedAircraft?.fmsStatus || "0"]}
</p>
)}
{pageName == "sds-sent" && (
<p className="absolute left-[17.5%] top-[44%] h-[10%] w-[39%] text-lg">
{fmsStatusDescription[stringifiedData.sentSdsText || "0"]}
</p>
)}
{pageName == "sds-received" && (
<p className="absolute left-[17.5%] top-[39%] h-[24%] w-[60%] whitespace-normal break-words">
{stringifiedData.sdsText}
</p>
)}
{pageName == "group-selection" && (
<>
<p className="absolute left-[24%] top-[39%] h-[5%] w-[30%]">{group?.name}</p>
<p className="absolute left-[24%] top-[50%] flex h-[9%] w-[31%] items-end justify-end">
{stringifiedData.groupSelectionGroupId}
</p>
</>
)}
</div>
);
};
return (
<>
<Image src={popupImage} alt="" className="z-30 col-span-full row-span-full" />
<div className="relative z-30 col-span-full row-span-full overflow-hidden">
<DisplayText pageName={popup} />
</div>
</>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -1,15 +1,58 @@
import { Prisma } from "@repo/db";
import { getPublicUser, MissionSdsStatusLog, Prisma } from "@repo/db";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import { pilotSocket } from "(app)/pilot/socket";
import { editConnectedAircraftAPI } from "_querys/aircrafts";
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSounds } from "./useSounds";
import { sendSdsStatusMessageAPI } from "_querys/missions";
import { useSession } from "next-auth/react";
import { ROOMS } from "_data/livekitRooms";
import { useAudioStore } from "_store/audioStore";
type ButtonTypes =
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "0"
| "home"
| "3l"
| "3r"
| "wheel-knob"
| "arrow-up"
| "arrow-down"
| "arrow-left"
| "arrow-right"
| "end-call";
export const useButtons = () => {
const station = usePilotConnectionStore((state) => state.selectedStation);
const connectedAircraft = usePilotConnectionStore((state) => state.connectedAircraft);
const connectionStatus = usePilotConnectionStore((state) => state.status);
const session = useSession();
const { connect, setSelectedRoom, selectedRoom } = useAudioStore((state) => state);
const { longBtnPressSoundRef, statusSentSoundRef } = useSounds();
const queryClient = useQueryClient();
const {
status: pilotState,
selectedStation,
connectedAircraft,
} = usePilotConnectionStore((state) => state);
const sendSdsStatusMutation = useMutation({
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
if (!connectedAircraft?.id) throw new Error("No connected aircraft");
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
const updateAircraftMutation = useMutation({
mutationKey: ["edit-pilot-connected-aircraft"],
mutationFn: ({
@@ -21,56 +64,161 @@ export const useButtons = () => {
}) => editConnectedAircraftAPI(aircraftId, data),
});
const { setPage } = useMrtStore((state) => state);
const { setPage, setPopup, page, popup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const handleButton =
(button: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0" | "home") => () => {
if (connectionStatus !== "connected") return;
if (!station) return;
if (!connectedAircraft?.id) return;
if (
button === "1" ||
button === "2" ||
button === "3" ||
button === "4" ||
button === "5" ||
button === "6" ||
button === "7" ||
button === "8" ||
button === "9" ||
button === "0"
) {
setPage({ page: "sending-status", station });
const role =
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId;
setTimeout(async () => {
await updateAircraftMutation.mutateAsync({
aircraftId: connectedAircraft.id,
data: {
fmsStatus: button,
},
});
setPage({
page: "home",
station,
const handleHold = (button: ButtonTypes) => async () => {
/* if (connectionStatus !== "connected") return; */
if (button === "end-call") {
setPage({ page: "off" });
setPopup(null);
}
if (button === "1" && page === "off") {
setPage({ page: "startup" });
return;
}
if (!selectedStation) return;
if (!session.data?.user) return;
if (!connectedAircraft?.id) return;
if (
button === "1" ||
button === "2" ||
button === "3" ||
button === "4" ||
button === "6" ||
button === "7" ||
button === "8"
) {
longBtnPressSoundRef.current?.play();
const delay = Math.random() * 1500 + 500;
setTimeout(async () => {
await updateAircraftMutation.mutateAsync({
aircraftId: connectedAircraft.id,
data: {
fmsStatus: button,
});
}, 1000);
} else {
setPage({ page: "home", fmsStatus: connectedAircraft.fmsStatus || "6", station });
}
};
},
});
setPopup({ popup: "status-sent" });
statusSentSoundRef.current?.play();
}, delay);
} else if (button === "5" || button === "9" || button === "0") {
longBtnPressSoundRef.current?.play();
const delay = Math.random() * 1500 + 500;
setTimeout(async () => {
await sendSdsStatusMutation.mutateAsync({
sdsMessage: {
type: "sds-status-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
direction: "to-lst",
stationId: selectedStation.id,
station: selectedStation,
user: getPublicUser(session.data?.user),
status: button,
},
},
});
setStringifiedData({ sentSdsText: button });
statusSentSoundRef.current?.play();
setPopup({ popup: "sds-sent" });
}, delay);
}
};
const handleKlick = (button: ButtonTypes) => async () => {
console.log("Button clicked:", button);
//implement Kurzwahl when button is clicked short to dial
switch (button) {
case "0":
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
case "9":
//handle short press number buttons for kurzwahl
if (popup === "group-selection") {
if (stringifiedData.groupSelectionGroupId?.length === 4) {
setStringifiedData({ groupSelectionGroupId: button });
} else {
setStringifiedData({
groupSelectionGroupId: (stringifiedData.groupSelectionGroupId || "") + button,
});
}
}
if (page === "home" && !popup) {
setPopup({ popup: "group-selection" });
setStringifiedData({ groupSelectionGroupId: button });
}
break;
case "3r":
if (popup === "sds-received" || popup === "group-selection") {
setPopup(null);
} else if (page === "home") {
setPopup({ popup: "group-selection" });
setStringifiedData({ groupSelectionGroupId: selectedRoom?.id || ROOMS[0]!.id });
} else if (page === "voice-call") {
setPage({ page: "home" });
}
break;
case "wheel-knob":
setPopup(popup === "group-selection" ? null : { popup: "group-selection" });
setStringifiedData({ groupSelectionGroupId: selectedRoom?.id || ROOMS[0]!.id });
break;
case "arrow-right":
if (popup === "group-selection") {
let currentGroupIndex = ROOMS.findIndex(
(r) => r.id === stringifiedData.groupSelectionGroupId,
);
if (currentGroupIndex === ROOMS.length - 1) currentGroupIndex = -1;
const nextGroup = ROOMS[currentGroupIndex + 1];
if (nextGroup) {
setStringifiedData({ groupSelectionGroupId: nextGroup.id });
}
}
break;
case "arrow-left":
if (popup === "group-selection") {
let currentGroupIndex = ROOMS.findIndex(
(r) => r.id === stringifiedData.groupSelectionGroupId,
);
if (currentGroupIndex === 0) currentGroupIndex = ROOMS.length;
const previousGroup = ROOMS[currentGroupIndex - 1];
if (previousGroup) {
setStringifiedData({ groupSelectionGroupId: previousGroup.id });
}
}
break;
case "3l":
if (popup === "group-selection") {
const group = ROOMS.find((r) => r.id === stringifiedData.groupSelectionGroupId);
if (group && role) {
setSelectedRoom(group);
connect(group, role);
setPopup(null);
}
}
}
return false;
};
useEffect(() => {
pilotSocket.on("connect", () => {
if (!station) return;
setPage({ page: "home", fmsStatus: "6", station });
const { page } = useMrtStore.getState();
if (!selectedStation || page !== "off") return;
setPage({ page: "startup" });
});
}, [setPage, selectedStation, setPopup]);
pilotSocket.on("aircraft-update", () => {
if (!station) return;
setPage({ page: "new-status", station });
});
}, [setPage, station]);
return { handleButton };
return { handleKlick, handleHold };
};

View File

@@ -1,52 +1,39 @@
"use client";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import { useAudioStore } from "_store/audioStore";
import { RoomEvent } from "livekit-client";
import { useEffect, useRef } from "react";
export const useSounds = () => {
const mrtState = useMrtStore((state) => state);
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
const setPage = useMrtStore((state) => state.setPage);
const MRTstatusSoundRef = useRef<HTMLAudioElement>(null);
const MrtMessageReceivedSoundRef = useRef<HTMLAudioElement>(null);
const { room } = useAudioStore((state) => state);
const longBtnPressSoundRef = useRef<HTMLAudioElement>(null);
const statusSentSoundRef = useRef<HTMLAudioElement>(null);
const sdsReceivedSoundRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (typeof window !== "undefined") {
MRTstatusSoundRef.current = new Audio("/sounds/MRT-status.mp3");
MrtMessageReceivedSoundRef.current = new Audio("/sounds/MRT-message-received.mp3");
MRTstatusSoundRef.current.onended = () => {
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
setPage({
page: "home",
station: selectedStation,
fmsStatus: connectedAircraft?.fmsStatus,
});
};
MrtMessageReceivedSoundRef.current.onended = () => {
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
if (mrtState.page === "sds") return;
setPage({
page: "home",
station: selectedStation,
fmsStatus: connectedAircraft?.fmsStatus,
});
};
longBtnPressSoundRef.current = new Audio("/sounds/1504.wav");
statusSentSoundRef.current = new Audio("/sounds/403.wav");
sdsReceivedSoundRef.current = new Audio("/sounds/775.wav");
}
}, [connectedAircraft?.fmsStatus, selectedStation, setPage, mrtState.page]);
const fmsStatus = connectedAircraft?.fmsStatus || "NaN";
}, []);
useEffect(() => {
if (!connectedAircraft) return;
if (mrtState.page === "new-status") {
if (fmsStatus === "J" || fmsStatus === "c") {
MrtMessageReceivedSoundRef.current?.play();
} else {
MRTstatusSoundRef.current?.play();
}
} else if (mrtState.page === "sds") {
MrtMessageReceivedSoundRef.current?.play();
}
}, [mrtState, fmsStatus, connectedAircraft, selectedStation]);
const handleRoomConnected = () => {
// Play a sound when connected to the room
// connectedSound.play();
statusSentSoundRef.current?.play();
console.log("Room connected - played sound");
};
room?.on(RoomEvent.Connected, handleRoomConnected);
return () => {
room?.off(RoomEvent.Connected, handleRoomConnected);
};
}, [room]);
return {
longBtnPressSoundRef,
statusSentSoundRef,
sdsReceivedSoundRef,
};
};

View File

@@ -4,12 +4,12 @@ import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations";
import toast from "react-hot-toast";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { Prisma } from "@repo/db";
import { 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);
@@ -23,12 +23,24 @@ export const ConnectionBtn = () => {
selectedStationId: null,
debugPosition: false,
});
const [logoffDebounce, setLogoffDebounce] = useState<NodeJS.Timeout | null>(null);
const { data: stations } = useQuery({
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,
@@ -53,48 +65,21 @@ export const ConnectionBtn = () => {
return () => {
connection.disconnect();
};
}, [connection, connection.disconnect]);
const [logoffHours, logoffMinutes] = form.logoffTime?.split(":").map(Number) || [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection.disconnect]);
const { data: connectedAircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
});
useEffect(() => {
if (!logoffHours || !logoffMinutes || !connection.connectedAircraft) return;
if (logoffDebounce) clearTimeout(logoffDebounce);
const timeout = setTimeout(async () => {
if (!connection.connectedAircraft?.id) return;
await aircraftMutation.mutateAsync({
sessionId: connection.connectedAircraft.id,
change: {
esimatedLogoutTime:
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
},
});
modalRef.current?.close();
toast.success("Änderung gespeichert!");
}, 2000);
setLogoffDebounce(timeout);
// Cleanup function to clear timeout
return () => {
if (logoffDebounce) clearTimeout(logoffDebounce);
};
}, [logoffHours, logoffMinutes, connection.connectedAircraft, aircraftMutation, logoffDebounce]);
const session = useSession();
const uid = session.data?.user?.id;
if (!uid) return null;
return (
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && (
<span className="mx-2 text-error">{connection.message}</span>
<span className="text-error mx-2">{connection.message}</span>
)}
{connection.status == "connected" ? (
@@ -124,12 +109,12 @@ export const ConnectionBtn = () => {
<dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center">
{connection.status == "connected" ? (
<h3 className="text-lg font-bold mb-5">
<h3 className="mb-5 text-lg font-bold">
Verbunden als{" "}
<span className="text-info">&lt;{connection.selectedStation?.bosCallsign}&gt;</span>
</h3>
) : (
<h3 className="text-lg font-bold mb-5">Als Pilot anmelden</h3>
<h3 className="mb-5 text-lg font-bold">Als Pilot anmelden</h3>
)}
{connection.status !== "connected" && (
<div className="w-full">
@@ -146,25 +131,44 @@ 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>
)}
<fieldset className="fieldset w-full mt-2">
<fieldset className="fieldset mt-4 w-full">
<label className="floating-label w-full text-base">
<span>Logoff Zeit (LCL)</span>
<input
@@ -185,58 +189,89 @@ export const ConnectionBtn = () => {
)}
</fieldset>
{session.data?.user.permissions.includes("ADMIN_STATION") && (
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-full border p-4">
<legend className="fieldset-legend">Debug-optionen</legend>
<label className="label">
<input
checked={form.debugPosition}
onChange={(e) => setForm({ ...form, debugPosition: e.target.checked })}
type="checkbox"
className="checkbox"
/>
Zufalls Position für 2h anzeigen
</label>
</fieldset>
)}
<div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between">
{session.data?.user.permissions.includes("ADMIN_STATION") &&
connection.status === "disconnected" && (
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-full border p-4">
<legend className="fieldset-legend">Debug-optionen</legend>
<label className="label">
<input
checked={form.debugPosition}
onChange={(e) => setForm({ ...form, debugPosition: e.target.checked })}
type="checkbox"
className="checkbox"
/>
Zufalls Position für 2h anzeigen
</label>
</fieldset>
)}
<div className="modal-action flex w-full justify-between">
<form method="dialog" className="flex w-full justify-between">
<button className="btn btn-soft">Zurück</button>
{connection.status == "connected" ? (
<button
className="btn btn-soft btn-error"
type="submit"
onSubmit={() => false}
onClick={() => {
connection.disconnect();
}}
>
Verbindung Trennen
</button>
<>
<Button
className="btn"
onClick={async () => {
if (!connection.connectedAircraft) return;
const [logoffHours, logoffMinutes] =
form.logoffTime?.split(":").map(Number) || [];
await aircraftMutation.mutateAsync({
sessionId: connection.connectedAircraft.id,
change: {
esimatedLogoutTime:
logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes)
: null,
},
});
modalRef.current?.close();
}}
>
Logoff-Zeit speichern
</Button>
<button
className="btn btn-soft btn-error"
type="submit"
onSubmit={() => false}
onClick={() => {
connection.disconnect();
modalRef.current?.close();
}}
>
Verbindung Trennen
</button>
</>
) : (
<button
<Button
type="submit"
onSubmit={() => false}
onClick={() => {
onClick={async () => {
const selectedStation = stations?.find(
(station) =>
station.id === parseInt(form.selectedStationId?.toString() || ""),
);
if (selectedStation) {
connection.connect(
const [logoffHours, logoffMinutes] =
form.logoffTime?.split(":").map(Number) || [];
await connection.connect(
uid,
form.selectedStationId?.toString() || "",
form.logoffTime || "",
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
: "",
selectedStation,
session.data!.user,
form.debugPosition,
);
}
modalRef.current?.close();
}}
className="btn btn-soft btn-info"
>
{connection.status == "disconnected" ? "Verbinden" : connection.status}
</button>
</Button>
)}
</form>
</div>

View File

@@ -1,41 +0,0 @@
import { Connection } from "./_components/Connection";
import { Audio } from "_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Settings } from "_components/navbar/Settings";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import { WarningAlert } from "_components/navbar/PageAlert";
export default function Navbar() {
return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
<ModeSwitchDropdown />
<WarningAlert />
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />
</div>
<div className="flex items-center">
<Connection />
</div>
<div className="flex items-center">
<Settings />
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" />
</button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,21 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react";
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({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
@@ -19,43 +24,68 @@ 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(() => {
if (typeof window !== "undefined") {
testSoundRef.current = new Audio("/sounds/Melder3.wav");
testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
}
}, []);
const modalRef = useRef<HTMLDialogElement>(null);
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string | null>(
user?.settingsMicDevice || null,
);
const [showIndication, setShowIndication] = useState<boolean>(false);
const [micVol, setMicVol] = useState<number>(1);
const [funkVolume, setFunkVol] = useState<number>(0.8);
const [dmeVolume, setDmeVol] = useState<number>(0.8);
const setMic = useAudioStore((state) => state.setMic);
const [settings, setSettings] = useState({
micDeviceId: user?.settingsMicDevice || null,
micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8,
dmeVolume: user?.settingsDmeVolume || 0.8,
pilotNtfyRoom: user?.settingsNtfyRoom || "",
});
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
useEffect(() => {
if (user) {
setSelectedDevice(user.settingsMicDevice);
setMic(user.settingsMicDevice, user.settingsMicVolume || 1);
setMicVol(user.settingsMicVolume || 1);
setFunkVol(user.settingsRadioVolume || 0.8);
setDmeVol(user.settingsDmeVolume || 0.8);
}
}, [user, setMic]);
useEffect(() => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
navigator.mediaDevices.enumerateDevices().then((devices) => {
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
setAudioSettings({
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
dmeVolume: user.settingsDmeVolume || 0.8,
});
setSettings({
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
dmeVolume: user.settingsDmeVolume || 0.8,
pilotNtfyRoom: user.settingsNtfyRoom || "",
});
}
}, [user, setSettings, setAudioSettings]);
const setSettingsPartial = (newSettings: Partial<typeof settings>) => {
setSettings((prev) => ({
...prev,
...newSettings,
}));
};
useEffect(() => {
const setDevices = async () => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
}
};
setDevices();
}, []);
return (
@@ -67,23 +97,23 @@ export const SettingsBtn = () => {
modalRef.current?.showModal();
}}
>
<GearIcon className="w-5 h-5" />
<GearIcon className="h-5 w-5" />
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box">
<h3 className="flex items-center gap-2 text-lg font-bold mb-5">
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset w-full mb-2">
<fieldset className="fieldset mb-2 w-full">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
className="input w-full"
value={selectedDevice ? selectedDevice : ""}
value={settings.micDeviceId ? settings.micDeviceId : ""}
onChange={(e) => {
setSelectedDevice(e.target.value);
setSettingsPartial({ micDeviceId: e.target.value });
setShowIndication(true);
}}
>
@@ -98,7 +128,7 @@ export const SettingsBtn = () => {
</select>
</label>
</fieldset>
<p className="flex items-center gap-2 text-base mb-2 justify-start w-full">
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
<Volume2 size={20} /> Eingabelautstärke
</p>
<div className="w-full">
@@ -109,13 +139,13 @@ export const SettingsBtn = () => {
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setMicVol(value);
setSettingsPartial({ micVolume: value });
setShowIndication(true);
}}
value={micVol}
value={settings.micVolume}
className="range range-xs range-accent w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
@@ -124,14 +154,17 @@ export const SettingsBtn = () => {
</div>
</div>
{showIndication && (
<MicVolumeBar deviceId={selectedDevice ? selectedDevice : ""} volumeInput={micVol} />
<MicVolumeBar
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
volumeInput={settings.micVolume}
/>
)}
<div className="divider w-full" />
</div>
<p className="flex items-center gap-2 text-base mb-2">
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Funk Lautstärke
</p>
<div className="w-full mb-2">
<div className="mb-2 w-full">
<input
type="range"
min={0}
@@ -139,12 +172,12 @@ export const SettingsBtn = () => {
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setFunkVol(value);
setSettingsPartial({ radioVolume: value });
}}
value={funkVolume}
value={settings.radioVolume}
className="range range-xs range-primary w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
@@ -152,10 +185,8 @@ export const SettingsBtn = () => {
<span>100%</span>
</div>
</div>
<div className="flex justify-center w-full">
<div className="divider w-1/2" />
</div>
<p className="flex items-center gap-2 text-base mb-2">
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Melder Lautstärke
</p>
<div className="w-full">
@@ -166,15 +197,15 @@ export const SettingsBtn = () => {
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setDmeVol(value);
setSettingsPartial({ dmeVolume: value });
if (!testSoundRef.current) return;
testSoundRef.current.volume = value;
testSoundRef.current.play();
}}
value={dmeVolume}
value={settings.dmeVolume}
className="range range-xs range-primary w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
@@ -182,8 +213,36 @@ export const SettingsBtn = () => {
<span>100%</span>
</div>
</div>
<div className="flex w-full justify-center">
<div className="divider w-full" />
</div>
<div className="w-full">
<label className="floating-label w-full">
<span className="flex items-center gap-2 text-lg">
<Bell /> NTFY room
</span>
<input
placeholder="Erhalte eine Benachrichtigung auf dein Handy über NTFY"
className="input input-bordered w-full"
value={settings.pilotNtfyRoom}
onChange={(e) => setSettingsPartial({ pilotNtfyRoom: e.target.value })}
/>
</label>
<div className="flex justify-between modal-action">
<p className="label mt-2 w-full">
<Link
href="https://docs.virtualairrescue.com/pilotenbereich/app-alarmierung.html#download"
target="_blank"
rel="noopener noreferrer"
className="link link-hover link-primary"
>
Hier
</Link>
findest du mehr Informationen!
</p>
</div>
<div className="modal-action flex justify-between">
<button
className="btn btn-soft"
type="submit"
@@ -195,7 +254,7 @@ export const SettingsBtn = () => {
>
Schließen
</button>
<button
<Button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
@@ -204,26 +263,31 @@ export const SettingsBtn = () => {
await editUserMutation.mutateAsync({
id: session.data!.user.id,
user: {
settingsMicDevice: selectedDevice,
settingsMicVolume: micVol,
settingsRadioVolume: funkVolume,
settingsDmeVolume: dmeVolume,
settingsMicDevice: settings.micDeviceId,
settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume,
settingsDmeVolume: settings.dmeVolume,
settingsNtfyRoom: settings.pilotNtfyRoom,
},
});
setMic(selectedDevice, micVol);
setAudioSettings({
micDeviceId: settings.micDeviceId,
micVolume: settings.micVolume,
radioVolume: settings.radioVolume,
dmeVolume: settings.dmeVolume,
});
modalRef.current?.close();
toast.success("Einstellungen gespeichert");
}}
>
Speichern
</button>
</Button>
</div>
</div>
</dialog>
</div>
);
};
export const Settings = () => {
return (
<div>

View File

@@ -1,26 +0,0 @@
"use client";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
interface ThemeSwapProps {
isDark: boolean;
toggleTheme: () => void;
}
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
isDark,
toggleTheme,
}) => {
return (
<label className="swap swap-rotate">
<input
type="checkbox"
className="theme-controller"
checked={isDark}
onChange={toggleTheme}
/>
<MoonIcon className="swap-off h-5 w-5 fill-current" />
<SunIcon className="swap-on h-5 w-5 fill-current" />
</label>
);
};

View File

@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "_components/Error";
import Navbar from "(app)/_components/Navbar";
import { Audio } from "_components/Audio/Audio";
import { Connection } from "./_components/navbar/Connection";
import { Settings } from "./_components/navbar/Settings";
export const metadata: Metadata = {
title: "VAR: Pilot",
@@ -16,11 +19,21 @@ export default async function RootLayout({
const session = await getServerSession();
if (!session?.user.permissions.includes("PILOT"))
return <Error title="Zugriff verweigert" statusCode={403} />;
return (
<Error
title=" Fehlende Berechtigung"
description="Du hast nicht die erforderlichen Berechtigungen, dich als Pilot anzumelden. Du kannst in HUB Kurse abschließen um die Berechtigung zu erhalten."
statusCode={403}
/>
);
return (
<>
<Navbar />
<Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
{children}
</>
);

View File

@@ -6,11 +6,17 @@ 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 { getAircraftsAPI } from "_querys/aircrafts";
import { checkSimulatorConnected } from "@repo/shared-components";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
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,
@@ -18,50 +24,119 @@ const Map = dynamic(() => import("_components/map/Map"), {
const PilotPage = () => {
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const { data: ownAircraftArray = [] } = useQuery({
queryKey: ["own-aircraft", connectedAircraft?.id],
queryFn: () =>
getAircraftsAPI({
id: connectedAircraft?.id,
}),
refetchInterval: 10000,
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 ownAircraft = ownAircraftArray[0];
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;
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full h-screen overflow-hidden">
<div className="ease relative flex h-screen w-full flex-1 overflow-hidden transition-all duration-500">
{/* <MapToastCard2 /> */}
<div className="flex flex-1 relative w-full h-full">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
<div className="relative flex h-full w-full flex-1">
<div className="absolute left-0 top-1/2 z-20 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
<Chat />
<div className="mt-2">
<Report />
</div>
<Report />
<BugReport />
</div>
<div className="flex w-2/3 h-full">
<div className="relative flex flex-1 h-full">
<div className="flex h-full w-2/3">
<div className="relative flex h-full flex-1">
<div className="top-19/20 absolute left-0 z-20 -translate-y-1/2 transform pl-4">
<div className="flex items-center justify-between gap-4">
<SettingsBoard />
</div>
</div>
<Map />
<div className="absolute top-5 right-10 z-99999 space-y-2">
{!simulatorConnected && status === "connected" && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
<div className="absolute right-10 top-5 z-20 space-y-2">
{!simulatorConnected &&
status === "connected" &&
connectedAircraft &&
!shortlyConnected && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
<ConnectedDispatcher />
</div>
</div>
</div>
<div className="flex w-1/3 h-full">
<div className="flex flex-col w-full h-full p-4 bg-base-300">
<h2 className="card-title mb-2">MRT & DME</h2>
<div className="card bg-base-200 shadow-xl mb-4">
<div className="card-body w-full h-full flex items-center justify-center">
<div className=" max-w-150">
<div className="flex h-full w-1/3 min-w-[500px]">
<div className="bg-base-300 flex h-full w-full flex-col p-4">
<div className="flex justify-between">
<div className="mb-2 flex items-center justify-end gap-2">
<h2 className="card-title">MRT & DME</h2>
<a
href="https://docs.virtualairrescue.com/allgemein/var-systeme/leitstelle/pilot.html"
target="_blank"
rel="noopener noreferrer"
className="link text-xs text-gray-500 hover:underline"
>
Hilfe
</a>
</div>
<div
className="tooltip tooltip-left mb-4"
data-tip="Dadurch wird der Einsatz erneut an den Desktop-Client gesendet."
>
<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">
<Mrt />
</div>
</div>
</div>
<div className="card bg-base-200 shadow-xl h-1/2 flex">
<div className="card-body w-full h-full p-4 mb-0 flex items-center justify-center">
<div className=" max-w-140">
<div className="card bg-base-200 flex h-1/2 shadow-xl">
<div className="card-body mb-0 flex h-full w-full items-center justify-center p-4">
<div className="max-w-140">
<Dme />
</div>
</div>

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

@@ -24,7 +24,9 @@ import { useSounds } from "_components/Audio/useSounds";
export const Audio = () => {
const {
selectedRoom,
speakingParticipants,
resetSpeakingParticipants,
isTalking,
toggleTalking,
transmitBlocked,
@@ -36,8 +38,9 @@ export const Audio = () => {
room,
message,
removeMessage,
setSelectedRoom,
} = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useSounds({
isReceiving: speakingParticipants.length > 0,
isTransmitting: isTalking,
@@ -46,7 +49,7 @@ export const Audio = () => {
});
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state);
const { status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession();
const [isReceivingBlick, setIsReceivingBlick] = useState(false);
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
@@ -91,7 +94,7 @@ export const Audio = () => {
const canStopOtherSpeakers = dispatcherState === "connected";
const role =
(dispatcherState === "connected" && selectedZone) ||
(dispatcherState === "connected" && "VAR LST") ||
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId;
@@ -104,7 +107,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 +126,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 +136,7 @@ export const Audio = () => {
const payload = JSON.stringify({
by: role,
});
resetSpeakingParticipants("dich");
speakingParticipants.forEach(async (p) => {
await room?.localParticipant.performRpc({
destinationIdentity: p.identity,
@@ -159,54 +163,54 @@ 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>
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
{ROOMS.map((r) => (
<li key={r}>
<li key={r.id}>
<button
className="btn btn-sm btn-ghost 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;
if (selectedRoom?.name === r.name) return;
setSelectedRoom(r);
connect(r, role);
}}
>
{room?.name === r && (
<Disc className="text-success text-sm absolute left-2" width={15} />
{room?.name === r.name && (
<Disc className="text-success absolute left-2 text-sm" width={15} />
)}
<span className="flex-1 text-center">{r}</span>
<span className="flex-1 text-center">{r.name}</span>
</button>
</li>
))}
<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

@@ -47,27 +47,28 @@ export default function MicrophoneLevel({ deviceId, volumeInput }: MicrophoneLev
};
}, [deviceId, volumeInput]);
const barWidth = Math.max((volumeLevel / 70) * 100 - 35, 0);
const barWidth = Math.min((volumeLevel / 140) * 100, 100);
return (
<div className="w-full">
<div className="relative w-full bg-base-300 h-5 rounded">
<div
className={cn("bg-primary h-full rounded", barWidth > 100 && "bg-red-400")}
className={cn("bg-primary h-full rounded", barWidth == 100 && "bg-red-400")}
style={{
width: `${barWidth > 100 ? 100 : barWidth}%`,
width: `${barWidth}%`,
transition: "width 0.2s",
}}
/>
<div
className="absolute top-0 left-[60%] w-[20%] h-full bg-green-500 opacity-40 rounded"
className="absolute top-0 left-[60%] w-[30%] h-full bg-green-500 opacity-40 rounded"
style={{
transform: "translateX(-50%)",
}}
/>
</div>
<p className="text-gray-500 text-sm">
Lautstärke sollte beim Sprechen in dem Grünen bereich bleiben
Lautstärke sollte beim Sprechen in dem Grünen bereich bleiben. Beachte das scharfe Laute
(z.B. "S" oder "Z") die Anzeige verfälschen können.
</p>
</div>
);

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";
@@ -11,9 +11,11 @@ import { useMapStore } from "_store/mapStore";
import { AdminMessageToast } from "_components/customToasts/AdminMessage";
import { pilotSocket } from "(app)/pilot/socket";
import { QUICK_RESPONSE, StatusToast } from "_components/customToasts/StationStatusToast";
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(
() =>
@@ -21,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",
});
},
@@ -29,6 +31,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
},
}),
);
useEffect(() => {
notificationSound.current = new Audio("/sounds/notification.mp3");
}, []);
useEffect(() => {
const invalidateMission = () => {
queryClient.invalidateQueries({
@@ -58,8 +63,19 @@ export function QueryProvider({ children }: { children: ReactNode }) {
};
const handleNotification = (notification: NotificationPayload) => {
console.log("Received notification:", notification);
const playNotificationSound = () => {
if (notificationSound.current) {
notificationSound.current.currentTime = 0;
notificationSound.current
.play()
.catch((e) => console.error("Notification sound error:", e));
}
};
switch (notification.type) {
case "hpg-validation":
playNotificationSound();
toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{
@@ -69,15 +85,30 @@ export function QueryProvider({ children }: { children: ReactNode }) {
break;
case "admin-message":
playNotificationSound();
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999,
});
break;
case "station-status":
console.log("station Status", QUICK_RESPONSE[notification.status]);
if (!QUICK_RESPONSE[notification.status]) return;
toast.custom((e) => <StatusToast event={notification} t={e} />, {
duration: 60000,
});
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

@@ -139,10 +139,10 @@ export const SmartPopup = (
<Popup {...props} className={cn("relative", wrapperClassName)}>
<div
className={cn(
"pointer-events-auto bg-base-100 relative",
"bg-base-100 pointer-events-auto relative",
anchor.includes("right") && "-translate-x-full",
anchor.includes("bottom") && "-translate-y-full",
!showContent && "opacity-0 pointer-events-none",
!showContent && "pointer-events-none opacity-0",
className,
)}
>
@@ -150,7 +150,7 @@ export const SmartPopup = (
data-id={id}
id={`popup-domain-${id}`}
className={cn(
"map-collision absolute w-[200%] h-[200%] top-0 left-0 transform pointer-events-none",
"map-collision pointer-events-none absolute left-0 top-0 h-[200%] w-[200%] transform",
anchor.includes("left") && "-translate-x-1/2",
anchor.includes("top") && "-translate-y-1/2",
)}

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

@@ -15,10 +15,19 @@ export const HPGnotificationToast = ({
}) => {
const handleClick = () => {
toast.dismiss(t.id);
mapStore.setOpenMissionMarker({
open: [{ id: event.data.mission.id, tab: "home" }],
close: [],
});
if (mapStore.userSettings.settingsAutoCloseMapPopup) {
mapStore.setOpenMissionMarker({
open: [{ id: event.data.mission.id, tab: "home" }],
close: mapStore.openMissionMarker?.map((m) => m.id) || [],
});
} else {
mapStore.setOpenMissionMarker({
open: [{ id: event.data.mission.id, tab: "home" }],
close: [],
});
}
mapStore.setMap({
center: [event.data.mission.addressLat, event.data.mission.addressLng],
zoom: 14,
@@ -29,7 +38,7 @@ export const HPGnotificationToast = ({
return (
<BaseNotification icon={<Cross />} className="flex flex-row">
<div className="flex-1">
<h1 className="text-red-500 font-bold">HPG validierung fehlgeschlagen</h1>
<h1 className="font-bold text-red-500">HPG validierung fehlgeschlagen</h1>
<p>{event.message}</p>
</div>
<div className="ml-11">
@@ -43,7 +52,7 @@ export const HPGnotificationToast = ({
return (
<BaseNotification icon={<Check />} className="flex flex-row">
<div className="flex-1">
<h1 className="text-green-600 font-bold">HPG validierung erfolgreich</h1>
<h1 className="font-bold text-green-600">HPG validierung erfolgreich</h1>
<p className="text-sm">{event.message}</p>
</div>
<div className="ml-11">

View File

@@ -0,0 +1,100 @@
import { getPublicUser, MissionAutoClose, Prisma } from "@repo/db";
import { JsonValueType } from "@repo/db/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BaseNotification } from "_components/customToasts/BaseNotification";
import { editMissionAPI } from "_querys/missions";
import { MapStore } from "_store/mapStore";
import { Clock, X } from "lucide-react";
import { useSession } from "next-auth/react";
import toast, { Toast } from "react-hot-toast";
export const MissionAutoCloseToast = ({
event,
t,
mapStore,
}: {
event: MissionAutoClose;
t: Toast;
mapStore: MapStore;
}) => {
const { data: session } = useSession();
const queryClient = useQueryClient();
const editMissionMutation = useMutation({
mutationFn: ({ id, mission }: { id: number; mission: Partial<Prisma.MissionUpdateInput> }) =>
editMissionAPI(id, mission),
mutationKey: ["missions"],
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
return (
<BaseNotification icon={<Clock />} className="flex flex-row">
<div className="flex-1">
<h1 className="text-warning font-bold">Inaktiver Einsatz wurde automatisch geschlossen</h1>
<p>{event.message}</p>
</div>
<div className="ml-11">
<button
className="btn"
onClick={async () => {
if (!session?.user) return;
const mission = await editMissionMutation.mutateAsync({
id: event.data.missionId,
mission: {
state: "running",
missionLog: {
push: {
type: "reopened-log",
timeStamp: new Date().toISOString(),
data: {
user: getPublicUser(session?.user, {
ignorePrivacy: true,
}) as unknown as JsonValueType,
},
},
},
},
});
mapStore.setMap({
zoom: 14,
center: {
lat: mission.addressLat,
lng: mission.addressLng,
},
});
if (mapStore.userSettings.settingsAutoCloseMapPopup) {
mapStore.setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: mapStore.openMissionMarker?.map((m) => m.id) || [],
});
} else {
mapStore.setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
});
}
toast.dismiss(t.id);
}}
>
schließen widerrufen
</button>
<button className="btn btn-ghost btn-sm" onClick={() => toast.remove(t.id)}>
<X size={16} />
</button>
</div>
</BaseNotification>
);
};

View File

@@ -1,12 +1,16 @@
import { Prisma, StationStatus } from "@repo/db";
import { getPublicUser, MissionSdsStatusLog, StationStatus } from "@repo/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BaseNotification } from "_components/customToasts/BaseNotification";
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getLivekitRooms } from "_querys/livekit";
import { sendSdsStatusMessageAPI } from "_querys/missions";
import { getStationsAPI } from "_querys/stations";
import { useAudioStore } from "_store/audioStore";
import { useMapStore } from "_store/mapStore";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { Toast, toast } from "react-hot-toast";
export const QUICK_RESPONSE: Record<string, string[]> = {
@@ -20,6 +24,25 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const status5Sounds = useRef<HTMLAudioElement | null>(null);
const status9Sounds = useRef<HTMLAudioElement | null>(null);
const session = useSession();
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
refetchInterval: 5000,
});
const audioRoom = useAudioStore((s) => s.room?.name);
const participants =
livekitRooms?.flatMap((room) =>
room.participants.map((p) => ({
...p,
roomName: room.room.name,
})),
) || [];
const livekitUser = participants.find((p) => p.attributes.userId === event.data?.userId);
useEffect(() => {
if (typeof window !== "undefined") {
status0Sounds.current = new Audio("/sounds/status-0.mp3");
@@ -27,8 +50,9 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
status9Sounds.current = new Audio("/sounds/status-9.mp3");
}
}, []);
const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
const mapStore = useMapStore((s) => s);
//const mapStore = useMapStore((s) => s);
const { setOpenAircraftMarker, setMap } = useMapStore((store) => store);
const { data: connectedAircrafts } = useQuery({
queryKey: ["aircrafts"],
@@ -45,28 +69,16 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const station = stations?.find((s) => s.id === event.data?.stationId);
const queryClient = useQueryClient();
const changeAircraftMutation = useMutation({
mutationFn: async ({
id,
update,
}: {
id: number;
update: Prisma.ConnectedAircraftUpdateInput;
}) => {
await editConnectedAircraftAPI(id, update);
const sendSdsStatusMutation = useMutation({
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
if (!connectedAircraft?.id) throw new Error("No connected aircraft");
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
queryClient.invalidateQueries({
queryKey: ["aircrafts"],
queryKey: ["missions"],
});
},
});
useEffect(() => {
if (event.status !== connectedAircraft?.fmsStatus && aircraftDataAcurate) {
toast.remove(t.id);
} else if (event.status == connectedAircraft?.fmsStatus && !aircraftDataAcurate) {
setAircraftDataAccurate(true);
}
}, [aircraftDataAcurate, connectedAircraft, event.status, t.id]);
console.log("Audio Room:", audioRoom, participants, livekitUser, event);
useEffect(() => {
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
@@ -83,6 +95,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
default:
soundRef = null;
}
if (audioRoom && livekitUser?.roomName && audioRoom !== livekitUser?.roomName) {
toast.remove(t.id);
return;
}
if (soundRef?.current) {
soundRef.current.currentTime = 0;
soundRef.current.volume = 0.7;
@@ -94,22 +112,23 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
soundRef.current.currentTime = 0;
}
};
}, [event.status]);
}, [event.status, livekitUser?.roomName, audioRoom, t.id]);
if (!connectedAircraft || !station) return null;
if (!connectedAircraft || !station || !session.data) return null;
return (
<BaseNotification>
<div className="flex flex-row gap-14 items-center">
<div className="flex flex-row items-center gap-14">
<p>
<span
className="underline mr-1 cursor-pointer font-bold"
className="mr-1 cursor-pointer font-bold underline"
onClick={() => {
if (!connectedAircraft.posLat || !connectedAircraft.posLng) return;
mapStore.setOpenAircraftMarker({
setOpenAircraftMarker({
open: [{ id: connectedAircraft.id, tab: "fms" }],
close: [],
});
mapStore.setMap({
setMap({
center: [connectedAircraft.posLat, connectedAircraft.posLng],
zoom: 14,
});
@@ -119,12 +138,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
</span>
sendet Status {event.status}
</p>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
{QUICK_RESPONSE[String(event.status)]?.map((status) => (
<button
key={status}
className={
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold"
"flex min-h-10 min-w-10 cursor-pointer items-center justify-center text-lg font-bold"
}
style={{
backgroundColor: FMS_STATUS_COLORS[status],
@@ -136,10 +155,18 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
toast.error("Keine Flugzeug-ID gefunden");
return;
}
await changeAircraftMutation.mutateAsync({
id: event.data?.aircraftId,
update: {
fmsStatus: status,
await sendSdsStatusMutation.mutateAsync({
sdsMessage: {
type: "sds-status-log",
auto: false,
data: {
direction: "to-aircraft",
stationId: event.data.stationId!,
station: station,
user: getPublicUser(session.data?.user),
status,
},
timeStamp: new Date().toISOString(),
},
});
toast.remove(t.id);

View File

@@ -0,0 +1,16 @@
import { Bug } from "lucide-react";
export const BugReport = () => {
return (
<div className="indicator">
<a
className="btn btn-soft btn-sm btn-warning tooltip tooltip-right"
data-tip="Fehler melden"
href="https://discord.com/channels/1077269395019141140/1395892524404576367"
target="_blank"
>
<Bug className="h-4 w-4" />
</a>
</div>
);
};

View File

@@ -2,15 +2,19 @@
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react";
import { Fragment, useEffect, useState } from "react";
import { Fragment, useEffect, useState, useRef } from "react";
import { cn } from "@repo/shared-components";
import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
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,
@@ -26,6 +30,10 @@ export const Chat = () => {
const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>("default");
const [message, setMessage] = useState<string>("");
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
const [someChat, setSomeChat] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"],
@@ -36,6 +44,7 @@ export const Chat = () => {
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
enabled: dispatcherConnected,
});
useEffect(() => {
@@ -43,18 +52,51 @@ 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 filteredAircrafts = aircrafts?.filter((a) => a.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 && !chats[a.userId],
);
const btnActive = pilotConnected || dispatcherConnected;
useEffect(() => {
if (!filteredDispatcher?.length && !filteredAircrafts?.length) {
setAddTabValue("default");
}
}, [filteredDispatcher, filteredAircrafts]);
useEffect(() => {
if (!btnActive) {
setChatOpen(false);
}
}, [btnActive, setChatOpen]);
useEffect(() => {
if (Object.values(chats).some((c) => c.notification)) {
setSomeChat(true);
if (audioRef.current) {
audioRef.current.volume = 0.5;
audioRef.current.play().catch(() => {});
}
} else {
setSomeChat(false);
}
}, [chats]);
return (
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
<audio ref={audioRef} src="/sounds/newChat.mp3" preload="auto" />
<div className="indicator">
{Object.values(chats).some((c) => c.notification) && (
<span className="indicator-item status status-info"></span>
)}
<button
className="btn btn-soft btn-sm btn-primary"
className={cn(
"btn btn-soft btn-sm cursor-default",
btnActive && "btn-primary cursor-pointer",
someChat && "border-warning animate-pulse",
)}
onClick={() => {
if (!btnActive) return;
setReportTabOpen(false);
setChatOpen(!chatOpen);
if (selectedChat) {
@@ -62,23 +104,23 @@ export const Chat = () => {
}
}}
>
<ChatBubbleIcon className="w-4 h-4" />
<ChatBubbleIcon className="h-4 w-4" />
</button>
</div>
{chatOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100] max-h-[480px] ml-2 border-1 border-primary"
className="dropdown-content card bg-base-200 w-150 border-1 border-primary z-[1100] ml-2 max-h-[480px] shadow-md"
>
<div className="card-body relative">
<button
className="absolute top-2 right-2 btn btn-xs btn-circle btn-ghost"
className="btn btn-xs btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setChatOpen(false)}
type="button"
>
<span className="text-xl leading-none">&times;</span>
</button>
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
<ChatBubbleIcon /> Chat
</h2>
<div className="join">
@@ -114,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>
@@ -132,14 +178,20 @@ export const Chat = () => {
if (!chat) return null;
return (
<Fragment key={userId}>
<a
<div
className={cn("indicator tab", selectedChat === userId && "tab-active")}
onClick={() => setSelectedChat(userId)}
onClick={() => {
if (selectedChat === userId) {
setSelectedChat(null);
return;
}
setSelectedChat(userId);
}}
>
{chat.name}
{chat.notification && <span className="indicator-item status status-info" />}
</a>
<div className="tab-content bg-base-100 border-base-300 p-6 overflow-y-auto max-h-[250px]">
</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) => {
const isSender = chatMessage.senderId === session.data?.user.id;
@@ -170,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

@@ -10,6 +10,8 @@ import { useQuery } from "@tanstack/react-query";
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { sendReportAPI } from "_querys/report";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
export const Report = () => {
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore();
@@ -18,6 +20,9 @@ export const Report = () => {
const [selectedPlayer, setSelectedPlayer] = useState<string>("default");
const [message, setMessage] = useState<string>("");
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
useEffect(() => {
if (!session.data?.user.id) return;
setOwnId(session.data.user.id);
@@ -36,6 +41,13 @@ export const Report = () => {
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
const btnActive = pilotConnected || dispatcherConnected;
useEffect(() => {
if (!btnActive) {
setReportTabOpen(false);
}
}, [btnActive, setReportTabOpen]);
return (
<div
@@ -43,8 +55,12 @@ export const Report = () => {
>
<div className="indicator">
<button
className="btn btn-soft btn-sm btn-error"
className={cn(
"btn btn-soft btn-sm cursor-default",
btnActive && "cursor-pointer btn-error",
)}
onClick={() => {
if (!btnActive) return;
setChatOpen(false);
setReportTabOpen(!reportTabOpen);
}}

View File

@@ -0,0 +1,104 @@
"use client";
import { useLeftMenuStore } from "_store/leftMenuStore";
import { cn } from "@repo/shared-components";
import { SettingsIcon } from "lucide-react";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
export const SettingsBoard = () => {
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
const { followOwnAircraft, showOtherAircrafts, showOtherMissions, setMapOptions } =
usePilotConnectionStore();
const cross = (
<svg
aria-label="disabled"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
const check = (
<svg aria-label="enabled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth="4"
fill="none"
stroke="currentColor"
>
<path d="M20 6 9 17l-5-5"></path>
</g>
</svg>
);
return (
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
<div className="indicator">
<button
className="btn btn-soft btn-sm btn-info"
onClick={() => {
setSituationTabOpen(!situationTabOpen);
}}
>
<SettingsIcon size={18} />
</button>
</div>
{situationTabOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 shadow-md z-[1100] ml-2 border-1 border-info min-w-[300px] max-h-[300px]"
>
<div className="card-body flex flex-row gap-4">
<div className="flex flex-col w-full h-full gap-2">
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<SettingsIcon size={18} /> Map Einstellungen
</h2>
<div className="flex items-center gap-2">
<label className="toggle text-base-content">
<input
type="checkbox"
checked={followOwnAircraft}
onChange={(e) => setMapOptions({ followOwnAircraft: e.target.checked })}
/>
{cross}
{check}
</label>
Folge mir selbst
</div>
<div className="flex items-center gap-2">
<label className="toggle text-base-content">
<input
type="checkbox"
checked={showOtherAircrafts}
onChange={(e) => setMapOptions({ showOtherAircrafts: e.target.checked })}
/>
{cross}
{check}
</label>
Zeige andere Piloten
</div>
<div className="flex items-center gap-2">
<label className="toggle text-base-content">
<input
type="checkbox"
checked={showOtherMissions}
onChange={(e) => setMapOptions({ showOtherMissions: e.target.checked })}
/>
{cross}
{check}
</label>
Zeige andere Einsätze
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -9,6 +9,7 @@ import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
export const SituationBoard = () => {
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
@@ -53,7 +54,14 @@ export const SituationBoard = () => {
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
});
const { setOpenAircraftMarker, setOpenMissionMarker, setMap } = useMapStore((state) => state);
const {
setOpenAircraftMarker,
setOpenMissionMarker,
setMap,
userSettings,
openAircraftMarker,
openMissionMarker,
} = useMapStore((state) => state);
return (
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
@@ -64,17 +72,17 @@ export const SituationBoard = () => {
setSituationTabOpen(!situationTabOpen);
}}
>
<ListCollapse className="w-4 h-4" />
<ListCollapse className="h-4 w-4" />
</button>
</div>
{situationTabOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 shadow-md z-[1100] ml-2 border-1 border-info"
className="dropdown-content card bg-base-200 border-1 border-info z-[1100] ml-2 max-h-[300px] min-w-[900px] shadow-md"
>
<div className="card-body flex flex-row gap-4">
<div className="flex-1">
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
<ListCollapse /> Einsatzliste{" "}
</h2>
<div>
@@ -90,8 +98,8 @@ export const SituationBoard = () => {
</label>
</div>
</div>
<div className="overflow-x-auto">
<table className="table table-xs">
<div className="max-h-[170px] select-none overflow-x-auto overflow-y-auto">
<table className="table-xs table">
{/* head */}
<thead>
<tr>
@@ -106,16 +114,32 @@ export const SituationBoard = () => {
(mission) =>
(dispatcherConnected || mission.state !== "draft") && (
<tr
className={cn(
"cursor-pointer",
mission.state === "draft" && "missionListItem",
)}
onDoubleClick={() => {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
});
if (userSettings.settingsAutoCloseMapPopup) {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: openMissionMarker?.map((m) => m.id) || [],
});
} else {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
});
}
setMap({
center: {
lat: mission.addressLat,
@@ -125,9 +149,8 @@ export const SituationBoard = () => {
});
}}
key={mission.id}
className={cn(mission.state === "draft" && "missionListItem")}
>
<td>{mission.publicId}</td>
<td>{mission.publicId.replace("ENr.: ", "")}</td>
<td>{mission.missionKeywordAbbreviation}</td>
<td>{mission.addressCity}</td>
<td>
@@ -142,13 +165,13 @@ export const SituationBoard = () => {
</table>
</div>
</div>
<div className="w-px bg-gray-400 mx-2" />
<div className="mx-2 w-px bg-gray-400" />
<div className="flex-1">
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
<Plane /> Stationen
</h2>
<div className="overflow-x-auto">
<table className="table table-xs">
<div className="max-h-[200px] select-none overflow-x-auto overflow-y-auto">
<table className="table-xs table">
<thead>
<tr>
<th>BOS Name</th>
@@ -157,40 +180,59 @@ export const SituationBoard = () => {
</tr>
</thead>
<tbody>
{connectedAircrafts?.map((station) => (
{connectedAircrafts?.map((aircraft) => (
<tr
key={station.id}
className="cursor-pointer"
key={aircraft.id}
onDoubleClick={() => {
setOpenAircraftMarker({
open: [
{
id: station.id,
tab: "home",
},
],
close: [],
});
if (station.posLat === null || station.posLng === null) return;
if (userSettings.settingsAutoCloseMapPopup) {
setOpenAircraftMarker({
open: [
{
id: aircraft.id,
tab: "home",
},
],
close: openAircraftMarker?.map((m) => m.id) || [],
});
} else {
setOpenAircraftMarker({
open: [
{
id: aircraft.id,
tab: "home",
},
],
close: [],
});
}
if (aircraft.posLat === null || aircraft.posLng === null) return;
setMap({
center: {
lat: station.posLat,
lng: station.posLng,
lat: aircraft.posLat,
lng: aircraft.posLng,
},
zoom: 14,
});
}}
>
<td>{station.Station.bosCallsignShort}</td>
<td>{aircraft.Station.bosCallsignShort}</td>
<td
className="text-center font-lg font-semibold"
className="font-lg text-center font-semibold"
style={{
color: FMS_STATUS_TEXT_COLORS[station.fmsStatus],
backgroundColor: FMS_STATUS_COLORS[station.fmsStatus],
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
}}
>
{station.fmsStatus}
{aircraft.fmsStatus}
</td>
<td className="whitespace-nowrap">
{aircraft.posLng || !aircraft.posLat ? (
<>{findLeitstelleForPosition(aircraft.posLng!, aircraft.posLat!)}</>
) : (
aircraft.Station.bosRadioArea
)}
</td>
<td className="whitespace-nowrap">{station.Station.bosRadioArea}</td>
</tr>
))}
</tbody>

View File

@@ -16,6 +16,7 @@ import { useQuery } from "@tanstack/react-query";
import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getMissionsAPI } from "_querys/missions";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
const AircraftPopupContent = ({
aircraft,
@@ -36,7 +37,7 @@ const AircraftPopupContent = ({
);
const { data: missions } = useQuery({
queryKey: ["missions", "missions-map"],
queryKey: ["missions", "missions-aircraft-marker", aircraft.id],
queryFn: () =>
getMissionsAPI({
state: "running",
@@ -60,8 +61,8 @@ const AircraftPopupContent = ({
return mission ? (
<MissionTab mission={mission} />
) : (
<div className="flex flex-col items-center justify-center min-h-full">
<span className="text-gray-500 my-10 font-semibold">Kein aktiver Einsatz</span>
<div className="flex min-h-full flex-col items-center justify-center">
<span className="my-10 font-semibold text-gray-500">Kein aktiver Einsatz</span>
</div>
);
case "chat":
@@ -76,7 +77,7 @@ const AircraftPopupContent = ({
return (
<>
<div
className="absolute p-1 z-99 top-0 right-0 transform -translate-y-full bg-base-100 cursor-pointer"
className="z-99 bg-base-100 absolute right-0 top-0 -translate-y-full transform cursor-pointer p-1"
onClick={() => {
setOpenAircraftMarker({
open: [],
@@ -89,7 +90,7 @@ const AircraftPopupContent = ({
<div
className={cn(
"absolute w-[calc(100%+2px)] h-4 z-99", // As offset is 2px, we need to add 2px to the width
"z-99 absolute h-4 w-[calc(100%+2px)]", // As offset is 2px, we need to add 2px to the width
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
)}
@@ -110,13 +111,13 @@ const AircraftPopupContent = ({
/>
<div>
<div
className="flex gap-[2px] text-white pb-0.5 overflow-auto"
className="flex gap-[2px] pb-0.5 text-white"
style={{
backgroundColor: `${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`,
}}
>
<div
className="px-3 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center px-3"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -129,7 +130,7 @@ const AircraftPopupContent = ({
<House className="text-sm" />
</div>
<div
className="px-4 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center px-4"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
}}
@@ -144,7 +145,7 @@ const AircraftPopupContent = ({
<ChevronsRightLeft className="text-sm" />
</div>
<div
className="flex justify-center items-center text-5xl font-bold px-6 cursor-pointer"
className="flex cursor-pointer items-center justify-center px-6 text-5xl font-bold"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -158,7 +159,7 @@ const AircraftPopupContent = ({
{aircraft.fmsStatus}
</div>
<div
className="cursor-pointer px-2 min-w-[130px]"
className="cursor-pointer px-2"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -168,8 +169,8 @@ const AircraftPopupContent = ({
}}
onClick={() => handleTabChange("aircraft")}
>
<span className="text-white text-base font-medium truncate">
{aircraft.Station.bosCallsign.length > 20
<span className="truncate text-base font-medium text-white">
{aircraft.Station.bosCallsign.length > 15
? aircraft.Station.bosCallsignShort
: aircraft.Station.bosCallsign}
</span>
@@ -178,10 +179,11 @@ const AircraftPopupContent = ({
{aircraft.Station.bosUse === "DUAL_USE" && "(dual use)"}
{aircraft.Station.bosUse === "PRIMARY" && "(primär)"}
{aircraft.Station.bosUse === "SECONDARY" && "(sekundär)"}
{aircraft.Station.bosUse === "SPECIAL_OPS" && "(Special OPS)"}
</span>
</div>
<div
className="w-150 cursor-pointer px-2"
className="flex-1 cursor-pointer overflow-x-hidden px-2"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -192,14 +194,15 @@ const AircraftPopupContent = ({
}}
onClick={() => handleTabChange("mission")}
>
<span className="text-white text-base font-medium">Einsatz</span>
<span className="text-base font-medium text-white">Einsatz</span>
<br />
<span className="text-white text-sm font-medium">
{mission?.publicId || "kein Einsatz"}
</span>
{!mission?.publicId && <span className="text-sm text-gray-400">Kein Einsatz</span>}
{mission?.publicId && (
<span className="text-sm font-medium text-white">{mission.publicId}</span>
)}
</div>
<div
className="px-4 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center px-4"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -226,7 +229,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
const markerRef = useRef<LMarker>(null);
const popupRef = useRef<LPopup>(null);
const { openAircraftMarker, setOpenAircraftMarker } = useMapStore((store) => store);
const { openAircraftMarker, setOpenAircraftMarker, userSettings } = useMapStore((store) => store);
const { data: positionLog } = useQuery({
queryKey: ["positionlog", aircraft.id],
queryFn: () =>
@@ -262,14 +265,16 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
return () => {
marker?.off("click", handleClick);
};
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker]);
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker, userSettings]);
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
"topleft",
);
const handleConflict = useCallback(() => {
const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker");
const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker", {
ignoreCluster: true,
});
setAnchor(newAnchor);
}, [aircraft.id]);
@@ -371,7 +376,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
closeOnClick={false}
autoPan={false}
wrapperClassName="relative"
className="w-[502px]"
className="w-[512px]"
>
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
<AircraftPopupContent aircraft={aircraft} />
@@ -392,14 +397,51 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
export const AircraftLayer = () => {
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryKey: ["connected-aircrafts", "map"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
refetchInterval: 15000,
});
const { setMap } = useMapStore((state) => state);
const map = useMap();
const {
connectedAircraft,
status: pilotConnectionStatus,
showOtherAircrafts,
followOwnAircraft,
} = usePilotConnectionStore((state) => state);
const filteredAircrafts = useMemo(() => {
if (!aircrafts) return [];
return aircrafts.filter((aircraft) => {
if (pilotConnectionStatus === "connected" && !showOtherAircrafts) {
return connectedAircraft?.stationId === aircraft.stationId;
}
return true;
});
}, [aircrafts, pilotConnectionStatus, connectedAircraft, showOtherAircrafts]);
const ownAircraft = useMemo(() => {
return aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
}, [aircrafts, connectedAircraft]);
useEffect(() => {
if (pilotConnectionStatus === "connected" && followOwnAircraft && ownAircraft) {
if (!ownAircraft.posLat || !ownAircraft.posLng) return;
setMap({
center: [ownAircraft.posLat, ownAircraft.posLng],
zoom: map.getZoom(),
});
}
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
console.debug("Hubschrauber auf Karte:", {
total: aircrafts?.length,
displayed: filteredAircrafts.length,
});
return (
<>
{aircrafts?.map((aircraft) => {
{filteredAircrafts?.map((aircraft) => {
return <AircraftMarker key={aircraft.id} aircraft={aircraft} />;
})}
</>

View File

@@ -1,5 +1,5 @@
"use client";
import { Control, Icon, LatLngExpression } from "leaflet";
import { Control, divIcon, Icon, LatLngExpression } from "leaflet";
import { useEffect, useRef, useState } from "react";
import {
LayerGroup,
@@ -20,10 +20,11 @@ import L from "leaflet";
import LEITSTELLENBERECHE from "./_geojson/Leitstellen.json";
import WINDFARMS from "./_geojson/Windfarms.json";
import { createCustomMarker } from "_components/map/_components/createCustomMarker";
import { Station } from "@repo/db";
import { Heliport, Station } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations";
import "./darkMapStyles.css";
import { getHeliportsAPI } from "_querys/heliports";
const RadioAreaLayer = () => {
const getColor = (randint: number) => {
@@ -67,6 +68,199 @@ const RadioAreaLayer = () => {
);
};
const HeliportsLayer = () => {
const { data: heliports } = useQuery({
queryKey: ["heliports"],
queryFn: () => getHeliportsAPI(),
});
const [heliportsWithIcon, setHeliportsWithIcon] = useState<(Heliport & { icon?: string })[]>([]);
const map = useMap();
const [isVisible, setIsVisible] = useState(true);
const [boxContent, setBoxContent] = useState<React.ReactNode>(null);
// Übergangslösung
const formatDate = (date: Date): string => {
const year = date.getFullYear().toString().slice(-2); // Letzte 2 Stellen des Jahres
const month = (date.getMonth() + 1).toString().padStart(2, "0"); // Monat (mit führender Null, falls notwendig)
const day = date.getDate().toString().padStart(2, "0"); // Tag (mit führender Null, falls notwendig)
return `${year}${month}${day}`;
};
const replaceWithYesterdayDate = (url: string): string => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 2); // Einen Tag zurücksetzen
const formattedDate = formatDate(yesterday);
return url.replace(/\.at\/lo\/\d{6}/, `.at/lo/${formattedDate}`);
};
const resetSelection = () => {
setBoxContent(null);
};
useMapEvent("click", () => {
resetSelection();
});
useEffect(() => {
const handleZoom = () => {
setIsVisible(map.getZoom() > 8);
};
handleZoom();
map.on("zoomend", handleZoom);
const fetchIcons = async () => {
if (!heliports) return;
const urls = await Promise.all(
heliports.map(async (heliport) => {
return createCustomMarker(heliport.type);
}),
);
setHeliportsWithIcon(
heliports.map((heliport, index) => ({ ...heliport, icon: urls[index] })),
);
};
const filterVisibleHeliports = () => {
const bounds = map?.getBounds();
if (!heliports?.length) return;
// Filtere die Heliports, die innerhalb der Kartenansicht liegen
const visibleHeliports = heliports.filter((heliport) => {
const coordinates: LatLngExpression = [heliport.lat, heliport.lng];
return bounds.contains(coordinates); // Überprüft, ob der Heliport innerhalb der aktuellen Bounds liegt
});
setHeliportsWithIcon(visibleHeliports);
};
if (heliports?.length) {
fetchIcons();
filterVisibleHeliports();
}
handleZoom();
map.on("zoomend", handleZoom);
map.on("moveend", filterVisibleHeliports);
return () => {
map.off("zoomend", handleZoom);
map.off("moveend", filterVisibleHeliports);
};
}, [map, heliports]);
const createCustomIcon = (heliportType: string) => {
if (heliportType === "POI") {
return divIcon({
className: "custom-marker no-pointer", // CSS-Klasse für Styling
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">H</span></div>',
iconSize: [15, 15], // Größe des Icons
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
});
}
// Heliport Typ: H-Icon
if (heliportType === "HELIPAD") {
return divIcon({
className: "custom-marker no-pointer", // CSS-Klasse für Styling
html: '<div style="width: 15px; height: 15px; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">H</span></div>',
iconSize: [15, 15], // Größe des Icons (15x15 px Viereck)
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
});
}
// Mountain Typ: Kreis mit "M"
if (heliportType === "MOUNTAIN") {
return divIcon({
className: "custom-marker no-pointer",
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">M</span></div>',
iconSize: [15, 15], // Größe des Icons
iconAnchor: [7.5, 15], // Ankerpunkt des Icons
});
}
// Falls kein Typ übereinstimmt, standardmäßig das POI-Icon mit Fragezeichen verwenden
return divIcon({
className: "custom-marker no-pointer",
html: '<div style="width: 15px; height: 15px; border-radius: 50%; background-color: white; border: 2px solid #7f7f7f; display: flex; align-items: center; justify-content: center;"><span style="font-size: 12px; color: #7f7f7f;">?</span></div>',
iconSize: [15, 15],
iconAnchor: [7.5, 15],
});
};
return (
<>
<FeatureGroup attribution="">
{isVisible &&
heliportsWithIcon.map((heliport) => {
const coordinates: LatLngExpression = [heliport.lat, heliport.lng];
const designatorLabel = heliport.designator.charAt(0).toUpperCase();
const heliportType = heliport.type;
return (
<Marker
key={heliport.id}
position={coordinates}
icon={createCustomIcon(heliportType)}
eventHandlers={{
mouseover: (e) => {
const tooltipContent = `${heliport.siteNameSub26} (${heliport.designator})`;
e.target
.bindTooltip(tooltipContent, {
direction: "top",
offset: [4, -15],
})
.openTooltip();
},
mouseout: (e) => {
e.target.closeTooltip();
},
click: () => {
setBoxContent(
<div>
<h4>{heliport.siteNameSub26}</h4>
<p>
<strong>Designator:</strong> {heliport.designator}
</p>
{heliport.info?.startsWith("http") ? (
<p>
<a
href={replaceWithYesterdayDate(heliport.info)}
target="_blank"
rel="noopener noreferrer"
>
{heliport.info}
</a>
</p>
) : (
<p>{heliport.info}</p>
)}
<p>
{heliport.lat} °N, {heliport.lng} °E
</p>
</div>,
);
},
}}
>
<Tooltip direction="top" sticky>
<div style={{ textAlign: "center" }}>
<strong>{heliport.designator}</strong>
<small style={{ fontWeight: "bold", fontSize: "0.7em" }}>
{` (${designatorLabel})`}
</small>
<br />
</div>
</Tooltip>
</Marker>
);
})}
</FeatureGroup>
{boxContent && <div className="modal-box">{boxContent}</div>}
</>
);
};
const StationsLayer = ({ attribution }: { attribution: Control.Attribution }) => {
const { data: stations } = useQuery({
queryKey: ["stations"],
@@ -260,6 +454,25 @@ const NiederschlagOverlay = () => {
);
};
const SlopesOverlay = () => {
const tileLayerRef = useRef<L.TileLayer.WMS | null>(null);
return (
<WMSTileLayer
ref={tileLayerRef}
eventHandlers={{
add: () => {
tileLayerRef.current?.bringToFront();
},
}}
attribution="Opensnowmap.org (CC-BY-SA)"
url="http://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png?"
transparent
zIndex={1000}
/>
);
};
const WindfarmOutlineLayer = () => {
const map = useMap();
const [isVisible, setIsVisible] = useState(false);
@@ -304,7 +517,7 @@ const WindfarmOutlineLayer = () => {
export const BaseMaps = () => {
const map = useMap();
return (
<LayersControl position="topleft">
<LayersControl position="bottomright">
<LayersControl.Overlay name={"Leitstellenbereiche"}>
<RadioAreaLayer />
</LayersControl.Overlay>
@@ -319,9 +532,15 @@ export const BaseMaps = () => {
<LayersControl.Overlay name={"LRZs"}>
<StationsLayer attribution={map.attributionControl} />
</LayersControl.Overlay>
<LayersControl.Overlay name={"Heliports"}>
<HeliportsLayer />
</LayersControl.Overlay>
<LayersControl.Overlay name={"OpenAIP"}>
<OpenAIP />
</LayersControl.Overlay>
<LayersControl.Overlay name={"Skigebiete"}>
<SlopesOverlay />
</LayersControl.Overlay>
<LayersControl.BaseLayer name="OpenStreetMap Dark" checked>
<TileLayer

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,
@@ -20,18 +27,22 @@ export const ContextMenu = () => {
setSearchPopup,
toggleSearchElementSelection,
} = useMapStore();
const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore(
(state) => state,
);
const [showRulerOptions, setShowRulerOptions] = useState(false);
const {
missionFormValues,
setMissionFormValues,
setOpen,
isOpen: isPannelOpen,
} = usePannelStore((state) => state);
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) => {
@@ -53,7 +64,8 @@ export const ContextMenu = () => {
if (!contextMenu || !dispatcherConnected) return null;
const einsatzBtnText = missionFormValues && isOpen ? "Position übernehmen" : "Einsatz erstellen";
const missionBtnText =
missionFormValues && isPannelOpen ? "Position übernehmen" : "Einsatz erstellen";
const addOSMobjects = async (ignorePreviosSelected?: boolean) => {
const res = await fetch(
@@ -101,14 +113,14 @@ export const ContextMenu = () => {
autoPan={false}
>
<div
className="absolute opacity-100 pointer-events-none p-3 flex items-center justify-center"
className="pointer-events-none absolute flex items-center justify-center p-3 opacity-100"
style={{ left: "-13px", top: "-13px" }}
>
<div className="relative w-38 h-38 flex items-center justify-center -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="w-38 h-38 pointer-events-none relative flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
{/* Top Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 top-0 pointer-events-auto opacity-80 tooltip tooltip-top tooltip-accent"
data-tip={einsatzBtnText}
className="btn btn-circle bg-rescuetrack tooltip tooltip-top tooltip-accent pointer-events-auto absolute left-1/2 top-0 h-10 w-10 opacity-80"
data-tip={missionBtnText}
style={{ transform: "translateX(-50%)" }}
onClick={async () => {
const { parsed } = await getOsmAddress(contextMenu.lat, contextMenu.lng);
@@ -131,27 +143,31 @@ export const ContextMenu = () => {
if (closestObject) {
toggleSearchElementSelection(closestObject.wayID, true);
}
map.setView([contextMenu.lat, contextMenu.lng], 18, {
animate: true,
});
if (isPannelOpen) {
map.setView([contextMenu.lat, contextMenu.lng], 18, {
animate: true,
});
}
}}
>
<MapPinned size={20} />
</button>
{/* Left Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 left-0 pointer-events-auto opacity-80"
className="btn btn-circle bg-rescuetrack pointer-events-auto absolute left-0 top-1/2 h-10 w-10 opacity-80"
style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)}
disabled
disabled={
!isPannelOpen ||
!xPlaneObjectsAvailable(missionFormValues?.missionStationIds, aircrafts)
}
>
<Ruler size={20} />
<Car size={20} />
</button>
{/* Bottom Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 bottom-0 pointer-events-auto opacity-80 tooltip tooltip-bottom tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-bottom tooltip-accent pointer-events-auto absolute bottom-0 left-1/2 h-10 w-10 opacity-80"
data-tip="Koordinaten kopieren"
style={{ transform: "translateX(-50%)" }}
onClick={async () => {
@@ -164,7 +180,7 @@ export const ContextMenu = () => {
</button>
{/* Right Button (original Search button) */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 right-0 pointer-events-auto opacity-80 tooltip tooltip-right tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-right tooltip-accent pointer-events-auto absolute right-0 top-1/2 h-10 w-10 opacity-80"
data-tip="Gebäude suchen"
style={{ transform: "translateY(-50%)" }}
onClick={async () => {
@@ -173,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="absolute flex flex-col items-center pointer-events-auto"
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 w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
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 w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
data-tip="Radius Messen"
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
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 w-10 h-10 opacity-80 tooltip tooltip-left tooltip-accent"
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

@@ -12,6 +12,7 @@ import { MarkerCluster } from "_components/map/_components/MarkerCluster";
import { useEffect, useRef } from "react";
import { Map as TMap } from "leaflet";
import { DistanceLayer } from "_components/map/Measurement";
import { MapAdditionals } from "_components/map/MapAdditionals";
const Map = () => {
const ref = useRef<TMap | null>(null);
@@ -36,7 +37,7 @@ const Map = () => {
return (
<MapContainer
ref={ref}
className="flex-1 bg-base-200"
className="bg-base-200 z-10 flex-1"
center={map.center}
zoom={map.zoom}
fadeAnimation={false}
@@ -48,6 +49,7 @@ const Map = () => {
<MissionLayer />
<AircraftLayer />
<DistanceLayer />
<MapAdditionals />
</MapContainer>
);
};

View File

@@ -0,0 +1,168 @@
"use client";
import { usePannelStore } from "_store/pannelStore";
import { Marker, useMap } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { XplaneObject } from "@repo/db";
import { useEffect, useState } from "react";
export const MapAdditionals = () => {
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: () =>
getMissionsAPI({
OR: [{ state: "draft" }, { state: "running" }],
}),
refetchInterval: 10_000,
});
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) =>
HPGValidationRequired(m.missionStationIds, aircrafts, m.hpgMissionString) &&
m.hpgValidationState === "POSITION_AMANDED" &&
m.state === "draft" &&
m.hpgLocationLat &&
dispatcherConnectionState === "connected" &&
m.hpgLocationLng &&
openMissionMarker.find((openMission) => openMission.id === m.id),
);
return (
<>
{missionFormValues?.addressLat && missionFormValues?.addressLng && isOpen && (
<Marker
position={[missionFormValues.addressLat, missionFormValues.addressLng]}
icon={L.icon({
iconUrl: "/icons/mapMarker.png",
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
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/mapMarkerAttention.png",
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
eventHandlers={{
click: () =>
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
}),
}}
/>
))}
</>
);
};

View File

@@ -17,12 +17,13 @@ import { getMissionsAPI } from "_querys/missions";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
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> = {
@@ -81,7 +82,7 @@ const MissionPopupContent = ({
return (
<>
<div
className="absolute p-1 z-99 top-0 right-0 transform -translate-y-full bg-base-100 cursor-pointer"
className="z-99 bg-base-100 absolute right-0 top-0 -translate-y-full transform cursor-pointer p-1"
onClick={() => {
setOpenMissionMarker({
open: [],
@@ -94,7 +95,7 @@ const MissionPopupContent = ({
<div
className={cn(
"absolute w-[calc(100%+2px)] h-4 z-99",
"z-99 absolute h-4 w-[calc(100%+2px)]",
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
)}
@@ -115,13 +116,13 @@ const MissionPopupContent = ({
/>
<div>
<div
className="flex gap-[2px] text-white pb-0.5"
className="flex gap-[2px] pb-0.5 text-white"
style={{
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`,
}}
>
<div
className="p-2 px-3 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center p-2 px-3"
style={{
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
borderBottom:
@@ -134,7 +135,7 @@ const MissionPopupContent = ({
<House className="text-sm" />
</div>
<div
className="p-2 px-4 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center p-2 px-4"
style={{
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
borderBottom:
@@ -147,7 +148,7 @@ const MissionPopupContent = ({
<Cross className="text-sm" />
</div>
<div
className="p-2 px-4 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center p-2 px-4"
style={{
backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
borderBottom:
@@ -161,7 +162,7 @@ const MissionPopupContent = ({
</div>
{mission.state === "draft" && (
<div
className="p-2 px-4 flex justify-center items-center cursor-pointer ml-auto"
className="ml-auto flex cursor-pointer items-center justify-center p-2 px-4"
style={{
backgroundColor: `${MISSION_STATUS_COLORS["attention"]}`,
borderBottom: "5px solid transparent",
@@ -172,9 +173,9 @@ const MissionPopupContent = ({
addressMissionDestination: mission.addressMissionDestination ?? undefined,
addressAdditionalInfo: mission.addressAdditionalInfo ?? undefined,
state: "draft",
hpgAmbulanceState: "NOT_REQUESTED",
hpgFireEngineState: "NOT_REQUESTED",
hpgPoliceState: "NOT_REQUESTED",
hpgAmbulanceState: mission.hpgAmbulanceState ?? "NOT_REQUESTED",
hpgFireEngineState: mission.hpgFireEngineState ?? "NOT_REQUESTED",
hpgPoliceState: mission.hpgPoliceState ?? "NOT_REQUESTED",
hpgLocationLat: mission.hpgLocationLat ?? undefined,
hpgLocationLng: mission.hpgLocationLng ?? undefined,
});
@@ -187,7 +188,7 @@ const MissionPopupContent = ({
)}
<div
className={cn(
"p-2 px-4 flex justify-center items-center cursor-pointer",
"flex cursor-pointer items-center justify-center p-2 px-4",
mission.state !== "draft" && "ml-auto",
)}
style={{
@@ -208,7 +209,15 @@ const MissionPopupContent = ({
);
};
const MissionMarker = ({ mission }: { mission: Mission }) => {
const MissionMarker = ({
mission,
options,
}: {
mission: Mission;
options: {
hideDetailedKeyword?: boolean;
};
}) => {
const map = useMap();
const [hideMarker, setHideMarker] = useState(false);
const { editingMissionId, missionFormValues } = usePannelStore((state) => state);
@@ -221,7 +230,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
refetchInterval: 10000,
});
const { openMissionMarker, setOpenMissionMarker } = useMapStore((store) => store);
const { openMissionMarker, setOpenMissionMarker, userSettings } = useMapStore((store) => store);
const needsAction =
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString) &&
@@ -253,14 +262,16 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
return () => {
markerCopy?.off("click", handleClick);
};
}, [mission.id, openMissionMarker, setOpenMissionMarker]);
}, [mission.id, openMissionMarker, setOpenMissionMarker, userSettings]);
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
"topleft",
);
const handleConflict = useCallback(() => {
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker");
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker", {
ignoreCluster: true,
});
setAnchor(newAnchor);
}, [mission.id]);
@@ -317,7 +328,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
"
></div>
<span class="text-white text-[15px] text-nowrap">
${mission.missionKeywordAbbreviation} ${mission.missionKeywordName}
${mission.missionKeywordAbbreviation} ${options.hideDetailedKeyword ? "" : mission.missionKeywordName}
</span>
<div
data-anchor-lat="${mission.addressLat}"
@@ -337,22 +348,15 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
return [
editingMissionId === mission.id && missionFormValues?.addressLat
? missionFormValues.addressLat
: mission.hpgValidationState !== "POSITION_AMANDED" && mission.hpgLocationLat
? mission.hpgLocationLat
: mission.addressLat,
: mission.addressLat,
editingMissionId === mission.id && missionFormValues?.addressLng
? missionFormValues.addressLng
: mission.hpgValidationState !== "POSITION_AMANDED" && mission.hpgLocationLng
? mission.hpgLocationLng
: mission.addressLng,
: mission.addressLng,
];
}, [
editingMissionId,
mission.addressLat,
mission.addressLng,
mission.hpgLocationLat,
mission.hpgLocationLng,
mission.hpgValidationState,
mission.id,
missionFormValues?.addressLat,
missionFormValues?.addressLng,
@@ -396,7 +400,17 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
export const MissionLayer = () => {
const dispatchState = useDispatchConnectionStore((s) => s);
const dispatcherConnected = dispatchState.status === "connected";
const {
status: pilotConnectionStatus,
showOtherMissions,
selectedStation,
} = usePilotConnectionStore((state) => state);
const { data: aircrafts = [] } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
});
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
queryFn: () =>
@@ -410,15 +424,32 @@ export const MissionLayer = () => {
return missions.filter((m: Mission) => {
if (m.state === "draft" && !dispatcherConnected) return false;
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
if (pilotConnectionStatus === "connected" && !showOtherMissions)
return m.missionStationIds.includes(selectedStation!.id);
return true;
});
}, [missions, dispatcherConnected, dispatchState.hideDraftMissions]);
}, [
missions,
dispatcherConnected,
dispatchState.hideDraftMissions,
pilotConnectionStatus,
showOtherMissions,
selectedStation,
]);
// IDEA: Add Marker to Map Layer / LayerGroup
return (
<>
{filteredMissions.map((mission) => {
return <MissionMarker key={mission.id} mission={mission as Mission} />;
return (
<MissionMarker
key={mission.id}
mission={mission as Mission}
options={{
hideDetailedKeyword: missions.length + aircrafts.length > 10,
}}
/>
);
})}
</>
);

View File

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

View File

@@ -9,6 +9,7 @@ import {
Mission,
MissionLog,
MissionSdsLog,
MissionSdsStatusLog,
MissionStationLog,
Prisma,
PublicUser,
@@ -40,7 +41,7 @@ import {
TextSearch,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { sendSdsMessageAPI } from "_querys/missions";
import { sendSdsMessageAPI, sendSdsStatusMessageAPI } from "_querys/missions";
import { getLivekitRooms } from "_querys/livekit";
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
import { formatDistance } from "date-fns";
@@ -54,9 +55,13 @@ const FMSStatusHistory = ({
mission?: Mission;
}) => {
const log = ((mission?.missionLog as unknown as MissionLog[]) || [])
.filter((entry) => entry.type === "station-log" && entry.data.stationId === aircraft.Station.id)
.filter(
(entry) =>
(entry.type === "station-log" || entry.type == "sds-status-log") &&
entry.data.stationId === aircraft.Station.id,
)
.reverse()
.splice(0, 6) as MissionStationLog[];
.splice(0, 6) as (MissionStationLog | MissionSdsStatusLog)[];
const aircraftUser: PublicUser =
typeof aircraft.publicUser === "string" ? JSON.parse(aircraft.publicUser) : aircraft.publicUser;
@@ -64,9 +69,9 @@ const FMSStatusHistory = ({
return (
<div className="p-4">
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<p className="flex items-center gap-2 flex-1">
<PersonIcon className="w-5 h-5" /> {aircraftUser.fullName} ({aircraftUser.publicId}){" "}
<li className="mb-1 flex items-center gap-2">
<p className="flex flex-1 items-center gap-2">
<PersonIcon className="h-5 w-5" /> {aircraftUser.fullName} ({aircraftUser.publicId}){" "}
{(() => {
const badges = aircraftUser.badges
.filter((b) => b.startsWith("P") && b.length == 2)
@@ -96,17 +101,20 @@ const FMSStatusHistory = ({
</p>
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<ul className="space-y-2">
{log.map((entry, index) => (
<li key={index} className="flex items-center gap-2">
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
color:
FMS_STATUS_TEXT_COLORS[
entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus
],
}}
>
{entry.data.newFMSstatus}
{entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus}
</span>
<span className="text-base-content">
{new Date(entry.timeStamp).toLocaleTimeString([], {
@@ -126,6 +134,7 @@ const FMSStatusSelector = ({
}: {
aircraft: ConnectedAircraft & { Station: Station };
}) => {
const session = useSession();
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const [hoveredStatus, setHoveredStatus] = useState<string | null>(null);
const queryClient = useQueryClient();
@@ -144,9 +153,23 @@ const FMSStatusSelector = ({
},
});
const sendSdsStatusMutation = useMutation({
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
if (!aircraft?.id) throw new Error("No connected aircraft");
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: aircraft?.id });
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
if (!session.data?.user) {
return null;
}
return (
<div className="flex flex-col gap-2 mt-2 p-4 text-base-content">
<div className="flex gap-2 justify-center items-center h-full">
<div className="text-base-content mt-2 flex flex-col gap-2 p-4">
<div className="flex h-full items-center justify-center gap-2">
{Array.from({ length: 9 }, (_, i) => (i + 1).toString())
.filter((status) => status !== "5") // Exclude status 5
.map((status) => (
@@ -154,7 +177,7 @@ const FMSStatusSelector = ({
disabled={!dispatcherConnected}
key={status}
className={cn(
"flex justify-center items-center min-w-13 min-h-13 cursor-pointer text-4xl font-bold",
"min-w-13 min-h-13 flex cursor-pointer items-center justify-center text-4xl font-bold",
!dispatcherConnected && "cursor-not-allowed",
)}
style={{
@@ -187,13 +210,13 @@ const FMSStatusSelector = ({
</button>
))}
</div>
<div className="flex gap-1 p-2 justify-center items-center">
<div className="flex items-center justify-center gap-1 p-2">
{["E", "C", "F", "J", "L", "c", "d", "h", "o", "u"].map((status) => (
<button
disabled={!dispatcherConnected}
key={status}
className={cn(
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold",
"flex min-h-10 min-w-10 cursor-pointer items-center justify-center text-lg font-bold",
!dispatcherConnected && "cursor-not-allowed",
)}
style={{
@@ -213,12 +236,21 @@ const FMSStatusSelector = ({
onMouseEnter={() => setHoveredStatus(status)}
onMouseLeave={() => setHoveredStatus(null)}
onClick={async () => {
await changeAircraftMutation.mutateAsync({
id: aircraft.id,
update: {
fmsStatus: status,
await sendSdsStatusMutation.mutateAsync({
sdsMessage: {
type: "sds-status-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
status: status,
direction: "to-aircraft",
stationId: aircraft.Station.id,
station: aircraft.Station,
user: getPublicUser(session.data?.user),
},
},
});
toast.success(`SDS Status ${status} gesendet`);
}}
>
{status}
@@ -234,7 +266,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
refetchInterval: 10000,
refetchInterval: 5000,
});
const participants =
@@ -245,7 +277,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
})),
) || [];
const livekitUser = participants.find((p) => (p.attributes.userId = aircraft.userId));
const livekitUser = participants.find((p) => p.attributes.userId === aircraft.userId);
const lstName = useMemo(() => {
if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea;
@@ -253,17 +285,17 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
}, [aircraft.posLng, aircraft.posLat, station.bosRadioArea]);
return (
<div className="p-4 text-base-content">
<div className="text-base-content p-4">
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<Component size={16} /> Aktuelle Rufgruppe: {livekitUser?.roomName || "Nicht verbunden"}
</li>
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<RadioTower size={16} /> Leitstellenbereich: {lstName || station.bosRadioArea}
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2 mb-2">
<div className="divider mb-0 mt-0" />
<div className="mb-2 mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
<span className="flex items-center gap-2">
<Clock size={16} /> {station.is24h ? "24h Betrieb" : "Tagbetrieb"}
</span>
@@ -277,8 +309,8 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
<TextSearch size={16} /> {station.aircraftRegistration}
</span>
</div>
<div className="divider mt-0 mb-0" />
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
<div className="divider mb-0 mt-0" />
<div className="mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
<span className="flex items-center gap-2">
<CompassIcon size={16} /> HDG: {aircraft.posHeading}°
</span>
@@ -289,13 +321,19 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
<CircleGaugeIcon size={16} /> ALT: {aircraft.posAlt} ft
</span>
</div>
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
<div className="mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posH145active && "text-green-500")}>
{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>
);
@@ -303,22 +341,22 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
const MissionTab = ({ mission }: { mission: Mission }) => {
return (
<div className="p-4 text-base-content">
<div className="text-base-content p-4">
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<BellRing size={16} /> {mission.missionKeywordCategory}
</li>
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<ListCollapse size={16} />
{mission.missionKeywordName}
</li>
<li className="flex items-center gap-2 mt-3">
<li className="mt-3 flex items-center gap-2">
<Hash size={16} />
__{new Date().toISOString().slice(0, 10).replace(/-/g, "")}
{mission.id}
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="text-sm font-semibold">
<p className="flex items-center gap-2">
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
@@ -372,7 +410,9 @@ const SDSTab = ({
?.slice()
.reverse()
.filter(
(entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id,
(entry) =>
(entry.type === "sds-log" || entry.type == "sds-status-log") &&
entry.data.stationId === aircraft.Station.id,
) || [],
[mission?.missionLog, aircraft.Station.id],
);
@@ -416,7 +456,7 @@ const SDSTab = ({
<div className="flex items-center gap-2">
{!isChatOpen ? (
<button
className="text-base-content text-base cursor-pointer"
className="text-base-content cursor-pointer text-base"
onClick={() => setIsChatOpen(true)}
>
<span className="flex items-center gap-2">
@@ -424,7 +464,7 @@ const SDSTab = ({
</span>
</button>
) : (
<div className="flex items-center gap-2 w-full">
<div className="flex w-full items-center gap-2">
<input
autoFocus
type="text"
@@ -463,9 +503,9 @@ const SDSTab = ({
<div className="divider m-0" />
</div>
)}
<ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto">
<ul className="max-h-[300px] space-y-2 overflow-x-auto overflow-y-auto">
{log.map((entry, index) => {
const sdsEntry = entry as MissionSdsLog;
const sdsEntry = entry as MissionSdsLog | MissionSdsStatusLog;
return (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">
@@ -475,7 +515,7 @@ const SDSTab = ({
})}
</span>
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[6],
}}
@@ -483,12 +523,14 @@ const SDSTab = ({
{sdsEntry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
{sdsEntry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
</span>
<span className="text-base-content">{sdsEntry.data.message}</span>
<span className="text-base-content">
{sdsEntry.type == "sds-log" ? sdsEntry.data.message : sdsEntry.data.status}
</span>
</li>
);
})}
{!log.length && (
<p className="text-gray-500 w-full text-center my-10 font-semibold">
<p className="my-10 w-full text-center font-semibold text-gray-500">
Kein SDS-Verlauf verfügbar
</p>
)}

View File

@@ -11,6 +11,7 @@ import { getMissionsAPI } from "_querys/missions";
import { useEffect, useMemo, useState } from "react";
import { useMap } from "react-leaflet";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
const PopupContent = ({
aircrafts,
@@ -20,7 +21,13 @@ const PopupContent = ({
missions: Mission[];
}) => {
const { anchor } = useSmartPopup();
const { setOpenAircraftMarker, setOpenMissionMarker } = useMapStore((state) => state);
const {
setOpenAircraftMarker,
setOpenMissionMarker,
openAircraftMarker,
openMissionMarker,
userSettings,
} = useMapStore((state) => state);
const map = useMap();
let borderColor = "";
@@ -41,10 +48,10 @@ const PopupContent = ({
return (
<>
<div className="relative flex flex-col text-white min-w-[200px]">
<div className="relative flex min-w-fit flex-col text-white">
<div
className={cn(
"absolute w-[calc(100%+2px)] h-4 z-99 pointer-events-none",
"z-99 pointer-events-none absolute h-4 w-[calc(100%+2px)]",
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
)}
@@ -67,7 +74,7 @@ const PopupContent = ({
return (
<div
key={mission.id}
className={cn("relative inline-flex items-center gap-2 text-nowrap w-full")}
className={cn("relative inline-flex w-full items-center gap-2 text-nowrap")}
style={{
backgroundColor: markerColor,
cursor: "pointer",
@@ -76,15 +83,27 @@ const PopupContent = ({
<span
className="mx-2 my-0.5 flex-1 cursor-pointer"
onClick={() => {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
});
if (userSettings.settingsAutoCloseMapPopup) {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: openMissionMarker?.map((m) => m.id) || [],
});
} else {
setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
});
}
map.setView([mission.addressLat, mission.addressLng], 12, {
animate: true,
});
@@ -98,34 +117,50 @@ const PopupContent = ({
{aircrafts.map((aircraft) => (
<div
key={aircraft.id}
className="relative w-auto inline-flex items-center gap-2 text-nowrap cursor-pointer"
className="relative inline-flex w-auto cursor-pointer items-center gap-2 text-nowrap px-2"
style={{
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
}}
onClick={() => {
setOpenAircraftMarker({
open: [
{
id: aircraft.id,
tab: "aircraft",
},
],
close: [],
});
if (userSettings.settingsAutoCloseMapPopup) {
setOpenAircraftMarker({
open: [
{
id: aircraft.id,
tab: "home",
},
],
close: openAircraftMarker?.map((m) => m.id) || [],
});
} else {
setOpenAircraftMarker({
open: [
{
id: aircraft.id,
tab: "home",
},
],
close: [],
});
}
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
animate: true,
});
}}
>
<span
className="mx-2 my-0.5 text-gt font-bold"
className="text-gt my-0.5 font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
}}
>
{aircraft.fmsStatus}
</span>
<span>{aircraft.Station.bosCallsign}</span>
<span>
{aircraft.Station.bosCallsign.length > 15
? aircraft.Station.bosCallsignShort
: aircraft.Station.bosCallsign}
</span>
</div>
))}
</div>
@@ -136,6 +171,7 @@ const PopupContent = ({
export const MarkerCluster = () => {
const map = useMap();
const dispatchState = useDispatchConnectionStore((s) => s);
const pilotState = usePilotConnectionStore((s) => s);
const dispatcherConnected = dispatchState.status === "connected";
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
@@ -155,9 +191,36 @@ export const MarkerCluster = () => {
return missions.filter((m: Mission) => {
if (m.state === "draft" && !dispatcherConnected) return false;
if (dispatchState.hideDraftMissions && m.state === "draft") return false;
if (
pilotState.status === "connected" &&
!pilotState.showOtherMissions &&
pilotState.selectedStation
)
return m.missionStationIds.includes(pilotState.selectedStation.id);
return true;
});
}, [missions, dispatcherConnected, dispatchState.hideDraftMissions]);
}, [
missions,
dispatcherConnected,
dispatchState.hideDraftMissions,
pilotState.selectedStation,
pilotState.showOtherMissions,
pilotState.status,
]);
const filteredAircrafts = useMemo(() => {
return aircrafts?.filter((a: ConnectedAircraft) => {
if (pilotState.status === "connected" && !pilotState.showOtherAircrafts) {
return a.stationId === pilotState.connectedAircraft?.stationId;
}
return true;
});
}, [
aircrafts,
pilotState.status,
pilotState.showOtherAircrafts,
pilotState.connectedAircraft?.stationId,
]);
// Track zoom level in state
const [zoom, setZoom] = useState(() => map.getZoom());
@@ -178,12 +241,12 @@ export const MarkerCluster = () => {
lat: number;
lng: number;
}[] = [];
aircrafts?.forEach((aircraft) => {
filteredAircrafts?.forEach((aircraft) => {
const lat = aircraft.posLat!;
const lng = aircraft.posLng!;
const existingClusterIndex = newCluster.findIndex(
(c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1,
(c) => Math.abs(c.lat - lat) < 1.55 && Math.abs(c.lng - lng) < 1,
);
const existingCluster = newCluster[existingClusterIndex];
if (existingCluster) {
@@ -255,7 +318,7 @@ export const MarkerCluster = () => {
});
return clusterWithAvgPos;
}, [aircrafts, filteredMissions, zoom]);
}, [filteredAircrafts, filteredMissions, zoom]);
return (
<>
@@ -270,7 +333,7 @@ export const MarkerCluster = () => {
position={[c.lat, c.lng]}
autoPan={false}
autoClose={false}
className="w-[202px]"
className="min-w-fit"
>
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
</SmartPopup>

View File

@@ -93,8 +93,8 @@ const Einsatzdetails = ({
const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state);
const [ignoreHpg, setIgnoreHpg] = useState(false);
return (
<div className="p-4 text-base-content">
<div className="flex items-center justify-between mb-3">
<div className="text-base-content p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Flag /> Einsatzdetails
</h2>
@@ -126,7 +126,7 @@ const Einsatzdetails = ({
</button>
</div>
<div
className="tooltip tooltip-warning tooltip-left font-semibold z-[9999]"
className="tooltip tooltip-warning tooltip-left z-[9999] font-semibold"
data-tip="Einsatz abschließen"
>
<button
@@ -161,19 +161,19 @@ const Einsatzdetails = ({
)}
</div>
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<BellRing size={16} /> {mission.missionKeywordCategory}
</li>
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<ListCollapse size={16} />
{mission.missionKeywordName}
</li>
<li className="flex items-center gap-2 mt-3">
<li className="mt-3 flex items-center gap-2">
<Hash size={16} />
{mission.publicId}
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="text-sm font-semibold">
<p className="flex items-center gap-2">
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
@@ -192,7 +192,7 @@ const Einsatzdetails = ({
</div>
{mission.type == "sekundär" && (
<>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="text-sm font-semibold">
<p className="flex items-center gap-2">
<Route size={16} /> {mission.addressMissionDestination}
@@ -202,11 +202,11 @@ const Einsatzdetails = ({
)}
{mission.state === "draft" && (
<div>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
{hpgNeedsAttention && mission.hpgValidationState !== "POSITION_AMANDED" && (
<div className="form-control mb-2 flex justify-between items-center">
<label className="flex items-center gap-2 cursor-pointer">
<div className="form-control mb-2 flex items-center justify-between">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="checkbox checkbox-sm checkbox-primary"
@@ -214,7 +214,7 @@ const Einsatzdetails = ({
onChange={(e) => setIgnoreHpg(e.target.checked)}
/>
<span
className="label-text font-semibold leading-6 tooltip"
className="label-text tooltip font-semibold leading-6"
data-tip="Die HPG-Alarmierung wird trotzdem ausgeführt. Die Position des HPG-Einsatzes kann gravierend von der Einsatzposition abweichen"
>
HPG-Fehler ignorieren
@@ -235,7 +235,7 @@ const Einsatzdetails = ({
</div>
)}
<div className="flex items-center gap-2 w-full">
<div className="flex w-full items-center gap-2">
{(!hpgNeedsAttention || ignoreHpg) &&
mission.hpgValidationState !== HpgValidationState.POSITION_AMANDED && (
<button
@@ -354,13 +354,13 @@ const Einsatzdetails = ({
const Patientdetails = ({ mission }: { mission: Mission }) => {
return (
<div className="p-4 text-base-content">
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
<div className="text-base-content p-4">
<h2 className="mb-3 flex items-center gap-2 text-lg font-bold">
<User /> Patientendetails
</h2>
<p className="text-base-content font-semibold">{mission.missionPatientInfo}</p>
<div className="divider my-2" />
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
<h2 className="mb-3 flex items-center gap-2 text-lg font-bold">
<Cross /> Einsatzinformationen
</h2>
<p className="text-base-content font-semibold">{mission.missionAdditionalInfo}</p>
@@ -370,9 +370,17 @@ const Patientdetails = ({ mission }: { mission: Mission }) => {
const Rettungsmittel = ({ mission }: { mission: Mission }) => {
const queryClient = useQueryClient();
const [selectedStation, setSelectedStation] = useState<number | "RTW" | "POL" | "FW" | null>(
null,
);
const [selectedStation, setSelectedStation] = useState<{
selectedStationId: number | undefined;
hpgAmbulanceState: HpgState;
hpgFireEngineState: HpgState;
hpgPoliceState: HpgState;
}>({
selectedStationId: undefined,
hpgAmbulanceState: HpgState.NOT_REQUESTED,
hpgFireEngineState: HpgState.NOT_REQUESTED,
hpgPoliceState: HpgState.NOT_REQUESTED,
});
const { data: connectedAircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
@@ -432,7 +440,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
const HPGVehicle = ({ state, name }: { state: HpgState; name: string }) => (
<li className="flex items-center gap-2">
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[hpgStateToFMSStatus(state)],
}}
@@ -449,8 +457,8 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
);
return (
<div className="p-4 text-base-content">
<div className="flex items-center w-full justify-between mb-2">
<div className="text-base-content p-4">
<div className="mb-2 flex w-full items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-bold">
<SmartphoneNfc /> Rettungsmittel
</h2>
@@ -472,9 +480,9 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
</div>
)}
</div>
<ul className="space-y-2 h-[130px] overflow-y-auto overflow-x-auto flex-1">
<ul className="h-[130px] flex-1 space-y-2 overflow-x-auto overflow-y-auto">
{mission.missionStationIds.length === 0 && (
<p className="text-gray-500 w-full text-center my-10 font-semibold">
<p className="my-10 w-full text-center font-semibold text-gray-500">
Keine Rettungsmittel zugewiesen
</p>
)}
@@ -486,17 +494,17 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
return (
<li key={index} className="flex items-center gap-2">
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[connectedAircraft?.fmsStatus || "6"],
}}
>
{connectedAircraft?.fmsStatus || "6"}
</span>
<span className="text-base-content flex flex-col ">
<span className="text-base-content flex flex-col">
<span className="font-bold">{station.bosCallsign}</span>
{!connectedAircraft && (
<span className="text-gray-400 text-xs">Kein Benutzer verbunden</span>
<span className="text-xs text-gray-400">Kein Benutzer verbunden</span>
)}
</span>
</li>
@@ -514,16 +522,24 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
</ul>
{dispatcherConnected && (
<div>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="flex items-center gap-2">
{/* TODO: make it a small multiselect */}
<StationsSelect
menuPlacement="top"
className="min-w-[320px] flex-1"
isMulti={false}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(v: any) => {
setSelectedStation(v);
onChange={(v) => {
console.log("Selected Station:", v);
setSelectedStation({
selectedStationId: v?.selectedStationIds[0],
hpgAmbulanceState:
v.hpgAmbulanceState || mission.hpgAmbulanceState || HpgState.NOT_REQUESTED,
hpgFireEngineState:
v.hpgFireEngineState || mission.hpgFireEngineState || HpgState.NOT_REQUESTED,
hpgPoliceState:
v.hpgPoliceState || mission.hpgPoliceState || HpgState.NOT_REQUESTED,
});
}}
selectedStations={mission.missionStationIds}
filterSelected
@@ -536,24 +552,40 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
<button
className="btn btn-sm btn-primary btn-outline"
onClick={async () => {
if (typeof selectedStation === "string") {
console.log("Selected Station:", selectedStation);
if (
selectedStation.hpgAmbulanceState !== "NOT_REQUESTED" ||
selectedStation.hpgFireEngineState !== "NOT_REQUESTED" ||
selectedStation.hpgPoliceState !== "NOT_REQUESTED"
) {
// Determine which vehicle type is selected
let vehicleName: "RTW" | "POL" | "FW" | undefined;
if (selectedStation.hpgAmbulanceState !== "NOT_REQUESTED") {
vehicleName = "RTW";
} else if (selectedStation.hpgPoliceState !== "NOT_REQUESTED") {
vehicleName = "POL";
} else if (selectedStation.hpgFireEngineState !== "NOT_REQUESTED") {
vehicleName = "FW";
}
await sendAlertMutation.mutate({
id: mission.id,
vehicleName: selectedStation,
vehicleName: vehicleName,
});
} else {
if (!selectedStation) return;
if (typeof selectedStation.selectedStationId !== "number") return;
await updateMissionMutation.mutateAsync({
id: mission.id,
missionEdit: {
missionStationIds: {
push: selectedStation,
push: selectedStation.selectedStationId,
},
},
});
await sendAlertMutation.mutate({
id: mission.id,
stationId: selectedStation,
stationId: selectedStation.selectedStationId,
});
}
}}
@@ -629,7 +661,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
<div className="flex items-center gap-2">
{!isAddingNote ? (
<button
className="text-base-content text-base cursor-pointer"
className="text-base-content cursor-pointer text-base"
onClick={() => setIsAddingNote(true)}
>
<span className="flex items-center gap-2">
@@ -637,7 +669,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
</span>
</button>
) : (
<div className="flex items-center gap-2 w-full">
<div className="flex w-full items-center gap-2">
<input
type="text"
placeholder=""
@@ -669,7 +701,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
<div className="divider m-0" />
</div>
)}
<ul className="space-y-1 max-h-[300px] overflow-y-auto overflow-x-auto">
<ul className="max-h-[300px] space-y-1 overflow-x-auto overflow-y-auto">
{(mission.missionLog as unknown as MissionLog[])
.slice()
.reverse()
@@ -684,7 +716,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</span>
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
}}
@@ -694,7 +726,11 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
<span className="text-base-content">{entry.data.station.bosCallsign}</span>
</li>
);
if (entry.type === "message-log" || entry.type === "sds-log")
if (
entry.type === "message-log" ||
entry.type === "sds-log" ||
entry.type === "sds-status-log"
)
return (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">
@@ -704,14 +740,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</span>
<span
className="font-bold text-base flex items-center gap-0.5"
className="flex items-center gap-0.5 text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[6],
}}
>
{entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
{entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
{entry.type === "sds-log" && (
{entry.type == "sds-status-log" && entry.data.direction == "to-lst"
? entry.data.station.bosCallsignShort
: `${entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}${entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}`}
{(entry.type === "sds-log" || entry.type === "sds-status-log") && (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -728,17 +765,28 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
/>
</svg>
{entry.data.station.bosCallsignShort}
{entry.type == "sds-status-log" && entry.data.direction == "to-aircraft"
? entry.data.station.bosCallsignShort
: "LST"}
</>
)}
</span>
<span className="text-base-content">{entry.data.message}</span>
<span className="text-base-content">
{entry.type === "sds-log" || entry.type === "message-log"
? entry.data.message
: entry.data.status}
</span>
</li>
);
if (entry.type === "alert-log") {
const alertReceiver = entry.auto
? null
: entry.data.station?.bosCallsignShort || entry.data.vehicle;
if (
entry.type === "alert-log" ||
entry.type === "completed-log" ||
entry.type === "reopened-log"
) {
const alertReceiver =
entry.auto || entry.type !== "alert-log"
? null
: entry.data.station?.bosCallsignShort || entry.data.vehicle;
return (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">
@@ -748,15 +796,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</span>
<span
className="font-bold text-base flex items-center gap-0.5"
className="flex items-center gap-0.5 text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[6],
}}
>
{!entry.auto && (
<>
{entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
{entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
{entry.data.user?.firstname?.[0]?.toUpperCase() ?? "?"}
{entry.data.user?.lastname?.[0]?.toUpperCase() ?? "?"}
</>
)}
{entry.auto && "AUTO"}
@@ -781,7 +829,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
</>
)}
</span>
<span className="text-base-content">Einsatz alarmiert</span>
{entry.type === "alert-log" && (
<span className="text-base-content">Einsatz alarmiert</span>
)}
{entry.type === "completed-log" && (
<span className="text-base-content">Einsatz abgeschlossen</span>
)}
{entry.type === "reopened-log" && (
<span className="text-base-content">Einsatz wiedereröffnet</span>
)}
</li>
);
}
@@ -789,7 +845,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</ul>
{!mission.missionLog.length && (
<p className="text-gray-500 w-full text-center my-10 font-semibold">
<p className="my-10 w-full text-center font-semibold text-gray-500">
Keine Notizen verfügbar
</p>
)}

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