226 Commits

Author SHA1 Message Date
PxlLoewe
90fcaf259e repaired nextJS dockerfiles 2026-01-15 21:35:28 +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
394 changed files with 54839 additions and 18894 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,202 @@
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index";
import cron from "node-cron";
import { changeMemberRoles } from "routes/member";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
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 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 () => {
const activePenalties = await prisma.penalty.findMany({
where: {
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of activePenalties) {
const user = penalty.User;
if (user.DiscordAccount) {
await changeMemberRoles(
user.DiscordAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
for (const formerAccount of user.FormerDiscordAccounts) {
await changeMemberRoles(
formerAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
}
};
cron.schedule("*/5 * * * *", async () => {
await removePermissionsForBannedUsers();
});
cron.schedule("*/1 * * * *", async () => {
try {
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", "start": "tsx index.ts --transpile-only",
"build": "tsc" "build": "tsc"
}, },
"packageManager": "pnpm@10.11.0", "packageManager": "pnpm@10.13.1",
"devDependencies": { "devDependencies": {
"@repo/db": "workspace:*", "@repo/db": "workspace:*",
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/cors": "^2.8.18", "@types/cors": "^2.8.19",
"@types/express": "^5.0.2", "@types/express": "^5.0.3",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2", "concurrently": "^9.2.0",
"typescript": "latest" "typescript": "latest"
}, },
"dependencies": { "dependencies": {
"axios": "^1.9.0", "@socket.io/redis-adapter": "^8.3.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron": "^4.3.1", "cron": "^4.3.2",
"discord.js": "^14.19.3", "discord.js": "^14.21.0",
"dotenv": "^16.5.0", "dotenv": "^17.2.0",
"express": "^5.1.0", "express": "^5.1.0",
"node-cron": "^4.1.0", "node-cron": "^4.2.1",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"react": "^19.1.0", "react": "^19.1.0",
"tsx": "^4.19.4" "redis": "^5.6.0",
"socket.io": "^4.8.1",
"tsx": "^4.20.3"
} }
} }

View File

@@ -19,7 +19,6 @@ router.post("/set-standard-name", async (req, res) => {
id: userId, id: userId,
}, },
}); });
console.log(`Setting standard name for user ${userId} (${user?.publicId}) to member ${memberId}`);
if (!user) { if (!user) {
res.status(404).json({ error: "User not found" }); res.status(404).json({ error: "User not found" });
return; return;
@@ -33,6 +32,25 @@ router.post("/set-standard-name", async (req, res) => {
}, },
}); });
const activePenaltys = await prisma.penalty.findMany({
where: {
userId: user.id,
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
});
participant.forEach(async (p) => { participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return; if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) { if (eventCompleted(p.Event, p)) {
@@ -49,8 +67,12 @@ router.post("/set-standard-name", async (req, res) => {
const isPilot = user.permissions.includes("PILOT"); const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO"); const isDispatcher = user.permissions.includes("DISPO");
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove"); if (activePenaltys.length > 0) {
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove"); await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], "remove");
} else {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
}
}); });
export default router; export default router;

View File

@@ -1,7 +1,8 @@
DISPATCH_SERVER_PORT=3002 DISPATCH_SERVER_PORT=3002
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
CORE_SERVER_URL=http://core-server CORE_SERVER_URL=http://localhost:3005
DISPATCH_APP_TOKEN=dispatch DISPATCH_APP_TOKEN=dispatch
LIVEKIT_API_KEY=APIAnsGdtdYp2Ho 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 cookieParser from "cookie-parser";
import cors from "cors"; import cors from "cors";
import { authMiddleware } from "modules/expressMiddleware"; import { authMiddleware } from "modules/expressMiddleware";
import "modules/chron";
const app = express(); const app = express();
const server = createServer(app); const server = createServer(app);
export const io = new Server(server, { export const io = new Server(server, {
adapter: createAdapter(pubClient, subClient), adapter:
process.env.REDIS_HOST && process.env.REDIS_PORT
? createAdapter(pubClient, subClient)
: undefined,
cors: {}, cors: {},
}); });
io.use(jwtMiddleware); 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

@@ -6,121 +6,131 @@ export const sendAlert = async (
id: number, id: number,
{ {
stationId, stationId,
desktopOnly,
}: { }: {
stationId?: number; stationId?: number;
desktopOnly?: boolean;
}, },
user: User | "HPG", user: User | "HPG",
): Promise<{ ): Promise<{
connectedAircrafts: ConnectedAircraft[]; connectedAircrafts: ConnectedAircraft[];
mission: Mission; mission: Mission;
}> => { }> => {
const mission = await prisma.mission.findUnique({ try {
where: { id: id }, 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,
}); });
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", { const Stations = await prisma.station.findMany({
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: { where: {
missionId: mission.id, id: {
userId: aircraft.userId, in: mission?.missionStationIds,
stationId: aircraft.stationId, },
}, },
}); });
if (!existingMissionOnStationUser) if (!mission) {
await prisma.missionOnStationUsers.create({ throw new Error("Mission not found");
data: { }
// 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, missionId: mission.id,
userId: aircraft.userId, userId: aircraft.userId,
stationId: aircraft.stationId, stationId: aircraft.stationId,
}, },
}); });
}
// for statistics only if (!existingMissionOnStationUser)
await prisma.missionsOnStations await prisma.missionOnStationUsers.create({
.createMany({ data: {
data: mission.missionStationIds.map((stationId) => ({ missionId: mission.id,
missionId: mission.id, userId: aircraft.userId,
stationId, stationId: aircraft.stationId,
})), },
}) });
.catch((err) => { }
// Ignore if the entry already exists
}); // for statistics only
if (user === "HPG") { await prisma.missionsOnStations
await prisma.mission.update({ .createMany({
where: { id: Number(id) }, data: mission.missionStationIds.map((stationId) => ({
data: { missionId: mission.id,
state: "running", stationId,
missionLog: { })),
push: { })
type: "alert-log", .catch((err) => {
auto: true, // Ignore if the entry already exists
timeStamp: new Date().toISOString(), });
} as any, 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 {
} else { await prisma.mission.update({
await prisma.mission.update({ where: { id: Number(id) },
where: { id: Number(id) }, data: {
data: { state: "running",
state: "running", missionLog: {
missionLog: { push: {
push: { type: "alert-log",
type: "alert-log", auto: false,
auto: false, timeStamp: new Date().toISOString(),
timeStamp: new Date().toISOString(), data: {
data: { stationId: stationId,
stationId: stationId, user: getPublicUser(user, { ignorePrivacy: true }),
user: getPublicUser(user, { ignorePrivacy: true }), },
}, } as any,
} 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(" / ")} `; return `🚁 RTH${callsigns.length > 1 ? "s" : ""}: ${callsigns.join(" / ")} `;
}; };
const getNtfyHeader = ( const getNtfyHeader = (mission: Mission, clientStation: Station): NtfyHeader => ({
mission: Mission,
clientStation: Station,
): NtfyHeader => ({
headers: { headers: {
Title: `${clientStation.bosCallsignShort} / ${mission.missionKeywordAbbreviation} / ${mission.missionKeywordCategory}`, Title: `${clientStation.bosCallsignShort} / ${mission.missionKeywordAbbreviation} / ${mission.missionKeywordCategory}`,
Tags: "pager", Tags: "pager",
@@ -76,9 +73,13 @@ export const sendNtfyMission = async (
clientStation: Station, clientStation: Station,
ntfyRoom: string, ntfyRoom: string,
) => { ) => {
axios.post( try {
`https://ntfy.sh/${ntfyRoom}`, await axios.post(
getNtfyData(mission, stations), `https://ntfy.sh/${ntfyRoom}`,
getNtfyHeader(mission, clientStation), 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"; import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({ 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(); export const subClient: RedisClientType = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => { if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) {
console.log("Redis connected"); console.warn("REDIS_HOST or REDIS_PORT not set, skipping Redis connection");
pubClient.keys("dispatchers*").then((keys) => { } else {
if (!keys) return; Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
keys.forEach(async (key) => { console.log("Redis connected");
await pubClient.json.del(key);
});
}); });
}); }
pubClient.on("error", (err) => console.log("Redis Client Error", err)); pubClient.on("error", (err) => console.log("Redis Client Error", err));
subClient.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", "start": "tsx index.ts --transpile-only",
"build": "tsc" "build": "tsc"
}, },
"packageManager": "pnpm@10.11.0", "packageManager": "pnpm@10.13.1",
"devDependencies": { "devDependencies": {
"@repo/db": "workspace:*", "@repo/db": "workspace:*",
"@repo/shared-components": "workspace:*", "@repo/shared-components": "workspace:*",
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.9",
"@types/cors": "^2.8.18", "@types/cors": "^2.8.19",
"@types/express": "^5.0.2", "@types/express": "^5.0.3",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2", "concurrently": "^9.2.0",
"typescript": "latest" "typescript": "latest"
}, },
"dependencies": { "dependencies": {
"@react-email/components": "^0.0.41", "@react-email/components": "^0.3.2",
"@redis/json": "^5.1.1", "@redis/json": "^5.6.0",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.10",
"axios": "^1.9.0", "axios": "^1.10.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron": "^4.3.1", "cron": "^4.3.2",
"dotenv": "^16.5.0", "dotenv": "^17.2.0",
"express": "^5.1.0", "express": "^5.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.13.0", "livekit-server-sdk": "^2.13.1",
"node-cron": "^4.1.0", "node-cron": "^4.2.1",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.5",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"react": "^19.1.0", "react": "^19.1.0",
"redis": "^5.1.1", "redis": "^5.6.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"tsx": "^4.19.4" "tsx": "^4.20.3"
} }
} }

View File

@@ -2,6 +2,7 @@ import {
AdminMessage, AdminMessage,
getPublicUser, getPublicUser,
MissionLog, MissionLog,
MissionSdsStatusLog,
NotificationPayload, NotificationPayload,
Prisma, Prisma,
prisma, prisma,
@@ -83,6 +84,7 @@ router.patch("/:id", async (req, res) => {
data: { data: {
stationId: updatedConnectedAircraft.stationId, stationId: updatedConnectedAircraft.stationId,
aircraftId: updatedConnectedAircraft.id, aircraftId: updatedConnectedAircraft.id,
userId: updatedConnectedAircraft.userId,
}, },
} as NotificationPayload); } as NotificationPayload);
} }
@@ -129,6 +131,44 @@ 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,
},
},
});
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,
},
} as NotificationPayload);
res.sendStatus(204);
});
// Kick a connectedAircraft by ID // Kick a connectedAircraft by ID
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
const { id } = req.params; 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( await renameMember(
discordAccount.discordId.toString(), discordAccount.discordId.toString(),
`${getPublicUser(newDispatcher.user).fullName}${newDispatcher.zone}`, `${getPublicUser(newDispatcher.user).fullName}${newDispatcher.zone}`,

View File

@@ -86,7 +86,30 @@ router.patch("/:id", async (req, res) => {
where: { id: Number(id) }, where: { id: Number(id) },
data: req.body, data: req.body,
}); });
io.to("dispatchers").emit("update-mission", updatedMission); io.to("dispatchers").emit("update-mission", { updatedMission });
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); res.json(updatedMission);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -113,9 +136,10 @@ router.delete("/:id", async (req, res) => {
router.post("/:id/send-alert", async (req, res) => { router.post("/:id/send-alert", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { stationId, vehicleName } = req.body as { const { stationId, vehicleName, desktopOnly } = req.body as {
stationId?: number; stationId?: number;
vehicleName?: "RTW" | "POL" | "FW"; vehicleName?: "RTW" | "POL" | "FW";
desktopOnly?: boolean;
}; };
if (!req.user) { if (!req.user) {
@@ -180,7 +204,11 @@ router.post("/:id/send-alert", async (req, res) => {
return; 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); io.to("dispatchers").emit("update-mission", mission);
res.status(200).json({ res.status(200).json({
@@ -189,7 +217,9 @@ router.post("/:id/send-alert", async (req, res) => {
return; return;
} catch (error) { } catch (error) {
console.error(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; return;
} }
}); });
@@ -241,9 +271,6 @@ router.post("/:id/hpg-validation-result", async (req, res) => {
const newMission = await prisma.mission.update({ const newMission = await prisma.mission.update({
where: { id: Number(missionId) }, where: { id: Number(missionId) },
data: { data: {
// save position of new mission
addressLat: result.state === "POSITION_AMANDED" ? result.lat : undefined,
addressLng: result.state === "POSITION_AMANDED" ? result.lng : undefined,
hpgLocationLat: result.lat, hpgLocationLat: result.lat,
hpgLocationLng: result.lng, hpgLocationLng: result.lng,
hpgValidationState: result.state, hpgValidationState: result.state,
@@ -261,9 +288,7 @@ router.post("/:id/hpg-validation-result", async (req, res) => {
}, },
} as NotificationPayload); } as NotificationPayload);
console.log("Got positiv validation Result", result.alertWhenValid);
if (result.alertWhenValid) { if (result.alertWhenValid) {
console.log(req.user);
sendAlert(Number(missionId), {}, "HPG"); sendAlert(Number(missionId), {}, "HPG");
} }
} else { } else {

View File

View File

@@ -1,12 +1,20 @@
import { getPublicUser, prisma, User } from "@repo/db"; import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; 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 { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
export const handleConnectDispatch = export const handleConnectDispatch =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
async ({ logoffTime, selectedZone }: { logoffTime: string; selectedZone: string }) => { async ({
logoffTime,
selectedZone,
ghostMode,
}: {
logoffTime: string;
selectedZone: string;
ghostMode: boolean;
}) => {
try { try {
const user: User = socket.data.user; // User ID aus dem JWT-Token const user: User = socket.data.user; // User ID aus dem JWT-Token
@@ -20,8 +28,17 @@ export const handleConnectDispatch =
return; return;
} }
if (!user.permissions?.includes("DISPO")) { const userPenaltys = await getUserPenaltys(user.id);
socket.emit("error", "You do not have permission to connect to the dispatch server.");
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; return;
} }
@@ -45,17 +62,15 @@ export const handleConnectDispatch =
}); });
} }
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
const connectedDispatcherEntry = await prisma.connectedDispatcher.create({ const connectedDispatcherEntry = await prisma.connectedDispatcher.create({
data: { data: {
publicUser: getPublicUser(user) as any, publicUser: getPublicUser(user) as any,
esimatedLogoutTime: esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
lastHeartbeat: new Date().toISOString(), lastHeartbeat: new Date().toISOString(),
userId: user.id, userId: user.id,
zone: selectedZone, zone: selectedZone,
loginTime: new Date().toISOString(), loginTime: new Date().toISOString(),
ghostMode,
}, },
}); });
@@ -64,7 +79,7 @@ export const handleConnectDispatch =
userId: user.id, userId: user.id,
}, },
}); });
if (discordAccount?.id) { if (discordAccount?.id && !ghostMode) {
await renameMember( await renameMember(
discordAccount.discordId.toString(), discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${selectedZone}`, `${getPublicUser(user).fullName}${selectedZone}`,

View File

@@ -1,8 +1,8 @@
import { getPublicUser, prisma, User } from "@repo/db"; import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db"; import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
import { getUserPenaltys } from "@repo/shared-components";
export const handleConnectPilot = export const handleConnectPilot =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
@@ -34,6 +34,19 @@ export const handleConnectPilot =
socket.disconnect(); socket.disconnect();
return; 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"); if (!user) return Error("User not found");
@@ -73,18 +86,18 @@ export const handleConnectPilot =
} }
const randomPos = debug ? getRandomGermanPosition() : undefined; const randomPos = debug ? getRandomGermanPosition() : undefined;
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
const connectedAircraftEntry = await prisma.connectedAircraft.create({ const connectedAircraftEntry = await prisma.connectedAircraft.create({
data: { data: {
publicUser: getPublicUser(user) as any, publicUser: getPublicUser(user) as any,
esimatedLogoutTime: esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
logoffHours && logoffMinutes ? getNextDateWithTime(logoffHours, logoffMinutes) : null,
userId: userId, userId: userId,
stationId: parseInt(stationId), stationId: parseInt(stationId),
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined, lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat, posLat: randomPos?.lat,
posLng: randomPos?.lng, 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 = export const handleSendMessage =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
async ( async (
{ userId, message }: { userId: string; message: string }, { userId, message, role }: { userId: string; message: string; role: string },
cb: (err: { error?: string }) => void, cb: (err: { error?: string }) => void,
) => { ) => {
console.log("send-message", userId, message);
const senderId = socket.data.user.id; const senderId = socket.data.user.id;
const senderUser = await prisma.user.findUnique({ const senderUser = await prisma.user.findUnique({
@@ -25,7 +24,7 @@ export const handleSendMessage =
receiverId: userId, receiverId: userId,
senderId, senderId,
receiverName: `${receiverUser?.firstname} ${receiverUser?.lastname[0]}. - ${receiverUser?.publicId}`, 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 FROM node:22-alpine AS base
ARG NEXT_PUBLIC_DISPATCH_URL ARG NEXT_PUBLIC_DISPATCH_URL="http://localhost:3001"
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL ARG NEXT_PUBLIC_DISPATCH_SERVER_URL="http://localhost:4001"
ARG NEXT_PUBLIC_HUB_URL ARG NEXT_PUBLIC_HUB_URL="http://localhost:3002"
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID="1"
ARG NEXT_PUBLIC_LIVEKIT_URL ARG NEXT_PUBLIC_LIVEKIT_URL="http://localhost:7880"
ARG NEXT_PUBLIC_DISCORD_URL 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_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL
ENV NEXT_PUBLIC_HUB_URL=$NEXT_PUBLIC_HUB_URL ENV NEXT_PUBLIC_HUB_URL=$NEXT_PUBLIC_HUB_URL
ENV NEXT_PUBLIC_DISPATCH_SERVICE_ID=$NEXT_PUBLIC_DISPATCH_SERVICE_ID ENV NEXT_PUBLIC_DISPATCH_SERVICE_ID=$NEXT_PUBLIC_DISPATCH_SERVICE_ID
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
ENV NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL
FROM base AS builder
ENV PNPM_HOME="/usr/local/pnpm" ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}" ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5 RUN pnpm add -g turbo@^2.5
FROM base AS builder
RUN apk update RUN apk update
RUN apk add --no-cache libc6-compat 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_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_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_DISPATCH_SERVER_URL is: $NEXT_PUBLIC_DISPATCH_SERVER_URL"
RUN echo "NEXT_PUBLIC_LIVEKIT_URL is: $NEXT_PUBLIC_LIVEKIT_URL"
COPY . . COPY . .
RUN turbo prune dispatch --docker RUN turbo prune dispatch --docker
FROM base AS installer 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 update
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -48,19 +58,22 @@ COPY --from=builder /usr/app/out/full/ .
RUN turbo run build RUN turbo run build
FROM base AS runner FROM node:22-alpine AS runner
WORKDIR /usr/app WORKDIR /usr/app
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # 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 the application port
EXPOSE 3001 EXPOSE 3000
CMD ["pnpm", "--dir", "apps/dispatch", "run", "start"] CMD ["node", "apps/dispatch/server.js"]

View File

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

View File

@@ -2,19 +2,29 @@ import { Connection } from "./_components/Connection";
import { Audio } from "../../../../_components/Audio/Audio"; import { Audio } from "../../../../_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { Settings } from "_components/navbar/Settings"; import { Settings } from "./_components/Settings";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import AdminPanel from "_components/navbar/AdminPanel"; import AdminPanel from "_components/navbar/AdminPanel";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { WarningAlert } from "_components/navbar/PageAlert"; import { WarningAlert } from "_components/navbar/PageAlert";
import { Radar } from "lucide-react";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
import { prisma } from "@repo/db";
export default async function Navbar() { export default async function Navbar() {
const session = await getServerSession(); const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return ( return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between"> <div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ModeSwitchDropdown /> <div>
<p className="text-xl font-semibold normal-case">VAR Leitstelle</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />} {session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div> </div>
<WarningAlert /> <WarningAlert />
@@ -27,18 +37,23 @@ export default async function Navbar() {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Settings /> <Settings />
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
<button className="btn btn-ghost">
<Radar size={19} /> Tracker
</button>
</Link>
<Link <Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"} href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB <ExternalLinkIcon className="h-4 w-4" /> HUB
</button> </button>
</Link> </Link>
<Link href={"/logout"}> <Link href={"/logout"}>
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" /> <ExitIcon className="h-4 w-4" />
</button> </button>
</Link> </Link>
</div> </div>

View File

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

View File

@@ -0,0 +1,274 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery, 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) {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
}
};
setDevices();
}, []);
return (
<div>
<button
className="btn btn-ghost"
onSubmit={() => false}
onClick={() => {
modalRef.current?.showModal();
}}
>
<GearIcon className="h-5 w-5" />
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box">
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset mb-2 w-full">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
className="input w-full"
value={settings.micDeviceId ? settings.micDeviceId : ""}
onChange={(e) => {
setSettingsPartial({ micDeviceId: e.target.value });
setShowIndication(true);
}}
>
<option key={0} value={0} disabled>
Bitte wähle ein Eingabegerät...
</option>
{inputDevices.map((device, index) => (
<option key={index} value={device.deviceId}>
{device.label}
</option>
))}
</select>
</label>
</fieldset>
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
<Volume2 size={20} /> Eingabelautstärke
</p>
<div className="w-full">
<input
type="range"
min={0}
max={3}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ micVolume: value });
setShowIndication(true);
}}
value={settings.micVolume}
className="range range-xs range-accent w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
{showIndication && (
<MicVolumeBar
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
volumeInput={settings.micVolume}
/>
)}
<div className="divider w-full" />
</div>
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Funk Lautstärke
</p>
<div className="mb-2 w-full">
<input
type="range"
min={0}
max={1}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ radioVolume: value });
}}
value={settings.radioVolume}
className="range range-xs range-primary w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
<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 als Disponent verwenden
</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

@@ -28,8 +28,11 @@ import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { cn } from "@repo/shared-components"; import { cn } from "@repo/shared-components";
import { StationsSelect } from "(app)/dispatch/_components/StationSelect"; import { StationsSelect } from "(app)/dispatch/_components/StationSelect";
import { getUserAPI } from "_querys/user";
export const MissionForm = () => { export const MissionForm = () => {
const session = useSession();
const { editingMissionId, setEditingMission } = usePannelStore(); const { editingMissionId, setEditingMission } = usePannelStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s); const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s);
@@ -44,6 +47,10 @@ export const MissionForm = () => {
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000, refetchInterval: 10000,
}); });
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const createMissionMutation = useMutation({ const createMissionMutation = useMutation({
mutationFn: createMissionAPI, mutationFn: createMissionAPI,
@@ -81,7 +88,6 @@ export const MissionForm = () => {
}, },
}); });
const session = useSession();
const defaultFormValues = React.useMemo( const defaultFormValues = React.useMemo(
() => () =>
({ ({
@@ -108,6 +114,7 @@ export const MissionForm = () => {
hpgSelectedMissionString: null, hpgSelectedMissionString: null,
hpg: null, hpg: null,
missionLog: [], missionLog: [],
xPlaneObjects: [],
}) as MissionOptionalDefaults, }) as MissionOptionalDefaults,
[session.data?.user.id], [session.data?.user.id],
); );
@@ -116,13 +123,16 @@ export const MissionForm = () => {
resolver: zodResolver(MissionOptionalDefaultsSchema), resolver: zodResolver(MissionOptionalDefaultsSchema),
defaultValues: defaultFormValues, defaultValues: defaultFormValues,
}); });
const { missionFormValues, setOpen } = usePannelStore((state) => state); const { missionFormValues, setOpen, setMissionFormValues } = usePannelStore((state) => state);
const validationRequired = HPGValidationRequired( const validationRequired =
form.watch("missionStationIds"), HPGValidationRequired(
aircrafts, form.watch("missionStationIds"),
form.watch("hpgMissionString"), aircrafts,
); form.watch("hpgMissionString"),
) &&
!form.watch("hpgMissionString")?.startsWith("kein Szenario") &&
user?.settingsUseHPGAsDispatcher;
useEffect(() => { useEffect(() => {
if (session.data?.user.id) { if (session.data?.user.id) {
@@ -144,6 +154,7 @@ export const MissionForm = () => {
return; return;
} }
for (const key in missionFormValues) { for (const key in missionFormValues) {
console.debug(key, missionFormValues[key as keyof MissionOptionalDefaults]);
if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately
form.setValue( form.setValue(
key as keyof MissionOptionalDefaults, key as keyof MissionOptionalDefaults,
@@ -153,6 +164,22 @@ export const MissionForm = () => {
} }
}, [missionFormValues, form, defaultFormValues]); }, [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 ( const saveMission = async (
mission: MissionOptionalDefaults, mission: MissionOptionalDefaults,
{ alertWhenValid = false, createNewMission = false } = {}, { alertWhenValid = false, createNewMission = false } = {},
@@ -195,7 +222,7 @@ export const MissionForm = () => {
<form className="space-y-4"> <form className="space-y-4">
{/* Koorinaten Section */} {/* Koorinaten Section */}
<div className="form-control"> <div className="form-control">
<h2 className="text-lg font-bold mb-2">Koordinaten</h2> <h2 className="mb-2 text-lg font-bold">Koordinaten</h2>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<input <input
type="text" type="text"
@@ -219,12 +246,12 @@ export const MissionForm = () => {
{/* Adresse Section */} {/* Adresse Section */}
<div className="form-control"> <div className="form-control">
<h2 className="text-lg font-bold mb-2">Adresse</h2> <h2 className="mb-2 text-lg font-bold">Adresse</h2>
<input <input
type="text" type="text"
{...form.register("addressStreet")} {...form.register("addressStreet")}
placeholder="Straße" placeholder="Straße"
className="input input-primary input-bordered w-full mb-4" className="input input-primary input-bordered mb-4 w-full"
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<input <input
@@ -244,17 +271,16 @@ export const MissionForm = () => {
type="text" type="text"
{...form.register("addressAdditionalInfo")} {...form.register("addressAdditionalInfo")}
placeholder="Zusätzliche Adressinformationen" placeholder="Zusätzliche Adressinformationen"
className="input input-primary input-bordered w-full mt-4" className="input input-primary input-bordered mt-4 w-full"
/> />
</div> </div>
{/* Rettungsmittel Section */} {/* Rettungsmittel Section */}
<div className="form-control"> <div className="form-control">
<h2 className="text-lg font-bold mb-2">Rettungsmittel</h2> <h2 className="mb-2 text-lg font-bold">Rettungsmittel</h2>
<StationsSelect <StationsSelect
isMulti isMulti
selectedStations={form.watch("missionStationIds")} selectedStations={form.watch("missionStationIds")}
onChange={(v) => { onChange={(v) => {
console.log("Selected stations:", v);
form.setValue("missionStationIds", v.selectedStationIds); form.setValue("missionStationIds", v.selectedStationIds);
form.setValue("hpgAmbulanceState", v.hpgAmbulanceState); form.setValue("hpgAmbulanceState", v.hpgAmbulanceState);
form.setValue("hpgFireEngineState", v.hpgFireEngineState); form.setValue("hpgFireEngineState", v.hpgFireEngineState);
@@ -270,10 +296,10 @@ export const MissionForm = () => {
{/* Einsatzdaten Section */} {/* Einsatzdaten Section */}
<div className="form-control"> <div className="form-control">
<h2 className="text-lg font-bold mb-2">Einsatzdaten</h2> <h2 className="mb-2 text-lg font-bold">Einsatzdaten</h2>
<select <select
{...form.register("type")} {...form.register("type")}
className="select select-primary select-bordered w-full mb-4" className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => { onChange={(e) => {
form.setValue("type", e.target.value as missionType); form.setValue("type", e.target.value as missionType);
if (e.target.value === "primary") { if (e.target.value === "primary") {
@@ -295,7 +321,7 @@ export const MissionForm = () => {
<> <>
<select <select
{...form.register("missionKeywordCategory")} {...form.register("missionKeywordCategory")}
className="select select-primary select-bordered w-full mb-4" className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => { onChange={(e) => {
form.setValue("missionKeywordCategory", e.target.value as string); form.setValue("missionKeywordCategory", e.target.value as string);
form.setValue("missionKeywordName", null as any); form.setValue("missionKeywordName", null as any);
@@ -307,20 +333,22 @@ export const MissionForm = () => {
<option disabled value="please_select"> <option disabled value="please_select">
Einsatz Kategorie auswählen... Einsatz Kategorie auswählen...
</option> </option>
{Object.keys(KEYWORD_CATEGORY).map((use) => ( {Object.keys(KEYWORD_CATEGORY)
<option key={use} value={use}> .filter((k) => !k.startsWith("V_"))
{use} .map((use) => (
</option> <option key={use} value={use}>
))} {use}
</option>
))}
</select> </select>
{form.formState.errors.missionKeywordCategory && ( {form.formState.errors.missionKeywordCategory && (
<p className="text-error text-sm mb-4">Bitte wähle eine Kategorie aus.</p> <p className="text-error mb-4 text-sm">Bitte wähle eine Kategorie aus.</p>
)} )}
</> </>
)} )}
<select <select
{...form.register("missionKeywordAbbreviation")} {...form.register("missionKeywordAbbreviation")}
className="select select-primary select-bordered w-full mb-4" className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => { onChange={(e) => {
const keyword = keywords?.find((k) => k.abreviation === e.target.value); const keyword = keywords?.find((k) => k.abreviation === e.target.value);
form.setValue("missionKeywordName", keyword?.name || (null as any)); form.setValue("missionKeywordName", keyword?.name || (null as any));
@@ -342,7 +370,7 @@ export const MissionForm = () => {
))} ))}
</select> </select>
{form.formState.errors.missionKeywordAbbreviation && ( {form.formState.errors.missionKeywordAbbreviation && (
<p className="text-error text-sm mb-4">Bitte wähle ein Stichwort aus.</p> <p className="text-error mb-4 text-sm">Bitte wähle ein Stichwort aus.</p>
)} )}
<div className="mb-4"> <div className="mb-4">
<select <select
@@ -362,12 +390,13 @@ export const MissionForm = () => {
form.setValue("missionAdditionalInfo", name || ""); form.setValue("missionAdditionalInfo", name || "");
} }
}} }}
className="select select-primary select-bordered w-full mb-2" className="select select-primary select-bordered mb-2 w-full"
value={form.watch("hpgMissionString") || "please_select"} value={form.watch("hpgMissionString") || "please_select"}
> >
<option disabled value="please_select"> <option disabled value="please_select">
Einsatz Szenario auswählen... Einsatz Szenario auswählen...
</option> </option>
<option value={"kein Szenario:3_1_1_1-4_1"}>Kein Szenario</option>
{keywords && {keywords &&
keywords keywords
.find((k) => k.name === form.watch("missionKeywordName")) .find((k) => k.name === form.watch("missionKeywordName"))
@@ -381,14 +410,14 @@ export const MissionForm = () => {
})} })}
</select> </select>
{validationRequired && ( {validationRequired && (
<p className="text-sm text-warning">Szenario wird vor Alarmierung HPG-Validiert.</p> <p className="text-warning text-sm">Szenario wird vor Alarmierung HPG-Validiert.</p>
)} )}
</div> </div>
<textarea <textarea
{...form.register("missionAdditionalInfo")} {...form.register("missionAdditionalInfo")}
placeholder="Einsatzinformationen" placeholder="Einsatzinformationen"
className="textarea textarea-primary textarea-bordered w-full mb-4" className="textarea textarea-primary textarea-bordered mb-4 w-full"
/> />
{form.watch("type") === "sekundär" && ( {form.watch("type") === "sekundär" && (
<input <input
@@ -400,7 +429,7 @@ export const MissionForm = () => {
)} )}
</div> </div>
<div className="form-control"> <div className="form-control">
<h2 className="text-lg font-bold mb-2">Patienteninformationen</h2> <h2 className="mb-2 text-lg font-bold">Patienteninformationen</h2>
<textarea <textarea
{...form.register("missionPatientInfo")} {...form.register("missionPatientInfo")}
placeholder="Patienteninformationen" placeholder="Patienteninformationen"
@@ -414,6 +443,21 @@ export const MissionForm = () => {
In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude
</p> </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="form-control min-h-[140px]">
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -429,7 +473,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setEditingMission(null); setEditingMission(null);
setContextMenu(null); setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`); if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset(); form.reset();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
@@ -454,7 +502,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setContextMenu(null); setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`); if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset(); form.reset();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {

View File

@@ -16,7 +16,13 @@ export default async function RootLayout({
const session = await getServerSession(); const session = await getServerSession();
if (!session?.user.permissions.includes("DISPO")) if (!session?.user.permissions.includes("DISPO"))
return <Error title="Zugriff verweigert" statusCode={403} />; return (
<Error
title=" Fehlende Berechtigung"
description="Du hast nicht die erforderlichen Berechtigungen, dich als Disponent anzumelden. Du kannst im HUB Kurse abschließen um die Berechtigung zu erhalten."
statusCode={403}
/>
);
return ( return (
<> <>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
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 } = useMrtStore((state) => state);
return (
<Image
src={nightMode ? 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 { 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 "./mrt.css";
import Image from "next/image"; import { MrtBase } from "./Base";
import { useMrtStore } from "_store/pilot/MrtStore"; import { MrtDisplay } from "./MrtDisplay";
import { MrtButtons } from "./MrtButtons";
const MRT_BUTTON_STYLES: CSSProperties = { import { MrtPopups } from "./MrtPopups";
cursor: "pointer",
zIndex: "9999",
backgroundColor: "transparent",
border: "none",
};
const MRT_DISPLAYLINE_STYLES: CSSProperties = {
color: "white",
zIndex: 1,
};
export interface DisplayLineProps { export interface DisplayLineProps {
lineStyle?: CSSProperties; lineStyle?: CSSProperties;
@@ -27,45 +14,7 @@ export interface DisplayLineProps {
textSize: "1" | "2" | "3" | "4"; 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 = () => { export const Mrt = () => {
useSounds();
const { handleButton } = useButtons();
const { lines, page } = useMrtStore((state) => state);
return ( return (
<div <div
id="mrt-container" id="mrt-container"
@@ -78,150 +27,16 @@ export const Mrt = () => {
maxHeight: "100%", maxHeight: "100%",
maxWidth: "100%", maxWidth: "100%",
color: "white", color: "white",
gridTemplateColumns: "21.83% 4.43% 24.42% 18.08% 5.93% 1.98% 6.00% 1.69% 6.00% 9.35%", gridTemplateColumns:
gridTemplateRows: "21.58% 11.87% 3.55% 5.00% 6.84% 0.53% 3.03% 11.84% 3.55% 11.84% 20.39%", "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" && ( <MrtPopups />
<Image <MrtDisplay />
src={MrtImage} <MrtButtons />
alt="MrtImage" <MrtBase />
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,
}}
/>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,143 @@
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(onHold, 500);
};
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,266 @@
"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_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 } = 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(() => {
console.log("speakingParticipants", speakingParticipants, isTalking, page);
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":
setNextImage({ src: PAGE_HOME, 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]);
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">
{room?.name || "Keine RG gefunden"}
</p>
<p className="absolute left-[28%] top-[44.5%] h-[8%] w-[34%] text-xs">
{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

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 { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore"; import { useMrtStore } from "_store/pilot/MrtStore";
import { pilotSocket } from "(app)/pilot/socket"; import { pilotSocket } from "(app)/pilot/socket";
import { editConnectedAircraftAPI } from "_querys/aircrafts"; import { editConnectedAircraftAPI } from "_querys/aircrafts";
import { useEffect } from "react"; 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 = () => { export const useButtons = () => {
const station = usePilotConnectionStore((state) => state.selectedStation); const session = useSession();
const connectedAircraft = usePilotConnectionStore((state) => state.connectedAircraft); const { connect, setSelectedRoom, selectedRoom } = useAudioStore((state) => state);
const connectionStatus = usePilotConnectionStore((state) => state.status);
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({ const updateAircraftMutation = useMutation({
mutationKey: ["edit-pilot-connected-aircraft"], mutationKey: ["edit-pilot-connected-aircraft"],
mutationFn: ({ mutationFn: ({
@@ -21,56 +64,161 @@ export const useButtons = () => {
}) => editConnectedAircraftAPI(aircraftId, data), }) => editConnectedAircraftAPI(aircraftId, data),
}); });
const { setPage } = useMrtStore((state) => state); const { setPage, setPopup, page, popup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const handleButton = const role =
(button: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0" | "home") => () => { (pilotState == "connected" && selectedStation?.bosCallsignShort) ||
if (connectionStatus !== "connected") return; session.data?.user?.publicId;
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 });
setTimeout(async () => { const handleHold = (button: ButtonTypes) => async () => {
await updateAircraftMutation.mutateAsync({ /* if (connectionStatus !== "connected") return; */
aircraftId: connectedAircraft.id, if (button === "end-call") {
data: { setPage({ page: "off" });
fmsStatus: button, setPopup(null);
}, }
}); if (button === "1" && page === "off") {
setPage({ setPage({ page: "startup" });
page: "home", return;
station, }
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, fmsStatus: button,
}); },
}, 1000); });
} else { setPopup({ popup: "status-sent" });
setPage({ page: "home", fmsStatus: connectedAircraft.fmsStatus || "6", station }); 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(() => { useEffect(() => {
pilotSocket.on("connect", () => { pilotSocket.on("connect", () => {
if (!station) return; const { page } = useMrtStore.getState();
setPage({ page: "home", fmsStatus: "6", station }); if (!selectedStation || page !== "off") return;
setPage({ page: "startup" });
}); });
}, [setPage, selectedStation, setPopup]);
pilotSocket.on("aircraft-update", () => { return { handleKlick, handleHold };
if (!station) return;
setPage({ page: "new-status", station });
});
}, [setPage, station]);
return { handleButton };
}; };

View File

@@ -1,52 +1,22 @@
"use client"; "use client";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export const useSounds = () => { export const useSounds = () => {
const mrtState = useMrtStore((state) => state); const longBtnPressSoundRef = useRef<HTMLAudioElement>(null);
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state); const statusSentSoundRef = useRef<HTMLAudioElement>(null);
const sdsReceivedSoundRef = useRef<HTMLAudioElement>(null);
const setPage = useMrtStore((state) => state.setPage);
const MRTstatusSoundRef = useRef<HTMLAudioElement>(null);
const MrtMessageReceivedSoundRef = useRef<HTMLAudioElement>(null);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
MRTstatusSoundRef.current = new Audio("/sounds/MRT-status.mp3"); longBtnPressSoundRef.current = new Audio("/sounds/1504.wav");
MrtMessageReceivedSoundRef.current = new Audio("/sounds/MRT-message-received.mp3"); statusSentSoundRef.current = new Audio("/sounds/403.wav");
MRTstatusSoundRef.current.onended = () => { sdsReceivedSoundRef.current = new Audio("/sounds/775.wav");
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,
});
};
} }
}, [connectedAircraft?.fmsStatus, selectedStation, setPage, mrtState.page]); }, []);
const fmsStatus = connectedAircraft?.fmsStatus || "NaN"; return {
longBtnPressSoundRef,
useEffect(() => { statusSentSoundRef,
if (!connectedAircraft) return; sdsReceivedSoundRef,
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]);
}; };

View File

@@ -2,14 +2,26 @@ import { Connection } from "./_components/Connection";
import { Audio } from "_components/Audio/Audio"; import { Audio } from "_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { Settings } from "_components/navbar/Settings"; import { Settings } from "./_components/Settings";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import { WarningAlert } from "_components/navbar/PageAlert"; import { WarningAlert } from "_components/navbar/PageAlert";
import { Radar } from "lucide-react";
import { prisma } from "@repo/db";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
export default function Navbar() { export default async function Navbar() {
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return ( return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between"> <div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<ModeSwitchDropdown /> <div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
</div>
<WarningAlert /> <WarningAlert />
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -20,18 +32,23 @@ export default function Navbar() {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Settings /> <Settings />
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
<button className="btn btn-ghost">
<Radar size={19} /> Tracker
</button>
</Link>
<Link <Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"} href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB <ExternalLinkIcon className="h-4 w-4" /> HUB
</button> </button>
</Link> </Link>
<Link href={"/logout"}> <Link href={"/logout"}>
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" /> <ExitIcon className="h-4 w-4" />
</button> </button>
</Link> </Link>
</div> </div>

View File

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

View File

@@ -1,16 +1,21 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons"; 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 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 { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore"; import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => { export const SettingsBtn = () => {
const session = useSession(); const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({ const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id], queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id), queryFn: () => getUserAPI(session.data!.user.id),
@@ -19,43 +24,68 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({ const editUserMutation = useMutation({
mutationFn: editUserAPI, mutationFn: editUserAPI,
mutationKey: ["user", session.data?.user.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
}); });
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
testSoundRef.current = new Audio("/sounds/Melder3.wav"); testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
} }
}, []); }, []);
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string | null>(
user?.settingsMicDevice || null,
);
const [showIndication, setShowIndication] = useState<boolean>(false); const [showIndication, setShowIndication] = useState<boolean>(false);
const [micVol, setMicVol] = useState<number>(1);
const [funkVolume, setFunkVol] = useState<number>(0.8);
const [dmeVolume, setDmeVol] = useState<number>(0.8);
const setMic = useAudioStore((state) => state.setMic); const [settings, setSettings] = useState({
micDeviceId: user?.settingsMicDevice || null,
micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8,
dmeVolume: user?.settingsDmeVolume || 0.8,
pilotNtfyRoom: user?.settingsNtfyRoom || "",
});
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setSelectedDevice(user.settingsMicDevice); setAudioSettings({
setMic(user.settingsMicDevice, user.settingsMicVolume || 1); micDeviceId: user.settingsMicDevice,
setMicVol(user.settingsMicVolume || 1); micVolume: user.settingsMicVolume || 1,
setFunkVol(user.settingsRadioVolume || 0.8); radioVolume: user.settingsRadioVolume || 0.8,
setDmeVol(user.settingsDmeVolume || 0.8); dmeVolume: user.settingsDmeVolume || 0.8,
} });
}, [user, setMic]); setSettings({
micDeviceId: user.settingsMicDevice,
useEffect(() => { micVolume: user.settingsMicVolume || 1,
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) { radioVolume: user.settingsRadioVolume || 0.8,
navigator.mediaDevices.enumerateDevices().then((devices) => { dmeVolume: user.settingsDmeVolume || 0.8,
setInputDevices(devices.filter((d) => d.kind === "audioinput")); 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 ( return (
@@ -67,23 +97,23 @@ export const SettingsBtn = () => {
modalRef.current?.showModal(); modalRef.current?.showModal();
}} }}
> >
<GearIcon className="w-5 h-5" /> <GearIcon className="h-5 w-5" />
</button> </button>
<dialog ref={modalRef} className="modal"> <dialog ref={modalRef} className="modal">
<div className="modal-box"> <div className="modal-box">
<h3 className="flex items-center gap-2 text-lg font-bold mb-5"> <h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
<SettingsIcon size={20} /> Einstellungen <SettingsIcon size={20} /> Einstellungen
</h3> </h3>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<fieldset className="fieldset w-full mb-2"> <fieldset className="fieldset mb-2 w-full">
<label className="floating-label w-full text-base"> <label className="floating-label w-full text-base">
<span>Eingabegerät</span> <span>Eingabegerät</span>
<select <select
className="input w-full" className="input w-full"
value={selectedDevice ? selectedDevice : ""} value={settings.micDeviceId ? settings.micDeviceId : ""}
onChange={(e) => { onChange={(e) => {
setSelectedDevice(e.target.value); setSettingsPartial({ micDeviceId: e.target.value });
setShowIndication(true); setShowIndication(true);
}} }}
> >
@@ -98,7 +128,7 @@ export const SettingsBtn = () => {
</select> </select>
</label> </label>
</fieldset> </fieldset>
<p className="flex items-center gap-2 text-base mb-2 justify-start w-full"> <p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
<Volume2 size={20} /> Eingabelautstärke <Volume2 size={20} /> Eingabelautstärke
</p> </p>
<div className="w-full"> <div className="w-full">
@@ -109,13 +139,13 @@ export const SettingsBtn = () => {
step={0.01} step={0.01}
onChange={(e) => { onChange={(e) => {
const value = parseFloat(e.target.value); const value = parseFloat(e.target.value);
setMicVol(value); setSettingsPartial({ micVolume: value });
setShowIndication(true); setShowIndication(true);
}} }}
value={micVol} value={settings.micVolume}
className="range range-xs range-accent w-full" className="range range-xs range-accent w-full"
/> />
<div className="flex justify-between px-2.5 mt-2 text-xs"> <div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span> <span>0%</span>
<span>25%</span> <span>25%</span>
<span>50%</span> <span>50%</span>
@@ -124,14 +154,17 @@ export const SettingsBtn = () => {
</div> </div>
</div> </div>
{showIndication && ( {showIndication && (
<MicVolumeBar deviceId={selectedDevice ? selectedDevice : ""} volumeInput={micVol} /> <MicVolumeBar
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
volumeInput={settings.micVolume}
/>
)} )}
<div className="divider w-full" /> <div className="divider w-full" />
</div> </div>
<p className="flex items-center gap-2 text-base mb-2"> <p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Funk Lautstärke <Volume2 size={20} /> Funk Lautstärke
</p> </p>
<div className="w-full mb-2"> <div className="mb-2 w-full">
<input <input
type="range" type="range"
min={0} min={0}
@@ -139,12 +172,12 @@ export const SettingsBtn = () => {
step={0.01} step={0.01}
onChange={(e) => { onChange={(e) => {
const value = parseFloat(e.target.value); const value = parseFloat(e.target.value);
setFunkVol(value); setSettingsPartial({ radioVolume: value });
}} }}
value={funkVolume} value={settings.radioVolume}
className="range range-xs range-primary w-full" className="range range-xs range-primary w-full"
/> />
<div className="flex justify-between px-2.5 mt-2 text-xs"> <div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span> <span>0%</span>
<span>25%</span> <span>25%</span>
<span>50%</span> <span>50%</span>
@@ -152,10 +185,8 @@ export const SettingsBtn = () => {
<span>100%</span> <span>100%</span>
</div> </div>
</div> </div>
<div className="flex justify-center w-full">
<div className="divider w-1/2" /> <p className="mb-2 flex items-center gap-2 text-base">
</div>
<p className="flex items-center gap-2 text-base mb-2">
<Volume2 size={20} /> Melder Lautstärke <Volume2 size={20} /> Melder Lautstärke
</p> </p>
<div className="w-full"> <div className="w-full">
@@ -166,15 +197,15 @@ export const SettingsBtn = () => {
step={0.01} step={0.01}
onChange={(e) => { onChange={(e) => {
const value = parseFloat(e.target.value); const value = parseFloat(e.target.value);
setDmeVol(value); setSettingsPartial({ dmeVolume: value });
if (!testSoundRef.current) return; if (!testSoundRef.current) return;
testSoundRef.current.volume = value; testSoundRef.current.volume = value;
testSoundRef.current.play(); testSoundRef.current.play();
}} }}
value={dmeVolume} value={settings.dmeVolume}
className="range range-xs range-primary w-full" className="range range-xs range-primary w-full"
/> />
<div className="flex justify-between px-2.5 mt-2 text-xs"> <div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span> <span>0%</span>
<span>25%</span> <span>25%</span>
<span>50%</span> <span>50%</span>
@@ -182,8 +213,36 @@ export const SettingsBtn = () => {
<span>100%</span> <span>100%</span>
</div> </div>
</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 <button
className="btn btn-soft" className="btn btn-soft"
type="submit" type="submit"
@@ -195,7 +254,7 @@ export const SettingsBtn = () => {
> >
Schließen Schließen
</button> </button>
<button <Button
className="btn btn-soft btn-success" className="btn btn-soft btn-success"
type="submit" type="submit"
onSubmit={() => false} onSubmit={() => false}
@@ -204,26 +263,31 @@ export const SettingsBtn = () => {
await editUserMutation.mutateAsync({ await editUserMutation.mutateAsync({
id: session.data!.user.id, id: session.data!.user.id,
user: { user: {
settingsMicDevice: selectedDevice, settingsMicDevice: settings.micDeviceId,
settingsMicVolume: micVol, settingsMicVolume: settings.micVolume,
settingsRadioVolume: funkVolume, settingsRadioVolume: settings.radioVolume,
settingsDmeVolume: dmeVolume, 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(); modalRef.current?.close();
toast.success("Einstellungen gespeichert"); toast.success("Einstellungen gespeichert");
}} }}
> >
Speichern Speichern
</button> </Button>
</div> </div>
</div> </div>
</dialog> </dialog>
</div> </div>
); );
}; };
export const Settings = () => { export const Settings = () => {
return ( return (
<div> <div>

View File

@@ -16,7 +16,13 @@ export default async function RootLayout({
const session = await getServerSession(); const session = await getServerSession();
if (!session?.user.permissions.includes("PILOT")) if (!session?.user.permissions.includes("PILOT"))
return <Error title="Zugriff verweigert" statusCode={403} />; return (
<Error
title=" Fehlende Berechtigung"
description="Du hast nicht die erforderlichen Berechtigungen, dich als Pilot anzumelden. Du kannst in HUB Kurse abschließen um die Berechtigung zu erhalten."
statusCode={403}
/>
);
return ( return (
<> <>

View File

@@ -6,11 +6,17 @@ import { Report } from "../../_components/left/Report";
import { Dme } from "(app)/pilot/_components/dme/Dme"; import { Dme } from "(app)/pilot/_components/dme/Dme";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher"; 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 { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { getAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { checkSimulatorConnected } from "@repo/shared-components"; import { Button, checkSimulatorConnected, useDebounce } from "@repo/shared-components";
import { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert"; import { 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"), { const Map = dynamic(() => import("_components/map/Map"), {
ssr: false, ssr: false,
@@ -18,50 +24,119 @@ const Map = dynamic(() => import("_components/map/Map"), {
const PilotPage = () => { const PilotPage = () => {
const { connectedAircraft, status } = usePilotConnectionStore((state) => state); const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const { data: ownAircraftArray = [] } = useQuery({ const { latestMission } = useDmeStore((state) => state);
queryKey: ["own-aircraft", connectedAircraft?.id], // Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
queryFn: () => const { data: aircrafts } = useQuery({
getAircraftsAPI({ queryKey: ["aircrafts"],
id: connectedAircraft?.id, queryFn: () => getConnectedAircraftsAPI(),
}), refetchInterval: 10_000,
refetchInterval: 10000,
}); });
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; const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
return ( return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full h-screen overflow-hidden"> <div className="ease relative flex h-screen w-full flex-1 overflow-hidden transition-all duration-500">
{/* <MapToastCard2 /> */} {/* <MapToastCard2 /> */}
<div className="flex flex-1 relative w-full h-full"> <div className="relative flex h-full w-full flex-1">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999"> <div className="absolute left-0 top-1/2 z-20 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
<Chat /> <Chat />
<div className="mt-2"> <Report />
<Report /> <BugReport />
</div>
</div> </div>
<div className="flex w-2/3 h-full"> <div className="flex h-full w-2/3">
<div className="relative flex flex-1 h-full"> <div className="relative flex h-full flex-1">
<div className="top-19/20 absolute left-0 z-20 -translate-y-1/2 transform pl-4">
<div className="flex items-center justify-between gap-4">
<SettingsBoard />
</div>
</div>
<Map /> <Map />
<div className="absolute top-5 right-10 z-99999 space-y-2"> <div className="absolute right-10 top-5 z-20 space-y-2">
{!simulatorConnected && status === "connected" && ( {!simulatorConnected &&
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} /> status === "connected" &&
)} connectedAircraft &&
!shortlyConnected && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
<ConnectedDispatcher /> <ConnectedDispatcher />
</div> </div>
</div> </div>
</div> </div>
<div className="flex w-1/3 h-full"> <div className="flex h-full w-1/3 min-w-[500px]">
<div className="flex flex-col w-full h-full p-4 bg-base-300"> <div className="bg-base-300 flex h-full w-full flex-col p-4">
<h2 className="card-title mb-2">MRT & DME</h2> <div className="flex justify-between">
<div className="card bg-base-200 shadow-xl mb-4"> <div className="mb-2 flex items-center justify-end gap-2">
<div className="card-body w-full h-full flex items-center justify-center"> <h2 className="card-title">MRT & DME</h2>
<div className=" max-w-150"> <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 /> <Mrt />
</div> </div>
</div> </div>
</div> </div>
<div className="card bg-base-200 shadow-xl h-1/2 flex"> <div className="card bg-base-200 flex h-1/2 shadow-xl">
<div className="card-body w-full h-full p-4 mb-0 flex items-center justify-center"> <div className="card-body mb-0 flex h-full w-full items-center justify-center p-4">
<div className=" max-w-140"> <div className="max-w-140">
<Dme /> <Dme />
</div> </div>
</div> </div>

View File

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

View File

@@ -24,7 +24,9 @@ import { useSounds } from "_components/Audio/useSounds";
export const Audio = () => { export const Audio = () => {
const { const {
selectedRoom,
speakingParticipants, speakingParticipants,
resetSpeakingParticipants,
isTalking, isTalking,
toggleTalking, toggleTalking,
transmitBlocked, transmitBlocked,
@@ -36,8 +38,9 @@ export const Audio = () => {
room, room,
message, message,
removeMessage, removeMessage,
setSelectedRoom,
} = useAudioStore(); } = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useSounds({ useSounds({
isReceiving: speakingParticipants.length > 0, isReceiving: speakingParticipants.length > 0,
isTransmitting: isTalking, isTransmitting: isTalking,
@@ -46,7 +49,7 @@ export const Audio = () => {
}); });
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state); const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state); const { status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession(); const session = useSession();
const [isReceivingBlick, setIsReceivingBlick] = useState(false); const [isReceivingBlick, setIsReceivingBlick] = useState(false);
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]); const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
@@ -91,7 +94,7 @@ export const Audio = () => {
const canStopOtherSpeakers = dispatcherState === "connected"; const canStopOtherSpeakers = dispatcherState === "connected";
const role = const role =
(dispatcherState === "connected" && selectedZone) || (dispatcherState === "connected" && "VAR LST") ||
(pilotState == "connected" && selectedStation?.bosCallsignShort) || (pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId; session.data?.user?.publicId;
@@ -104,7 +107,7 @@ export const Audio = () => {
data-tip="Nachricht entfernen" data-tip="Nachricht entfernen"
> >
<button <button
className={cn("btn btn-sm btn-ghost border-warning bg-transparent ")} className={cn("btn btn-sm btn-ghost border-warning bg-transparent")}
onClick={() => { onClick={() => {
removeMessage(); removeMessage();
}} }}
@@ -123,9 +126,9 @@ export const Audio = () => {
> >
<button <button
className={cn( 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", canStopOtherSpeakers && speakingParticipants.length > 0 && "hover:bg-error",
speakingParticipants.length > 0 && " hover:bg-errorborder", speakingParticipants.length > 0 && "hover:bg-errorborder",
isReceivingBlick && "border-warning", isReceivingBlick && "border-warning",
)} )}
onClick={() => { onClick={() => {
@@ -133,6 +136,7 @@ export const Audio = () => {
const payload = JSON.stringify({ const payload = JSON.stringify({
by: role, by: role,
}); });
resetSpeakingParticipants("dich");
speakingParticipants.forEach(async (p) => { speakingParticipants.forEach(async (p) => {
await room?.localParticipant.performRpc({ await room?.localParticipant.performRpc({
destinationIdentity: p.identity, destinationIdentity: p.identity,
@@ -159,54 +163,54 @@ export const Audio = () => {
transmitBlocked && "bg-yellow-500 hover:bg-yellow-500", transmitBlocked && "bg-yellow-500 hover:bg-yellow-500",
state === "disconnected" && "bg-red-500 hover:bg-red-500", state === "disconnected" && "bg-red-500 hover:bg-red-500",
state === "error" && "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 === "connected" && <Mic className="h-5 w-5" />}
{state === "disconnected" && <WifiOff className="w-5 h-5" />} {state === "disconnected" && <WifiOff className="h-5 w-5" />}
{state === "connecting" && <PlugZap className="w-5 h-5" />} {state === "connecting" && <PlugZap className="h-5 w-5" />}
{state === "error" && <ServerCrash className="w-5 h-5" />} {state === "error" && <ServerCrash className="h-5 w-5" />}
</button> </button>
{state === "connected" && ( {state === "connected" && (
<details className="dropdown relative z-[1050]"> <details className="dropdown relative z-[1050]">
<summary className="dropdown btn btn-ghost flex items-center gap-1"> <summary className="dropdown btn btn-ghost flex items-center gap-1">
{connectionQuality === ConnectionQuality.Excellent && <Signal className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Excellent && <Signal className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Good && <SignalMedium className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Good && <SignalMedium className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Poor && <SignalLow className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Poor && <SignalLow className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Lost && <ZapOff className="w-5 h-5" />} {connectionQuality === ConnectionQuality.Lost && <ZapOff className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Unknown && ( {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> <div className="badge badge-sm badge-soft badge-success">{remoteParticipants}</div>
</summary> </summary>
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm"> <ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
{ROOMS.map((r) => ( {ROOMS.map((r) => (
<li key={r}> <li key={r.id}>
<button <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={() => { onClick={() => {
if (!role) return; if (!role) return;
if (selectedRoom === r) return; if (selectedRoom?.name === r.name) return;
setSelectedRoom(r); setSelectedRoom(r);
connect(r, role); connect(r, role);
}} }}
> >
{room?.name === r && ( {room?.name === r.name && (
<Disc className="text-success text-sm absolute left-2" width={15} /> <Disc className="text-success absolute left-2 text-sm" width={15} />
)} )}
<span className="flex-1 text-center">{r}</span> <span className="flex-1 text-center">{r.name}</span>
</button> </button>
</li> </li>
))} ))}
<li> <li>
<button <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={() => { onClick={() => {
disconnect(); 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> <span className="flex-1 text-center">Disconnect</span>
</button> </button>
</li> </li>

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { dispatchSocket } from "(app)/dispatch/socket";
import { NotificationPayload } from "@repo/db"; import { NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
@@ -11,9 +11,11 @@ import { useMapStore } from "_store/mapStore";
import { AdminMessageToast } from "_components/customToasts/AdminMessage"; import { AdminMessageToast } from "_components/customToasts/AdminMessage";
import { pilotSocket } from "(app)/pilot/socket"; import { pilotSocket } from "(app)/pilot/socket";
import { QUICK_RESPONSE, StatusToast } from "_components/customToasts/StationStatusToast"; import { QUICK_RESPONSE, StatusToast } from "_components/customToasts/StationStatusToast";
import { MissionAutoCloseToast } from "_components/customToasts/MissionAutoClose";
export function QueryProvider({ children }: { children: ReactNode }) { export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s); const mapStore = useMapStore((s) => s);
const notificationSound = useRef<HTMLAudioElement | null>(null);
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
@@ -21,7 +23,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
defaultOptions: { defaultOptions: {
mutations: { mutations: {
onError: (error) => { onError: (error) => {
toast.error("An error occurred: " + (error as Error).message, { toast.error("Ein Fehler ist aufgetreten: " + (error as Error).message, {
position: "top-right", position: "top-right",
}); });
}, },
@@ -29,6 +31,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}, },
}), }),
); );
useEffect(() => {
notificationSound.current = new Audio("/sounds/notification.mp3");
}, []);
useEffect(() => { useEffect(() => {
const invalidateMission = () => { const invalidateMission = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -58,8 +63,19 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}; };
const handleNotification = (notification: NotificationPayload) => { 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) { switch (notification.type) {
case "hpg-validation": case "hpg-validation":
playNotificationSound();
toast.custom( toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />, (t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{ {
@@ -69,15 +85,30 @@ export function QueryProvider({ children }: { children: ReactNode }) {
break; break;
case "admin-message": case "admin-message":
playNotificationSound();
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, { toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999, duration: 999999,
}); });
break; break;
case "station-status": case "station-status":
console.log("station Status", QUICK_RESPONSE[notification.status]);
if (!QUICK_RESPONSE[notification.status]) return; if (!QUICK_RESPONSE[notification.status]) return;
toast.custom((e) => <StatusToast event={notification} t={e} />, { toast.custom((e) => <StatusToast event={notification} t={e} />, {
duration: 60000, 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; break;
default: default:
toast("unbekanntes Notification-Event"); toast("unbekanntes Notification-Event");

View File

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

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

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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BaseNotification } from "_components/customToasts/BaseNotification"; import { BaseNotification } from "_components/customToasts/BaseNotification";
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors"; import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getLivekitRooms } from "_querys/livekit";
import { sendSdsStatusMessageAPI } from "_querys/missions";
import { getStationsAPI } from "_querys/stations"; import { getStationsAPI } from "_querys/stations";
import { useAudioStore } from "_store/audioStore";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { Toast, toast } from "react-hot-toast"; import { Toast, toast } from "react-hot-toast";
export const QUICK_RESPONSE: Record<string, string[]> = { 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 status5Sounds = useRef<HTMLAudioElement | null>(null);
const status9Sounds = 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(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
status0Sounds.current = new Audio("/sounds/status-0.mp3"); status0Sounds.current = new Audio("/sounds/status-0.mp3");
@@ -27,8 +50,9 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
status9Sounds.current = new Audio("/sounds/status-9.mp3"); 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({ const { data: connectedAircrafts } = useQuery({
queryKey: ["aircrafts"], queryKey: ["aircrafts"],
@@ -45,29 +69,16 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const station = stations?.find((s) => s.id === event.data?.stationId); const station = stations?.find((s) => s.id === event.data?.stationId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const changeAircraftMutation = useMutation({ const sendSdsStatusMutation = useMutation({
mutationFn: async ({ mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
id, if (!connectedAircraft?.id) throw new Error("No connected aircraft");
update, await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
}: {
id: number;
update: Prisma.ConnectedAircraftUpdateInput;
}) => {
await editConnectedAircraftAPI(id, update);
queryClient.invalidateQueries({ 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]);
useEffect(() => { useEffect(() => {
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null; let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
switch (event.status) { switch (event.status) {
@@ -83,6 +94,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
default: default:
soundRef = null; soundRef = null;
} }
if (audioRoom && livekitUser?.roomName && audioRoom !== livekitUser?.roomName) {
toast.remove(t.id);
return;
}
if (soundRef?.current) { if (soundRef?.current) {
soundRef.current.currentTime = 0; soundRef.current.currentTime = 0;
soundRef.current.volume = 0.7; soundRef.current.volume = 0.7;
@@ -94,22 +111,24 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
soundRef.current.currentTime = 0; soundRef.current.currentTime = 0;
} }
}; };
}, [event.status]); }, [event.status, livekitUser?.roomName, audioRoom, t.id]);
if (!connectedAircraft || !station) return null; console.log(connectedAircraft, station);
if (!connectedAircraft || !station || !session.data) return null;
return ( return (
<BaseNotification> <BaseNotification>
<div className="flex flex-row gap-14 items-center"> <div className="flex flex-row items-center gap-14">
<p> <p>
<span <span
className="underline mr-1 cursor-pointer font-bold" className="mr-1 cursor-pointer font-bold underline"
onClick={() => { onClick={() => {
if (!connectedAircraft.posLat || !connectedAircraft.posLng) return; if (!connectedAircraft.posLat || !connectedAircraft.posLng) return;
mapStore.setOpenAircraftMarker({
setOpenAircraftMarker({
open: [{ id: connectedAircraft.id, tab: "fms" }], open: [{ id: connectedAircraft.id, tab: "fms" }],
close: [], close: [],
}); });
mapStore.setMap({ setMap({
center: [connectedAircraft.posLat, connectedAircraft.posLng], center: [connectedAircraft.posLat, connectedAircraft.posLng],
zoom: 14, zoom: 14,
}); });
@@ -119,12 +138,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
</span> </span>
sendet Status {event.status} sendet Status {event.status}
</p> </p>
<div className="flex gap-2 items-center"> <div className="flex items-center gap-2">
{QUICK_RESPONSE[String(event.status)]?.map((status) => ( {QUICK_RESPONSE[String(event.status)]?.map((status) => (
<button <button
key={status} key={status}
className={ className={
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold" "flex min-h-10 min-w-10 cursor-pointer items-center justify-center text-lg font-bold"
} }
style={{ style={{
backgroundColor: FMS_STATUS_COLORS[status], backgroundColor: FMS_STATUS_COLORS[status],
@@ -136,10 +155,18 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
toast.error("Keine Flugzeug-ID gefunden"); toast.error("Keine Flugzeug-ID gefunden");
return; return;
} }
await changeAircraftMutation.mutateAsync({ await sendSdsStatusMutation.mutateAsync({
id: event.data?.aircraftId, sdsMessage: {
update: { type: "sds-status-log",
fmsStatus: status, 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); 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 { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useLeftMenuStore } from "_store/leftMenuStore"; import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState, useRef } from "react";
import { cn } from "@repo/shared-components"; import { cn } from "@repo/shared-components";
import { asPublicUser } from "@repo/db"; import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getConnectedDispatcherAPI } from "_querys/dispatcher"; import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { Trash } from "lucide-react";
export const Chat = () => { export const Chat = () => {
const { const {
removeChat,
setReportTabOpen, setReportTabOpen,
chatOpen, chatOpen,
setChatOpen, setChatOpen,
@@ -26,6 +30,10 @@ export const Chat = () => {
const session = useSession(); const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>("default"); const [addTabValue, setAddTabValue] = useState<string>("default");
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
const [someChat, setSomeChat] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { data: dispatcher } = useQuery({ const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"], queryKey: ["dispatcher"],
@@ -36,6 +44,7 @@ export const Chat = () => {
queryKey: ["aircrafts"], queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000, refetchInterval: 10000,
enabled: dispatcherConnected,
}); });
useEffect(() => { useEffect(() => {
@@ -43,18 +52,51 @@ export const Chat = () => {
setOwnId(session.data?.user.id); setOwnId(session.data?.user.id);
}, [session.data?.user.id, setOwnId]); }, [session.data?.user.id, setOwnId]);
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id); const filteredDispatcher = dispatcher?.filter(
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id); (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 ( return (
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}> <div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
<audio ref={audioRef} src="/sounds/newChat.mp3" preload="auto" />
<div className="indicator"> <div className="indicator">
{Object.values(chats).some((c) => c.notification) && (
<span className="indicator-item status status-info"></span>
)}
<button <button
className="btn btn-soft btn-sm btn-primary" className={cn(
"btn btn-soft btn-sm cursor-default",
btnActive && "btn-primary cursor-pointer",
someChat && "border-warning animate-pulse",
)}
onClick={() => { onClick={() => {
if (!btnActive) return;
setReportTabOpen(false); setReportTabOpen(false);
setChatOpen(!chatOpen); setChatOpen(!chatOpen);
if (selectedChat) { if (selectedChat) {
@@ -62,23 +104,23 @@ export const Chat = () => {
} }
}} }}
> >
<ChatBubbleIcon className="w-4 h-4" /> <ChatBubbleIcon className="h-4 w-4" />
</button> </button>
</div> </div>
{chatOpen && ( {chatOpen && (
<div <div
tabIndex={0} tabIndex={0}
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100] max-h-[480px] ml-2 border-1 border-primary" className="dropdown-content card bg-base-200 w-150 border-1 border-primary z-[1100] ml-2 max-h-[480px] shadow-md"
> >
<div className="card-body relative"> <div className="card-body relative">
<button <button
className="absolute top-2 right-2 btn btn-xs btn-circle btn-ghost" className="btn btn-xs btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setChatOpen(false)} onClick={() => setChatOpen(false)}
type="button" type="button"
> >
<span className="text-xl leading-none">&times;</span> <span className="text-xl leading-none">&times;</span>
</button> </button>
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2"> <h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
<ChatBubbleIcon /> Chat <ChatBubbleIcon /> Chat
</h2> </h2>
<div className="join"> <div className="join">
@@ -114,13 +156,17 @@ export const Chat = () => {
<button <button
className="btn btn-sm btn-soft btn-primary join-item" className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => { onClick={() => {
if (addTabValue === "default") return;
const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue); const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue);
const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue); const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue);
const user = aircraftUser || dispatcherUser; const user = aircraftUser || dispatcherUser;
console.log("Adding chat for user:", addTabValue, user);
if (!user) return; if (!user) return;
const role = "Station" in user ? user.Station.bosCallsignShort : user.zone; const role = "Station" in user ? user.Station.bosCallsignShort : user.zone;
console.log("Adding chat for user:", addTabValue);
addChat(addTabValue, `${asPublicUser(user.publicUser).fullName} (${role})`); addChat(addTabValue, `${asPublicUser(user.publicUser).fullName} (${role})`);
setSelectedChat(addTabValue); setSelectedChat(addTabValue);
setAddTabValue("default");
}} }}
> >
<span className="text-xl">+</span> <span className="text-xl">+</span>
@@ -132,14 +178,20 @@ export const Chat = () => {
if (!chat) return null; if (!chat) return null;
return ( return (
<Fragment key={userId}> <Fragment key={userId}>
<a <div
className={cn("indicator tab", selectedChat === userId && "tab-active")} className={cn("indicator tab", selectedChat === userId && "tab-active")}
onClick={() => setSelectedChat(userId)} onClick={() => {
if (selectedChat === userId) {
setSelectedChat(null);
return;
}
setSelectedChat(userId);
}}
> >
{chat.name} {chat.name}
{chat.notification && <span className="indicator-item status status-info" />} {chat.notification && <span className="indicator-item status status-info" />}
</a> </div>
<div className="tab-content bg-base-100 border-base-300 p-6 overflow-y-auto max-h-[250px]"> <div className="tab-content bg-base-100 border-base-300 max-h-[250px] overflow-y-auto p-6">
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */} {/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
{chat.messages.map((chatMessage) => { {chat.messages.map((chatMessage) => {
const isSender = chatMessage.senderId === session.data?.user.id; const isSender = chatMessage.senderId === session.data?.user.id;
@@ -170,6 +222,16 @@ export const Chat = () => {
)} )}
{selectedChat && ( {selectedChat && (
<div className="join"> <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"> <div className="w-full">
<label className="input join-item w-full"> <label className="input join-item w-full">
<input <input

View File

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

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

View File

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

View File

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

View File

@@ -3,15 +3,22 @@ import { OSMWay } from "@repo/db";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { usePannelStore } from "_store/pannelStore"; 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 { getOsmAddress } from "_querys/osm";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Popup, useMap } from "react-leaflet"; import { Popup, useMap } from "react-leaflet";
import { findClosestPolygon } from "_helpers/findClosestPolygon"; import { findClosestPolygon } from "_helpers/findClosestPolygon";
import { xPlaneObjectsAvailable } from "_helpers/xPlaneObjectsAvailable";
import { useQuery } from "@tanstack/react-query";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const ContextMenu = () => { export const ContextMenu = () => {
const map = useMap(); const map = useMap();
const { data: aircrafts } = useQuery({
queryKey: ["connectedAircrafts"],
queryFn: getConnectedAircraftsAPI,
});
const { const {
contextMenu, contextMenu,
searchElements, searchElements,
@@ -20,18 +27,22 @@ export const ContextMenu = () => {
setSearchPopup, setSearchPopup,
toggleSearchElementSelection, toggleSearchElementSelection,
} = useMapStore(); } = useMapStore();
const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore( const {
(state) => state, missionFormValues,
); setMissionFormValues,
const [showRulerOptions, setShowRulerOptions] = useState(false); setOpen,
isOpen: isPannelOpen,
} = usePannelStore((state) => state);
const [showObjectOptions, setShowObjectOptions] = useState(false);
const [rulerHover, setRulerHover] = useState(false); const [rulerHover, setRulerHover] = useState(false);
const [rulerOptionsHover, setRulerOptionsHover] = useState(false); const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected"; const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
useEffect(() => { useEffect(() => {
setShowRulerOptions(rulerHover || rulerOptionsHover); const showObjectOptions = rulerHover || rulerOptionsHover;
}, [rulerHover, rulerOptionsHover]); setShowObjectOptions(showObjectOptions);
}, [isPannelOpen, rulerHover, rulerOptionsHover, setOpen]);
useEffect(() => { useEffect(() => {
const handleContextMenu = (e: any) => { const handleContextMenu = (e: any) => {
@@ -53,7 +64,8 @@ export const ContextMenu = () => {
if (!contextMenu || !dispatcherConnected) return null; if (!contextMenu || !dispatcherConnected) return null;
const einsatzBtnText = missionFormValues && isOpen ? "Position übernehmen" : "Einsatz erstellen"; const missionBtnText =
missionFormValues && isPannelOpen ? "Position übernehmen" : "Einsatz erstellen";
const addOSMobjects = async (ignorePreviosSelected?: boolean) => { const addOSMobjects = async (ignorePreviosSelected?: boolean) => {
const res = await fetch( const res = await fetch(
@@ -101,14 +113,14 @@ export const ContextMenu = () => {
autoPan={false} autoPan={false}
> >
<div <div
className="absolute opacity-100 pointer-events-none p-3 flex items-center justify-center" className="pointer-events-none absolute flex items-center justify-center p-3 opacity-100"
style={{ left: "-13px", top: "-13px" }} style={{ left: "-13px", top: "-13px" }}
> >
<div className="relative w-38 h-38 flex items-center justify-center -translate-x-1/2 -translate-y-1/2 pointer-events-none"> <div className="w-38 h-38 pointer-events-none relative flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
{/* Top Button */} {/* Top Button */}
<button <button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 top-0 pointer-events-auto opacity-80 tooltip tooltip-top tooltip-accent" className="btn btn-circle bg-rescuetrack tooltip tooltip-top tooltip-accent pointer-events-auto absolute left-1/2 top-0 h-10 w-10 opacity-80"
data-tip={einsatzBtnText} data-tip={missionBtnText}
style={{ transform: "translateX(-50%)" }} style={{ transform: "translateX(-50%)" }}
onClick={async () => { onClick={async () => {
const { parsed } = await getOsmAddress(contextMenu.lat, contextMenu.lng); const { parsed } = await getOsmAddress(contextMenu.lat, contextMenu.lng);
@@ -131,27 +143,31 @@ export const ContextMenu = () => {
if (closestObject) { if (closestObject) {
toggleSearchElementSelection(closestObject.wayID, true); toggleSearchElementSelection(closestObject.wayID, true);
} }
if (isPannelOpen) {
map.setView([contextMenu.lat, contextMenu.lng], 18, { map.setView([contextMenu.lat, contextMenu.lng], 18, {
animate: true, animate: true,
}); });
}
}} }}
> >
<MapPinned size={20} /> <MapPinned size={20} />
</button> </button>
{/* Left Button */} {/* Left Button */}
<button <button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 left-0 pointer-events-auto opacity-80" className="btn btn-circle bg-rescuetrack pointer-events-auto absolute left-0 top-1/2 h-10 w-10 opacity-80"
style={{ transform: "translateY(-50%)" }} style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)} onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)} onMouseLeave={() => setRulerHover(false)}
disabled disabled={
!isPannelOpen ||
!xPlaneObjectsAvailable(missionFormValues?.missionStationIds, aircrafts)
}
> >
<Ruler size={20} /> <Car size={20} />
</button> </button>
{/* Bottom Button */} {/* Bottom Button */}
<button <button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 bottom-0 pointer-events-auto opacity-80 tooltip tooltip-bottom tooltip-accent" className="btn btn-circle bg-rescuetrack tooltip tooltip-bottom tooltip-accent pointer-events-auto absolute bottom-0 left-1/2 h-10 w-10 opacity-80"
data-tip="Koordinaten kopieren" data-tip="Koordinaten kopieren"
style={{ transform: "translateX(-50%)" }} style={{ transform: "translateX(-50%)" }}
onClick={async () => { onClick={async () => {
@@ -164,7 +180,7 @@ export const ContextMenu = () => {
</button> </button>
{/* Right Button (original Search button) */} {/* Right Button (original Search button) */}
<button <button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 right-0 pointer-events-auto opacity-80 tooltip tooltip-right tooltip-accent" className="btn btn-circle bg-rescuetrack tooltip tooltip-right tooltip-accent pointer-events-auto absolute right-0 top-1/2 h-10 w-10 opacity-80"
data-tip="Gebäude suchen" data-tip="Gebäude suchen"
style={{ transform: "translateY(-50%)" }} style={{ transform: "translateY(-50%)" }}
onClick={async () => { onClick={async () => {
@@ -173,64 +189,75 @@ export const ContextMenu = () => {
> >
<Search size={20} /> <Search size={20} />
</button> </button>
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */} {/* XPlane Object Options - shown when Ruler button is hovered or options are hovered */}
{showRulerOptions && ( {showObjectOptions && (
<div <div
className="absolute flex flex-col items-center pointer-events-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"
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",
}}
onMouseEnter={() => setRulerOptionsHover(true)} onMouseEnter={() => setRulerOptionsHover(true)}
onMouseLeave={() => setRulerOptionsHover(false)} onMouseLeave={() => setRulerOptionsHover(false)}
> >
<div <div className="flex w-full flex-col">
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<button <button
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent" className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 ml-[30px] h-10 w-10 opacity-80"
data-tip="Strecke Messen" data-tip="Rettungswagen platzieren"
style={{
transform: "translateX(100%)",
}}
onClick={() => { onClick={() => {
/* ... */ setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "ambulance",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}} }}
> >
<RulerDimensionLine size={20} /> <Ambulance size={20} />
</button> </button>
<button <button
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent" className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="Radius Messen" data-tip="LF platzieren"
onClick={() => { 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>
<button <button
className="btn btn-circle bg-rescuetrack w-10 h-10 opacity-80 tooltip tooltip-left tooltip-accent" className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent ml-[30px] h-10 w-10 opacity-80"
data-tip="Fläche Messen" data-tip="Streifenwagen platzieren"
style={{
transform: "translateX(100%)",
}}
onClick={() => { 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> </button>
</div> </div>
</div> </div>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/dispatcher
import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit"; import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit";
import { ParticipantInfo } from "livekit-server-sdk"; import { ParticipantInfo } from "livekit-server-sdk";
import { import {
Dot,
LockKeyhole, LockKeyhole,
Plane, Plane,
RedoDot, RedoDot,
@@ -35,7 +36,7 @@ export default function AdminPanel() {
const { data: livekitRooms } = useQuery({ const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"], queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(), queryFn: () => getLivekitRooms(),
refetchInterval: 10000, refetchInterval: 5000,
}); });
const kickLivekitParticipantMutation = useMutation({ const kickLivekitParticipantMutation = useMutation({
mutationFn: kickLivekitParticipant, mutationFn: kickLivekitParticipant,
@@ -108,11 +109,11 @@ export default function AdminPanel() {
<form method="dialog"> <form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button> <button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form> </form>
<h3 className="font-bold text-lg flex items-center gap-2"> <h3 className="flex items-center gap-2 text-lg font-bold">
<Shield size={22} /> Admin Panel <Shield size={22} /> Admin Panel
</h3> </h3>
<div className="flex gap-2 mt-4 w-full"> <div className="mt-4 flex w-full gap-2">
<div className="card bg-base-300 shadow-md w-full h-96 overflow-y-auto"> <div className="card bg-base-300 h-96 w-full overflow-y-auto shadow-md">
<div className="card-body"> <div className="card-body">
<div className="card-title flex items-center gap-2"> <div className="card-title flex items-center gap-2">
<UserCheck size={20} /> Verbundene Clients <UserCheck size={20} /> Verbundene Clients
@@ -144,7 +145,12 @@ export default function AdminPanel() {
{!livekitParticipant ? ( {!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span> <span className="text-error">Nicht verbunden</span>
) : ( ) : (
<span className="text-success">{livekitParticipant.room}</span> <span className="text-success inline-flex items-center">
{livekitParticipant.room}{" "}
{livekitParticipant?.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
)} )}
</td> </td>
<td className="flex gap-2"> <td className="flex gap-2">
@@ -209,7 +215,12 @@ export default function AdminPanel() {
{!livekitParticipant ? ( {!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span> <span className="text-error">Nicht verbunden</span>
) : ( ) : (
<span className="text-success">{livekitParticipant.room}</span> <span className="text-success inline-flex items-center">
{livekitParticipant.room}{" "}
{livekitParticipant?.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
)} )}
</td> </td>
<td className="flex gap-2"> <td className="flex gap-2">
@@ -274,8 +285,13 @@ export default function AdminPanel() {
<td> <td>
<span className="text-error">Nicht verbunden</span> <span className="text-error">Nicht verbunden</span>
</td> </td>
<td> <td className="flex">
<span className="text-success">{p.room}</span> <span className="text-success inline-flex items-center">
{p.room}
{p.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
</td> </td>
<td className="flex gap-2"> <td className="flex gap-2">
<button <button

View File

@@ -0,0 +1,34 @@
"use client";
import { Changelog } from "@repo/db";
import { ChangelogModalBtn } from "@repo/shared-components";
import { useMutation } from "@tanstack/react-query";
import { editUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import toast from "react-hot-toast";
export const ChangelogWrapper = ({ latestChangelog }: { latestChangelog: Changelog | null }) => {
const { data: session } = useSession();
const editUserMutation = useMutation({
mutationFn: editUserAPI,
});
const autoOpen = !session?.user.changelogAck && !!latestChangelog;
if (!latestChangelog) return null;
if (!session) return null;
return (
<ChangelogModalBtn
hideIcon
className="text-sm text-gray-500"
latestChangelog={latestChangelog}
autoOpen={autoOpen}
onClose={async () => {
await editUserMutation.mutateAsync({ id: session?.user.id, user: { changelogAck: true } });
if (!session?.user.changelogAck) {
toast.success("Changelog als gelesen markiert");
}
}}
/>
);
};

View File

@@ -30,7 +30,7 @@ export default function ModeSwitchDropdown({ className }: { className?: string }
{session.data?.user.permissions?.includes("PILOT") && ( {session.data?.user.permissions?.includes("PILOT") && (
<li> <li>
<Link href={"/pilot"}> <Link href={"/pilot"}>
<Plane size={22} /> Pilot <Plane size={22} /> Operations Center
</Link> </Link>
</li> </li>
)} )}

View File

@@ -1,26 +1,53 @@
export const fmsStatusDescription: { [key: string]: string } = { export const fmsStatusDescription: { [key: string]: string } = {
NaN: "Keine Daten", NaN: "Keine Daten",
"0": "Prio. Sprechwunsch", "0": "Prio. Sprechwunsch",
"1": "Frei auf Funk", "1": "Einsatzbereit über Funk",
"2": "Einsatzbereit am LRZ", "2": "Einsatzbereit auf Wache",
"3": "Auf dem Weg", "3": "Einsatzübernahme",
"4": "Am Einsatzort", "4": "Einsatzort an",
"5": "Sprechwunsch", "5": "Sprechwunsch",
"6": "Nicht einsatzbereit", "6": "Nicht einsatzbereit",
"7": "Patient aufgenommen", "7": "Einsatzgebunden",
"8": "Am Transportziel", "8": "Bedingt verfügbar",
"9": "Fremdanmeldung", "9": "Fremdanmeldung",
E: "Indent/Abbruch/Einsatzbefehl abgebrochen", E: "Einsatzabbruch",
C: "Anmelden zur Übernahme des Einsatzes", C: "Einsatzübernahme melden",
F: "Kommen über Draht", F: "Kommen Sie über Draht",
H: "Fahren auf Wache", H: "Fahren auf Wache",
J: "Sprechaufforderung", J: "Sprechaufforderung",
L: "Lagebericht abgeben", L: "Geben Sie Lagemeldung",
P: "Einsatz mit Polizei/Pause machen", P: "Einsatz mit Polizei/Pause machen",
U: "Ungültiger Status", U: "Ungültige Statusfolge",
c: "Status korrigieren", c: "Status korrigieren",
d: "Transportziel angeben", d: "Transportziel angeben",
h: "Zielklinik verständigt", h: "Zielklinik verständigt",
o: "Warten, alle Abfrageplätze belegt", o: "Warten, alle Abfrageplätze belegt",
u: "Verstanden", u: "Verstanden",
}; };
export const fmsStatusDescriptionShort: { [key: string]: string } = {
NaN: "Keine D.",
"0": "Prio. Sprechen",
"1": "E.-bereit Funk",
"2": "E.-bereit Wache",
"3": "E.-übernahme",
"4": "Einsatzort",
"5": "Sprechwunsch",
"6": "Nicht e.-bereit",
"7": "Einsatzgeb.",
"8": "Bed. Verfügbar",
"9": "F-anmeldung",
E: "Einsatzabbruch",
C: "Melden Sie Einsatzübernahme",
F: "Kommen Sie über Draht",
H: "Fahren Sie Wache an",
J: "Sprechen Sie",
L: "Geben Sie Lagemeldung",
P: "Einsatz mit Polizei",
U: "Ungültige Statusfolge",
c: "Status korrigieren",
d: "Nennen Sie Transportziel",
h: "Zielklinik verständigt",
o: "Warten, alle Abfrageplätze belegt",
u: "Verstanden",
};

View File

@@ -1 +1,7 @@
export const ROOMS = ["LST_01", "LST_02", "LST_03", "LST_04", "LST_05"]; export const ROOMS = [
{ name: "VAR_LST_RD_01", id: "2201" },
{ name: "VAR_LST_RD_02", id: "2202" },
{ name: "VAR_LST_RD_03", id: "2203" },
{ name: "VAR_LST_RD_04", id: "2204" },
{ name: "VAR_LST_RD_05", id: "2205" },
];

View File

@@ -4,28 +4,40 @@ import {
RemoteParticipant, RemoteParticipant,
RemoteTrack, RemoteTrack,
RemoteTrackPublication, RemoteTrackPublication,
Track,
} from "livekit-client"; } from "livekit-client";
const initialTrackTimeouts = new Map<string, NodeJS.Timeout>();
export const handleTrackSubscribed = ( export const handleTrackSubscribed = (
track: RemoteTrack, track: RemoteTrack,
publication: RemoteTrackPublication, publication: RemoteTrackPublication,
participant: RemoteParticipant, participant: RemoteParticipant,
) => { ) => {
const element = track.attach();
element.pause();
if (!track.isMuted) { if (!track.isMuted) {
useAudioStore.getState().addSpeakingParticipant(participant); initialTrackTimeouts.set(
participant.sid,
setTimeout(() => {
useAudioStore.getState().addSpeakingParticipant(participant);
}, 1000),
);
} }
setTimeout(() => {
element.play();
}, 1000);
track.on("unmuted", () => { track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant); useAudioStore.getState().addSpeakingParticipant(participant);
element.volume = useAudioStore.getState().settings.radioVolume;
}); });
track.on("muted", () => { track.on("muted", () => {
clearTimeout(initialTrackTimeouts.get(participant.sid));
initialTrackTimeouts.get(participant.sid);
useAudioStore.getState().removeSpeakingParticipant(participant); useAudioStore.getState().removeSpeakingParticipant(participant);
}); });
if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) {
// attach it to a new HTMLVideoElement or HTMLAudioElement
const element = track.attach();
element.play();
}
}; };
export const handleTrackUnsubscribed = (track: RemoteTrack) => { export const handleTrackUnsubscribed = (track: RemoteTrack) => {

View File

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

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